arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, SystemTime};

use crate::project::ProjectConfig;

use super::{
    CancellableOutcome, ChildGuard, ProcessError, ProcessSpec, cleanup_vite_ipc, ipc_path,
    run_cancellable, spawn, start_vite_ipc, wait_ipc_ready,
};

pub(crate) fn supervise(project: &ProjectConfig) -> Result<(), ProcessError> {
    let stopping = install_shutdown_listener()?;
    if matches!(
        build_backend(project, &stopping)?,
        CancellableOutcome::Cancelled
    ) {
        return Ok(());
    }
    // Refresh the Unified Application Graph manifest from the freshly built
    // `arcature-metadata` binary before Vite starts. The `@arcature/client`
    // Vite plugin reads `.arcature/app-manifest.json` for cross-stack version
    // negotiation and serves the `@arcature/routes` / `@arcature/pages`
    // virtual modules from `.arcature/frontend/routes.ts` and
    // `.arcature/frontend/pages.d.ts`. A fresh manifest here means Vite sees
    // the routes/pages the backend currently declares. A failure is
    // non-fatal: the backend still serves, and a stale manifest only affects
    // frontend type accuracy (a warning, not a crash). The `arcature-metadata`
    // binary may not exist for apps that predate AP2.1-4 (no `arcature-build`
    // dep) — in that case `cargo run --bin arcature-metadata` fails to find
    // the target and we warn + continue.
    refresh_manifest(project);
    // One TCP port: the backend owns the listener. Vite runs in
    // `middlewareMode` over IPC — no TCP port. The backend's `dev-proxy`
    // layer forwards Vite-looking requests to the IPC endpoint.
    let ipc = ipc_path();
    let (mut vite_child, harness_path) = start_vite_ipc(project, &ipc)?;
    wait_ipc_ready(&ipc, &mut vite_child, &stopping)?;
    let mut backend = Some(start_backend(project, &ipc)?);
    if let Some(child) = backend.as_mut() {
        wait_ready("backend", project.backend_port, child, &stopping)?;
    }
    println!("ready  http://127.0.0.1:{}", project.backend_port);
    let mut snapshot = rust_snapshot(project.root(), &project.backend_src_dir)?;
    let result = loop {
        if stopping.load(Ordering::SeqCst) {
            break Ok(());
        }
        if let Some(status) = vite_child.try_wait()? {
            // A signal-induced or intentional termination during an
            // expected shutdown must not be misclassified as an
            // application crash. Re-check before reporting failure.
            if stopping.load(Ordering::SeqCst) {
                break Ok(());
            }
            break Err(ProcessError::Failed {
                program: "node (Vite)".to_owned(),
                directory: project.frontend_root(),
                status,
            });
        }
        if let Some(child) = backend.as_mut()
            && let Some(status) = child.try_wait()?
        {
            if stopping.load(Ordering::SeqCst) {
                break Ok(());
            }
            break Err(ProcessError::Failed {
                program: project.backend_binary.clone(),
                directory: project.root().to_path_buf(),
                status,
            });
        }
        thread::sleep(Duration::from_millis(250));
        let next = rust_snapshot(project.root(), &project.backend_src_dir)?;
        if next != snapshot {
            snapshot = next;
            if let Some(child) = backend.as_mut() {
                child.terminate()?;
            }
            backend = None;
            println!("backend   rebuilding");
            match build_backend(project, &stopping) {
                Ok(CancellableOutcome::Completed) => {}
                Ok(CancellableOutcome::Cancelled) => break Ok(()),
                Err(error) => {
                    eprintln!("backend   build failed: {error}");
                    continue;
                }
            }
            // Refresh the manifest after a successful rebuild so the frontend
            // sees the updated routes/pages. Non-fatal on failure (see
            // `refresh_manifest`).
            refresh_manifest(project);
            let mut child = start_backend(project, &ipc)?;
            wait_ready("backend", project.backend_port, &mut child, &stopping)?;
            backend = Some(child);
            println!("ready  http://127.0.0.1:{}", project.backend_port);
        }
    };
    let backend_stop = match backend.as_mut() {
        Some(child) => child.terminate(),
        None => Ok(()),
    };
    let vite_stop = vite_child.terminate();
    cleanup_vite_ipc(&ipc, &harness_path);
    result.and(backend_stop).and(vite_stop)
}

/// Refresh the Unified Application Graph artifacts
/// (`.arcature/app-manifest.json`, `.arcature/frontend/routes.ts`,
/// `.arcature/frontend/pages.d.ts`) by running the backend's
/// `arcature-metadata` binary in default mode.
///
/// The `arcature-metadata` binary writes all artifacts under `.arcature/`
/// relative to its working directory (the project root) — no `frontend/src/`
/// files are touched. The developer source tree stays clean after a route
/// change + refresh (the "zero-sync" contract, ADR-0006). The `@arcature/client`
/// Vite plugin reads these machine artifacts and exposes them as virtual
/// modules (`@arcature/routes`, `@arcature/pages`).
///
/// Non-fatal: a failure (binary missing, app predates AP2.1-4, transient
/// build race) only warns. The backend still serves; Vite still starts; a
/// stale or missing manifest/routes only reduces frontend type accuracy.
/// The `@arcature/client` plugin warns at config-resolve time if the
/// manifest is missing or schema-incompatible (AP2.1-4).
fn refresh_manifest(project: &ProjectConfig) {
    // Run the metadata binary from the project root so it writes to
    // `.arcature/` at the project root. The binary creates the directory
    // structure if it doesn't exist.
    let spec = ProcessSpec::new("cargo", project.root()).args([
        "run",
        "--quiet",
        "--package",
        &project.backend_package,
        "--bin",
        "arcature-metadata",
    ]);
    if let Err(error) = super::run_quiet(&spec) {
        eprintln!(
            "warn      could not refresh the UAG manifest and route helpers ({error}); the \
             @arcature/client Vite plugin may report a stale or missing manifest. Add \
             `arcature-build` as a build-dependency and an `arcature-metadata` bin to enable \
             cross-stack route/page types (AP2.1-4)."
        );
    }
}

fn install_shutdown_listener() -> Result<Arc<AtomicBool>, ProcessError> {
    let stopping = Arc::new(AtomicBool::new(false));
    let signal = Arc::clone(&stopping);
    let (sender, receiver) = std::sync::mpsc::sync_channel(1);
    thread::spawn(move || {
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_io()
            .build();
        match runtime {
            Ok(runtime) => {
                if sender.send(Ok(())).is_ok() && runtime.block_on(wait_for_shutdown()).is_ok() {
                    signal.store(true, Ordering::SeqCst);
                }
            }
            Err(error) => {
                let _ = sender.send(Err(error));
            }
        }
    });
    receiver
        .recv()
        .map_err(|error| ProcessError::Signal(std::io::Error::other(error)))?
        .map_err(ProcessError::Signal)?;
    Ok(stopping)
}

#[cfg(unix)]
async fn wait_for_shutdown() -> std::io::Result<()> {
    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
    tokio::select! { result = tokio::signal::ctrl_c() => result, _ = terminate.recv() => Ok(()) }
}

#[cfg(windows)]
async fn wait_for_shutdown() -> std::io::Result<()> {
    let mut control_break = tokio::signal::windows::ctrl_break()?;
    tokio::select! {
        result = tokio::signal::ctrl_c() => result,
        _ = control_break.recv() => Ok(()),
    }
}

#[cfg(not(any(unix, windows)))]
async fn wait_for_shutdown() -> std::io::Result<()> {
    tokio::signal::ctrl_c().await
}

fn build_backend(
    project: &ProjectConfig,
    stopping: &AtomicBool,
) -> Result<CancellableOutcome, ProcessError> {
    run_cancellable(
        &ProcessSpec::new("cargo", project.root())
            .args(["build", "--package", &project.backend_package])
            .new_process_group(true),
        stopping,
    )
}

fn start_backend(project: &ProjectConfig, ipc: &Path) -> Result<ChildGuard, ProcessError> {
    let extension = if cfg!(windows) { ".exe" } else { "" };
    let binary = project
        .root()
        .join("target")
        .join("debug")
        .join(format!("{}{extension}", project.backend_binary));
    spawn(
        &ProcessSpec::new(binary.into_os_string(), project.root())
            .env("ARCATURE_VITE_IPC", ipc.display().to_string())
            .env("ARCATURE_DEV", "1")
            .env("ARCATURE_BACKEND_PORT", project.backend_port.to_string())
            .new_process_group(true),
    )
    .map(|child| ChildGuard::new(child, "backend"))
}

fn wait_ready(
    service: &'static str,
    port: u16,
    child: &mut ChildGuard,
    stopping: &AtomicBool,
) -> Result<(), ProcessError> {
    let address = SocketAddr::from(([127, 0, 0, 1], port));
    for _ in 0..100 {
        if stopping.load(Ordering::SeqCst) {
            return Ok(());
        }
        if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() {
            return Ok(());
        }
        if let Some(status) = child.try_wait()? {
            if stopping.load(Ordering::SeqCst) {
                return Ok(());
            }
            return Err(ProcessError::Failed {
                program: service.to_owned(),
                directory: PathBuf::from("."),
                status,
            });
        }
        thread::sleep(Duration::from_millis(100));
    }
    Err(ProcessError::Readiness {
        service,
        address: address.to_string(),
    })
}

fn rust_snapshot(
    root: &Path,
    backend_src_dir: &Path,
) -> Result<BTreeMap<PathBuf, (SystemTime, u64)>, ProcessError> {
    let mut snapshot = BTreeMap::new();
    // Watch the application source root (ADR-0008: `backend_src_dir`, e.g.
    // `app/` for canonical starters or `src/` for existing apps) plus the
    // root manifests that drive recompilation.
    collect(&root.join(backend_src_dir), &mut snapshot)?;
    for name in ["Cargo.toml", "Cargo.lock", "build.rs"] {
        let path = root.join(name);
        if path.is_file() {
            insert_metadata(&path, &mut snapshot)?;
        }
    }
    Ok(snapshot)
}

fn collect(
    path: &Path,
    snapshot: &mut BTreeMap<PathBuf, (SystemTime, u64)>,
) -> Result<(), ProcessError> {
    let entries = match fs::read_dir(path) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(source) => {
            return Err(ProcessError::Inspect {
                path: path.to_path_buf(),
                source,
            });
        }
    };
    for entry in entries {
        let entry = entry.map_err(|source| ProcessError::Inspect {
            path: path.to_path_buf(),
            source,
        })?;
        let child = entry.path();
        let kind = entry.file_type().map_err(|source| ProcessError::Inspect {
            path: child.clone(),
            source,
        })?;
        if kind.is_dir() {
            collect(&child, snapshot)?;
        } else if kind.is_file() && child.extension().is_some_and(|extension| extension == "rs") {
            insert_metadata(&child, snapshot)?;
        }
    }
    Ok(())
}

fn insert_metadata(
    path: &Path,
    snapshot: &mut BTreeMap<PathBuf, (SystemTime, u64)>,
) -> Result<(), ProcessError> {
    let metadata = fs::metadata(path).map_err(|source| ProcessError::Inspect {
        path: path.to_path_buf(),
        source,
    })?;
    let modified = metadata
        .modified()
        .map_err(|source| ProcessError::Inspect {
            path: path.to_path_buf(),
            source,
        })?;
    snapshot.insert(path.to_path_buf(), (modified, metadata.len()));
    Ok(())
}