cratefield-cli 0.6.1

The fz CLI for Cratefield ventures: migrations collect/apply, doctor, modules, push
Documentation

cratefield-cli (fz)

The venture CLI: fz migrations collect, fz migrations apply, fz data export / fz data import, fz doctor, fz modules, fz push, fz auth import (issue #650), fz import supabase inspect (issue #658), the TypeScript client generator fz client-ts (issue #155), and the agent-safe manifest workflow — fz plan, fz deploy --plan, fz add, fz init, fz verify (harness #140).

fz links against your venture's compiled-in harness, so it runs as a bin target inside the venture repo — the shape fz build generates for every venture:

# venture Cargo.toml
[[bin]]
name = "fz"
path = "src/fz_main.rs"

[dependencies]
cratefield-cli = "0.1"
// venture src/fz_main.rs
fn main() { cratefield_cli::main_for(my_venture::harness); }

Then:

cargo run --bin fz -- migrations collect
cargo run --bin fz -- doctor
cargo run --bin fz -- modules

fz migrations collect [--dialect sqlite] [--out migrations]

Walks the harness's modules in config order and writes migrations/<GGGG>_<module>_<NNNN>_<name>.sql — the format wrangler d1 migrations apply executes in lexical (apply) order. migrations/.harness-lock.json pins <module>/<migration-id> to the global file name plus a sha256 of its content:

  • locked entries are never renamed or renumbered;
  • new migrations append with the next GGGG;
  • exits non-zero when a locked file is missing or was edited after being applied (restore the file or add a new migration instead).

--dialect postgres for collect is not a thing: collect writes the wrangler/D1 (sqlite) flow. Postgres migrations apply directly — see fz migrations apply below.

fz migrations apply [--dialect postgres] [--fleet|--plan] --url <URL>

Applies the harness's module migrations directly to a Postgres database (issue #18): per module the postgres migration set when shipped, else the sqlite set when it passes the portable-SQL lint, in lock order (the order migrations collect pins), tracked idempotently in harness_migrations(id, applied_at). The native counterpart of wrangler d1 migrations apply.

Requires building fz with the crate's postgres feature (so sqlx and tokio stay out of the default, wasm-safe dependency graph):

[dependencies]
cratefield-cli = { version = "0.1", features = ["postgres"] }
cargo run --bin fz -- migrations apply --dialect postgres \
  --url postgres://user:pass@host:5432/venture

The fleet: --fleet, --plan, --tenant, --strict

Those flags point the same command at a control database — the one holding the tenant registry — instead of a venture database, and reconcile each registered tenant against its own database (docs/RECONCILIATION.md, issue #30). --url therefore names a different database with them than without, which is why nothing here is implicit:

Invocation --url names What it writes
apply --url X the venture database X's harness_migrations
apply --fleet --url C the control database each registered tenant's own database
apply --plan [--tenant T] --url C the control database nothing

--strict turns a degraded tenant into a non-zero exit instead of letting its neighbours serve, and needs --fleet. --plan with --fleet, or --strict without it, is refused rather than guessed at. --fleet against a registry with no tenants in it says so.

fz data export [--plan] --db <PATH> --out <FILE.jsonl> / fz data import [--append] [--plan] --url <URL> <FILE.jsonl>

Moves a venture's D1 data to Postgres (issue #21). Export reads a venture SQLite database — D1 is SQLite; the Cloudflare-side step is wrangler d1 export loaded into a local file (docs/DATA-MOVE.md is the whole runbook) — and writes one JSON Lines file: a manifest line (per-table row counts and sha256, tables in lock order), then one {"table","row"} record per row. --plan prints the per-table summary without writing.

Import (built with the crate's postgres feature, like migrations apply) loads that file into Postgres in lock order:

  • every table's sha256 is verified against the manifest before anything is written — a tampered or truncated file leaves the target untouched;
  • a non-empty table is refused without --append (naming the table and the flag) before any write; --append adds to it, and overlapping primary keys fail loudly rather than duplicating;
  • inserts are batched multi-row statements inside one transaction per table; values bind by the target column's Postgres type (typed NULLs included), and the portable subset is TEXT/INTEGER/REAL/BOOLEAN — anything else fails by name;
  • after the writes, row counts are verified against the manifest;
  • --plan prints the target's state (tables, incoming rows, existing rows, refusal warnings) and writes nothing.

The manifest must be exactly this venture's tables — another venture's export file is refused before anything touches the network.

fz tables diff --from <MANIFEST> [--to venture.json]

What changed between two versions of a manifest's [tables] declaration, and what each change costs — the same expand/contract/rewrite vocabulary fz tables drift speaks.

This is the question before a deploy: "I edited the declaration; can I ship it?" drift is the question after one. Neither answers the other, and no database is involved in this one.

Both manifests are validated before they are compared, because differences against a declaration that could never be deployed are differences nobody can act on — and the reason it could not be deployed is the thing worth saying.

Exits non-zero when anything changed, so a CI job comparing a branch's manifest against main's has something to branch on.

fz tables drift [--manifest venture.json] --dialect postgres --url <URL>

What the database differs from the manifest's [tables] declaration by, in the expand/contract/rewrite vocabulary docs/ROLLBACK.md §5 sets out. Read-only: it opens the database, reads its catalog and prints. There is no --fix, because what a drift costs is the author's decision and three of the four answers are not "apply this".

Exits non-zero when there is drift, so a CI job asking "does this database still match the manifest?" has an answer it can branch on rather than a paragraph it has to read.

Two things it is careful about, both of which would otherwise produce false reports:

  • A database holds tables the declaration never named — the module crates own tables too — and those are not the declaration's to remove.
  • A catalog cannot see most of a declaration. text, uuid, json, enum and timestamp are all TEXT; bounds, formats, enum members and defaults are not recorded at all. The report ends with a line naming exactly what was not compared, because "no drift" is an answer about the things that were.

Needs an fz built with the postgres feature, like fz migrations apply; a D1 database is read through wrangler rather than a connection string.

fz doctor [--out migrations] [--json]

Fails when:

  • a module's harness_api differs from the cratefield-core it linked against (the message names the module, its version and the core crate; Harness::build already refuses this, the doctor re-asserts it — issue #17);
  • a module with public_writes() runs in a production venture without the Captcha port (section 11);
  • any module migration is not collected yet, a locked file is missing or edited;
  • a migration contains non-portable SQL: AUTOINCREMENT, datetime(, SERIAL, NOW(), json_extract, or backtick quoting.

With --json the doctor speaks for agents (harness #140): exactly one JSON object on stdout and nothing else —

{"schema":1,"ok":false,"failures":[{"code":"locked-migration-edited","message":"…"}]}

schema is 1 today so consumers can branch; every failure carries a code from the stable catalogue in cratefield_cli::codes — kebab-case, never renamed or removed, while message wording may change. The exit code still reflects the verdict; prose and operator warnings are unchanged without the flag.

fz plan [--manifest venture.json] [--migrations migrations] [--json]

The agent-safe workflow (harness #140) starts here. Reads the manifest, the migration lockfile and the deploy record, and describes what would change: modules added or removed (with each added module's catalog tier, required modules, and whether a dependency pulled it in), venture config deltas, migrations not yet collected, and the migration files a removal would orphan. It changes nothing on disk.

The JSON carries "schema": 1 and a digest: the sha256 over the normalised destination state (venture identity, env, resolved modules, per-module config, venture config, seed SQL, migration status). The same inputs always give the same digest; any input change gives a different one. The baseline-dependent deltas (added/removed lists) are presentation, deliberately not hashed — an approval is of the destination, not the journey.

fz deploy --plan <digest> [--manifest venture.json] [--i-am-deploying-to-production] [--i-am-removing-modules] [--json]

Applies exactly the approved plan: recomputes it from the current inputs and refuses any other digest — a stale approval cannot deploy (stale-plan), and the refusal names what moved since the recorded deployment. Deploying without --plan at all is refused (deploy-plan-required): approval is the point.

What "applies" means here is deliberate and bounded: fz deploy records the plan, bound to its digest, in .harness-deploy.json beside the manifest (written atomically, so an interrupted deploy leaves either the old record or the new one and re-running reconciles). It never runs wrangler, never compiles, and never touches a database — the printed next steps (fz migrations collect, worker-build, wrangler deploy) are needs-human and need credentials fz does not hold. Running the same approved deploy twice is safe: the second records nothing and reports changed: false.

Two consents, deliberately separate:

  • a venture whose manifest config resolves ENV=production refuses without --i-am-deploying-to-production (production-deploy-unauthorized);
  • a plan that removes modules refuses without --i-am-removing-modules (destructive-change-unauthorized). A removal stops serving those modules and deletes no data: their tables, and the migrations that created them, stay applied with their rows.

fz add <module> [--manifest venture.json] [--json]

Adds a module to the manifest's desired composition and nothing else: it never deploys, never provisions, never touches a database. The slug must be in the catalog (module-unknown otherwise); adding a module the manifest already lists is a no-op that succeeds with changed: false. .toml manifests are rewritten as TOML, others as JSON; comments and formatting are not preserved.

fz init --name <name> --host <host> [--manifest venture.json] [--force] [--json]

Writes a new venture manifest — name and host, no modules yet. Refuses to overwrite an existing manifest unless --force (manifest-exists).

fz verify [--manifest venture.json] [--migrations migrations] [--json]

Checks the recorded deployment still matches the manifest, reporting drift as coded failures in exactly the doctor's JSON shape: the module set (composition-drift), the config (config-drift), the resolved environment (env-drift), and — when nothing narrower explains it — the recorded digest no longer matching a recomputed plan (manifest-drift). No deployment recorded at all is not-deployed.

Every workflow command speaks both disciplines fz doctor --json established: under --json, exactly one JSON object on stdout, nothing on stderr, every failure carrying a stable code from the catalogue; --non-interactive is accepted everywhere (the commands never prompt — anything needing a human is a coded refusal, and the flag suppresses the human next: steps from prose output).

fz push (issue #184)

The operator side of the push transports. It reads the push environment through cratefield-push-wiring — the one build_push that serve() and fz doctor also call — so what fz push reaches is what the deployment reaches, and no second place in the workspace names a push variable (crates/cli-acceptance/tests/push_env_guard.rs fails the build if one does). None of it needs a compiled-in harness, so the standalone fz (and the Docker image) serve it too.

fz push vapid keygen [--file PATH] [--force] [--print-private]

Generates the P-256 key pair Web Push identifies this application server by and prints the public key — base64url of the 65-byte uncompressed point, which is exactly what a browser passes to pushManager.subscribe() as applicationServerKey.

  • --file writes the private key, owner-readable only. Pipe it straight into the secret: wrangler secret put VAPID_PRIVATE_KEY < that file.
  • The private key is never printed without --print-private.
  • An existing --file is not overwritten without --force, and both the refusal and the --force run say what a rotation costs: every existing browser subscription, none of which a server can recreate — each browser has to subscribe again, with the user back on the site.
  • Neither --file nor --print-private is refused: the run would keep no private key, and the public half alone is useless.

A venture generates one of these once and keeps it forever.

fz push send --transport apns|fcm|web-push --recipient … --title … --body …

Builds the venture's adapters from its environment and sends one notification — the live-proof tool (issue #186) and the first thing support runs. The report names the transport, a fingerprint of the recipient (never the token, the endpoint or the auth secret), the transport's wiring verdict, and then the outcome: DELIVERED, FAILED with the provider's reason and what to do about it, or NOT SENT with the reason nothing was routed.

--recipient is the bare device/registration token for apns and fcm, and the browser's subscription JSON for web-push ({"endpoint":…, "keys":{"p256dh":…,"auth":…}}, or the flattened form). Also --data, --url, --ttl, --priority and --silent.

--dry-run reports which transport would carry the send and why, without sending — it builds no HTTP client at all, so it cannot reach the network.

The send itself needs the crate's push-send feature, which pulls the native runtime's HTTP client (reqwest behind the outbound policy) and the tokio runtime it needs. That is a separately installed binary, never a feature flipped on the venture's own dependency:

cargo install cratefield-cli --features push-send   # a send-capable `fz`

fz push needs no compiled-in harness, so that binary — and the Docker image, which is built with the feature — sends against any venture's environment. Adding features = ["push-send"] to a venture's own cratefield-cli dependency would instead pull cratefield-runtime-native into the crate that also builds a wasm cdylib, and that crate compile_error!s on wasm32: the venture would stop building for the target it deploys to.

Without the feature every other fz push command still works, --dry-run included, and send says exactly that in one line. That outbound policy refuses loopback, private and link-local destinations, so a self-hosted push service on a private network is refused by the client, not by the protocol.

fz push inspect-subscription <json>

Validates a Web Push subscription with the adapter's own rules and prints the aud it will sign for it — the push service's origin, never the subscription's path, because a path-bearing aud is the commonest cause of a VAPID 401. Catches an endpoint that is not an absolute http(s) URL, a p256dh that is not a 65-byte uncompressed P-256 point on the curve, and an auth that is not 16 bytes.

fz auth import --target <URL> --admin-token-env <VAR> [--apply] [--merge-by-email] [--report <PATH>] [--batch-size N] <FILE.jsonl> (issue #650)

Loads users into a running venture's auth-core module over its admin API: one JSON object per line — external_provider, external_id, email, email_verified, and optionally password_hash, created_at, locale, identities ([{provider, subject}], the OIDC links to record) — blank lines skipped. The objects are passed through unchanged and sent to POST <target>/v1/auth-core/admin/users/import in batches of --batch-size (default 500, max 1000), behind Authorization: Bearer <ADMIN_TOKEN>: the token is read from the environment variable --admin-token-env names, never printed, and never a flag.

A dry run by default: the server validates every user and reports a verdict without writing, and only --apply writes. A line that is not a JSON object, or that misses a required field, and a duplicate (external_provider, external_id) in the file are local errors reported with their line number — the command aborts before sending anything.

It writes a report JSONL (default <FILE>.report.jsonl, or --report) with one {"external_provider","external_id","status","sub","reason"} line per user — never an email or a password hash — and prints a count per status. A batch that cannot be sent, or a non-2xx reply, exits non-zero; a completed run that reports conflict or invalid still exits zero, those being answers to act on rather than transport failures.

Like fz push send, the network leg is behind an opt-in feature — a send-capable fz is installed with cargo install cratefield-cli --features auth-import — and without it the command says exactly that, because the default fz graph carries no HTTP client and the feature pulls cratefield-runtime-native, which compile_error!s on wasm32. fz auth import needs no compiled-in harness, so the standalone fz runs it.

fz import supabase inspect --project <ref> [--json|--md] [--out FILE] (issue #658)

Step one of moving a Supabase project onto the harness (ADR 0026): connects read-only to the project's Postgres — and, with a token, the Supabase Management API — and writes the migration report, every item automatic, needs work or a blocker, with sizes and a transfer estimate. Markdown by default, --json for the later steps and the dashboard. The database URL comes from SUPABASE_DB_URL (or --db-url), the token from SUPABASE_ACCESS_TOKEN (or --management-token); fz never prompts, never prints them, and the report names the database by host and database only. --classify asks the TypeSafe classifier (TYPESAFE_API_KEY) to label the RLS policies no rule placed, advisory only. The exit code is non-zero for a refused input, a connection or a permission error — never for a blocker.

The engine reads Postgres through sqlx, so it is behind a feature, for the reason push-send is:

cargo install cratefield-cli --features import-supabase

Without it the command parses and says what to install. The whole guide — the read-only role to create, how to read the report — is docs/import/supabase.md.

fz build-key <manifest> [--catalog PATH] (issue #59)

Computes the artifact's content address — sha256 over the pinned module releases (slug, exact version, digest), the harness API, the rustc version and the build profile — and prints it with the canonical inputs that produced it. Runs no cargo, writes nothing. Module order in the manifest does not affect the key; any module version, harness_api, rustc or profile change does. The venture name, host, config and the sidecar mount table are deployment configuration, not artifact content: two customers on the same module set share the key and the artifact while keeping separate Workers, databases and secrets, and one wasm serves customers whose HARNESS_SIDECARS differ. See docs/ARTIFACT-CACHE.md for the guarantee.

fz client-ts --surface <PATH|-> --out <DIR> [--json] (issue #155)

Generates the typed TypeScript client for a venture: a /__surface contract document in, the files of a deterministic @cratefield/client npm package out, written under --out. The document is what the running venture serves — a path, or - to read it off stdin — so the client can be generated without checking the venture out at all:

curl -s https://venture.example/__surface | fz client-ts --surface - --out ./client

--out is written over, not cleaned: the six generated files are replaced in place and everything else already there is left alone — including older files under src/, which tsconfig.json's include: ["src"] will then type-check and package.json's files will ship. The composition hash covers only the six written files, so generate into a directory that holds nothing else.

Generation is deterministic: the same document generates byte-identical files, and the summary prints the package name, the sha256 composition hash over them and the file list, so a regenerated client can be checked against a published one before it ships. --json prints the same verdict as exactly one JSON object on stdout — schema, ok, and either the package, the hash and the files written or failures with stable codes — with the exit code still reflecting the verdict. Like fz build and the manifest workflow this command needs no venture harness, so the standalone fz runs it.

fz modules

Prints name version /v1/<name> emits=[…] tables=[…] per module.

Applying migrations (wrangler)

Staging and production run the same commands from the venture repo:

# staging (first time / new migrations)
wrangler d1 migrations apply <database-name> --remote --env staging

# production
wrangler d1 migrations apply <database-name> --remote --env production

Locally: wrangler d1 migrations apply <database-name> --local.


MIT. Built in the open for Cratefield, a Factory Zero venture.