cratestack-cli
Command-line tool for .cstack schema validation and client/Studio code generation.
Installation
Prebuilt binaries (macOS x64/arm64, Linux x64/arm64, Windows x64) are attached to every GitHub Release — no Rust toolchain required.
Via cargo-binstall:
Via npm (downloads the matching platform binary from GitHub Releases on install):
# or run without installing:
From source, with a Rust toolchain:
Or from the workspace:
Commands
check — validate a schema
Flags:
--schema <PATH>— path to the.cstackfile (required)--format <human|json>— output format (defaulthuman)
On success the human formatter writes schema OK: <path>; the JSON formatter prints a { ok: true, ... } document. On error the human formatter renders a diagnostic and exits non-zero; the JSON formatter prints { ok: false, diagnostics: [...] } and exits 1.
generate-dart — Dart package
Flags:
--schema <PATH>(required)--out <PATH>(required)--library-name <NAME>(defaultcratestack_client)--base-path <PATH>(default/api)--template-dir <PATH>(optional)--check(drift-detection mode — see below)--preset <default|riverpod>(defaultdefault) —defaultis today's monolithiclib/src/models.dart/lib/src/apis.dartlayout.riverpodemits one file per model underlib/src/models/, a shared file for cross-model types, procedures in their own file, and package-wide DI providers inlib/src/client.dart.--run-build-runner— after generation, shell out todart run build_runner build --delete-conflicting-outputsin--out. Every preset needs this now (issue #668 phase 2/3): every generated data class carries a@CratestackBuilder(...)annotation thatpackage:cratestack_builderexpands into a{Class}Builder;--preset riverpodadditionally needs the step for its own@riverpodannotations. The generated Dart doesn't compile/analyze untilbuild_runnerruns. Off by default. No effect together with--check. Requires a Dart SDK onPATH.
generate-typescript (alias generate-ts)
Flags:
-
--schema <PATH>(required) -
--out <PATH>(required) -
--package-name <NAME>(defaultcratestack-client) -
--base-path <PATH>(default/api) -
--template-dir <PATH>(optional) -
--check(drift-detection mode — see below) -
--full-selection(emit fully-required model interfaces, driven by the schema's own nullability, instead of the projection-driven optional-everywhere default — for consumers that never do partialfields/includeselection) -
--swr— additionally emit the file-per-model + SWR-hooks layout undersrc/swr/: onesrc/swr/models/<model>.tsper model (types + plain framework-free async functions) plus a sibling<model>.hooks.tsofuseSWR/useSWRMutationhooks, and asrc/swr/procedures.ts(+.hooks.ts) for procedures — reachable from a consumer as<package-name>/swr(plus/swr/models/*,/swr/procedures,/swr/procedures.hooks) via apackage.jsonexportssubpath. Purely additive: the default layout atsrc/is always emitted regardless of this flag,--swraddssrc/swr/alongside it rather than replacing it (issue #591 — this used to be the mutually-exclusive--preset <default|swr>; running the generator twice into two directories for both layouts is no longer necessary). -
--refine— additionally emitsrc/refine.ts, the@cratestack/refineresource manifest for this schema: one entry per model carrying its@idfield name,@@pagedflag, and@versionfield, bound to the matching generated model API. Purely additive — every other emitted file is byte-identical with and without it — and it also adds@cratestack/refine/@refinedev/coreto the generatedpackage.json's peer/dev dependencies. The emitted manifest is typedResourceMapfor REST andRpcResourceMapfor RPC, matching whichever@cratestack/refineprovider that transport ships. Composes freely with--swr: the manifest binds to the default layout's client class, which is always emitted regardless of--swr. -
--tanstack— additionally emitsrc/react-query.ts, TanStack Query (useQuery/useMutation) hooks over the default layout's client class, re-exported fromsrc/index.ts, and add@tanstack/react-queryto the generatedpackage.json's peer/dev dependencies. Before this flag existed (issue #617), all three were emitted unconditionally, for every schema and every transport. Purely additive — every other emitted file is byte-identical with and without it. Unlike--refine, this composes with EVERY transport:--tanstackgates the samesrc/react-query.tsthat used to be unconditional there too, it doesn't add support for a transport that lacked it before. Composes freely with--swr/--refine. -
--no-native-cbor— fall back to the pure-TypeScriptjsonRpcCodecinstead of the published@cratestack/cborpackage (napi-rs on Node, wasm-bindgen in the browser) as the generated RPC runtime's default codec (issue #746). No effect on a REST-transport schema —rest-runtime.ts.j2has no codec seam at all, so REST output never depends on this flag.@cratestack/cbor-node's napi target matrix coversx86_64/aarch64on macOS, glibc Linux and musl Linux (Alpine, since cratestack#850) plusx86_64-pc-windows-msvc—win32-arm64is the one remaining gap. There the napi loader fails with a generic "Cannot find native binding…" error rather than naming the real cause; pass--no-native-cboron that target to fall back tojsonRpcCodec, which has no native dependency and works everywhere. Purely additive: with an RPC-transport schema, every other emitted file is byte-identical with and without it; with a REST-transport schema, output is byte-identical regardless of this flag.Known bug with
--swr(issue #765): on an RPC-transport schema,--swr'ssrc/swr/runtime.tsignores this flag entirely and always emitsjsonRpcCodec, while the default layout'ssrc/runtime.tsstill honours it — so--swr --no-native-cbor(and even the plain default) ships one package with two runtimes speaking different codecs. Not intended; REST--swris unaffected since REST has no codec seam.
--check — drift detection (CI guard)
Both generate-dart and generate-typescript accept --check: instead of writing
to --out, the command generates in memory and diffs the result file-by-file
against what's already on disk. It exits 0 if they match, and non-zero with a
list of drifted files (modified, missing, or unexpected) otherwise. No files
under --out are written or modified in --check mode.
Use this in CI to catch a schema change that nobody regenerated the client for, or a hand-edit to committed generated code.
generate-wiremock — WireMock stub mappings
Generates WireMock stub mappings straight from the schema's model/
procedure declarations, so integration/e2e tests can run against a mock
backend whose wire contract can't drift from the real one without
regenerating. transport rest model CRUD is stateful (a create is visible
on a later list/get, a delete 404s) but needs more than a plain WireMock —
see cratestack-mock-wiremock's crate docs, its README.md, and
docs/design/wiremock-stubs.md for what's covered and what running the
stateful stubs costs.
Flags:
--schema <PATH>(required)--out <PATH>(required)--base-path <PREFIX>(default/api) — prepended to every stub'surlPath, matching the same-named flag ongenerate-dart/generate-typescript; must agree with whatever prefix the deployed server (and any generated client tested against this mock) use.--check(drift-detection mode, same semantics asgenerate-dart/generate-typescriptabove)
studio — admin and testing surface
Replaces the old generate-studio codegen scaffold. The studio reads a
workspace file (studio.toml) listing one or more .cstack schemas plus
their DB and/or API targets, then serves a single binary.
Subcommand flags:
init:--out <DIR>(default.),--forceto overwrite an existingstudio.tomlrun:--config <PATH>(defaultstudio.toml),--bind <ADDR>(default127.0.0.1:7878)eject:--out <DIR>(required),--name <NAME>(project name written into the generatedCargo.toml/README.md; defaults to--out's directory basename),--force(overwrite files in a non-empty--out),--with-ui(also unpack the Leptos+Trunk UI sources into<out>/ui/for front-end customization)
eject produces a self-contained Cargo binary crate (Cargo.toml,
README.md, studio.toml, schemas/example.cstack, src/main.rs) that
embeds the studio against your own schemas.
migrate diff — generate a migration
Diffs the current .cstack against the committed snapshot of the
previously-generated schema and writes SQL migrations under
<out-dir>/<backend>/<timestamp>_<name>/.
Flags:
--schema <PATH>(required)--out-dir <DIR>(defaultmigrations)--backend <postgres|sqlite|both>(defaultboth)--name <SLUG>(defaultmigration)--allow-destructive— required to emit a migration containing lossy ops (DropColumn,DropTable, narrowing type changes); without it the command refuses to write a destructive migration
See cratestack-migrate's README for the full IR/emitter design.
migrate baseline — adopt an existing database
Points migrate diff at a database that already has tables — hand-created,
from a prior tool, or from a previous internal migration system — instead
of the empty schema migrate diff otherwise assumes when no snapshot
exists yet. Introspects --database-url, diffs the live shape against
--schema for a drift report (grouped by table, each change tagged
safe/lossy/blocking), writes the snapshot from the introspected
shape (not from --schema — see below), and records a synthetic row in
cratestack_migrations so the runtime applier (cratestack-sqlx) and the
authoring side agree about what's already there.
Drift is reported, not resolved, and does not fail the command by
default — matching the adoption use case, where the live database rarely
matches the schema byte-for-byte on day one. Pass --strict to flip that:
exit non-zero on any drift, with no snapshot written and no row recorded,
for teams that want baselining to double as a "prove the schema already
matches" CI gate instead of an adoption tool.
Because the snapshot is written from what was actually introspected, a
database with drift bakes that drift into the snapshot as "already true" —
a later migrate diff will then propose the DDL to reconcile it, rather
than silently treating undeclared drift as permanent. Refuses to run
(non-zero exit, no writes, no DB round-trip) if a snapshot already exists
at <out-dir>/postgres/schema.snapshot.json — baselining an
already-managed backend is almost certainly a mistake.
Flags:
--schema <PATH>(required)--database-url <URL>(required) — the live Postgres database to introspect and to record the baseline row into--out-dir <DIR>(defaultmigrations)--backend <postgres>(default, and currently the only accepted value — baseline is Postgres-only for now)--strict— fail instead of reporting drift
Postgres-only for v1; no --backend sqlite/both. See
docs/design/migrate-baseline.md for the full design.
diff — schema-change detector
Diffs two .cstack schemas and classifies each change by its effect on
the generated wire contract (breaking / additive / internal-only). Exits
non-zero if any breaking change is found, so it can gate CI on schema PRs.
Flags:
old— path to the baseline schema (positional, required)new— path to the candidate schema (positional, required)--json— emit machine-readable JSON instead of the human report
print-ir — dump parsed schema IR
contract — per-op contract digests and the compatible-contract lock
| | | )
digest prints one SHA-256 per op (keyed by the RPC op_id, or
"<METHOD> <route>" on REST) plus the client contract digest. An op's digest
moves only when that op's wire shape moves: policies, indexes, SQL bodies,
validators, the auth block, @server_only fields, other ops and new
declarations leave it alone. print shows the canonical JSON the digest is
taken over, for "why did this op's digest move". --op takes the same key
digest prints; a miss lists the known keys. Both are read-only.
A server can keep accepting the contract an installed client was built
against, while it stays wire-compatible with the current one: pass
contracts = "catalog.contracts.lock" to include_server_schema!, and the
generated ACCEPTED_CONTRACTS lists, per op, the current digest then the
locked ones (newest first). lock records the current contracts as a
generation (creating the file; idempotent; run it before shipping a client
build; it refuses while the current contract breaks a locked one, and --date
is a real YYYY-MM-DD date, today in UTC when omitted). check is the CI
gate: exit 1 if the current contract is not a locked generation, or breaks a
locked one (the op and the reason are printed; --json gives {ok, client_contract, locked, incompatible: [{op, digest, reasons}]}). Exit codes
for the whole group: 0 ok, 1 a failed verdict, 2 a tool error (an unreadable
schema or lock, a bad date or flag value); with --json, check prints one
JSON document on every path, a tool error being {ok: false, error, client_contract, locked: false, incompatible: []} (client_contract is null
when the schema did not parse). An incompatible locked entry is also
a compile error in the server crate. prune drops history: --op stops
accepting older clients of one op (the deliberate way to ship a breaking
change to it: those clients get the unsigned 426 for that op only),
--before (a real date, compared as a date) / --keep / --generation drop
whole generations; a stored
contract no generation references any more goes with them. A missing or
hand-edited lock is an error, never a pass (cratestack#1123).
Build Integration
See Also
- Quickstart
cratestack-client-dart— Dart package structurecratestack-client-typescript— TypeScript package structurecratestack-studio— Studio server +ejectscaffold implementationcratestack-migrate— schema diff / migration generator behindmigrate diff
License
MIT