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}