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
//! `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:4190`
//! 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.

use std::net::SocketAddr;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::time::Duration;

use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, ChildStdout};
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::build::{build_application, host_binary};

/// Bound on waiting for the booted application to announce its URL and accept
/// connections, matching the generated application's own boot-proof deadline.
const READY_DEADLINE: Duration = Duration::from_secs(30);

/// Bound on waiting for the application to exit after SIGTERM, matching the
/// generated application's own teardown-proof deadline.
const TEARDOWN_DEADLINE: Duration = Duration::from_secs(15);

/// 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
}

fn spawn_host(root: &Path, binary: &PathBuf) -> Result<Child, CliError> {
    let mut command = std::process::Command::new(binary);
    command.current_dir(root);
    // Its own process group: terminal signals reach frame alone, so
    // teardown is always the one ordered SIGTERM below.
    command.process_group(0);
    tokio::process::Command::from(command)
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|source| CliError::CommandSpawn {
            command: binary.display().to_string(),
            source,
        })
}

/// Forwards every line the host writes to stdout verbatim to `frame run`'s own
/// stdout, and sends the first announced page-server address (parsed from the
/// host's `serving at http://HOST:PORT` boot line) back through `url_tx`. The
/// forwarding continues for the child's whole lifetime so the host never
/// blocks on a full stdout pipe.
async fn forward_and_detect_url(stdout: ChildStdout, url_tx: oneshot::Sender<SocketAddr>) {
    let mut lines = BufReader::new(stdout).lines();
    let mut url_tx = Some(url_tx);
    loop {
        match lines.next_line().await {
            Ok(Some(line)) => {
                println!("{line}");
                if let Some(addr) = parse_serving_url(&line)
                    && let Some(sender) = url_tx.take()
                {
                    // The receiver is dropped once readiness is established;
                    // a failed send simply means the URL is already known.
                    let _ = sender.send(addr);
                }
            }
            Ok(None) => return,
            Err(error) => {
                eprintln!("frame run: reading host output failed: {error}");
                return;
            }
        }
    }
}

/// Parses the page-server address out of a host boot line of the form
/// `... serving at http://127.0.0.1:4191`. Returns `None` for any other line.
fn parse_serving_url(line: &str) -> Option<SocketAddr> {
    let after = line.split("http://").nth(1)?;
    let token: String = after.chars().take_while(|c| !c.is_whitespace()).collect();
    token.parse().ok()
}

/// How the boot wait ended: the application announced its URL and is
/// accepting connections, or a stop signal arrived first.
enum BootOutcome {
    /// The host announced this address and it accepts connections.
    Ready(SocketAddr),
    /// Ctrl-C or SIGTERM arrived during boot.
    Signaled,
}

/// Waits for the host to announce its chosen URL, then confirms the address
/// accepts a connection — racing the child's own exit (a failed boot surfaces
/// as that failure, never as a timeout), the stop signals (a Ctrl-C during
/// boot still gets the ordered teardown), and a hard deadline.
async fn wait_until_ready(
    child: &mut Child,
    url_rx: oneshot::Receiver<SocketAddr>,
    interrupt: &mut tokio::signal::unix::Signal,
    terminate: &mut tokio::signal::unix::Signal,
) -> Result<BootOutcome, CliError> {
    let deadline = tokio::time::sleep(READY_DEADLINE);
    tokio::pin!(deadline);
    let addr = tokio::select! {
        announced = url_rx => match announced {
            Ok(addr) => addr,
            // The stdout pump ended without ever announcing a URL — the host
            // closed its output, which means it exited before boot completed.
            Err(_) => return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: "exited before announcing its serving URL".to_owned(),
            }),
        },
        status = child.wait() => {
            let status = status.map_err(|source| CliError::CommandSpawn {
                command: "the application host".to_owned(),
                source,
            })?;
            return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: status.to_string(),
            });
        }
        _ = interrupt.recv() => return Ok(BootOutcome::Signaled),
        _ = terminate.recv() => return Ok(BootOutcome::Signaled),
        () = &mut deadline => return Err(CliError::NeverReady {
            bind: "the page server (no URL announced)".to_owned(),
            deadline: READY_DEADLINE,
        }),
    };
    // The URL line is printed the instant the listener is bound; confirm the
    // address actually accepts before declaring the app ready, still racing a
    // late boot failure and the stop signals.
    confirm_accepting(child, addr, interrupt, terminate).await
}

/// Polls `addr` until it accepts a connection, racing the child's exit, the
/// stop signals, and the boot deadline.
async fn confirm_accepting(
    child: &mut Child,
    addr: SocketAddr,
    interrupt: &mut tokio::signal::unix::Signal,
    terminate: &mut tokio::signal::unix::Signal,
) -> Result<BootOutcome, CliError> {
    let deadline = tokio::time::Instant::now() + READY_DEADLINE;
    loop {
        if let Ok(Some(status)) = child.try_wait() {
            return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: status.to_string(),
            });
        }
        if tokio::net::TcpStream::connect(addr).await.is_ok() {
            return Ok(BootOutcome::Ready(addr));
        }
        if tokio::time::Instant::now() >= deadline {
            return Err(CliError::NeverReady {
                bind: addr.to_string(),
                deadline: READY_DEADLINE,
            });
        }
        tokio::select! {
            () = tokio::time::sleep(Duration::from_millis(50)) => {}
            _ = interrupt.recv() => return Ok(BootOutcome::Signaled),
            _ = terminate.recv() => return Ok(BootOutcome::Signaled),
        }
    }
}

/// Forwards one SIGTERM and requires the application's ordered shutdown to
/// finish inside the deadline; a deadline miss is killed and reported as
/// the application bug it is.
async fn stop_child(binary: &Path, mut child: Child) -> Result<(), CliError> {
    eprintln!("stopping…");
    if let Some(id) = child.id() {
        send_sigterm(id)?;
    }
    match tokio::time::timeout(TEARDOWN_DEADLINE, child.wait()).await {
        Ok(status) => {
            let status = status.map_err(|source| CliError::CommandSpawn {
                command: binary.display().to_string(),
                source,
            })?;
            let verdict = exit_verdict(binary, status);
            if verdict.is_ok() {
                eprintln!("stopped cleanly");
            }
            verdict
        }
        Err(_elapsed) => {
            child
                .kill()
                .await
                .map_err(|source| CliError::CommandSpawn {
                    command: binary.display().to_string(),
                    source,
                })?;
            Err(CliError::TeardownTimeout {
                deadline: TEARDOWN_DEADLINE,
            })
        }
    }
}

/// Sends SIGTERM (never SIGKILL) so the application takes its graceful,
/// ordered-teardown path.
fn send_sigterm(pid: u32) -> Result<(), CliError> {
    let status = std::process::Command::new("kill")
        .arg("-TERM")
        .arg(pid.to_string())
        .status()
        .map_err(|source| CliError::CommandSpawn {
            command: format!("kill -TERM {pid}"),
            source,
        })?;
    if status.success() {
        Ok(())
    } else {
        Err(CliError::CommandFailed {
            command: format!("kill -TERM {pid}"),
            status: status.to_string(),
        })
    }
}

fn exit_verdict(binary: &Path, status: ExitStatus) -> Result<(), CliError> {
    if status.success() {
        Ok(())
    } else {
        Err(CliError::CommandFailed {
            command: binary.display().to_string(),
            status: status.to_string(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::parse_serving_url;

    #[test]
    fn parses_the_host_boot_line() {
        assert_eq!(
            parse_serving_url("demo_app serving at http://127.0.0.1:4191")
                .map(|addr| addr.to_string()),
            Some("127.0.0.1:4191".to_owned())
        );
        assert_eq!(
            parse_serving_url("frame-host serving at http://127.0.0.1:4190")
                .map(|addr| addr.to_string()),
            Some("127.0.0.1:4190".to_owned())
        );
    }

    #[test]
    fn ignores_unrelated_lines() {
        assert!(parse_serving_url("INFO booting embedded bus component").is_none());
        assert!(parse_serving_url("no url here").is_none());
        // A non-address after the scheme is not a serving line.
        assert!(parse_serving_url("see http://example.com/docs").is_none());
    }
}