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