arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
#![cfg(unix)]

use std::fs;
use std::net::{TcpListener, TcpStream};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

#[test]
fn restarts_only_backend_and_cleans_children_on_sigterm() {
    let root = std::env::temp_dir().join(format!("arcature-dev-lifecycle-{}", std::process::id()));
    let backend_port = free_port();
    create_fixture(&root, backend_port);
    let path = format!(
        "{}:{}",
        root.join("bin").display(),
        std::env::var("PATH").unwrap_or_default()
    );
    let mut arc = Command::new(env!("CARGO_BIN_EXE_arc"))
        .arg("dev")
        .current_dir(&root)
        .env("PATH", path)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("arc dev should start");
    let ipc = ipc_path(arc.id());
    wait_ipc(&ipc, &mut arc);
    wait_port(backend_port, &mut arc);
    thread::sleep(Duration::from_millis(500));
    fs::write(root.join("src/main.rs"), "// changed\n").expect("source should change");
    wait_for_build_count(&root.join("cargo.log"), 2, &mut arc);
    assert_eq!(
        fs::read_to_string(root.join("node.log"))
            .expect("node log should exist")
            .lines()
            .count(),
        1
    );
    assert!(
        Command::new("kill")
            .args(["-TERM", &arc.id().to_string()])
            .status()
            .expect("kill should run")
            .success()
    );
    assert!(arc.wait().expect("arc should exit").success());
    wait_closed(backend_port);
    fs::remove_dir_all(root).expect("fixture should be removed");
}

#[test]
fn vite_ipc_start_failure_exits_without_backend() {
    let root = std::env::temp_dir().join(format!("arcature-dev-failure-{}", std::process::id()));
    let backend_port = free_port();
    create_fixture(&root, backend_port);
    let path = format!(
        "{}:{}",
        root.join("bin").display(),
        std::env::var("PATH").unwrap_or_default()
    );
    let status = Command::new(env!("CARGO_BIN_EXE_arc"))
        .arg("dev")
        .current_dir(&root)
        .env("PATH", path)
        .env("ARC_FIXTURE_FRONTEND_FAIL", "1")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .expect("arc dev should run");
    assert!(!status.success());
    wait_closed(backend_port);
    fs::remove_dir_all(root).expect("fixture should be removed");
}

#[test]
fn sigterm_cancels_initial_backend_build_before_starting_frontend() {
    let root = std::env::temp_dir().join(format!(
        "arcature-dev-build-shutdown-{}",
        std::process::id()
    ));
    create_fixture(&root, free_port());
    let path = format!(
        "{}:{}",
        root.join("bin").display(),
        std::env::var("PATH").unwrap_or_default()
    );
    let mut arc = Command::new(env!("CARGO_BIN_EXE_arc"))
        .arg("dev")
        .current_dir(&root)
        .env("PATH", path)
        .env("ARC_FIXTURE_SLOW_BUILD", "1")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("arc dev should start");
    wait_for_file(&root.join("cargo.log"), &mut arc);
    assert!(
        Command::new("kill")
            .args(["-TERM", &arc.id().to_string()])
            .status()
            .expect("kill should run")
            .success()
    );
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        if let Some(status) = arc.try_wait().expect("arc should be inspectable") {
            assert!(status.success());
            break;
        }
        assert!(
            Instant::now() < deadline,
            "arc did not cancel Cargo promptly"
        );
        thread::sleep(Duration::from_millis(25));
    }
    assert!(!root.join("node.log").exists());
    fs::remove_dir_all(root).expect("fixture should be removed");
}

fn ipc_path(pid: u32) -> PathBuf {
    std::env::temp_dir().join(format!("arcature-vite-{pid}.sock"))
}

fn create_fixture(root: &Path, backend_port: u16) {
    fs::create_dir_all(root.join("src")).expect("src should be created");
    fs::create_dir_all(root.join("frontend/node_modules/vite/bin"))
        .expect("Vite marker should be created");
    fs::create_dir_all(root.join("bin")).expect("bin should be created");
    fs::write(root.join("src/main.rs"), "// initial\n").expect("source should be written");
    fs::write(root.join("frontend/node_modules/vite/bin/vite.js"), "")
        .expect("Vite marker should be written");
    fs::write(root.join("arcature.toml"), format!("frontend = \"react\"\nfrontend_dir = \"frontend\"\nbackend_package = \"demo\"\nbackend_binary = \"demo\"\nbackend_port = {backend_port}\n")).expect("config should be written");
    write_executable(
        &root.join("bin/cargo"),
        "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'cargo 1.97.1'; exit 0; fi\n# `cargo run --bin arcature-metadata` (the UAG manifest refresh from\n# `arc dev`, AP2.1-4) is a no-op in this fixture: the demo app has no\n# `arcature-metadata` binary, so the fake cargo exits 0 without appending to\n# `cargo.log` (which counts real `cargo build` invocations only) and without\n# overwriting `target/debug/demo` (which is about to be started by the real\n# `start_backend`).\nif [ \"$1\" = \"run\" ]; then exit 0; fi\necho build >> cargo.log\nif [ \"$ARC_FIXTURE_SLOW_BUILD\" = \"1\" ]; then exec python3 -c 'import time; time.sleep(30)'; fi\nmkdir -p target/debug\nprintf '#!/bin/sh\\nexec python3 -m http.server \"$ARCATURE_BACKEND_PORT\" --bind 127.0.0.1\\n' > target/debug/demo\nchmod +x target/debug/demo\n",
    );
    write_executable(
        &root.join("bin/node"),
        "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'v24.19.0'; exit 0; fi\nif [ \"$ARC_FIXTURE_FRONTEND_FAIL\" = \"1\" ]; then exit 4; fi\necho start >> ../node.log\nexec python3 -c \"\nimport socket, os, signal, sys, time\npath = os.environ['ARCATURE_VITE_IPC']\nif os.path.exists(path): os.unlink(path)\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\ns.bind(path)\ns.listen(1)\nsignal.signal(signal.SIGTERM, lambda *a: sys.exit(0))\nwhile True: time.sleep(1)\n\"\n",
    );
    write_executable(&root.join("bin/pnpm"), "#!/bin/sh\necho '11.20.0'\n");
}

fn write_executable(path: &Path, contents: &str) {
    fs::write(path, contents).expect("fixture executable should be written");
    let mut permissions = fs::metadata(path)
        .expect("fixture metadata should exist")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).expect("fixture should be executable");
}

fn free_port() -> u16 {
    TcpListener::bind("127.0.0.1:0")
        .expect("ephemeral port should bind")
        .local_addr()
        .expect("address should exist")
        .port()
}

fn wait_ipc(path: &Path, child: &mut Child) {
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline {
        assert!(
            child
                .try_wait()
                .expect("child should be inspectable")
                .is_none(),
            "arc dev exited before ipc ready"
        );
        if path.exists() {
            return;
        }
        thread::sleep(Duration::from_millis(50));
    }
    panic!("ipc endpoint {} did not become ready", path.display());
}

fn wait_port(port: u16, child: &mut Child) {
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline {
        assert!(
            child
                .try_wait()
                .expect("child should be inspectable")
                .is_none(),
            "arc dev exited before readiness"
        );
        if TcpStream::connect(("127.0.0.1", port)).is_ok() {
            return;
        }
        thread::sleep(Duration::from_millis(50));
    }
    panic!("port {port} did not become ready");
}

fn wait_for_build_count(path: &Path, count: usize, child: &mut Child) {
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline {
        assert!(
            child
                .try_wait()
                .expect("child should be inspectable")
                .is_none(),
            "arc dev exited before restart"
        );
        if fs::read_to_string(path).map_or(0, |text| text.lines().count()) >= count {
            return;
        }
        thread::sleep(Duration::from_millis(50));
    }
    panic!("backend was not rebuilt");
}

fn wait_for_file(path: &Path, child: &mut Child) {
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline {
        assert!(
            child
                .try_wait()
                .expect("child should be inspectable")
                .is_none(),
            "arc dev exited before the build started"
        );
        if path.is_file() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("backend build did not start");
}

fn wait_closed(port: u16) {
    let deadline = Instant::now() + Duration::from_secs(5);
    while Instant::now() < deadline {
        if TcpStream::connect(("127.0.0.1", port)).is_err() {
            return;
        }
        thread::sleep(Duration::from_millis(50));
    }
    panic!("port {port} remained open after shutdown");
}