cratefield-cli 0.5.0

The fz CLI for Cratefield ventures: migrations collect/apply, doctor, modules, push
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
<p align="center">
  <a href="https://github.com/Cratefield/harness">
    <img src="https://raw.githubusercontent.com/Cratefield/harness/main/assets/banners/cratefield-cli.png" alt="cratefield-cli — The fz binary." width="100%">
  </a>
</p>

<p align="center">
  <a href="https://crates.io/crates/cratefield-cli"><img src="https://img.shields.io/crates/v/cratefield-cli.svg?style=flat-square&labelColor=0A0A0B&color=4C6FFF" alt="cratefield-cli on crates.io"></a>
  <a href="https://docs.rs/cratefield-cli"><img src="https://img.shields.io/docsrs/cratefield-cli?style=flat-square&labelColor=0A0A0B&color=EDEBE6" alt="cratefield-cli documentation"></a>
  <a href="https://github.com/Cratefield/harness/blob/main/LICENSE"><img src="https://img.shields.io/badge/LICENSE-MIT-4C6FFF?style=flat-square&labelColor=0A0A0B" alt="MIT"></a>
</p>

# 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:

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

[dependencies]
cratefield-cli = "0.1"
```

```rust,ignore
// venture src/fz_main.rs
fn main() { cratefield_cli::main_for(my_venture::harness); }
```

Then:

```sh
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):

```toml
[dependencies]
cratefield-cli = { version = "0.1", features = ["postgres"] }
```

```sh
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 —

```json
{"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:

```sh
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` —
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:

```sh
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](../../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:

```sh
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:

```sh
# 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](https://cratefield.com), a [Factory Zero](https://factory0.ventures) venture.