Menu
AkurAI-Build
publicLatest change 18b39dea5ae62e0776a868d194eb39d528555ca9 - cli: propagate manifest-read errors so doctor exits 1, not 3 (closes #9) by Olafur Bui
use std::{fs, net::SocketAddr, path::PathBuf, process::Command as ProcessCommand, sync::Arc};
use anyhow::{Context, Result, ensure};
use bunfork::{
DEFAULT_ADDRESS,
artifact::{self, AdmitOptions, Mode, TrailingSlash},
db::{
Database, generate_secret, load_secret, parse_embedding, restore_database, secrets_equal,
},
deploy, server,
};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::Serialize;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(
name = "bunfork",
version,
about = "Small, secure Linux-first Rust web runtime (not a fork of Bun)"
)]
struct Cli {
#[arg(
long,
global = true,
env = "BUNFORK_DB",
default_value = "data/bunfork.db"
)]
database: PathBuf,
#[arg(
long,
global = true,
env = "BUNFORK_KEY_FILE",
default_value = ".bunfork.key"
)]
key_file: PathBuf,
#[arg(long, global = true, env = "BUNFORK_TENANT", default_value = "default")]
tenant: String,
#[arg(long, global = true, env = "BUNFORK_MODEL", default_value = "default")]
model: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Create a private 256-bit database key or API token file.
Keygen {
#[arg(long, default_value = ".bunfork.key")]
out: PathBuf,
},
/// Run an explicit frontend build command, then admit its static output.
Build(BuildArgs),
/// Create a deterministic bunfork-static-v1 manifest for prebuilt files.
Admit(ArtifactArgs),
/// Validate a frozen static artifact without starting a listener.
Doctor(DoctorArgs),
/// Run with request-time page and route reloads.
Dev(ServerArgs),
/// Run the production server.
#[command(visible_alias = "start")]
Serve(ServerArgs),
/// Apply all embedded database migrations transactionally.
Migrate,
/// Create a verified, encrypted, atomic database backup.
Backup {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
/// Restore a verified backup while preserving the previous database.
Restore {
from: PathBuf,
#[arg(
long,
required = true,
help = "Required acknowledgement for this destructive operation"
)]
yes: bool,
},
/// Store, search, or delete encrypted vector records.
Vector {
#[command(subcommand)]
command: VectorCommand,
},
/// Run isolated route, migration, backup, and vector checks.
Test {
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
},
/// Test, release-build, and create a local deployment directory.
Deploy {
#[arg(long, default_value = "bunfork-dist")]
out: PathBuf,
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
#[arg(long, requires = "static_manifest")]
artifact: Option<PathBuf>,
#[arg(long = "static-manifest", requires = "artifact")]
static_manifest: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Args)]
struct BuildArgs {
#[arg(long = "run")]
program: String,
#[arg(long = "arg", allow_hyphen_values = true)]
arguments: Vec<String>,
#[arg(long, default_value = ".")]
cwd: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[command(flatten)]
artifact: ArtifactOptions,
#[arg(long = "env", value_name = "NAME=VALUE")]
environment: Vec<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ArtifactArgs {
#[arg(long)]
artifact: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[command(flatten)]
options: ArtifactOptions,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct DoctorArgs {
#[arg(long = "static")]
artifact: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ArtifactOptions {
#[arg(long, value_enum, default_value_t = ArtifactMode::Mpa)]
mode: ArtifactMode,
#[arg(long, default_value = "/")]
base: String,
#[arg(long)]
fallback: Option<String>,
#[arg(long, value_enum, default_value_t = ArtifactTrailingSlash::Directory)]
trailing_slash: ArtifactTrailingSlash,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArtifactMode {
Mpa,
Spa,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArtifactTrailingSlash {
Ignore,
Redirect,
Directory,
}
#[derive(Debug, Args)]
struct ServerArgs {
#[arg(
long,
env = "BUNFORK_ADDRESS",
default_value = DEFAULT_ADDRESS
)]
address: SocketAddr,
/// Serve a verified bunfork-static-v1 artifact without database state.
#[arg(long = "static", value_name = "DIR", requires = "manifest")]
static_site: Option<PathBuf>,
/// Frozen bunfork-static-v1 manifest for --static.
#[arg(long, value_name = "FILE", requires = "static_site")]
manifest: Option<PathBuf>,
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
#[arg(long, env = "BUNFORK_TOKEN_FILE", default_value = ".bunfork.token")]
token_file: PathBuf,
#[arg(long, env = "BUNFORK_PUBLIC_ORIGIN")]
public_origin: Option<String>,
}
#[derive(Debug, Subcommand)]
enum VectorCommand {
Put {
id: String,
#[arg(long)]
content_file: PathBuf,
#[arg(long, help = "Comma-separated finite f32 values")]
embedding: String,
},
Search {
#[arg(long, help = "Comma-separated finite f32 values")]
embedding: String,
#[arg(long, default_value_t = 10)]
limit: usize,
},
Delete {
id: String,
},
}
#[derive(Serialize)]
struct AdmissionReport<'a> {
ok: bool,
schema: &'a str,
mode: &'a str,
base: &'a str,
files: usize,
bytes: u64,
}
#[derive(Serialize)]
struct DoctorReport<'a> {
ok: bool,
checks: Vec<DoctorCheck<'a>>,
}
#[derive(Serialize)]
struct DoctorCheck<'a> {
id: &'a str,
status: &'a str,
message: &'a str,
}
#[tokio::main]
async fn main() -> Result<()> {
init_logging()?;
let cli = Cli::parse();
match &cli.command {
Commands::Keygen { out } => {
generate_secret(out)?;
println!("created {}", out.display());
}
Commands::Build(args) => build_frontend(args)?,
Commands::Admit(args) => {
let manifest = artifact::admit(
&args.artifact,
&args.manifest,
artifact_options(&args.options),
)?;
print_admission(&manifest, args.json)?;
}
Commands::Doctor(args) => doctor(args)?,
Commands::Dev(args) => run_server(&cli, args, true).await?,
Commands::Serve(args) => run_server(&cli, args, false).await?,
Commands::Migrate => {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_for_migration(&cli.database, key.as_str())?;
let applied = database.migrate()?;
database.validate_schema()?;
println!("applied {applied} migration(s)");
}
Commands::Backup { out, json } => {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_existing(&cli.database, key.as_str())?;
let out = out.clone().unwrap_or_else(|| {
PathBuf::from("backups").join(format!("bunfork-{}.db", deploy::timestamp_or_zero()))
});
database.backup(&out, key.as_str())?;
if *json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"path": out.display().to_string(),
}))?
);
} else {
println!("created verified encrypted backup {}", out.display());
}
}
Commands::Restore { from, yes } => {
ensure!(*yes, "restore requires --yes");
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let preserved = restore_database(&cli.database, from, key.as_str())?;
println!(
"restored {}; previous database kept at {}",
from.display(),
preserved.display()
);
}
Commands::Vector { command } => run_vector_command(&cli, command)?,
Commands::Test { pages, public } => deploy::smoke_test(pages, public)?,
Commands::Deploy {
out,
pages,
public,
artifact,
static_manifest,
force,
} => deploy::deploy(
out,
pages,
public,
artifact.as_deref(),
static_manifest.as_deref(),
*force,
)?,
}
Ok(())
}
impl From<ArtifactMode> for Mode {
fn from(value: ArtifactMode) -> Self {
match value {
ArtifactMode::Mpa => Self::Mpa,
ArtifactMode::Spa => Self::Spa,
}
}
}
impl From<ArtifactTrailingSlash> for TrailingSlash {
fn from(value: ArtifactTrailingSlash) -> Self {
match value {
ArtifactTrailingSlash::Ignore => Self::Ignore,
ArtifactTrailingSlash::Redirect => Self::Redirect,
ArtifactTrailingSlash::Directory => Self::Directory,
}
}
}
fn artifact_options(options: &ArtifactOptions) -> AdmitOptions {
AdmitOptions {
mode: options.mode.into(),
base: options.base.clone(),
fallback: options.fallback.clone(),
trailing_slash: options.trailing_slash.into(),
}
}
fn build_frontend(args: &BuildArgs) -> Result<()> {
ensure!(!args.program.is_empty(), "build program cannot be empty");
let cwd = fs::canonicalize(&args.cwd).context("resolve build working directory")?;
ensure!(cwd.is_dir(), "build working directory is not a directory");
let mut command = ProcessCommand::new(&args.program);
command
.args(&args.arguments)
.current_dir(&cwd)
.env_clear()
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
for name in [
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CACHE_HOME",
"CARGO_HOME",
"RUSTUP_HOME",
] {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
for assignment in &args.environment {
let (name, value) = assignment
.split_once('=')
.context("--env must use NAME=VALUE")?;
ensure!(
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'),
"invalid build environment name"
);
ensure!(
!matches!(name, "BUNFORK_DB_KEY" | "BUNFORK_API_TOKEN"),
"refuse to pass Bunfork secrets to a build"
);
command.env(name, value);
}
let status = command
.status()
.with_context(|| format!("run frontend build program {}", args.program))?;
ensure!(status.success(), "frontend build failed with {status}");
let artifact_root = if args.output.is_absolute() {
args.output.clone()
} else {
cwd.join(&args.output)
};
let manifest = artifact::admit(
&artifact_root,
&args.manifest,
artifact_options(&args.artifact),
)?;
print_admission(&manifest, args.json)
}
fn print_admission(manifest: &artifact::Manifest, json: bool) -> Result<()> {
let bytes = manifest.files.values().map(|record| record.bytes).sum();
let report = AdmissionReport {
ok: true,
schema: &manifest.schema,
mode: match manifest.mode {
Mode::Mpa => "mpa",
Mode::Spa => "spa",
},
base: &manifest.base,
files: manifest.files.len(),
bytes,
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"admitted {} {} artifact: {} files, {} bytes, base {}",
report.schema, report.mode, report.files, report.bytes, report.base
);
}
Ok(())
}
/// Exit code contract: 0 = verified, 2 = CLI misuse (clap), 3 = verification
/// failed. Other operational errors keep anyhow's exit 1 so scripts can
/// distinguish 'artifact is bad' from 'doctor could not run'.
const EXIT_VERIFICATION_FAILED: i32 = 3;
fn doctor(args: &DoctorArgs) -> Result<()> {
// Propagate manifest-read failures (missing file, permission denied,
// malformed JSON) with `?` so they keep anyhow's exit 1 ('doctor could
// not run'); only a verify() mismatch reserves exit 3 ('artifact is
// bad'), matching the contract documented above.
let manifest = artifact::read_manifest(&args.manifest)?;
let failure = artifact::verify(&args.artifact, &manifest).err();
let message = match &failure {
None => "artifact matches manifest".to_owned(),
// Include the full anyhow chain so JSON consumers see the actual
// divergence instead of an opaque 'validation failed'.
Some(error) => format!("{error:#}"),
};
if args.json {
let report = DoctorReport {
ok: failure.is_none(),
checks: vec![DoctorCheck {
id: "artifact.manifest",
status: if failure.is_none() { "pass" } else { "fail" },
message: &message,
}],
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else if failure.is_none() {
println!("ok: artifact matches manifest");
} else {
eprintln!("error: {message}");
}
if failure.is_some() {
std::process::exit(EXIT_VERIFICATION_FAILED);
}
Ok(())
}
async fn run_server(cli: &Cli, args: &ServerArgs, development: bool) -> Result<()> {
if let (Some(root), Some(manifest)) = (&args.static_site, &args.manifest) {
return server::serve_static(args.address, root, manifest, development).await;
}
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let token = load_secret(&args.token_file, "BUNFORK_API_TOKEN")?;
ensure!(
!secrets_equal(key.as_str(), token.as_str())?,
"API token must differ from the database key"
);
let database_path = fs::canonicalize(&cli.database).context("resolve database path")?;
let key_file = fs::symlink_metadata(&cli.key_file)
.ok()
.map(|_| fs::canonicalize(&cli.key_file).context("resolve database key file"))
.transpose()?;
let token_file = fs::symlink_metadata(&args.token_file)
.ok()
.map(|_| fs::canonicalize(&args.token_file).context("resolve API token file"))
.transpose()?;
let pages = fs::canonicalize(&args.pages).context("resolve pages directory")?;
let public = fs::canonicalize(&args.public).context("resolve public directory")?;
ensure!(
!pages.starts_with(&public) && !public.starts_with(&pages),
"pages and public directories must be disjoint"
);
for (label, sensitive) in [
("database", Some(&database_path)),
("database key", key_file.as_ref()),
("API token", token_file.as_ref()),
]
.into_iter()
.filter_map(|(label, path)| path.map(|path| (label, path)))
{
ensure!(
!sensitive.starts_with(&pages) && !sensitive.starts_with(&public),
"{label} must be outside the pages and public directories"
);
}
let database = Database::open_existing(&database_path, key.as_str())?;
server::serve(
database,
server::ServeOptions {
address: args.address,
pages,
public,
tenant: cli.tenant.clone(),
model: cli.model.clone(),
development,
api_token: Arc::new(token),
public_origin: args.public_origin.clone(),
},
)
.await
}
fn run_vector_command(cli: &Cli, command: &VectorCommand) -> Result<()> {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_existing(&cli.database, key.as_str())?;
match command {
VectorCommand::Put {
id,
content_file,
embedding,
} => {
let content = fs::read_to_string(content_file)
.with_context(|| format!("read {}", content_file.display()))?;
let embedding = parse_embedding(embedding)?;
database.upsert_vector(&cli.tenant, &cli.model, id, &content, &embedding)?;
println!("stored {id}");
}
VectorCommand::Search { embedding, limit } => {
let embedding = parse_embedding(embedding)?;
let matches = database.search_vectors(&cli.tenant, &cli.model, &embedding, *limit)?;
println!("{}", serde_json::to_string_pretty(&matches)?);
}
VectorCommand::Delete { id } => {
ensure!(
database.delete_vector(&cli.tenant, &cli.model, id)?,
"vector not found: {id}"
);
println!("deleted {id}");
}
}
Ok(())
}
fn init_logging() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("bunfork=info,tower_http=info")),
)
.with_target(false)
.compact()
.try_init()
.map_err(|error| anyhow::anyhow!("initialize logging: {error}"))
}