cratefield_cli/lib.rs
1//! `fz` — the Factory Zero venture CLI (issue #8): `fz migrations
2//! collect`, `fz migrations apply` (issue #18), `fz doctor`, `fz
3//! modules`, `fz push` (issue #184), and the agent-safe workflow over
4//! the venture manifest (harness #140): `fz plan`, `fz deploy --plan`,
5//! `fz add`, `fz init`, `fz verify`.
6//!
7//! `fz` is linked into the venture as a bin target so it can see the
8//! compiled-in harness. The pattern `fz build` generates:
9//!
10//! ```text
11//! // src/lib.rs
12//! pub use harness::harness;
13//! // Cargo.toml
14//! [[bin]]
15//! name = "fz"
16//! path = "src/fz_main.rs"
17//! // src/fz_main.rs
18//! fn main() { cratefield_cli::main_for(venture::harness); }
19//! ```
20//!
21//! Then `cargo run -p venture --bin fz -- migrations collect`.
22
23#![doc = include_str!("../README.md")]
24#![forbid(unsafe_code)]
25
26pub mod apply;
27pub mod auth_import;
28pub mod build;
29pub mod build_key;
30pub mod client_ts;
31pub mod codes;
32mod collect;
33pub mod data;
34pub mod doctor;
35pub mod import;
36pub mod import_users;
37pub mod lint;
38mod lock;
39pub mod push;
40pub mod sidecars;
41pub mod tables;
42pub mod workflow;
43
44use clap::{Args, Parser, Subcommand};
45use cratefield_core::{Config, Harness};
46use std::path::PathBuf;
47use std::process::ExitCode;
48
49/// The process environment as a [`Config`], so `fz` reads a variable the
50/// same way a deployed runtime does (issue #143): the doctor for `ENV`, and
51/// `fz push` for the push transports' secrets — which it reads only through
52/// `cratefield_push_wiring`, never by name.
53pub(crate) struct EnvVars;
54
55impl Config for EnvVars {
56 fn get(&self, key: &str) -> Option<String> {
57 std::env::var(key).ok().filter(|value| !value.is_empty())
58 }
59}
60
61#[derive(Parser)]
62#[command(name = "fz", about = "Factory Zero venture CLI")]
63struct Cli {
64 /// The sidecar mount table, as the runtime reads it from config:
65 /// `{"module-name":"BINDING"}`. Defaults to the `HARNESS_SIDECARS`
66 /// environment variable. Commands that walk the compiled-in modules
67 /// use it to say what they cannot see (issue #66).
68 #[arg(long, global = true, value_name = "JSON")]
69 sidecars: Option<String>,
70 #[command(subcommand)]
71 command: Command,
72}
73
74#[derive(Subcommand)]
75enum Command {
76 /// Writes wrangler-compatible migration files and maintains the
77 /// `.harness-lock.json` pinning module migrations to file names.
78 Migrations {
79 #[command(subcommand)]
80 command: MigrationsCommand,
81 },
82 /// Validates the harness, the production-captcha rule, the lockfile
83 /// and the portable-SQL lint.
84 Doctor {
85 /// Migration directory (default `migrations`).
86 #[arg(long, default_value = "migrations")]
87 out: PathBuf,
88 /// Accept a production venture without a Captcha port for the
89 /// stated reason; prints a warning instead of failing.
90 #[arg(long, value_name = "REASON")]
91 allow_no_captcha: Option<String>,
92 /// Prints exactly one JSON object to stdout — `schema`, `ok` and
93 /// the failures with their stable codes (`cratefield_cli::codes`)
94 /// — instead of prose. The exit code still reflects the verdict.
95 #[arg(long)]
96 json: bool,
97 },
98 /// Prints modules, versions, route prefixes, emitted events, tables.
99 Modules,
100 /// Generates a deterministic Cloudflare venture crate from a venture
101 /// manifest (issue #138): resolve the module set, write `Cargo.toml`,
102 /// `src/lib.rs`, `src/fz_main.rs`, `wrangler.toml`. Standalone — it
103 /// needs no compiled-in harness because it produces one. The final
104 /// `worker-build` + `wrangler deploy` are printed as the next steps.
105 Build {
106 /// The venture manifest (`.json` or `.toml`).
107 #[arg(value_name = "MANIFEST")]
108 manifest: PathBuf,
109 /// Output directory for the generated venture crate.
110 #[arg(long, default_value = "dist")]
111 out: PathBuf,
112 /// Point the generated crate at a local harness checkout (path
113 /// deps) instead of published versions — for offline / in-repo
114 /// builds, as the Docker image does.
115 #[arg(long, value_name = "PATH")]
116 harness_path: Option<String>,
117 /// A stamped catalog JSON instead of the built-in one (issue
118 /// #142): the build service points this at the release-stamped
119 /// catalog whose digests are real content addresses.
120 #[arg(long, value_name = "PATH")]
121 catalog: Option<PathBuf>,
122 /// RFC 3339 timestamp recorded in provenance (issue #142). The
123 /// CLI owns no clock, so a build without this (or
124 /// `FZ_BUILD_TIMESTAMP`) records `built-at: null` rather than
125 /// inventing one.
126 #[arg(long, value_name = "RFC3339")]
127 built_at: Option<String>,
128 /// Builder identity recorded in provenance (issue #142);
129 /// `FZ_BUILDER` is the fallback, then `"local"`.
130 #[arg(long, value_name = "WHO")]
131 builder: Option<String>,
132 },
133 /// Computes the artifact's content address (issue #59): sha256 over
134 /// the pinned module releases, the harness API, the rustc version
135 /// and the build profile. Prints the key and the inputs that
136 /// produced it; runs no cargo, writes nothing. The venture name,
137 /// host, config and the sidecar mount table are deployment
138 /// configuration, not artifact content, so they are not inputs —
139 /// two customers on the same module set share the artifact and the
140 /// same key. Standalone, like `fz build`.
141 BuildKey {
142 /// The venture manifest (`.json` or `.toml`).
143 #[arg(value_name = "MANIFEST")]
144 manifest: PathBuf,
145 /// A stamped catalog JSON instead of the built-in one — the
146 /// same catalog the matching `fz build --catalog` would use.
147 #[arg(long, value_name = "PATH")]
148 catalog: Option<PathBuf>,
149 },
150 /// Generates the typed TypeScript client for a venture (issue #155):
151 /// a `/__surface` contract document in, the files of a deterministic
152 /// `@cratefield/client` package out, written to `--out`. Standalone —
153 /// it reads the contract the venture serves, not a compiled-in
154 /// venture, so the standalone `fz` runs it too.
155 ClientTs {
156 /// The `/__surface` document to read: a path, or `-` for stdin
157 /// (`curl -s https://venture.example/__surface | fz client-ts
158 /// --surface - --out ./client`).
159 #[arg(long, value_name = "PATH")]
160 surface: String,
161 /// Output directory for the generated client package.
162 #[arg(long, value_name = "DIR")]
163 out: PathBuf,
164 /// Prints exactly one JSON object to stdout — `schema`, `ok`, the
165 /// package, the composition hash and the files written — instead
166 /// of prose. The exit code still reflects the verdict.
167 #[arg(long)]
168 json: bool,
169 },
170 /// Moves venture data between engines (issue #21): export D1/SQLite
171 /// data to JSONL with a manifest, import into Postgres.
172 Data {
173 #[command(subcommand)]
174 command: DataCommand,
175 },
176 /// Declared tables (issue #153).
177 Tables {
178 #[command(subcommand)]
179 command: TablesCommand,
180 },
181 /// Describes what the manifest would change — modules added or
182 /// removed, the capabilities they require, config changes and
183 /// migrations not yet collected — and prints a `digest` that
184 /// approves exactly that plan. Read-only (harness #140).
185 Plan {
186 /// The venture manifest (`.json` or `.toml`).
187 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
188 manifest: PathBuf,
189 /// Migration directory holding `.harness-lock.json`.
190 #[arg(long, default_value = "migrations")]
191 migrations: PathBuf,
192 /// Prints exactly one JSON object to stdout — `schema`, `ok`,
193 /// `failures`, the plan `digest` and its content — instead of
194 /// prose. Nothing else reaches stdout or stderr.
195 #[arg(long)]
196 json: bool,
197 /// Never prompts (the workflow commands never do; anything
198 /// needing a human is a coded refusal) and suppresses the
199 /// human `next:` steps from the prose output.
200 #[arg(long)]
201 non_interactive: bool,
202 },
203 /// Applies exactly the approved plan: recomputes it from the
204 /// current inputs and refuses any other digest. Records the plan in
205 /// `.harness-deploy.json` beside the manifest — compiling,
206 /// standing up the Worker and applying migrations stay with the
207 /// needs-human steps it prints. A production venture needs
208 /// `--i-am-deploying-to-production` as a second, separate consent
209 /// (harness #140); a plan that removes modules needs
210 /// `--i-am-removing-modules`, a consent separate from that one.
211 Deploy {
212 /// The digest `fz plan --json` printed — the approval token.
213 #[arg(long, value_name = "DIGEST")]
214 plan: Option<String>,
215 /// The venture manifest (`.json` or `.toml`).
216 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
217 manifest: PathBuf,
218 /// Migration directory holding `.harness-lock.json`.
219 #[arg(long, default_value = "migrations")]
220 migrations: PathBuf,
221 /// The second consent: required when the venture resolves to
222 /// production.
223 #[arg(long)]
224 i_am_deploying_to_production: bool,
225 /// Required when the plan drops modules from the served
226 /// composition. Separate from the production flag on purpose: a
227 /// removal in development is not a production deploy, and one
228 /// flag for both would teach an operator to pass the production
229 /// one by habit.
230 #[arg(long)]
231 i_am_removing_modules: bool,
232 /// Prints exactly one JSON object to stdout instead of prose.
233 #[arg(long)]
234 json: bool,
235 /// Never prompts; suppresses the human `next:` steps.
236 #[arg(long)]
237 non_interactive: bool,
238 },
239 /// Adds a module to the manifest's desired composition and nothing
240 /// else — never deploys, never touches a database (harness #140).
241 /// Adding a module already present is a no-op that succeeds.
242 Add {
243 /// The module slug, as the catalog names it.
244 #[arg(value_name = "MODULE")]
245 module: String,
246 /// The venture manifest (`.json` or `.toml`).
247 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
248 manifest: PathBuf,
249 /// Prints exactly one JSON object to stdout instead of prose.
250 #[arg(long)]
251 json: bool,
252 /// Never prompts; suppresses the human `next:` steps.
253 #[arg(long)]
254 non_interactive: bool,
255 },
256 /// Writes a new venture manifest — name and host, no modules.
257 /// Refuses to overwrite an existing one unless `--force`
258 /// (harness #140).
259 Init {
260 /// The venture name (becomes the generated crate name).
261 #[arg(long, value_name = "NAME")]
262 name: String,
263 /// The primary host the backend answers on.
264 #[arg(long, value_name = "HOST")]
265 host: String,
266 /// Where to write the manifest.
267 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
268 manifest: PathBuf,
269 /// Replace an existing manifest instead of refusing.
270 #[arg(long)]
271 force: bool,
272 /// Prints exactly one JSON object to stdout instead of prose.
273 #[arg(long)]
274 json: bool,
275 /// Never prompts; suppresses the human `next:` steps.
276 #[arg(long)]
277 non_interactive: bool,
278 },
279 /// Checks the recorded deployment still matches the manifest,
280 /// reporting drift as coded failures in the doctor's JSON shape
281 /// (harness #140).
282 Verify {
283 /// The venture manifest (`.json` or `.toml`).
284 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
285 manifest: PathBuf,
286 /// Migration directory holding `.harness-lock.json`.
287 #[arg(long, default_value = "migrations")]
288 migrations: PathBuf,
289 /// Prints exactly one JSON object — the doctor's
290 /// `{schema, ok, failures}` — instead of prose.
291 #[arg(long)]
292 json: bool,
293 /// Never prompts; verify never does anyway.
294 #[arg(long)]
295 non_interactive: bool,
296 },
297 /// The push transports (issue #184): generate the VAPID key pair,
298 /// send one notification through the adapters this venture's
299 /// environment configures, inspect a Web Push subscription.
300 ///
301 /// Needs no compiled-in harness: push is configured by the
302 /// environment, so these run from the standalone `fz` too.
303 Push {
304 #[command(subcommand)]
305 command: PushCommand,
306 },
307 /// Loads users into a running venture's `auth-core` module over its
308 /// admin API (issue #650). Needs no compiled-in harness: it talks to
309 /// a URL, so the standalone `fz` runs it.
310 Auth {
311 #[command(subcommand)]
312 command: AuthCommand,
313 },
314 /// Moves a project from another platform onto the harness (ADR 0026).
315 /// Supabase first; step one is a read-only inspection and its report.
316 ///
317 /// Needs no compiled-in harness, so it runs from the standalone `fz`
318 /// too. The network leg needs the `import-supabase` feature.
319 Import {
320 #[command(subcommand)]
321 command: ImportCommand,
322 },
323}
324
325#[derive(Subcommand)]
326enum AuthCommand {
327 /// Imports a JSONL file of users into `auth-core` — one JSON object
328 /// per line, blank lines skipped — through
329 /// `POST <target>/v1/auth-core/admin/users/import`.
330 ///
331 /// A dry run by default: the server validates every user and reports
332 /// a verdict without writing. `--apply` is the run that writes.
333 Import {
334 /// The venture's base URL, e.g. `https://venture.example`.
335 #[arg(long, value_name = "URL")]
336 target: String,
337 /// The environment variable holding the `ADMIN_TOKEN` to present.
338 /// Required; the value is never printed.
339 #[arg(long, value_name = "VAR")]
340 admin_token_env: String,
341 /// Actually write. Without it the run is a dry run.
342 #[arg(long)]
343 apply: bool,
344 /// Let the server merge a user into an existing account that
345 /// shares the email, instead of reporting a conflict.
346 #[arg(long)]
347 merge_by_email: bool,
348 /// Where the report JSONL goes (default `<file>.report.jsonl`).
349 #[arg(long, value_name = "PATH")]
350 report: Option<PathBuf>,
351 /// Users per request (default 500, max 1000).
352 #[arg(long, default_value_t = auth_import::DEFAULT_BATCH_SIZE, value_name = "N")]
353 batch_size: usize,
354 /// The JSONL file of user objects.
355 #[arg(value_name = "FILE")]
356 file: PathBuf,
357 },
358}
359
360#[derive(Subcommand)]
361enum ImportCommand {
362 /// A Supabase project (ADR 0026, issues #658, #728). With no
363 /// subcommand it inspects the source, reads the target and writes the
364 /// report and the plan; `inspect` writes the report alone.
365 #[command(args_conflicts_with_subcommands = true)]
366 Supabase {
367 #[command(subcommand)]
368 command: Option<SupabaseCommand>,
369 #[command(flatten)]
370 run: SupabaseRunArgs,
371 },
372}
373
374/// The source and target the bare `fz import supabase` (and `plan`) runs
375/// against, shared by both. `project` is `Option` so a subcommand can
376/// replace the run; the run refuses without it.
377#[derive(Args)]
378struct SupabaseRunArgs {
379 /// The Supabase project ref (the `<ref>` in
380 /// `https://<ref>.supabase.co`).
381 #[arg(long, value_name = "REF")]
382 project: Option<String>,
383 /// The source database URL of a read-only role. Prefer the
384 /// `SUPABASE_DB_URL` environment variable: a flag lands in the shell
385 /// history and the process list.
386 #[arg(long, value_name = "URL")]
387 db_url: Option<String>,
388 /// A Supabase Management API personal access token. Prefer
389 /// `SUPABASE_ACCESS_TOKEN`.
390 #[arg(long, value_name = "TOKEN")]
391 management_token: Option<String>,
392 /// The name of the environment variable holding the target Postgres
393 /// URL. Unset, the plan carries a `target_not_checked` blocker rather
394 /// than failing.
395 #[arg(long, value_name = "VAR", default_value = import::DEFAULT_TARGET_ENV)]
396 target_env: String,
397 /// The directory the run writes `report.json`, `report.md` and
398 /// `plan.json` to.
399 #[arg(long, value_name = "DIR", default_value = ".")]
400 dir: PathBuf,
401 /// The throughput the transfer estimate assumes, in Mbit/s.
402 #[arg(long, default_value_t = 100, value_name = "MBPS")]
403 transfer_mbps: u32,
404 /// Ask the `TypeSafe` classifier (Jev; `TYPESAFE_API_KEY`) about the
405 /// RLS policies no rule placed. Advisory only.
406 #[arg(long)]
407 classify: bool,
408 /// The confidence a classifier label needs; below it the policy stays
409 /// `needs_review`.
410 #[arg(long, default_value_t = 0.8, value_name = "0..1")]
411 classify_threshold: f32,
412 /// Apply an approved plan instead of writing a new one. Needs `--plan`.
413 #[arg(long, requires = "plan")]
414 apply: bool,
415 /// The plan file to apply. Needs `--apply`.
416 #[arg(long, value_name = "FILE", requires = "apply")]
417 plan: Option<PathBuf>,
418}
419
420#[derive(Subcommand)]
421enum SupabaseCommand {
422 /// Connects read-only to the project's Postgres (and, with a token,
423 /// the Management API) and writes the migration report: every item
424 /// automatic, needs work or a blocker, with size and transfer
425 /// estimates. Never writes to the project. The exit code is non-zero
426 /// for a refused input, a connection or a permission error — never
427 /// for a blocker, which is part of the report.
428 Inspect {
429 #[command(flatten)]
430 source: SourceArgs,
431 /// Write the JSON report (the format later steps read).
432 #[arg(long, conflicts_with = "md")]
433 json: bool,
434 /// Write the Markdown report (the default).
435 #[arg(long)]
436 md: bool,
437 /// Write the report to this file instead of stdout.
438 #[arg(long, value_name = "FILE")]
439 out: Option<PathBuf>,
440 /// The throughput the transfer estimate assumes, in Mbit/s.
441 #[arg(long, default_value_t = 100, value_name = "MBPS")]
442 transfer_mbps: u32,
443 /// Ask the `TypeSafe` classifier (Jev; `TYPESAFE_API_KEY`) about the
444 /// RLS policies no rule placed. Advisory only: it labels a
445 /// policy, it never writes a check. Sends policy SQL and table and
446 /// column names, never a row.
447 #[arg(long)]
448 classify: bool,
449 /// The confidence a classifier label needs; below it the policy
450 /// stays `needs_review`. Per adapter: it is `TypeSafe`'s number.
451 #[arg(long, default_value_t = 0.8, value_name = "0..1")]
452 classify_threshold: f32,
453 /// Apply this dispositions file: give each needs-work and blocker
454 /// item its own `covered` or `waived` entry (ADR 0026, Decision
455 /// 5). A refused file is a non-zero exit naming the entry.
456 #[arg(long, value_name = "FILE")]
457 dispositions: Option<PathBuf>,
458 },
459 /// The dispositions file (ADR 0026, Decision 5).
460 Dispositions {
461 #[command(subcommand)]
462 command: DispositionsCommand,
463 },
464 /// Inspects the source, reads the target and writes the report and the
465 /// plan — the same run as the bare `fz import supabase`.
466 Plan {
467 #[command(flatten)]
468 args: SupabaseRunArgs,
469 },
470 /// Moves the project's auth users into a running venture's `auth-core`
471 /// (issue #659, step 2): reads `auth.users` and `auth.identities`
472 /// read-only, sends each account's address, verified flag, bcrypt hash
473 /// and OIDC links to the admin import, and (with `--apply`) records the
474 /// source-to-account mapping the data step rewrites references through.
475 /// A dry run unless `--apply`. The network leg needs the
476 /// `import-supabase` feature.
477 Users {
478 /// The database URL of a read-only role. Prefer the
479 /// `SUPABASE_DB_URL` environment variable: a flag lands in the
480 /// shell history and the process list.
481 #[arg(long, value_name = "URL")]
482 db_url: Option<String>,
483 /// The venture's base URL, e.g. `https://venture.example`.
484 #[arg(long, value_name = "URL")]
485 target: String,
486 /// The environment variable holding the target's `ADMIN_TOKEN`.
487 /// Never printed.
488 #[arg(long, value_name = "VAR")]
489 admin_token_env: String,
490 /// The environment variable holding the *target* database URL (the
491 /// harness Postgres, where `import_supabase_users` lives).
492 /// Required with `--apply`.
493 #[arg(long, value_name = "VAR")]
494 map_db_url_env: Option<String>,
495 /// A Supabase OAuth provider configured on the deployment
496 /// (`google`, `apple`, `facebook`), repeatable: only these
497 /// identities are linked. An unlisted one is reported unmapped and
498 /// signs in by magic link.
499 #[arg(long, value_name = "NAME")]
500 oidc_provider: Vec<String>,
501 /// Users per request (default 500, max 1000).
502 #[arg(long, default_value_t = auth_import::DEFAULT_BATCH_SIZE, value_name = "N")]
503 batch_size: usize,
504 /// Let the server merge a user into an existing account that
505 /// shares the email, instead of reporting a conflict.
506 #[arg(long)]
507 merge_by_email: bool,
508 /// Actually write: import the users and record the mapping. Without
509 /// it the run is a dry run.
510 #[arg(long)]
511 apply: bool,
512 /// Where the JSON report goes (default stdout).
513 #[arg(long, value_name = "PATH")]
514 report: Option<PathBuf>,
515 },
516}
517
518/// The source flags `inspect` and `dispositions init` share: one read-only
519/// connection to the project.
520#[derive(Args)]
521struct SourceArgs {
522 /// The Supabase project ref (the `<ref>` in
523 /// `https://<ref>.supabase.co`).
524 #[arg(long, value_name = "REF")]
525 project: String,
526 /// The database URL of a read-only role. Prefer the
527 /// `SUPABASE_DB_URL` environment variable: a flag lands in the
528 /// shell history and the process list.
529 #[arg(long, value_name = "URL")]
530 db_url: Option<String>,
531 /// A Supabase Management API personal access token, for Edge
532 /// Functions and the auth configuration. Prefer
533 /// `SUPABASE_ACCESS_TOKEN`. Without one those are reported as not
534 /// inspected (unknown, not none).
535 #[arg(long, value_name = "TOKEN")]
536 management_token: Option<String>,
537}
538
539#[derive(Subcommand)]
540enum DispositionsCommand {
541 /// Writes a skeleton dispositions file: one placeholder entry per item
542 /// that needs a decision, to fill in. Refuses to overwrite an existing
543 /// file without `--force`.
544 Init {
545 #[command(flatten)]
546 source: SourceArgs,
547 /// Where to write the skeleton.
548 #[arg(
549 long,
550 value_name = "FILE",
551 default_value = "import/supabase-dispositions.toml"
552 )]
553 out: PathBuf,
554 /// Overwrite an existing file.
555 #[arg(long)]
556 force: bool,
557 /// Skip items already decided in this file.
558 #[arg(long, value_name = "FILE")]
559 dispositions: Option<PathBuf>,
560 },
561}
562
563#[derive(Subcommand)]
564enum PushCommand {
565 /// VAPID: the P-256 key pair Web Push identifies this application
566 /// server by.
567 Vapid {
568 #[command(subcommand)]
569 command: VapidCommand,
570 },
571 /// Sends one notification through the adapters this venture's
572 /// environment configures — the same construction `serve()` uses, so
573 /// what this reaches is what the deployment reaches.
574 Send {
575 /// Which transport to send over; also how `--recipient` is read.
576 #[arg(long, value_enum)]
577 transport: push::Transport,
578 /// An APNs device token, an FCM registration token, or a Web Push
579 /// subscription JSON (the browser's own shape, or the flattened
580 /// one). Credential material: it is in this shell's history.
581 #[arg(long, value_name = "TOKEN-OR-JSON")]
582 recipient: String,
583 /// The notification's title.
584 #[arg(long)]
585 title: String,
586 /// The notification's body.
587 #[arg(long)]
588 body: String,
589 /// Custom JSON payload the app reads alongside the alert.
590 #[arg(long, value_name = "JSON")]
591 data: Option<String>,
592 /// Where a tap should take the user.
593 #[arg(long, value_name = "URL")]
594 url: Option<String>,
595 /// How long the push service may hold it while the device is
596 /// offline, in seconds. `0` means deliver now or drop.
597 #[arg(long, value_name = "SECONDS")]
598 ttl: Option<u64>,
599 /// Delivery priority.
600 #[arg(long, value_enum, default_value_t = push::PriorityArg::Immediate)]
601 priority: push::PriorityArg,
602 /// A data-only notification: nothing is shown, the app is woken.
603 #[arg(long)]
604 silent: bool,
605 /// Report which transport would carry this and why, without
606 /// sending. Reaches no network at all — it builds no HTTP client.
607 #[arg(long)]
608 dry_run: bool,
609 },
610 /// Validates a Web Push subscription and prints the `aud` the adapter
611 /// will sign for it — a wrong `aud` is the commonest VAPID `401`.
612 InspectSubscription {
613 /// The subscription JSON, as the browser hands it over.
614 #[arg(value_name = "JSON")]
615 json: String,
616 },
617}
618
619#[derive(Subcommand)]
620enum VapidCommand {
621 /// Generates a P-256 key pair, printing the public half in the form a
622 /// browser passes to `pushManager.subscribe()`.
623 ///
624 /// A venture generates one of these once and keeps it: rotating it
625 /// invalidates every existing browser subscription, which no server
626 /// can recreate.
627 Keygen {
628 /// Write the private key here (owner-readable only).
629 #[arg(long, value_name = "PATH")]
630 file: Option<PathBuf>,
631 /// Overwrite an existing `--file`. This is a rotation.
632 #[arg(long)]
633 force: bool,
634 /// Print the private key to stdout as well.
635 #[arg(long)]
636 print_private: bool,
637 },
638}
639
640#[derive(Subcommand)]
641enum DataCommand {
642 /// Exports the venture's tables from a SQLite database (the D1
643 /// stand-in — `docs/DATA-MOVE.md`) to manifest + JSONL records.
644 Export {
645 /// The SQLite database file to read.
646 #[arg(long, value_name = "PATH")]
647 db: PathBuf,
648 /// Export without the tables of any sidecar-mounted module,
649 /// recording the omission in the manifest. Without this, an
650 /// export refuses to run when `HARNESS_SIDECARS` is set, rather
651 /// than producing an artifact that looks complete (issue #66).
652 #[arg(long)]
653 without_sidecar_tables: bool,
654 /// The JSONL file to write (manifest line first).
655 #[arg(long, value_name = "FILE")]
656 out: PathBuf,
657 /// Prints the per-table summary without writing the file.
658 #[arg(long)]
659 plan: bool,
660 },
661 /// Loads an export file into a Postgres database in lock order,
662 /// verifying sha256 and row counts against the manifest. Requires
663 /// the `postgres` feature.
664 Import {
665 /// Connection string of the target Postgres database.
666 #[arg(long, value_name = "URL")]
667 url: String,
668 /// The export file written by `fz data export`.
669 #[arg(value_name = "FILE")]
670 file: PathBuf,
671 /// Adds to non-empty tables instead of refusing them.
672 #[arg(long)]
673 append: bool,
674 /// Prints what would be imported and the target state, writing
675 /// nothing.
676 #[arg(long)]
677 plan: bool,
678 },
679}
680
681/// `fz tables`'s own dispatch, in its own function.
682///
683/// `run` is at clippy's line limit and a subcommand arm has now pushed it
684/// over twice; a group that grows belongs in a function that can grow.
685fn run_tables(command: TablesCommand) -> Result<(), String> {
686 match command {
687 TablesCommand::Diff { from, to } => tables::diff(&from, &to),
688 TablesCommand::Drift {
689 manifest,
690 dialect,
691 url,
692 } => tables::drift(&manifest, &dialect, &url),
693 }
694}
695
696#[derive(Subcommand)]
697enum TablesCommand {
698 /// Reports what changed between two versions of a manifest's
699 /// `[tables]` declaration, and what each change costs. Read-only,
700 /// and no database is involved.
701 Diff {
702 /// The manifest as it was — a checkout of `main`, say.
703 #[arg(long, value_name = "MANIFEST")]
704 from: PathBuf,
705 /// The manifest as it is now.
706 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
707 to: PathBuf,
708 },
709 /// Reports what a database differs from the manifest's `[tables]`
710 /// declaration by, in expand/contract/rewrite terms. Read-only.
711 Drift {
712 /// The venture manifest (`.json` or `.toml`).
713 #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
714 manifest: PathBuf,
715 /// Target SQL dialect: postgres.
716 #[arg(long, default_value = "postgres")]
717 dialect: String,
718 /// Connection string of the database to read.
719 #[arg(long, value_name = "URL")]
720 url: String,
721 },
722}
723
724#[derive(Subcommand)]
725enum MigrationsCommand {
726 /// Collects module migrations into wrangler-ordered files.
727 Collect {
728 /// SQL dialect of the collected files (sqlite for wrangler/D1).
729 #[arg(long, default_value = "sqlite")]
730 dialect: String,
731 /// Output directory for wrangler `migrations_dir`.
732 #[arg(long, default_value = "migrations")]
733 out: PathBuf,
734 },
735 /// Applies the harness's migrations directly to the database
736 /// `--url` names — the native counterpart of
737 /// `wrangler d1 migrations apply` (issue #18). With `--fleet` or
738 /// `--plan` the URL names the **control database** instead and the
739 /// tenants in its registry are reconciled (RECONCILIATION.md).
740 Apply {
741 /// Target SQL dialect: postgres.
742 #[arg(long, default_value = "postgres")]
743 dialect: String,
744 /// Connection string of the target database (the control
745 /// database when `--fleet`, `--plan` or `--tenant` is given),
746 /// e.g. `postgres://user:pass@host:5432/venture`.
747 #[arg(long, value_name = "URL")]
748 url: String,
749 /// Reconcile every tenant in the control database's registry,
750 /// each against its own database, instead of applying to the
751 /// database `--url` names.
752 #[arg(long)]
753 fleet: bool,
754 /// Print what would be applied, per tenant and per module,
755 /// without applying anything.
756 #[arg(long)]
757 plan: bool,
758 /// Reconcile one tenant only (registry id, not a DSN).
759 #[arg(long, value_name = "TENANT")]
760 tenant: Option<String>,
761 /// Abort with a non-zero exit when any tenant is degraded,
762 /// instead of letting the others serve. Needs `--fleet`.
763 #[arg(long)]
764 strict: bool,
765 },
766}
767
768/// Runs `fz` with the venture's harness and the given arguments.
769///
770/// # Panics
771///
772/// Never; failures are reported on stderr with a non-zero exit code.
773#[must_use = "call process::exit with the returned ExitCode"]
774pub fn run(build: impl Fn() -> Harness, args: impl IntoIterator<Item = String>) -> ExitCode {
775 let mut full_argv: Vec<String> = Vec::with_capacity(8);
776 full_argv.push("fz".to_string());
777 full_argv.extend(args);
778 let cli = Cli::parse_from(full_argv);
779 // The harness-free commands run before (and without) the venture's
780 // compiled-in harness.
781 if let Some(exit) = harness_free(&cli.command) {
782 return exit;
783 }
784 let harness = build();
785 let sidecars = crate::sidecars::from_cli_or_env(cli.sidecars.as_deref());
786 let result = match cli.command {
787 Command::Tables { command } => run_tables(command),
788 Command::Migrations {
789 command: MigrationsCommand::Collect { dialect, out },
790 } => collect::collect(&harness, &dialect, &out),
791 Command::Migrations {
792 command:
793 MigrationsCommand::Apply {
794 dialect,
795 url,
796 fleet,
797 plan,
798 tenant,
799 strict,
800 },
801 } => apply::apply(
802 &harness,
803 &dialect,
804 &url,
805 plan,
806 fleet,
807 tenant.as_deref(),
808 strict,
809 ),
810 Command::Doctor {
811 out,
812 allow_no_captcha,
813 json,
814 } => {
815 if json {
816 return doctor::doctor_json(
817 &harness,
818 &out,
819 allow_no_captcha.as_deref(),
820 sidecars.as_deref(),
821 );
822 }
823 doctor::doctor(
824 &harness,
825 &out,
826 allow_no_captcha.as_deref(),
827 sidecars.as_deref(),
828 )
829 }
830 Command::Modules => {
831 print_modules(&harness);
832 Ok(())
833 }
834 Command::Data {
835 command:
836 DataCommand::Export {
837 db,
838 out,
839 plan,
840 without_sidecar_tables,
841 },
842 } => data::export(
843 &harness,
844 &db,
845 &out,
846 plan,
847 without_sidecar_tables,
848 sidecars.as_deref(),
849 ),
850 Command::Data {
851 command:
852 DataCommand::Import {
853 url,
854 file,
855 append,
856 plan,
857 },
858 } => data::import(&harness, &file, &url, append, plan),
859 // Handled by `harness_free` before `build()` above: build and
860 // client-ts generate from a file or a served contract rather than
861 // a compiled-in harness, the workflow commands work on the
862 // manifest and its records, and push reads the venture's
863 // environment.
864 Command::Build { .. }
865 | Command::BuildKey { .. }
866 | Command::ClientTs { .. }
867 | Command::Plan { .. }
868 | Command::Deploy { .. }
869 | Command::Add { .. }
870 | Command::Init { .. }
871 | Command::Verify { .. }
872 | Command::Push { .. }
873 | Command::Auth { .. }
874 | Command::Import { .. } => unreachable!("dispatched before the harness is built"),
875 };
876 finish(result)
877}
878
879/// The commands that need no compiled-in harness, run — the one seam
880/// both entry points dispatch through, before [`run`] builds the harness
881/// and instead of the harness [`run_standalone`] does not have.
882///
883/// `fz build` **generates** a harness, so it cannot have one; the manifest
884/// workflow — `fz plan` / `deploy` / `add` / `init` / `verify`, routed by
885/// [`workflow::dispatch`] — works on the manifest and its on-disk records
886/// rather than the compiled-in modules; `fz push` reads the venture's
887/// *environment* rather than its modules; and `fz auth import` talks to a
888/// URL. `None` for every other command,
889/// which is what sends [`run_standalone`] to its refusal and [`run`] on to
890/// `build()`.
891fn harness_free(command: &Command) -> Option<ExitCode> {
892 match command {
893 Command::Build {
894 manifest,
895 out,
896 harness_path,
897 catalog,
898 built_at,
899 builder,
900 } => Some(finish(build::run(
901 manifest,
902 out,
903 harness_path.as_deref(),
904 catalog.as_deref(),
905 built_at.as_deref(),
906 builder.as_deref(),
907 ))),
908 Command::BuildKey { manifest, catalog } => {
909 Some(finish(crate::build_key::run(manifest, catalog.as_deref())))
910 }
911 // Two output disciplines, exactly as `fz doctor` runs them: prose
912 // failures go through `finish`, `--json` prints its own object
913 // and owns the exit code.
914 Command::ClientTs { surface, out, json } => Some(if *json {
915 client_ts::run_json(surface, out)
916 } else {
917 finish(client_ts::run(surface, out))
918 }),
919 Command::Push { command } => Some(finish(run_push(command))),
920 Command::Auth { command } => Some(finish(run_auth(command))),
921 Command::Import { command } => Some(finish(run_import(command))),
922 _ => workflow::dispatch(command),
923 }
924}
925
926/// `fz import` (issues #658, #727, #728): the bare command and `plan` run
927/// inspect + plan; `inspect` writes the report alone; `dispositions init`
928/// writes a skeleton dispositions file.
929fn run_import(command: &ImportCommand) -> Result<(), String> {
930 let ImportCommand::Supabase { command, run } = command;
931 match command {
932 None => import::plan(&plan_args(run)?),
933 Some(SupabaseCommand::Plan { args }) => import::plan(&plan_args(args)?),
934 Some(SupabaseCommand::Inspect {
935 source,
936 json,
937 md: _,
938 out,
939 transfer_mbps,
940 classify,
941 classify_threshold,
942 dispositions,
943 }) => import::inspect(&import::InspectArgs {
944 project: source.project.clone(),
945 db_url: source.db_url.clone(),
946 management_token: source.management_token.clone(),
947 format: if *json {
948 import::Format::Json
949 } else {
950 import::Format::Markdown
951 },
952 out: out.clone(),
953 transfer_mbps: *transfer_mbps,
954 classify: *classify,
955 classify_threshold: *classify_threshold,
956 dispositions: dispositions.clone(),
957 }),
958 Some(SupabaseCommand::Dispositions {
959 command:
960 DispositionsCommand::Init {
961 source,
962 out,
963 force,
964 dispositions,
965 },
966 }) => import::dispositions_init(&import::DispositionsInitArgs {
967 project: source.project.clone(),
968 db_url: source.db_url.clone(),
969 management_token: source.management_token.clone(),
970 out: out.clone(),
971 force: *force,
972 dispositions: dispositions.clone(),
973 }),
974 Some(SupabaseCommand::Users {
975 db_url,
976 target,
977 admin_token_env,
978 map_db_url_env,
979 oidc_provider,
980 batch_size,
981 merge_by_email,
982 apply,
983 report,
984 }) => import_users::run(&import_users::UsersArgs {
985 db_url: db_url.clone(),
986 target: target.clone(),
987 admin_token_env: admin_token_env.clone(),
988 map_db_url_env: map_db_url_env.clone(),
989 oidc_providers: oidc_provider.clone(),
990 batch_size: *batch_size,
991 merge_by_email: *merge_by_email,
992 apply: *apply,
993 report: report.clone(),
994 }),
995 }
996}
997
998fn plan_args(run: &SupabaseRunArgs) -> Result<import::PlanArgs, String> {
999 let project = run.project.clone().ok_or_else(|| {
1000 "no project ref: pass --project <ref>, the `<ref>` in https://<ref>.supabase.co".to_owned()
1001 })?;
1002 Ok(import::PlanArgs {
1003 project,
1004 db_url: run.db_url.clone(),
1005 management_token: run.management_token.clone(),
1006 target_env: run.target_env.clone(),
1007 dir: run.dir.clone(),
1008 transfer_mbps: run.transfer_mbps,
1009 classify: run.classify,
1010 classify_threshold: run.classify_threshold,
1011 apply: run.apply,
1012 plan: run.plan.clone(),
1013 })
1014}
1015
1016/// `fz push` (issue #184). Every path prints its report to stdout and
1017/// reports the verdict as the exit code.
1018fn run_push(command: &PushCommand) -> Result<(), String> {
1019 match command {
1020 PushCommand::Vapid {
1021 command:
1022 VapidCommand::Keygen {
1023 file,
1024 force,
1025 print_private,
1026 },
1027 } => {
1028 let generated = push::vapid_keygen(&push::KeygenOptions {
1029 file: file.as_deref(),
1030 force: *force,
1031 print_private: *print_private,
1032 })?;
1033 // The rotation warning is a diagnostic, so it goes to stderr:
1034 // stdout is the stream the README's recipe reads the public
1035 // key off, and a warning in it ends up in somebody's
1036 // `applicationServerKey`.
1037 if let Some(warning) = generated.warning() {
1038 eprint!("{warning}");
1039 }
1040 print!("{}", generated.render());
1041 if *print_private {
1042 print!("\n{}", *generated.private_key_disclosure());
1043 }
1044 Ok(())
1045 }
1046 PushCommand::Send {
1047 transport,
1048 recipient,
1049 title,
1050 body,
1051 data,
1052 url,
1053 ttl,
1054 priority,
1055 silent,
1056 dry_run,
1057 } => {
1058 let request = push::SendRequest::from_args(&push::SendArgs {
1059 transport: *transport,
1060 recipient: recipient.clone(),
1061 title: title.clone(),
1062 body: body.clone(),
1063 data: data.clone(),
1064 url: url.clone(),
1065 ttl: *ttl,
1066 priority: *priority,
1067 silent: *silent,
1068 })?;
1069 let report = if *dry_run {
1070 push::plan(&EnvVars, &request)
1071 } else {
1072 push::send_now(&EnvVars, &request)?
1073 };
1074 print!("{}", report.render());
1075 if report.ok() {
1076 Ok(())
1077 } else {
1078 // The report already said what happened, in full; the exit
1079 // code is what a script reads, and `finish` prefixes this.
1080 Err("nothing was delivered — see the report above".to_owned())
1081 }
1082 }
1083 PushCommand::InspectSubscription { json } => {
1084 print!("{}", push::inspect_subscription(json)?.render());
1085 Ok(())
1086 }
1087 }
1088}
1089
1090/// `fz auth` (issue #650). The admin token is read from the environment
1091/// here — by the name `--admin-token-env` gives, through the same
1092/// [`EnvVars`] the rest of `fz` reads config with — and handed to the
1093/// import as a value, so it never becomes a flag in a shell history.
1094fn run_auth(command: &AuthCommand) -> Result<(), String> {
1095 match command {
1096 AuthCommand::Import {
1097 target,
1098 admin_token_env,
1099 apply,
1100 merge_by_email,
1101 report,
1102 batch_size,
1103 file,
1104 } => {
1105 let Some(admin_token) = EnvVars.get(admin_token_env) else {
1106 return Err(format!(
1107 "the environment variable `{admin_token_env}` is not set or is empty (it \
1108 must hold the auth-core admin token — the value the venture has as \
1109 `ADMIN_TOKEN`)"
1110 ));
1111 };
1112 auth_import::run(&auth_import::ImportOptions {
1113 file: file.clone(),
1114 target: target.clone(),
1115 admin_token,
1116 apply: *apply,
1117 merge_by_email: *merge_by_email,
1118 report: report.clone(),
1119 batch_size: *batch_size,
1120 })
1121 }
1122 }
1123}
1124
1125/// Runs the harness-free `fz` commands from a standalone binary (no
1126/// compiled-in venture): `fz build <manifest>`, which generates a harness;
1127/// `fz client-ts`, which generates the typed TypeScript client from the
1128/// contract a venture serves; the manifest workflow — `fz plan` /
1129/// `deploy` / `add` / `init` / `verify` — which works on the manifest and
1130/// its records, not on compiled-in modules; `fz push`, which reads the
1131/// venture's environment; and `fz auth`, which talks to a URL. Every
1132/// other command needs the venture's harness and says so.
1133#[must_use = "call process::exit with the returned ExitCode"]
1134pub fn run_standalone(args: impl IntoIterator<Item = String>) -> ExitCode {
1135 let mut full_argv: Vec<String> = Vec::with_capacity(8);
1136 full_argv.push("fz".to_string());
1137 full_argv.extend(args);
1138 let cli = Cli::parse_from(full_argv);
1139 let Some(exit) = harness_free(&cli.command) else {
1140 eprintln!(
1141 "fz: this command must run inside a venture (it needs the compiled-in harness — see \
1142 the cratefield-cli README). Only `fz build <manifest>`, `fz client-ts`, the manifest \
1143 workflow (`fz plan` / `deploy` / `add` / `init` / `verify`), `fz push`, `fz auth` \
1144 and `fz import` run standalone."
1145 );
1146 return ExitCode::FAILURE;
1147 };
1148 exit
1149}
1150
1151fn finish(result: Result<(), String>) -> ExitCode {
1152 match result {
1153 Ok(()) => ExitCode::SUCCESS,
1154 Err(message) => {
1155 eprintln!("fz: {message}");
1156 ExitCode::FAILURE
1157 }
1158 }
1159}
1160
1161/// Entry point for the venture-side `fz` bin (see the crate docs).
1162pub fn main_for(build: fn() -> Harness) -> ! {
1163 let code = run(build, std::env::args().skip(1));
1164 std::process::exit(match code {
1165 ExitCode::SUCCESS => 0,
1166 _ => 1,
1167 });
1168}
1169
1170fn print_modules(harness: &Harness) {
1171 for module in harness.modules() {
1172 println!(
1173 "{} {} /v1/{} emits=[{}] tables=[{}]",
1174 module.name(),
1175 module.version(),
1176 module.name(),
1177 module.emits().join(", "),
1178 module.tables().join(", "),
1179 );
1180 }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::{Cli, Command, ImportCommand, SupabaseCommand};
1186 use clap::Parser as _;
1187 use std::ffi::OsString;
1188
1189 fn parse(parts: &[&str]) -> Result<Cli, clap::Error> {
1190 let mut argv = vec![OsString::from("fz")];
1191 argv.extend(parts.iter().map(OsString::from));
1192 Cli::try_parse_from(argv)
1193 }
1194
1195 #[test]
1196 fn the_bare_supabase_command_parses_the_run() {
1197 let cli = parse(&["import", "supabase", "--project", "abcdefghijklmnopqrst"])
1198 .expect("the bare run parses");
1199 let Command::Import {
1200 command: ImportCommand::Supabase { command, run },
1201 } = cli.command
1202 else {
1203 panic!("expected `import supabase`");
1204 };
1205 assert!(command.is_none());
1206 assert_eq!(run.project.as_deref(), Some("abcdefghijklmnopqrst"));
1207 assert_eq!(run.target_env, "DATABASE_URL");
1208 assert_eq!(run.dir, std::path::PathBuf::from("."));
1209 assert!(!run.apply);
1210 assert!(run.plan.is_none());
1211 }
1212
1213 #[test]
1214 fn supabase_inspect_still_parses_without_the_run_args() {
1215 let cli = parse(&[
1216 "import",
1217 "supabase",
1218 "inspect",
1219 "--project",
1220 "abcdefghijklmnopqrst",
1221 "--json",
1222 ])
1223 .expect("inspect parses as before");
1224 let Command::Import {
1225 command: ImportCommand::Supabase { command, .. },
1226 } = cli.command
1227 else {
1228 panic!("expected `import supabase`");
1229 };
1230 assert!(matches!(
1231 command,
1232 Some(SupabaseCommand::Inspect { json: true, .. })
1233 ));
1234 }
1235
1236 #[test]
1237 fn supabase_plan_parses_its_run_args() {
1238 let cli = parse(&[
1239 "import",
1240 "supabase",
1241 "plan",
1242 "--project",
1243 "abcdefghijklmnopqrst",
1244 "--dir",
1245 "/tmp/plan",
1246 ])
1247 .expect("plan parses");
1248 let Command::Import {
1249 command: ImportCommand::Supabase { command, .. },
1250 } = cli.command
1251 else {
1252 panic!("expected `import supabase`");
1253 };
1254 let Some(SupabaseCommand::Plan { args }) = command else {
1255 panic!("expected `plan`");
1256 };
1257 assert_eq!(args.project.as_deref(), Some("abcdefghijklmnopqrst"));
1258 assert_eq!(args.dir, std::path::PathBuf::from("/tmp/plan"));
1259 }
1260
1261 #[test]
1262 fn apply_without_a_plan_is_refused() {
1263 let Err(error) = parse(&[
1264 "import",
1265 "supabase",
1266 "--project",
1267 "abcdefghijklmnopqrst",
1268 "--apply",
1269 ]) else {
1270 panic!("--apply without --plan must be refused");
1271 };
1272 assert!(error.to_string().contains("--plan"), "{error}");
1273 }
1274
1275 #[test]
1276 fn the_bare_command_needs_a_project_at_run_time() {
1277 let Ok(cli) = parse(&["import", "supabase"]) else {
1278 panic!("the bare command parses (a subcommand may replace the run)");
1279 };
1280 let Command::Import {
1281 command: ImportCommand::Supabase { run, .. },
1282 } = cli.command
1283 else {
1284 panic!("expected `import supabase`");
1285 };
1286 assert!(run.project.is_none());
1287 let error = super::plan_args(&run).expect_err("a project ref is required");
1288 assert!(error.contains("--project"), "{error}");
1289 }
1290}