#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
pub mod apply;
pub mod auth_import;
pub mod build;
pub mod build_key;
pub mod client_ts;
pub mod codes;
mod collect;
pub mod data;
pub mod doctor;
pub mod import;
pub mod import_users;
pub mod lint;
mod lock;
pub mod push;
pub mod sidecars;
pub mod tables;
pub mod workflow;
use clap::{Args, Parser, Subcommand};
use cratefield_core::{Config, Harness};
use std::path::PathBuf;
use std::process::ExitCode;
pub(crate) struct EnvVars;
impl Config for EnvVars {
fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.is_empty())
}
}
#[derive(Parser)]
#[command(name = "fz", about = "Factory Zero venture CLI")]
struct Cli {
#[arg(long, global = true, value_name = "JSON")]
sidecars: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Migrations {
#[command(subcommand)]
command: MigrationsCommand,
},
Doctor {
#[arg(long, default_value = "migrations")]
out: PathBuf,
#[arg(long, value_name = "REASON")]
allow_no_captcha: Option<String>,
#[arg(long)]
json: bool,
},
Modules,
Build {
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, default_value = "dist")]
out: PathBuf,
#[arg(long, value_name = "PATH")]
harness_path: Option<String>,
#[arg(long, value_name = "PATH")]
catalog: Option<PathBuf>,
#[arg(long, value_name = "RFC3339")]
built_at: Option<String>,
#[arg(long, value_name = "WHO")]
builder: Option<String>,
},
BuildKey {
#[arg(value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, value_name = "PATH")]
catalog: Option<PathBuf>,
},
ClientTs {
#[arg(long, value_name = "PATH")]
surface: String,
#[arg(long, value_name = "DIR")]
out: PathBuf,
#[arg(long)]
json: bool,
},
Data {
#[command(subcommand)]
command: DataCommand,
},
Tables {
#[command(subcommand)]
command: TablesCommand,
},
Plan {
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, default_value = "migrations")]
migrations: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
non_interactive: bool,
},
Deploy {
#[arg(long, value_name = "DIGEST")]
plan: Option<String>,
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, default_value = "migrations")]
migrations: PathBuf,
#[arg(long)]
i_am_deploying_to_production: bool,
#[arg(long)]
i_am_removing_modules: bool,
#[arg(long)]
json: bool,
#[arg(long)]
non_interactive: bool,
},
Add {
#[arg(value_name = "MODULE")]
module: String,
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
non_interactive: bool,
},
Init {
#[arg(long, value_name = "NAME")]
name: String,
#[arg(long, value_name = "HOST")]
host: String,
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
#[arg(long)]
non_interactive: bool,
},
Verify {
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, default_value = "migrations")]
migrations: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
non_interactive: bool,
},
Push {
#[command(subcommand)]
command: PushCommand,
},
Auth {
#[command(subcommand)]
command: AuthCommand,
},
Import {
#[command(subcommand)]
command: ImportCommand,
},
}
#[derive(Subcommand)]
enum AuthCommand {
Import {
#[arg(long, value_name = "URL")]
target: String,
#[arg(long, value_name = "VAR")]
admin_token_env: String,
#[arg(long)]
apply: bool,
#[arg(long)]
merge_by_email: bool,
#[arg(long, value_name = "PATH")]
report: Option<PathBuf>,
#[arg(long, default_value_t = auth_import::DEFAULT_BATCH_SIZE, value_name = "N")]
batch_size: usize,
#[arg(value_name = "FILE")]
file: PathBuf,
},
}
#[derive(Subcommand)]
enum ImportCommand {
#[command(args_conflicts_with_subcommands = true)]
Supabase {
#[command(subcommand)]
command: Option<SupabaseCommand>,
#[command(flatten)]
run: SupabaseRunArgs,
},
}
#[derive(Args)]
struct SupabaseRunArgs {
#[arg(long, value_name = "REF")]
project: Option<String>,
#[arg(long, value_name = "URL")]
db_url: Option<String>,
#[arg(long, value_name = "TOKEN")]
management_token: Option<String>,
#[arg(long, value_name = "VAR", default_value = import::DEFAULT_TARGET_ENV)]
target_env: String,
#[arg(long, value_name = "DIR", default_value = ".")]
dir: PathBuf,
#[arg(long, default_value_t = 100, value_name = "MBPS")]
transfer_mbps: u32,
#[arg(long)]
classify: bool,
#[arg(long, default_value_t = 0.8, value_name = "0..1")]
classify_threshold: f32,
#[arg(long, requires = "plan")]
apply: bool,
#[arg(long, value_name = "FILE", requires = "apply")]
plan: Option<PathBuf>,
}
#[derive(Subcommand)]
enum SupabaseCommand {
Inspect {
#[command(flatten)]
source: SourceArgs,
#[arg(long, conflicts_with = "md")]
json: bool,
#[arg(long)]
md: bool,
#[arg(long, value_name = "FILE")]
out: Option<PathBuf>,
#[arg(long, default_value_t = 100, value_name = "MBPS")]
transfer_mbps: u32,
#[arg(long)]
classify: bool,
#[arg(long, default_value_t = 0.8, value_name = "0..1")]
classify_threshold: f32,
#[arg(long, value_name = "FILE")]
dispositions: Option<PathBuf>,
},
Dispositions {
#[command(subcommand)]
command: DispositionsCommand,
},
Plan {
#[command(flatten)]
args: SupabaseRunArgs,
},
Users {
#[arg(long, value_name = "URL")]
db_url: Option<String>,
#[arg(long, value_name = "URL")]
target: String,
#[arg(long, value_name = "VAR")]
admin_token_env: String,
#[arg(long, value_name = "VAR")]
map_db_url_env: Option<String>,
#[arg(long, value_name = "NAME")]
oidc_provider: Vec<String>,
#[arg(long, default_value_t = auth_import::DEFAULT_BATCH_SIZE, value_name = "N")]
batch_size: usize,
#[arg(long)]
merge_by_email: bool,
#[arg(long)]
apply: bool,
#[arg(long, value_name = "PATH")]
report: Option<PathBuf>,
},
}
#[derive(Args)]
struct SourceArgs {
#[arg(long, value_name = "REF")]
project: String,
#[arg(long, value_name = "URL")]
db_url: Option<String>,
#[arg(long, value_name = "TOKEN")]
management_token: Option<String>,
}
#[derive(Subcommand)]
enum DispositionsCommand {
Init {
#[command(flatten)]
source: SourceArgs,
#[arg(
long,
value_name = "FILE",
default_value = "import/supabase-dispositions.toml"
)]
out: PathBuf,
#[arg(long)]
force: bool,
#[arg(long, value_name = "FILE")]
dispositions: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum PushCommand {
Vapid {
#[command(subcommand)]
command: VapidCommand,
},
Send {
#[arg(long, value_enum)]
transport: push::Transport,
#[arg(long, value_name = "TOKEN-OR-JSON")]
recipient: String,
#[arg(long)]
title: String,
#[arg(long)]
body: String,
#[arg(long, value_name = "JSON")]
data: Option<String>,
#[arg(long, value_name = "URL")]
url: Option<String>,
#[arg(long, value_name = "SECONDS")]
ttl: Option<u64>,
#[arg(long, value_enum, default_value_t = push::PriorityArg::Immediate)]
priority: push::PriorityArg,
#[arg(long)]
silent: bool,
#[arg(long)]
dry_run: bool,
},
InspectSubscription {
#[arg(value_name = "JSON")]
json: String,
},
}
#[derive(Subcommand)]
enum VapidCommand {
Keygen {
#[arg(long, value_name = "PATH")]
file: Option<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
print_private: bool,
},
}
#[derive(Subcommand)]
enum DataCommand {
Export {
#[arg(long, value_name = "PATH")]
db: PathBuf,
#[arg(long)]
without_sidecar_tables: bool,
#[arg(long, value_name = "FILE")]
out: PathBuf,
#[arg(long)]
plan: bool,
},
Import {
#[arg(long, value_name = "URL")]
url: String,
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
append: bool,
#[arg(long)]
plan: bool,
},
}
fn run_tables(command: TablesCommand) -> Result<(), String> {
match command {
TablesCommand::Diff { from, to } => tables::diff(&from, &to),
TablesCommand::Drift {
manifest,
dialect,
url,
} => tables::drift(&manifest, &dialect, &url),
}
}
#[derive(Subcommand)]
enum TablesCommand {
Diff {
#[arg(long, value_name = "MANIFEST")]
from: PathBuf,
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
to: PathBuf,
},
Drift {
#[arg(long, default_value = "venture.json", value_name = "MANIFEST")]
manifest: PathBuf,
#[arg(long, default_value = "postgres")]
dialect: String,
#[arg(long, value_name = "URL")]
url: String,
},
}
#[derive(Subcommand)]
enum MigrationsCommand {
Collect {
#[arg(long, default_value = "sqlite")]
dialect: String,
#[arg(long, default_value = "migrations")]
out: PathBuf,
},
Apply {
#[arg(long, default_value = "postgres")]
dialect: String,
#[arg(long, value_name = "URL")]
url: String,
#[arg(long)]
fleet: bool,
#[arg(long)]
plan: bool,
#[arg(long, value_name = "TENANT")]
tenant: Option<String>,
#[arg(long)]
strict: bool,
},
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn run(build: impl Fn() -> Harness, args: impl IntoIterator<Item = String>) -> ExitCode {
let mut full_argv: Vec<String> = Vec::with_capacity(8);
full_argv.push("fz".to_string());
full_argv.extend(args);
let cli = Cli::parse_from(full_argv);
if let Some(exit) = harness_free(&cli.command) {
return exit;
}
let harness = build();
let sidecars = crate::sidecars::from_cli_or_env(cli.sidecars.as_deref());
let result = match cli.command {
Command::Tables { command } => run_tables(command),
Command::Migrations {
command: MigrationsCommand::Collect { dialect, out },
} => collect::collect(&harness, &dialect, &out),
Command::Migrations {
command:
MigrationsCommand::Apply {
dialect,
url,
fleet,
plan,
tenant,
strict,
},
} => apply::apply(
&harness,
&dialect,
&url,
plan,
fleet,
tenant.as_deref(),
strict,
),
Command::Doctor {
out,
allow_no_captcha,
json,
} => {
if json {
return doctor::doctor_json(
&harness,
&out,
allow_no_captcha.as_deref(),
sidecars.as_deref(),
);
}
doctor::doctor(
&harness,
&out,
allow_no_captcha.as_deref(),
sidecars.as_deref(),
)
}
Command::Modules => {
print_modules(&harness);
Ok(())
}
Command::Data {
command:
DataCommand::Export {
db,
out,
plan,
without_sidecar_tables,
},
} => data::export(
&harness,
&db,
&out,
plan,
without_sidecar_tables,
sidecars.as_deref(),
),
Command::Data {
command:
DataCommand::Import {
url,
file,
append,
plan,
},
} => data::import(&harness, &file, &url, append, plan),
Command::Build { .. }
| Command::BuildKey { .. }
| Command::ClientTs { .. }
| Command::Plan { .. }
| Command::Deploy { .. }
| Command::Add { .. }
| Command::Init { .. }
| Command::Verify { .. }
| Command::Push { .. }
| Command::Auth { .. }
| Command::Import { .. } => unreachable!("dispatched before the harness is built"),
};
finish(result)
}
fn harness_free(command: &Command) -> Option<ExitCode> {
match command {
Command::Build {
manifest,
out,
harness_path,
catalog,
built_at,
builder,
} => Some(finish(build::run(
manifest,
out,
harness_path.as_deref(),
catalog.as_deref(),
built_at.as_deref(),
builder.as_deref(),
))),
Command::BuildKey { manifest, catalog } => {
Some(finish(crate::build_key::run(manifest, catalog.as_deref())))
}
Command::ClientTs { surface, out, json } => Some(if *json {
client_ts::run_json(surface, out)
} else {
finish(client_ts::run(surface, out))
}),
Command::Push { command } => Some(finish(run_push(command))),
Command::Auth { command } => Some(finish(run_auth(command))),
Command::Import { command } => Some(finish(run_import(command))),
_ => workflow::dispatch(command),
}
}
fn run_import(command: &ImportCommand) -> Result<(), String> {
let ImportCommand::Supabase { command, run } = command;
match command {
None => import::plan(&plan_args(run)?),
Some(SupabaseCommand::Plan { args }) => import::plan(&plan_args(args)?),
Some(SupabaseCommand::Inspect {
source,
json,
md: _,
out,
transfer_mbps,
classify,
classify_threshold,
dispositions,
}) => import::inspect(&import::InspectArgs {
project: source.project.clone(),
db_url: source.db_url.clone(),
management_token: source.management_token.clone(),
format: if *json {
import::Format::Json
} else {
import::Format::Markdown
},
out: out.clone(),
transfer_mbps: *transfer_mbps,
classify: *classify,
classify_threshold: *classify_threshold,
dispositions: dispositions.clone(),
}),
Some(SupabaseCommand::Dispositions {
command:
DispositionsCommand::Init {
source,
out,
force,
dispositions,
},
}) => import::dispositions_init(&import::DispositionsInitArgs {
project: source.project.clone(),
db_url: source.db_url.clone(),
management_token: source.management_token.clone(),
out: out.clone(),
force: *force,
dispositions: dispositions.clone(),
}),
Some(SupabaseCommand::Users {
db_url,
target,
admin_token_env,
map_db_url_env,
oidc_provider,
batch_size,
merge_by_email,
apply,
report,
}) => import_users::run(&import_users::UsersArgs {
db_url: db_url.clone(),
target: target.clone(),
admin_token_env: admin_token_env.clone(),
map_db_url_env: map_db_url_env.clone(),
oidc_providers: oidc_provider.clone(),
batch_size: *batch_size,
merge_by_email: *merge_by_email,
apply: *apply,
report: report.clone(),
}),
}
}
fn plan_args(run: &SupabaseRunArgs) -> Result<import::PlanArgs, String> {
let project = run.project.clone().ok_or_else(|| {
"no project ref: pass --project <ref>, the `<ref>` in https://<ref>.supabase.co".to_owned()
})?;
Ok(import::PlanArgs {
project,
db_url: run.db_url.clone(),
management_token: run.management_token.clone(),
target_env: run.target_env.clone(),
dir: run.dir.clone(),
transfer_mbps: run.transfer_mbps,
classify: run.classify,
classify_threshold: run.classify_threshold,
apply: run.apply,
plan: run.plan.clone(),
})
}
fn run_push(command: &PushCommand) -> Result<(), String> {
match command {
PushCommand::Vapid {
command:
VapidCommand::Keygen {
file,
force,
print_private,
},
} => {
let generated = push::vapid_keygen(&push::KeygenOptions {
file: file.as_deref(),
force: *force,
print_private: *print_private,
})?;
if let Some(warning) = generated.warning() {
eprint!("{warning}");
}
print!("{}", generated.render());
if *print_private {
print!("\n{}", *generated.private_key_disclosure());
}
Ok(())
}
PushCommand::Send {
transport,
recipient,
title,
body,
data,
url,
ttl,
priority,
silent,
dry_run,
} => {
let request = push::SendRequest::from_args(&push::SendArgs {
transport: *transport,
recipient: recipient.clone(),
title: title.clone(),
body: body.clone(),
data: data.clone(),
url: url.clone(),
ttl: *ttl,
priority: *priority,
silent: *silent,
})?;
let report = if *dry_run {
push::plan(&EnvVars, &request)
} else {
push::send_now(&EnvVars, &request)?
};
print!("{}", report.render());
if report.ok() {
Ok(())
} else {
Err("nothing was delivered — see the report above".to_owned())
}
}
PushCommand::InspectSubscription { json } => {
print!("{}", push::inspect_subscription(json)?.render());
Ok(())
}
}
}
fn run_auth(command: &AuthCommand) -> Result<(), String> {
match command {
AuthCommand::Import {
target,
admin_token_env,
apply,
merge_by_email,
report,
batch_size,
file,
} => {
let Some(admin_token) = EnvVars.get(admin_token_env) else {
return Err(format!(
"the environment variable `{admin_token_env}` is not set or is empty (it \
must hold the auth-core admin token — the value the venture has as \
`ADMIN_TOKEN`)"
));
};
auth_import::run(&auth_import::ImportOptions {
file: file.clone(),
target: target.clone(),
admin_token,
apply: *apply,
merge_by_email: *merge_by_email,
report: report.clone(),
batch_size: *batch_size,
})
}
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn run_standalone(args: impl IntoIterator<Item = String>) -> ExitCode {
let mut full_argv: Vec<String> = Vec::with_capacity(8);
full_argv.push("fz".to_string());
full_argv.extend(args);
let cli = Cli::parse_from(full_argv);
let Some(exit) = harness_free(&cli.command) else {
eprintln!(
"fz: this command must run inside a venture (it needs the compiled-in harness — see \
the cratefield-cli README). Only `fz build <manifest>`, `fz client-ts`, the manifest \
workflow (`fz plan` / `deploy` / `add` / `init` / `verify`), `fz push`, `fz auth` \
and `fz import` run standalone."
);
return ExitCode::FAILURE;
};
exit
}
fn finish(result: Result<(), String>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("fz: {message}");
ExitCode::FAILURE
}
}
}
pub fn main_for(build: fn() -> Harness) -> ! {
let code = run(build, std::env::args().skip(1));
std::process::exit(match code {
ExitCode::SUCCESS => 0,
_ => 1,
});
}
fn print_modules(harness: &Harness) {
for module in harness.modules() {
println!(
"{} {} /v1/{} emits=[{}] tables=[{}]",
module.name(),
module.version(),
module.name(),
module.emits().join(", "),
module.tables().join(", "),
);
}
}
#[cfg(test)]
mod tests {
use super::{Cli, Command, ImportCommand, SupabaseCommand};
use clap::Parser as _;
use std::ffi::OsString;
fn parse(parts: &[&str]) -> Result<Cli, clap::Error> {
let mut argv = vec![OsString::from("fz")];
argv.extend(parts.iter().map(OsString::from));
Cli::try_parse_from(argv)
}
#[test]
fn the_bare_supabase_command_parses_the_run() {
let cli = parse(&["import", "supabase", "--project", "abcdefghijklmnopqrst"])
.expect("the bare run parses");
let Command::Import {
command: ImportCommand::Supabase { command, run },
} = cli.command
else {
panic!("expected `import supabase`");
};
assert!(command.is_none());
assert_eq!(run.project.as_deref(), Some("abcdefghijklmnopqrst"));
assert_eq!(run.target_env, "DATABASE_URL");
assert_eq!(run.dir, std::path::PathBuf::from("."));
assert!(!run.apply);
assert!(run.plan.is_none());
}
#[test]
fn supabase_inspect_still_parses_without_the_run_args() {
let cli = parse(&[
"import",
"supabase",
"inspect",
"--project",
"abcdefghijklmnopqrst",
"--json",
])
.expect("inspect parses as before");
let Command::Import {
command: ImportCommand::Supabase { command, .. },
} = cli.command
else {
panic!("expected `import supabase`");
};
assert!(matches!(
command,
Some(SupabaseCommand::Inspect { json: true, .. })
));
}
#[test]
fn supabase_plan_parses_its_run_args() {
let cli = parse(&[
"import",
"supabase",
"plan",
"--project",
"abcdefghijklmnopqrst",
"--dir",
"/tmp/plan",
])
.expect("plan parses");
let Command::Import {
command: ImportCommand::Supabase { command, .. },
} = cli.command
else {
panic!("expected `import supabase`");
};
let Some(SupabaseCommand::Plan { args }) = command else {
panic!("expected `plan`");
};
assert_eq!(args.project.as_deref(), Some("abcdefghijklmnopqrst"));
assert_eq!(args.dir, std::path::PathBuf::from("/tmp/plan"));
}
#[test]
fn apply_without_a_plan_is_refused() {
let Err(error) = parse(&[
"import",
"supabase",
"--project",
"abcdefghijklmnopqrst",
"--apply",
]) else {
panic!("--apply without --plan must be refused");
};
assert!(error.to_string().contains("--plan"), "{error}");
}
#[test]
fn the_bare_command_needs_a_project_at_run_time() {
let Ok(cli) = parse(&["import", "supabase"]) else {
panic!("the bare command parses (a subcommand may replace the run)");
};
let Command::Import {
command: ImportCommand::Supabase { run, .. },
} = cli.command
else {
panic!("expected `import supabase`");
};
assert!(run.project.is_none());
let error = super::plan_args(&run).expect_err("a project ref is required");
assert!(error.contains("--project"), "{error}");
}
}