Skip to main content

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 lint;
37mod lock;
38pub mod push;
39pub mod sidecars;
40pub mod tables;
41pub mod workflow;
42
43use clap::{Parser, Subcommand};
44use cratefield_core::{Config, Harness};
45use std::path::PathBuf;
46use std::process::ExitCode;
47
48/// The process environment as a [`Config`], so `fz` reads a variable the
49/// same way a deployed runtime does (issue #143): the doctor for `ENV`, and
50/// `fz push` for the push transports' secrets — which it reads only through
51/// `cratefield_push_wiring`, never by name.
52pub(crate) struct EnvVars;
53
54impl Config for EnvVars {
55    fn get(&self, key: &str) -> Option<String> {
56        std::env::var(key).ok().filter(|value| !value.is_empty())
57    }
58}
59
60#[derive(Parser)]
61#[command(name = "fz", about = "Factory Zero venture CLI")]
62struct Cli {
63    /// The sidecar mount table, as the runtime reads it from config:
64    /// `{"module-name":"BINDING"}`. Defaults to the `HARNESS_SIDECARS`
65    /// environment variable. Commands that walk the compiled-in modules
66    /// use it to say what they cannot see (issue #66).
67    #[arg(long, global = true, value_name = "JSON")]
68    sidecars: Option<String>,
69    #[command(subcommand)]
70    command: Command,
71}
72
73#[derive(Subcommand)]
74enum Command {
75    /// Writes wrangler-compatible migration files and maintains the
76    /// `.harness-lock.json` pinning module migrations to file names.
77    Migrations {
78        #[command(subcommand)]
79        command: MigrationsCommand,
80    },
81    /// Validates the harness, the production-captcha rule, the lockfile
82    /// and the portable-SQL lint.
83    Doctor {
84        /// Migration directory (default `migrations`).
85        #[arg(long, default_value = "migrations")]
86        out: PathBuf,
87        /// Accept a production venture without a Captcha port for the
88        /// stated reason; prints a warning instead of failing.
89        #[arg(long, value_name = "REASON")]
90        allow_no_captcha: Option<String>,
91        /// Prints exactly one JSON object to stdout — `schema`, `ok` and
92        /// the failures with their stable codes (`cratefield_cli::codes`)
93        /// — instead of prose. The exit code still reflects the verdict.
94        #[arg(long)]
95        json: bool,
96    },
97    /// Prints modules, versions, route prefixes, emitted events, tables.
98    Modules,
99    /// Generates a deterministic Cloudflare venture crate from a venture
100    /// manifest (issue #138): resolve the module set, write `Cargo.toml`,
101    /// `src/lib.rs`, `src/fz_main.rs`, `wrangler.toml`. Standalone — it
102    /// needs no compiled-in harness because it produces one. The final
103    /// `worker-build` + `wrangler deploy` are printed as the next steps.
104    Build {
105        /// The venture manifest (`.json` or `.toml`).
106        #[arg(value_name = "MANIFEST")]
107        manifest: PathBuf,
108        /// Output directory for the generated venture crate.
109        #[arg(long, default_value = "dist")]
110        out: PathBuf,
111        /// Point the generated crate at a local harness checkout (path
112        /// deps) instead of published versions — for offline / in-repo
113        /// builds, as the Docker image does.
114        #[arg(long, value_name = "PATH")]
115        harness_path: Option<String>,
116        /// A stamped catalog JSON instead of the built-in one (issue
117        /// #142): the build service points this at the release-stamped
118        /// catalog whose digests are real content addresses.
119        #[arg(long, value_name = "PATH")]
120        catalog: Option<PathBuf>,
121        /// RFC 3339 timestamp recorded in provenance (issue #142). The
122        /// CLI owns no clock, so a build without this (or
123        /// `FZ_BUILD_TIMESTAMP`) records `built-at: null` rather than
124        /// inventing one.
125        #[arg(long, value_name = "RFC3339")]
126        built_at: Option<String>,
127        /// Builder identity recorded in provenance (issue #142);
128        /// `FZ_BUILDER` is the fallback, then `"local"`.
129        #[arg(long, value_name = "WHO")]
130        builder: Option<String>,
131    },
132    /// Computes the artifact's content address (issue #59): sha256 over
133    /// the pinned module releases, the harness API, the rustc version
134    /// and the build profile. Prints the key and the inputs that
135    /// produced it; runs no cargo, writes nothing. The venture name,
136    /// host, config and the sidecar mount table are deployment
137    /// configuration, not artifact content, so they are not inputs —
138    /// two customers on the same module set share the artifact and the
139    /// same key. Standalone, like `fz build`.
140    BuildKey {
141        /// The venture manifest (`.json` or `.toml`).
142        #[arg(value_name = "MANIFEST")]
143        manifest: PathBuf,
144        /// A stamped catalog JSON instead of the built-in one — the
145        /// same catalog the matching `fz build --catalog` would use.
146        #[arg(long, value_name = "PATH")]
147        catalog: Option<PathBuf>,
148    },
149    /// Generates the typed TypeScript client for a venture (issue #155):
150    /// a `/__surface` contract document in, the files of a deterministic
151    /// `@cratefield/client` package out, written to `--out`. Standalone —
152    /// it reads the contract the venture serves, not a compiled-in
153    /// venture, so the standalone `fz` runs it too.
154    ClientTs {
155        /// The `/__surface` document to read: a path, or `-` for stdin
156        /// (`curl -s https://venture.example/__surface | fz client-ts
157        /// --surface - --out ./client`).
158        #[arg(long, value_name = "PATH")]
159        surface: String,
160        /// Output directory for the generated client package.
161        #[arg(long, value_name = "DIR")]
162        out: PathBuf,
163        /// Prints exactly one JSON object to stdout — `schema`, `ok`, the
164        /// package, the composition hash and the files written — instead
165        /// of prose. The exit code still reflects the verdict.
166        #[arg(long)]
167        json: bool,
168    },
169    /// Moves venture data between engines (issue #21): export D1/SQLite
170    /// data to JSONL with a manifest, import into Postgres.
171    Data {
172        #[command(subcommand)]
173        command: DataCommand,
174    },
175    /// Declared tables (issue #153).
176    Tables {
177        #[command(subcommand)]
178        command: TablesCommand,
179    },
180    /// Describes what the manifest would change — modules added or
181    /// removed, the capabilities they require, config changes and
182    /// migrations not yet collected — and prints a `digest` that
183    /// approves exactly that plan. Read-only (harness #140).
184    Plan {
185        /// The venture manifest (`.json` or `.toml`).
186        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
187        manifest: PathBuf,
188        /// Migration directory holding `.harness-lock.json`.
189        #[arg(long, default_value = "migrations")]
190        migrations: PathBuf,
191        /// Prints exactly one JSON object to stdout — `schema`, `ok`,
192        /// `failures`, the plan `digest` and its content — instead of
193        /// prose. Nothing else reaches stdout or stderr.
194        #[arg(long)]
195        json: bool,
196        /// Never prompts (the workflow commands never do; anything
197        /// needing a human is a coded refusal) and suppresses the
198        /// human `next:` steps from the prose output.
199        #[arg(long)]
200        non_interactive: bool,
201    },
202    /// Applies exactly the approved plan: recomputes it from the
203    /// current inputs and refuses any other digest. Records the plan in
204    /// `.harness-deploy.json` beside the manifest — compiling,
205    /// standing up the Worker and applying migrations stay with the
206    /// needs-human steps it prints. A production venture needs
207    /// `--i-am-deploying-to-production` as a second, separate consent
208    /// (harness #140); a plan that removes modules needs
209    /// `--i-am-removing-modules`, a consent separate from that one.
210    Deploy {
211        /// The digest `fz plan --json` printed — the approval token.
212        #[arg(long, value_name = "DIGEST")]
213        plan: Option<String>,
214        /// The venture manifest (`.json` or `.toml`).
215        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
216        manifest: PathBuf,
217        /// Migration directory holding `.harness-lock.json`.
218        #[arg(long, default_value = "migrations")]
219        migrations: PathBuf,
220        /// The second consent: required when the venture resolves to
221        /// production.
222        #[arg(long)]
223        i_am_deploying_to_production: bool,
224        /// Required when the plan drops modules from the served
225        /// composition. Separate from the production flag on purpose: a
226        /// removal in development is not a production deploy, and one
227        /// flag for both would teach an operator to pass the production
228        /// one by habit.
229        #[arg(long)]
230        i_am_removing_modules: bool,
231        /// Prints exactly one JSON object to stdout instead of prose.
232        #[arg(long)]
233        json: bool,
234        /// Never prompts; suppresses the human `next:` steps.
235        #[arg(long)]
236        non_interactive: bool,
237    },
238    /// Adds a module to the manifest's desired composition and nothing
239    /// else — never deploys, never touches a database (harness #140).
240    /// Adding a module already present is a no-op that succeeds.
241    Add {
242        /// The module slug, as the catalog names it.
243        #[arg(value_name = "MODULE")]
244        module: String,
245        /// The venture manifest (`.json` or `.toml`).
246        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
247        manifest: PathBuf,
248        /// Prints exactly one JSON object to stdout instead of prose.
249        #[arg(long)]
250        json: bool,
251        /// Never prompts; suppresses the human `next:` steps.
252        #[arg(long)]
253        non_interactive: bool,
254    },
255    /// Writes a new venture manifest — name and host, no modules.
256    /// Refuses to overwrite an existing one unless `--force`
257    /// (harness #140).
258    Init {
259        /// The venture name (becomes the generated crate name).
260        #[arg(long, value_name = "NAME")]
261        name: String,
262        /// The primary host the backend answers on.
263        #[arg(long, value_name = "HOST")]
264        host: String,
265        /// Where to write the manifest.
266        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
267        manifest: PathBuf,
268        /// Replace an existing manifest instead of refusing.
269        #[arg(long)]
270        force: bool,
271        /// Prints exactly one JSON object to stdout instead of prose.
272        #[arg(long)]
273        json: bool,
274        /// Never prompts; suppresses the human `next:` steps.
275        #[arg(long)]
276        non_interactive: bool,
277    },
278    /// Checks the recorded deployment still matches the manifest,
279    /// reporting drift as coded failures in the doctor's JSON shape
280    /// (harness #140).
281    Verify {
282        /// The venture manifest (`.json` or `.toml`).
283        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
284        manifest: PathBuf,
285        /// Migration directory holding `.harness-lock.json`.
286        #[arg(long, default_value = "migrations")]
287        migrations: PathBuf,
288        /// Prints exactly one JSON object — the doctor's
289        /// `{schema, ok, failures}` — instead of prose.
290        #[arg(long)]
291        json: bool,
292        /// Never prompts; verify never does anyway.
293        #[arg(long)]
294        non_interactive: bool,
295    },
296    /// The push transports (issue #184): generate the VAPID key pair,
297    /// send one notification through the adapters this venture's
298    /// environment configures, inspect a Web Push subscription.
299    ///
300    /// Needs no compiled-in harness: push is configured by the
301    /// environment, so these run from the standalone `fz` too.
302    Push {
303        #[command(subcommand)]
304        command: PushCommand,
305    },
306    /// Loads users into a running venture's `auth-core` module over its
307    /// admin API (issue #650). Needs no compiled-in harness: it talks to
308    /// a URL, so the standalone `fz` runs it.
309    Auth {
310        #[command(subcommand)]
311        command: AuthCommand,
312    },
313    /// Moves a project from another platform onto the harness (ADR 0026).
314    /// Supabase first; step one is a read-only inspection and its report.
315    ///
316    /// Needs no compiled-in harness, so it runs from the standalone `fz`
317    /// too. The network leg needs the `import-supabase` feature.
318    Import {
319        #[command(subcommand)]
320        command: ImportCommand,
321    },
322}
323
324#[derive(Subcommand)]
325enum AuthCommand {
326    /// Imports a JSONL file of users into `auth-core` — one JSON object
327    /// per line, blank lines skipped — through
328    /// `POST <target>/v1/auth-core/admin/users/import`.
329    ///
330    /// A dry run by default: the server validates every user and reports
331    /// a verdict without writing. `--apply` is the run that writes.
332    Import {
333        /// The venture's base URL, e.g. `https://venture.example`.
334        #[arg(long, value_name = "URL")]
335        target: String,
336        /// The environment variable holding the `ADMIN_TOKEN` to present.
337        /// Required; the value is never printed.
338        #[arg(long, value_name = "VAR")]
339        admin_token_env: String,
340        /// Actually write. Without it the run is a dry run.
341        #[arg(long)]
342        apply: bool,
343        /// Let the server merge a user into an existing account that
344        /// shares the email, instead of reporting a conflict.
345        #[arg(long)]
346        merge_by_email: bool,
347        /// Where the report JSONL goes (default `<file>.report.jsonl`).
348        #[arg(long, value_name = "PATH")]
349        report: Option<PathBuf>,
350        /// Users per request (default 500, max 1000).
351        #[arg(long, default_value_t = auth_import::DEFAULT_BATCH_SIZE, value_name = "N")]
352        batch_size: usize,
353        /// The JSONL file of user objects.
354        #[arg(value_name = "FILE")]
355        file: PathBuf,
356    },
357}
358
359#[derive(Subcommand)]
360enum ImportCommand {
361    /// A Supabase project (ADR 0026, issue #658).
362    Supabase {
363        #[command(subcommand)]
364        command: SupabaseCommand,
365    },
366}
367
368#[derive(Subcommand)]
369enum SupabaseCommand {
370    /// Connects read-only to the project's Postgres (and, with a token,
371    /// the Management API) and writes the migration report: every item
372    /// automatic, needs work or a blocker, with size and transfer
373    /// estimates. Never writes to the project. The exit code is non-zero
374    /// for a refused input, a connection or a permission error — never
375    /// for a blocker, which is part of the report.
376    Inspect {
377        /// The Supabase project ref (the `<ref>` in
378        /// `https://<ref>.supabase.co`).
379        #[arg(long, value_name = "REF")]
380        project: String,
381        /// The database URL of a read-only role. Prefer the
382        /// `SUPABASE_DB_URL` environment variable: a flag lands in the
383        /// shell history and the process list.
384        #[arg(long, value_name = "URL")]
385        db_url: Option<String>,
386        /// A Supabase Management API personal access token, for Edge
387        /// Functions and the auth configuration. Prefer
388        /// `SUPABASE_ACCESS_TOKEN`. Without one those are reported as not
389        /// inspected (unknown, not none).
390        #[arg(long, value_name = "TOKEN")]
391        management_token: Option<String>,
392        /// Write the JSON report (the format later steps read).
393        #[arg(long, conflicts_with = "md")]
394        json: bool,
395        /// Write the Markdown report (the default).
396        #[arg(long)]
397        md: bool,
398        /// Write the report to this file instead of stdout.
399        #[arg(long, value_name = "FILE")]
400        out: Option<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: it labels a
406        /// policy, it never writes a check. Sends policy SQL and table and
407        /// column names, never a row.
408        #[arg(long)]
409        classify: bool,
410        /// The confidence a classifier label needs; below it the policy
411        /// stays `needs_review`. Per adapter: it is `TypeSafe`'s number.
412        #[arg(long, default_value_t = 0.8, value_name = "0..1")]
413        classify_threshold: f32,
414    },
415}
416
417#[derive(Subcommand)]
418enum PushCommand {
419    /// VAPID: the P-256 key pair Web Push identifies this application
420    /// server by.
421    Vapid {
422        #[command(subcommand)]
423        command: VapidCommand,
424    },
425    /// Sends one notification through the adapters this venture's
426    /// environment configures — the same construction `serve()` uses, so
427    /// what this reaches is what the deployment reaches.
428    Send {
429        /// Which transport to send over; also how `--recipient` is read.
430        #[arg(long, value_enum)]
431        transport: push::Transport,
432        /// An APNs device token, an FCM registration token, or a Web Push
433        /// subscription JSON (the browser's own shape, or the flattened
434        /// one). Credential material: it is in this shell's history.
435        #[arg(long, value_name = "TOKEN-OR-JSON")]
436        recipient: String,
437        /// The notification's title.
438        #[arg(long)]
439        title: String,
440        /// The notification's body.
441        #[arg(long)]
442        body: String,
443        /// Custom JSON payload the app reads alongside the alert.
444        #[arg(long, value_name = "JSON")]
445        data: Option<String>,
446        /// Where a tap should take the user.
447        #[arg(long, value_name = "URL")]
448        url: Option<String>,
449        /// How long the push service may hold it while the device is
450        /// offline, in seconds. `0` means deliver now or drop.
451        #[arg(long, value_name = "SECONDS")]
452        ttl: Option<u64>,
453        /// Delivery priority.
454        #[arg(long, value_enum, default_value_t = push::PriorityArg::Immediate)]
455        priority: push::PriorityArg,
456        /// A data-only notification: nothing is shown, the app is woken.
457        #[arg(long)]
458        silent: bool,
459        /// Report which transport would carry this and why, without
460        /// sending. Reaches no network at all — it builds no HTTP client.
461        #[arg(long)]
462        dry_run: bool,
463    },
464    /// Validates a Web Push subscription and prints the `aud` the adapter
465    /// will sign for it — a wrong `aud` is the commonest VAPID `401`.
466    InspectSubscription {
467        /// The subscription JSON, as the browser hands it over.
468        #[arg(value_name = "JSON")]
469        json: String,
470    },
471}
472
473#[derive(Subcommand)]
474enum VapidCommand {
475    /// Generates a P-256 key pair, printing the public half in the form a
476    /// browser passes to `pushManager.subscribe()`.
477    ///
478    /// A venture generates one of these once and keeps it: rotating it
479    /// invalidates every existing browser subscription, which no server
480    /// can recreate.
481    Keygen {
482        /// Write the private key here (owner-readable only).
483        #[arg(long, value_name = "PATH")]
484        file: Option<PathBuf>,
485        /// Overwrite an existing `--file`. This is a rotation.
486        #[arg(long)]
487        force: bool,
488        /// Print the private key to stdout as well.
489        #[arg(long)]
490        print_private: bool,
491    },
492}
493
494#[derive(Subcommand)]
495enum DataCommand {
496    /// Exports the venture's tables from a SQLite database (the D1
497    /// stand-in — `docs/DATA-MOVE.md`) to manifest + JSONL records.
498    Export {
499        /// The SQLite database file to read.
500        #[arg(long, value_name = "PATH")]
501        db: PathBuf,
502        /// Export without the tables of any sidecar-mounted module,
503        /// recording the omission in the manifest. Without this, an
504        /// export refuses to run when `HARNESS_SIDECARS` is set, rather
505        /// than producing an artifact that looks complete (issue #66).
506        #[arg(long)]
507        without_sidecar_tables: bool,
508        /// The JSONL file to write (manifest line first).
509        #[arg(long, value_name = "FILE")]
510        out: PathBuf,
511        /// Prints the per-table summary without writing the file.
512        #[arg(long)]
513        plan: bool,
514    },
515    /// Loads an export file into a Postgres database in lock order,
516    /// verifying sha256 and row counts against the manifest. Requires
517    /// the `postgres` feature.
518    Import {
519        /// Connection string of the target Postgres database.
520        #[arg(long, value_name = "URL")]
521        url: String,
522        /// The export file written by `fz data export`.
523        #[arg(value_name = "FILE")]
524        file: PathBuf,
525        /// Adds to non-empty tables instead of refusing them.
526        #[arg(long)]
527        append: bool,
528        /// Prints what would be imported and the target state, writing
529        /// nothing.
530        #[arg(long)]
531        plan: bool,
532    },
533}
534
535/// `fz tables`'s own dispatch, in its own function.
536///
537/// `run` is at clippy's line limit and a subcommand arm has now pushed it
538/// over twice; a group that grows belongs in a function that can grow.
539fn run_tables(command: TablesCommand) -> Result<(), String> {
540    match command {
541        TablesCommand::Diff { from, to } => tables::diff(&from, &to),
542        TablesCommand::Drift {
543            manifest,
544            dialect,
545            url,
546        } => tables::drift(&manifest, &dialect, &url),
547    }
548}
549
550#[derive(Subcommand)]
551enum TablesCommand {
552    /// Reports what changed between two versions of a manifest's
553    /// `[tables]` declaration, and what each change costs. Read-only,
554    /// and no database is involved.
555    Diff {
556        /// The manifest as it was — a checkout of `main`, say.
557        #[arg(long, value_name = "MANIFEST")]
558        from: PathBuf,
559        /// The manifest as it is now.
560        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
561        to: PathBuf,
562    },
563    /// Reports what a database differs from the manifest's `[tables]`
564    /// declaration by, in expand/contract/rewrite terms. Read-only.
565    Drift {
566        /// The venture manifest (`.json` or `.toml`).
567        #[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
568        manifest: PathBuf,
569        /// Target SQL dialect: postgres.
570        #[arg(long, default_value = "postgres")]
571        dialect: String,
572        /// Connection string of the database to read.
573        #[arg(long, value_name = "URL")]
574        url: String,
575    },
576}
577
578#[derive(Subcommand)]
579enum MigrationsCommand {
580    /// Collects module migrations into wrangler-ordered files.
581    Collect {
582        /// SQL dialect of the collected files (sqlite for wrangler/D1).
583        #[arg(long, default_value = "sqlite")]
584        dialect: String,
585        /// Output directory for wrangler `migrations_dir`.
586        #[arg(long, default_value = "migrations")]
587        out: PathBuf,
588    },
589    /// Applies the harness's migrations directly to the database
590    /// `--url` names — the native counterpart of
591    /// `wrangler d1 migrations apply` (issue #18). With `--fleet` or
592    /// `--plan` the URL names the **control database** instead and the
593    /// tenants in its registry are reconciled (RECONCILIATION.md).
594    Apply {
595        /// Target SQL dialect: postgres.
596        #[arg(long, default_value = "postgres")]
597        dialect: String,
598        /// Connection string of the target database (the control
599        /// database when `--fleet`, `--plan` or `--tenant` is given),
600        /// e.g. `postgres://user:pass@host:5432/venture`.
601        #[arg(long, value_name = "URL")]
602        url: String,
603        /// Reconcile every tenant in the control database's registry,
604        /// each against its own database, instead of applying to the
605        /// database `--url` names.
606        #[arg(long)]
607        fleet: bool,
608        /// Print what would be applied, per tenant and per module,
609        /// without applying anything.
610        #[arg(long)]
611        plan: bool,
612        /// Reconcile one tenant only (registry id, not a DSN).
613        #[arg(long, value_name = "TENANT")]
614        tenant: Option<String>,
615        /// Abort with a non-zero exit when any tenant is degraded,
616        /// instead of letting the others serve. Needs `--fleet`.
617        #[arg(long)]
618        strict: bool,
619    },
620}
621
622/// Runs `fz` with the venture's harness and the given arguments.
623///
624/// # Panics
625///
626/// Never; failures are reported on stderr with a non-zero exit code.
627#[must_use = "call process::exit with the returned ExitCode"]
628pub fn run(build: impl Fn() -> Harness, args: impl IntoIterator<Item = String>) -> ExitCode {
629    let mut full_argv: Vec<String> = Vec::with_capacity(8);
630    full_argv.push("fz".to_string());
631    full_argv.extend(args);
632    let cli = Cli::parse_from(full_argv);
633    // The harness-free commands run before (and without) the venture's
634    // compiled-in harness.
635    if let Some(exit) = harness_free(&cli.command) {
636        return exit;
637    }
638    let harness = build();
639    let sidecars = crate::sidecars::from_cli_or_env(cli.sidecars.as_deref());
640    let result = match cli.command {
641        Command::Tables { command } => run_tables(command),
642        Command::Migrations {
643            command: MigrationsCommand::Collect { dialect, out },
644        } => collect::collect(&harness, &dialect, &out),
645        Command::Migrations {
646            command:
647                MigrationsCommand::Apply {
648                    dialect,
649                    url,
650                    fleet,
651                    plan,
652                    tenant,
653                    strict,
654                },
655        } => apply::apply(
656            &harness,
657            &dialect,
658            &url,
659            plan,
660            fleet,
661            tenant.as_deref(),
662            strict,
663        ),
664        Command::Doctor {
665            out,
666            allow_no_captcha,
667            json,
668        } => {
669            if json {
670                return doctor::doctor_json(
671                    &harness,
672                    &out,
673                    allow_no_captcha.as_deref(),
674                    sidecars.as_deref(),
675                );
676            }
677            doctor::doctor(
678                &harness,
679                &out,
680                allow_no_captcha.as_deref(),
681                sidecars.as_deref(),
682            )
683        }
684        Command::Modules => {
685            print_modules(&harness);
686            Ok(())
687        }
688        Command::Data {
689            command:
690                DataCommand::Export {
691                    db,
692                    out,
693                    plan,
694                    without_sidecar_tables,
695                },
696        } => data::export(
697            &harness,
698            &db,
699            &out,
700            plan,
701            without_sidecar_tables,
702            sidecars.as_deref(),
703        ),
704        Command::Data {
705            command:
706                DataCommand::Import {
707                    url,
708                    file,
709                    append,
710                    plan,
711                },
712        } => data::import(&harness, &file, &url, append, plan),
713        // Handled by `harness_free` before `build()` above: build and
714        // client-ts generate from a file or a served contract rather than
715        // a compiled-in harness, the workflow commands work on the
716        // manifest and its records, and push reads the venture's
717        // environment.
718        Command::Build { .. }
719        | Command::BuildKey { .. }
720        | Command::ClientTs { .. }
721        | Command::Plan { .. }
722        | Command::Deploy { .. }
723        | Command::Add { .. }
724        | Command::Init { .. }
725        | Command::Verify { .. }
726        | Command::Push { .. }
727        | Command::Auth { .. }
728        | Command::Import { .. } => unreachable!("dispatched before the harness is built"),
729    };
730    finish(result)
731}
732
733/// The commands that need no compiled-in harness, run — the one seam
734/// both entry points dispatch through, before [`run`] builds the harness
735/// and instead of the harness [`run_standalone`] does not have.
736///
737/// `fz build` **generates** a harness, so it cannot have one; the manifest
738/// workflow — `fz plan` / `deploy` / `add` / `init` / `verify`, routed by
739/// [`workflow::dispatch`] — works on the manifest and its on-disk records
740/// rather than the compiled-in modules; `fz push` reads the venture's
741/// *environment* rather than its modules; and `fz auth import` talks to a
742/// URL. `None` for every other command,
743/// which is what sends [`run_standalone`] to its refusal and [`run`] on to
744/// `build()`.
745fn harness_free(command: &Command) -> Option<ExitCode> {
746    match command {
747        Command::Build {
748            manifest,
749            out,
750            harness_path,
751            catalog,
752            built_at,
753            builder,
754        } => Some(finish(build::run(
755            manifest,
756            out,
757            harness_path.as_deref(),
758            catalog.as_deref(),
759            built_at.as_deref(),
760            builder.as_deref(),
761        ))),
762        Command::BuildKey { manifest, catalog } => {
763            Some(finish(crate::build_key::run(manifest, catalog.as_deref())))
764        }
765        // Two output disciplines, exactly as `fz doctor` runs them: prose
766        // failures go through `finish`, `--json` prints its own object
767        // and owns the exit code.
768        Command::ClientTs { surface, out, json } => Some(if *json {
769            client_ts::run_json(surface, out)
770        } else {
771            finish(client_ts::run(surface, out))
772        }),
773        Command::Push { command } => Some(finish(run_push(command))),
774        Command::Auth { command } => Some(finish(run_auth(command))),
775        Command::Import {
776            command:
777                ImportCommand::Supabase {
778                    command:
779                        SupabaseCommand::Inspect {
780                            project,
781                            db_url,
782                            management_token,
783                            json,
784                            md: _,
785                            out,
786                            transfer_mbps,
787                            classify,
788                            classify_threshold,
789                        },
790                },
791        } => Some(finish(import::inspect(&import::InspectArgs {
792            project: project.clone(),
793            db_url: db_url.clone(),
794            management_token: management_token.clone(),
795            format: if *json {
796                import::Format::Json
797            } else {
798                import::Format::Markdown
799            },
800            out: out.clone(),
801            transfer_mbps: *transfer_mbps,
802            classify: *classify,
803            classify_threshold: *classify_threshold,
804        }))),
805        _ => workflow::dispatch(command),
806    }
807}
808
809/// `fz push` (issue #184). Every path prints its report to stdout and
810/// reports the verdict as the exit code.
811fn run_push(command: &PushCommand) -> Result<(), String> {
812    match command {
813        PushCommand::Vapid {
814            command:
815                VapidCommand::Keygen {
816                    file,
817                    force,
818                    print_private,
819                },
820        } => {
821            let generated = push::vapid_keygen(&push::KeygenOptions {
822                file: file.as_deref(),
823                force: *force,
824                print_private: *print_private,
825            })?;
826            // The rotation warning is a diagnostic, so it goes to stderr:
827            // stdout is the stream the README's recipe reads the public
828            // key off, and a warning in it ends up in somebody's
829            // `applicationServerKey`.
830            if let Some(warning) = generated.warning() {
831                eprint!("{warning}");
832            }
833            print!("{}", generated.render());
834            if *print_private {
835                print!("\n{}", *generated.private_key_disclosure());
836            }
837            Ok(())
838        }
839        PushCommand::Send {
840            transport,
841            recipient,
842            title,
843            body,
844            data,
845            url,
846            ttl,
847            priority,
848            silent,
849            dry_run,
850        } => {
851            let request = push::SendRequest::from_args(&push::SendArgs {
852                transport: *transport,
853                recipient: recipient.clone(),
854                title: title.clone(),
855                body: body.clone(),
856                data: data.clone(),
857                url: url.clone(),
858                ttl: *ttl,
859                priority: *priority,
860                silent: *silent,
861            })?;
862            let report = if *dry_run {
863                push::plan(&EnvVars, &request)
864            } else {
865                push::send_now(&EnvVars, &request)?
866            };
867            print!("{}", report.render());
868            if report.ok() {
869                Ok(())
870            } else {
871                // The report already said what happened, in full; the exit
872                // code is what a script reads, and `finish` prefixes this.
873                Err("nothing was delivered — see the report above".to_owned())
874            }
875        }
876        PushCommand::InspectSubscription { json } => {
877            print!("{}", push::inspect_subscription(json)?.render());
878            Ok(())
879        }
880    }
881}
882
883/// `fz auth` (issue #650). The admin token is read from the environment
884/// here — by the name `--admin-token-env` gives, through the same
885/// [`EnvVars`] the rest of `fz` reads config with — and handed to the
886/// import as a value, so it never becomes a flag in a shell history.
887fn run_auth(command: &AuthCommand) -> Result<(), String> {
888    match command {
889        AuthCommand::Import {
890            target,
891            admin_token_env,
892            apply,
893            merge_by_email,
894            report,
895            batch_size,
896            file,
897        } => {
898            let Some(admin_token) = EnvVars.get(admin_token_env) else {
899                return Err(format!(
900                    "the environment variable `{admin_token_env}` is not set or is empty (it \
901                     must hold the auth-core admin token — the value the venture has as \
902                     `ADMIN_TOKEN`)"
903                ));
904            };
905            auth_import::run(&auth_import::ImportOptions {
906                file: file.clone(),
907                target: target.clone(),
908                admin_token,
909                apply: *apply,
910                merge_by_email: *merge_by_email,
911                report: report.clone(),
912                batch_size: *batch_size,
913            })
914        }
915    }
916}
917
918/// Runs the harness-free `fz` commands from a standalone binary (no
919/// compiled-in venture): `fz build <manifest>`, which generates a harness;
920/// `fz client-ts`, which generates the typed TypeScript client from the
921/// contract a venture serves; the manifest workflow — `fz plan` /
922/// `deploy` / `add` / `init` / `verify` — which works on the manifest and
923/// its records, not on compiled-in modules; `fz push`, which reads the
924/// venture's environment; and `fz auth`, which talks to a URL. Every
925/// other command needs the venture's harness and says so.
926#[must_use = "call process::exit with the returned ExitCode"]
927pub fn run_standalone(args: impl IntoIterator<Item = String>) -> ExitCode {
928    let mut full_argv: Vec<String> = Vec::with_capacity(8);
929    full_argv.push("fz".to_string());
930    full_argv.extend(args);
931    let cli = Cli::parse_from(full_argv);
932    let Some(exit) = harness_free(&cli.command) else {
933        eprintln!(
934            "fz: this command must run inside a venture (it needs the compiled-in harness — see \
935             the cratefield-cli README). Only `fz build <manifest>`, `fz client-ts`, the manifest \
936             workflow (`fz plan` / `deploy` / `add` / `init` / `verify`), `fz push`, `fz auth` \
937             and `fz import` run standalone."
938        );
939        return ExitCode::FAILURE;
940    };
941    exit
942}
943
944fn finish(result: Result<(), String>) -> ExitCode {
945    match result {
946        Ok(()) => ExitCode::SUCCESS,
947        Err(message) => {
948            eprintln!("fz: {message}");
949            ExitCode::FAILURE
950        }
951    }
952}
953
954/// Entry point for the venture-side `fz` bin (see the crate docs).
955pub fn main_for(build: fn() -> Harness) -> ! {
956    let code = run(build, std::env::args().skip(1));
957    std::process::exit(match code {
958        ExitCode::SUCCESS => 0,
959        _ => 1,
960    });
961}
962
963fn print_modules(harness: &Harness) {
964    for module in harness.modules() {
965        println!(
966            "{} {} /v1/{} emits=[{}] tables=[{}]",
967            module.name(),
968            module.version(),
969            module.name(),
970            module.emits().join(", "),
971            module.tables().join(", "),
972        );
973    }
974}