Ship your API as a CLI
gofastr generate cli turns your app's HTTP API into a branded terminal
client you distribute to your customers — the stripe/gh experience
for the app you built. The output is a standalone, stdlib-only
package main that imports exactly one thing: your generated
entities/client package. Customers authenticate with a scoped API
token minted in your app, and every entity gets the full set of CRUD
operations plus batch operations and a live event stream.
cd myapp # the directory holding entities/gofastr generate cligo build ./cmd/myapp
The generator reads the entity set from your project source
(entities/*.go, the same recovery machinery gofastr pack trusts) —
no blueprint or YAML involved. It needs an enclosing go.mod to derive
the client import path.
The output is pure stdlib Go, so it builds for every platform Go
targets — cross-compile releases with GOOS/GOARCH
(GOOS=windows GOARCH=amd64 go build ./cmd/myapp), and the config file
lands in each OS's native config dir (os.UserConfigDir). The default
output directory is cmd/<binary>/, the standard installable-main
layout: if your module is public, customers can install directly with
go install your.module/path/cmd/myapp@latestFor closed-source apps, distribute the cross-compiled binaries.
What your customers get
myapp login --url https://app.example.com --with-token # paste a token oncemyapp posts list --published true --sort -created_at -o tablemyapp posts list --views-gt 100 -q searchterm --include authormyapp posts get 42myapp posts create --title "Hello" --views 3myapp posts create --json @post.jsonmyapp posts patch 42 --published=false # explicit zeros survivemyapp posts update 42 --json '{"title":"Edited"}'myapp posts batch-create --json @rows.json # atomic, up to 100myapp posts batch-delete id1 id2 id3myapp posts watch # live SSE feed, one JSON line/eventmyapp posts delete 42
Help is built in at every level, always exiting 0: bare myapp (or
help/--help) lists every command, a bare entity command
(myapp posts) lists that entity's subcommands, and --help on any
verb prints its full flag reference. An unknown subcommand prints the
group usage and exits 2. version prints the binary name.
Per verb:
- list — an equality filter flag per field (
--status active,
comma list = IN), range flags (--views-gt/-gte/-lt/-lte) on
numeric/date/timestamp fields,--<field>-likeon text fields,
plus--sort,--page/--limit,--cursor(keyset),--include,
--fields, and--param k=v(repeatable) as the escape hatch for
anything else.-qappears only when the entity declares
SearchFields;--trashedonly withSoftDelete.-o table
renders an aligned table instead of JSON. - create / update / patch — per-field flags OR
--json
(inline,@file, or-for stdin); the two are mutually exclusive.
Field flags are presence-faithful: only explicitly-set flags enter
the body (viaflag.FlagSet.Visit), so--published=falsereally
sendsfalse— the payload is built as a raw map, never squeezed
through a struct withomitempty. - batch-create / batch-update —
--jsonwith an array, sent
through the atomic_batchroutes; the{committed, results[]}
envelope prints verbatim and a rollback exits 1. - watch — subscribes to
GET {entity}/_eventsand prints one
{"event":…,"data":…}line per server event until interrupted.
No auto-reconnect: wrap it in a shell loop if you need one.
Connection resolution, in order: --url/--token flags →
<BINARY>_URL/<BINARY>_TOKEN env vars → the config file written by
login (<user-config-dir>/<binary>/config.json, 0600). Exit codes:
0 success, 1 API/transport error (including a rolled-back batch),
2 usage, 4 authentication failure (401/403).
Auth: scoped API tokens
The CLI sends Authorization: Bearer gfsk_… on every request — wire
auth.TokenMiddleware
alongside your session middleware, mount auth.NewTokensPlugin so
logged-in users can mint their own scoped tokens in the app, and add
auth.RequireAPIScopes("/api") so those scopes are actually enforced
(a customers:* token gets 403 off every other resource). Bearer
requests bypass both CSRF layers by design, so the CLI needs no cookie
or CSRF handling. The customer flow:
- Log in to your app in a browser.
- Mint a scoped token (
POST /auth/tokens, plaintext shown once). echo "$TOKEN" | myapp login --url https://app.example.com --with-token.
--with-token reads stdin so the token never echoes or lands in shell
history; the interactive prompt warns that input echoes (the stdlib has
no termios). logout deletes the stored token — revoke it in the app
to invalidate it server-side.
Choosing what to expose
Everything is generated by default. Narrow it declaratively:
gofastr generate cli --only=posts,comments # entity allow-listgofastr generate cli --exclude=audit_logs # entity deny-listgofastr generate cli --verbs=list,get # read-only CLIgofastr generate cli --verbs='posts=list,get;comments=*'
Selection names match the entity name, table, or kebab command form; a
typo fails generation rather than silently generating everything.
Verbs: list get create update patch delete batch-create batch-update
batch-delete watch. Excluded entities and verbs render nothing — there
is no dead code to strip. The chosen selection is echoed in the
generated main.go header so a later --force regen can reproduce it.
Other flags: --out=cmd/<binary> (target directory), --binary=<name> (command
name; defaults to the project directory, and drives the env-var
prefix), --api-prefix=api (must match your AppConfig.APIPrefix —
it's baked into the client's base URL so customers pass a bare server
URL), --dry-run, --json.
Extending and regenerating
Generation is one-shot owned code: re-running refuses to overwrite and
--force regenerates — except custom.go, which is only ever
created when absent. That file is the extension seam:
customCommands()is merged over the generated command table; an
entry with a generated name ("posts list") replaces that command,
a new name adds one. Wrap rather than replace by calling the
generated run function (runPostsList(...)) from your own.configureClient(c *client.Client)runs before every request-bearing
command — install a customhttp.Transport, default headers, retries.- Custom server endpoints are reachable via the client's raw
Do(ctx, method, path, body, out)escape hatch.
Fields with hidden: true never appear; read_only fields appear as
list filters but not as mutation flags. Image/file fields are excluded
entirely — the generated client doesn't speak multipart yet. A field
whose name collides with a reserved flag (sort, page, json,
url, …) fails generation with the entity and field named: rename the
field or exclude the entity.
Common mistakes
- Mismatched
--api-prefix. The prefix is baked into the CLI at
generate time. If your app setsAppConfig.APIPrefix: "/api"but you
generate with--api-prefix=""(or vice versa), every command 404s.
Match them, and regenerate after changing the app's prefix. - No token middleware on the server. The CLI authenticates with
gfsk_bearer tokens; withoutauth.TokenMiddlewaremounted those
requests arrive anonymous and secure-by-default CRUD returns 401
(CLI exit code 4). Mount it alongsideSessionMiddlewareand give
usersTokensPluginto mint tokens. - Minting scopes without enforcing them.
TokenMiddlewarealone
authenticates the token's owner everywhere; the scope list is only
enforced where you gate it. Mountauth.RequireAPIScopes("/api")
(or per-routeRequireScope) or acustomers:*token silently
carries the user's full capability. - Editing generated files, then regenerating with
--force. Only
custom.gosurvives a forced regen. Put every customization there
(override, wrap, or add commands); treat the other files as
regenerable. - Expecting
--published falseto parse. Bool field flags follow
stdlibflagsemantics: bare--publishedsets true, and the
explicit-value form needs=—--published=false. The space form
treatsfalseas a positional and stops flag parsing.
Sibling: SDKs
gofastr generate sdk is the library twin of this command — a
downloadable Go module and JS/TS client for the same API, hosted by the
app itself behind a live docs site. See sdk.