AkurAI Build
Menu

AkurAI-Build

public

Latest 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}"))
}