frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! CLI-facing acceptance for the portless ruling (2026-07-22): `frame check`
//! coherence on a portless config, `frame run`/`frame host` preflight on an
//! EXPLICITLY-stated taken port, and `frame new`'s git-init (with its
//! inside-an-existing-repo guard). These exercise the shared machinery
//! directly — no scaffold build, no external toolchain — so they are fast and
//! deterministic.

use std::error::Error;
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};

use frame_cli::coherence::coherence_violations;
use frame_cli::error::CliError;
use frame_cli::preflight::ensure_ports_available;
use frame_cli::scaffold::{self, GitInit, NewOptions};
use frame_host::FrameConfig;

type TestResult = Result<(), Box<dyn Error>>;

static UNIQUE: AtomicU32 = AtomicU32::new(0);

fn temp_dir(tag: &str) -> Result<PathBuf, Box<dyn Error>> {
    let dir = std::env::temp_dir().join(format!(
        "frame-portless-cli-{tag}-{}-{}",
        std::process::id(),
        UNIQUE.fetch_add(1, Ordering::SeqCst)
    ));
    std::fs::create_dir_all(&dir)?;
    Ok(dir)
}

fn write_portless_config(dir: &Path) -> Result<PathBuf, Box<dyn Error>> {
    let path = dir.join("frame.toml");
    std::fs::write(
        &path,
        "[frame]\nassets = \"page/dist\"\nauth_token = \"\"\nchannel = \"demo.events\"\n",
    )?;
    Ok(path)
}

/// A portless config is coherent by construction: `frame check`'s coherence
/// pass reports zero violations (a clean pass), never a false complaint about
/// an OS-assigned `:0` or a not-yet-resolved page port.
#[test]
fn portless_config_has_no_coherence_violations() -> TestResult {
    let dir = temp_dir("coherence")?;
    let path = write_portless_config(&dir)?;
    let config = FrameConfig::load(&path)?;
    assert!(
        !config.ports_explicit,
        "a portless config is not a fully-stated topology"
    );
    let violations = coherence_violations(&config);
    assert!(
        violations.is_empty(),
        "a portless config must report no coherence violations, got: {violations:?}"
    );
    // Preflight has nothing to probe either — no stated ports.
    ensure_ports_available(&config)?;
    Ok(())
}

/// An EXPLICITLY-stated `[frame].bind` that is already taken fails preflight
/// loudly, naming the socket role and the exact frame.toml key — the parked
/// port-coherence guidance, now scoped to stated addresses only.
#[test]
fn explicit_stated_page_port_taken_is_named_by_preflight() -> TestResult {
    // Hold an ephemeral page port for the whole test, then state it explicitly.
    let squatter = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
    let taken = squatter.local_addr()?;
    // A full, explicit config stating the taken page port and free bus ports.
    let free: Vec<u16> = (0..3)
        .map(|_| -> Result<u16, Box<dyn Error>> {
            let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
            Ok(listener.local_addr()?.port())
        })
        .collect::<Result<_, _>>()?;
    let dir = temp_dir("explicit-taken")?;
    let path = dir.join("frame.toml");
    std::fs::write(
        &path,
        format!(
            r#"[frame]
bind = "127.0.0.1:{page}"
assets = "page/dist"
auth_token = ""
channel = "demo.events"

[bus]
listen_address = "127.0.0.1:{tcp}"
health_listen_address = "127.0.0.1:{health}"
drain_timeout_ms = 1000
channels = [{{ name = "demo.events", durable = false }}]
routing_rules = []

[bus.websocket]
listen_address = "127.0.0.1:{ws}"
path = "/liminal"
allowed_origins = ["http://127.0.0.1:{page}"]
"#,
            page = taken.port(),
            tcp = free[0],
            health = free[1],
            ws = free[2],
        ),
    )?;
    let config = FrameConfig::load(&path)?;
    assert!(config.ports_explicit, "this config states every port");
    let result = ensure_ports_available(&config);
    let Err(CliError::PortUnavailable {
        role, key, addr, ..
    }) = result
    else {
        return Err(format!("expected PortUnavailable, got {result:?}").into());
    };
    assert_eq!(role, "page server", "the refusal must name the socket role");
    assert_eq!(
        key, "[frame].bind",
        "the refusal must name the frame.toml key"
    );
    assert_eq!(addr, taken, "the refusal must name the taken address");
    drop(squatter);
    Ok(())
}

/// `frame new` in a clean directory initialises a git repository with an
/// initial commit that tracks the scaffolded source files.
#[test]
fn git_init_in_clean_dir_creates_repo_with_initial_commit() -> TestResult {
    if which_git().is_none() {
        // git absent: init_repo reports the skip and this environment cannot
        // prove the commit. The skip path itself is exercised by returning
        // early rather than asserting a repo that cannot exist.
        return Ok(());
    }
    let parent = temp_dir("git-clean")?;
    let project = scaffold::generate(&NewOptions {
        name: "clean_app".to_owned(),
        target_parent: parent.clone(),
    })?;
    let outcome = scaffold::init_repo(&project)?;
    assert_eq!(
        outcome,
        GitInit::Initialized,
        "a clean directory must get a fresh repository"
    );
    assert!(project.join(".git").is_dir(), "a .git directory must exist");
    // Exactly one commit exists.
    let log = git(&project, &["log", "--oneline"])?;
    assert_eq!(
        log.lines().count(),
        1,
        "there must be exactly one initial commit: {log}"
    );
    // Key scaffolded files are tracked (the .gitignore keeps compiled dist JS
    // out, but every source file is committed).
    let tracked = git(&project, &["ls-files"])?;
    for expected in [
        "Cargo.toml",
        "frame.toml",
        "README.md",
        ".gitignore",
        "host/src/lib.rs",
        "page/src/main.ts",
    ] {
        assert!(
            tracked.lines().any(|line| line == expected),
            "{expected} must be tracked in the initial commit:\n{tracked}"
        );
    }
    std::fs::remove_dir_all(&parent)?;
    Ok(())
}

/// `frame new` inside an EXISTING git work tree does NOT nest a second
/// repository — it leaves version control alone and reports the skip.
#[test]
fn git_init_inside_existing_repo_skips() -> TestResult {
    if which_git().is_none() {
        return Ok(());
    }
    let parent = temp_dir("git-nested")?;
    // Make the parent an existing repository.
    git(&parent, &["init", "-q"])?;
    let project = scaffold::generate(&NewOptions {
        name: "nested_app".to_owned(),
        target_parent: parent.clone(),
    })?;
    let outcome = scaffold::init_repo(&project)?;
    assert_eq!(
        outcome,
        GitInit::SkippedInsideExistingRepo,
        "a scaffold inside an existing work tree must not nest a repository"
    );
    assert!(
        !project.join(".git").exists(),
        "no nested .git may be created inside an existing repository"
    );
    std::fs::remove_dir_all(&parent)?;
    Ok(())
}

fn which_git() -> Option<()> {
    Command::new("git")
        .arg("--version")
        .output()
        .ok()
        .filter(|output| output.status.success())
        .map(|_| ())
}

fn git(dir: &Path, args: &[&str]) -> Result<String, Box<dyn Error>> {
    let output = Command::new("git").args(args).current_dir(dir).output()?;
    if !output.status.success() {
        return Err(format!(
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        )
        .into());
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}