Skip to main content

cratefield_cli/
lib.rs

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