arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc install` — prepare the frontend dependency graph.
//!
//! Lockfile-aware and fingerprinted so `arc dev` does not run `pnpm install`
//! on every start (AP2.1-4 CI correction §3):
//!
//! - If `pnpm-lock.yaml` exists, run `pnpm install --frozen-lockfile` — a
//!   locked, deterministic install that fails fast if `package.json` drifted
//!   from the lockfile (the actionable diagnostic is the frozen failure plus
//!   a hint to regenerate the lockfile).
//! - If no lockfile exists (a newly generated app), run `pnpm install` — the
//!   first install creates `pnpm-lock.yaml` deterministically.
//! - After a successful install, the combined contents of `package.json` +
//!   `pnpm-lock.yaml` are hashed and stored inside `node_modules/`. On the
//!   next call, if the hash is unchanged and `node_modules/` still exists,
//!   the install is skipped — unchanged dependencies do not reinstall.

use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;

use crate::error::CommandError;
use crate::process::{ProcessSpec, run};
use crate::project::{self, ProjectConfig};
use crate::tool::{Tool, probe, warn_if_unverified};

/// The fingerprint file, written inside `node_modules/` so it is removed
/// together with the install it tracks. Holds the hex hash of the frontend's
/// `package.json` + `pnpm-lock.yaml` contents, recording the exact
/// dependency-declaration state the last successful install synced
/// `node_modules/` to.
const FINGERPRINT_FILE: &str = ".arcature-install-fingerprint";

pub(crate) fn execute() -> Result<(), CommandError> {
    let project = project::discover()?;
    frontend(&project)
}

/// Prepare the frontend dependency graph for `project`. See the module docs
/// for the lockfile-aware + fingerprinted behavior.
pub(crate) fn frontend(project: &ProjectConfig) -> Result<(), CommandError> {
    for tool in [Tool::Node, Tool::Pnpm] {
        warn_if_unverified(tool, probe(tool)?);
    }
    let frontend = project.frontend_root();
    let package_json = frontend.join("package.json");
    let lockfile = frontend.join("pnpm-lock.yaml");
    let node_modules = frontend.join("node_modules");
    let fingerprint_path = node_modules.join(FINGERPRINT_FILE);

    // Skip if the install is already up to date: the stored fingerprint
    // matches the current `package.json` + lockfile and `node_modules/`
    // still exists. This is the "do not run pnpm install on every `arc dev`"
    // invariant (§3).
    if let Some(stored) = read_fingerprint(&fingerprint_path)
        && let Some(current) = fingerprint(&package_json, &lockfile)
        && node_modules.is_dir()
        && current == stored
    {
        println!("frontend  dependencies up to date");
        return Ok(());
    }

    if lockfile.is_file() {
        println!("frontend  installing locked dependencies");
        if let Err(error) = run(&ProcessSpec::new(Tool::Pnpm.executable(), &frontend)
            .args(["install", "--frozen-lockfile"]))
        {
            // The frozen install fails when `package.json` and the lockfile
            // disagree. Preserve the typed process error (AGENTS.md §18) and
            // print an actionable hint so the user knows how to recover.
            eprintln!(
                "hint    `pnpm-lock.yaml` may be out of sync with `package.json` — \
                 run `pnpm install` in {} to regenerate it, then retry",
                frontend.display()
            );
            return Err(error.into());
        }
    } else {
        println!("frontend  first dependency install (creating pnpm-lock.yaml)");
        run(&ProcessSpec::new(Tool::Pnpm.executable(), &frontend).args(["install"]))?;
    }

    // Record the post-install fingerprint (the lockfile may have just been
    // created by the first install). Best-effort: if the write fails the
    // next start reinstalls — correct, just slower. The fingerprint lives
    // inside `node_modules/`, so deleting `node_modules/` resets it.
    if let Some(fp) = fingerprint(&package_json, &lockfile)
        && let Some(parent) = fingerprint_path.parent()
        && fs::create_dir_all(parent).is_ok()
        && fs::write(&fingerprint_path, fp).is_err()
    {
        eprintln!("warn  cannot record install fingerprint; next start will reinstall");
    }
    Ok(())
}

/// Computes a fingerprint of the frontend's dependency-declaration state:
/// the contents of `package.json` and `pnpm-lock.yaml` (if present). Two
/// states with the same fingerprint produce the same `node_modules/`; a
/// changed fingerprint means the install must be re-synced.
fn fingerprint(package_json: &Path, lockfile: &Path) -> Option<String> {
    let pkg = fs::read(package_json).ok()?;
    let mut hasher = DefaultHasher::new();
    pkg.hash(&mut hasher);
    if lockfile.is_file()
        && let Ok(lock) = fs::read(lockfile)
    {
        lock.hash(&mut hasher);
    }
    Some(format!("{:016x}", hasher.finish()))
}

fn read_fingerprint(path: &Path) -> Option<String> {
    fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}