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}