arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
use crate::cli::OutputFormat;
use crate::contracts;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run, run_quiet};
use crate::project;
use crate::project::ProjectConfig;
use crate::tool::{HealthReport, HealthStatus, Tool, certification_status, probe};

pub(crate) fn execute(format: OutputFormat) -> Result<(), CommandError> {
    let project = project::discover()?;
    let mut report = HealthReport::default();
    report.push(
        "project",
        HealthStatus::Ok,
        project.root().display().to_string(),
    );
    report.push("frontend", HealthStatus::Ok, project.frontend.as_str());
    // `Cargo.toml` is at the project root; the frontend tooling files live
    // under the configured `frontend_dir` (ADR-0008: `resources/` for
    // canonical starters, `frontend/` for existing apps), so those checks
    // use `frontend_root()` rather than hardcoded `frontend/...` paths.
    let frontend_root = project.frontend_root();
    for (label, exists) in [
        ("Cargo.toml", project.root().join("Cargo.toml").is_file()),
        ("package.json", frontend_root.join("package.json").is_file()),
        (
            "vite.config.ts",
            frontend_root.join("vite.config.ts").is_file(),
        ),
        (
            "pnpm-lock.yaml",
            frontend_root.join("pnpm-lock.yaml").is_file(),
        ),
    ] {
        report.push(
            label,
            if exists {
                HealthStatus::Ok
            } else {
                HealthStatus::Error
            },
            if exists { "present" } else { "missing" },
        );
    }
    let tools_ok = [Tool::Cargo, Tool::Node, Tool::Pnpm]
        .into_iter()
        .all(|tool| match probe(tool) {
            Ok(version) => {
                report.push(
                    tool.program(),
                    certification_status(tool, version),
                    version.to_string(),
                );
                true
            }
            Err(error) => {
                report.push(tool.program(), HealthStatus::Error, error.to_string());
                false
            }
        });
    if tools_ok && !report.has_error() {
        match contracts::check(&project) {
            Ok(artifact) => {
                report.push(
                    "cross-stack linker",
                    HealthStatus::Ok,
                    "registered pages and props match",
                );
                match exposure_lint(&artifact) {
                    Ok(()) => report.push(
                        "exposure firewall",
                        HealthStatus::Ok,
                        "no secret-bearing field names in browser contracts",
                    ),
                    Err(error) => report.push("exposure firewall", HealthStatus::Error, error),
                }
            }
            Err(error) => report.push("cross-stack linker", HealthStatus::Error, error),
        }
        let typescript = ProcessSpec::new(Tool::Pnpm.executable(), project.frontend_root())
            .args(["exec", "tsc", "--noEmit"]);
        match run_for_format(&typescript, format) {
            Ok(()) => report.push("typescript", HealthStatus::Ok, "type check passed"),
            Err(error) => report.push("typescript", HealthStatus::Error, error.to_string()),
        }
        let cargo = ProcessSpec::new("cargo", project.root())
            .arg("check")
            .env("CARGO_TARGET_DIR", check_target_dir(&project));
        match run_for_format(&cargo, format) {
            Ok(()) => report.push("cargo", HealthStatus::Ok, "cargo check passed"),
            Err(error) => report.push("cargo", HealthStatus::Error, error.to_string()),
        }
    }
    match format {
        OutputFormat::Human => report.print_human(),
        OutputFormat::Json => report.print_json()?,
    }
    if report.has_error() {
        Err(CommandError::Unhealthy("project"))
    } else {
        Ok(())
    }
}

fn check_target_dir(project: &ProjectConfig) -> std::path::PathBuf {
    use std::hash::{DefaultHasher, Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    project.root().hash(&mut hasher);
    std::env::temp_dir()
        .join("arcature-check")
        .join(format!("{:016x}", hasher.finish()))
}

/// Run the Client Exposure Firewall dangerous-field lint against the loaded
/// artifact. Returns an error string listing every hit so `arc check` surfaces
/// the full picture rather than stopping at the first.
fn exposure_lint(artifact: &contracts::Artifact) -> Result<(), String> {
    let hits = contracts::dangerous_fields(&artifact.pages);
    if hits.is_empty() {
        return Ok(());
    }
    let listed = hits
        .iter()
        .map(|hit| format!("`{}`.`{}`", hit.page, hit.field))
        .collect::<Vec<_>>()
        .join(", ");
    Err(format!(
        "secret-bearing field names in browser contracts: {listed} (field-name linting is \
         defense-in-depth; the boundary is the explicit ClientData opt-in)"
    ))
}

fn run_for_format(
    specification: &ProcessSpec,
    format: OutputFormat,
) -> Result<(), crate::process::ProcessError> {
    match format {
        OutputFormat::Human => run(specification),
        OutputFormat::Json => run_quiet(specification),
    }
}