frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! `frame run` — build what changed, boot the application, print its URL,
//! and tear it down cleanly on Ctrl-C or SIGTERM.
//!
//! The child runs in its own process group so a terminal Ctrl-C reaches
//! frame alone; frame then forwards one SIGTERM and requires the
//! application's ordered shutdown to finish inside a deadline — the same
//! graceful path the generated application's own tests prove.
//!
//! Because a portless `frame.toml` states no page-server port (2026-07-22
//! ruling), the host CHOOSES its port at boot — preferring `127.0.0.1:6010`
//! and walking forward to a free one — and prints the chosen URL as its first
//! line of output. `frame run` therefore learns the real URL by reading the
//! host's own boot line from a piped stdout (which it forwards verbatim so the
//! operator still sees every log line), rather than assuming a port it can no
//! longer predict. An explicitly-stated port is still probed up front, so a
//! collision on a stated port fails with actionable guidance before the child
//! is even spawned.
//!
//! The boot/teardown machinery itself lives in the shared spine
//! (`commands::boot`) that `frame dev` also calls — one implementation of
//! how the node starts and how it stops (F-7b ruling C2).

use std::path::{Path, PathBuf};

use tokio::signal::unix::{SignalKind, signal};
use tokio::sync::oneshot;

use crate::doctor;
use crate::error::CliError;
use crate::preflight::ensure_ports_available;
use crate::project::App;

use super::boot::{
    BootOutcome, exit_verdict, forward_and_detect_url, spawn_host, stop_child, wait_until_ready,
};
use super::build::{build_application, host_binary};

/// Builds (if stale), boots, prints the URL, serves until Ctrl-C/SIGTERM,
/// and requires a clean ordered teardown.
///
/// # Errors
///
/// Refuses on missing prerequisites; fails on build failure, an
/// explicitly-stated port that is already taken, boot failure, readiness
/// timeout, an uncleanly exiting application, or a teardown that misses its
/// deadline.
pub fn run() -> Result<(), CliError> {
    let app = App::find_from_current_dir()?;
    doctor::require(doctor::BUILD_PREREQUISITES, "frame run")?;
    build_application(&app)?;
    let config = app.load_config()?;
    // Probe only EXPLICITLY-stated ports; a portless config walks forward /
    // OS-assigns and has nothing to probe. A stated-port collision fails here,
    // before the child is spawned, with the frame.toml key named.
    ensure_ports_available(&config)?;
    let binary = host_binary(&app);
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .map_err(|source| CliError::Runtime { source })?;
    runtime.block_on(serve(&app.root, &binary))
}

async fn serve(root: &Path, binary: &PathBuf) -> Result<(), CliError> {
    // Signal handlers are installed BEFORE the child exists: a Ctrl-C or
    // SIGTERM arriving during boot must queue for the ordered teardown
    // below, never hit this process's default disposition.
    let mut interrupt =
        signal(SignalKind::interrupt()).map_err(|source| CliError::Runtime { source })?;
    let mut terminate =
        signal(SignalKind::terminate()).map_err(|source| CliError::Runtime { source })?;

    let mut child = spawn_host(root, binary, &[])?;
    // The host's stdout is piped so `frame run` can read the chosen URL from
    // the host's own boot line; every line is forwarded verbatim so the
    // operator still sees the full log. stderr stays inherited (streams
    // straight to the terminal).
    let stdout = child.stdout.take().ok_or_else(|| CliError::CommandSpawn {
        command: binary.display().to_string(),
        source: std::io::Error::other("child host stdout was not captured"),
    })?;
    let (url_tx, url_rx) = oneshot::channel();
    let pump = tokio::spawn(forward_and_detect_url(stdout, url_tx));

    let addr = match wait_until_ready(&mut child, url_rx, &mut interrupt, &mut terminate).await? {
        BootOutcome::Signaled => {
            let verdict = stop_child(binary, child).await;
            pump.abort();
            return verdict;
        }
        BootOutcome::Ready(addr) => addr,
    };
    println!("serving at http://{addr}  (Ctrl-C to stop)");

    let verdict = tokio::select! {
        status = child.wait() => {
            // The application stopped on its own: propagate its verdict.
            let status = status.map_err(|source| CliError::CommandSpawn {
                command: binary.display().to_string(),
                source,
            })?;
            exit_verdict(binary, status)
        }
        _ = interrupt.recv() => stop_child(binary, child).await,
        _ = terminate.recv() => stop_child(binary, child).await,
    };
    pump.abort();
    verdict
}