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
[[]]
= "fz"
= "src/fz_main.rs"
[]
= "0.1"
// venture src/fz_main.rs
Then:
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):
[]
= { = "0.1", = ["postgres"] }
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;--appendadds 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;
--planprints 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,enumandtimestampare allTEXT; 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_apidiffers from thecratefield-coreit linked against (the message names the module, its version and the core crate;Harness::buildalready refuses this, the doctor re-asserts it — issue #17); - a module with
public_writes()runs in aproductionventure without the Captcha port (section 11); - any module migration is not collected yet, a locked file is missing or edited;
- a local dependency crate ships a sqlite migration file no mounted
module declares (issue #870): collect writes only what modules declare,
so the file never ships and its tables never exist on D1 — return it
from the module's
Module::migrations()or drop it; - 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 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=productionrefuses 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.
--filewrites 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
--fileis not overwritten without--force, and both the refusal and the--forcerun 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
--filenor--print-privateis 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:
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:
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:
|
--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)
# production
Locally: wrangler d1 migrations apply <database-name> --local.
MIT. Built in the open for Cratefield, a Factory Zero venture.