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