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