#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
pub mod apply;
pub mod build;
pub mod build_key;
pub mod client_ts;
pub mod codes;
mod collect;
pub mod data;
pub mod doctor;
pub mod lint;
mod lock;
pub mod push;
pub mod sidecars;
pub mod tables;
pub mod workflow;
use clap::{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,
},
}
#[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 { .. } => 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))),
_ => workflow::dispatch(command),
}
}
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(())
}
}
}
#[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`) and `fz push` 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(", "),
);
}
}