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
//! Pre-boot port-availability guidance for EXPLICITLY-stated addresses,
//! shared by `frame run` and `frame host`.
//!
//! Under the portless ruling (2026-07-22) a frame.toml need state no ports at
//! all: the page server prefers `127.0.0.1:4190` and walks forward to a free
//! port, and a synthesized `[bus]`'s internal listeners bind OS-assigned free
//! ports. Those are chosen at boot and cannot collide, so there is nothing to
//! probe for them.
//!
//! A STATED address is different: it is LAW, the host binds exactly it, and a
//! taken port is a loud refusal. This module probes each stated socket BEFORE
//! boot and, on a collision, returns a [`CliError::PortUnavailable`] that names
//! the socket role, the exact frame.toml key, and the coherence rule to follow
//! when moving it — an earlier, more actionable surfacing of the same refusal
//! the host would raise mid-bind.
//!
//! A socket counts as stated when its address is present in the loaded config
//! with a non-zero port: `[frame].bind` when present (the page server), and any
//! `[bus]` listener whose port is not the OS-assigned `0` sentinel. A fully
//! portless config therefore probes nothing and this is a clean pass.

use std::net::{SocketAddr, TcpListener};

use frame_host::FrameConfig;

use crate::error::CliError;

/// The coherence rule for the page server's own address: its origin is
/// echoed into the bus's allow-list, so moving it demands a matching edit
/// there or the served page is refused by the bus on Origin grounds.
const PAGE_COHERENCE: &str = "Pick a free port for [frame].bind, and because the page's origin \
    (http://HOST:PORT) is echoed in [bus.websocket].allowed_origins, change allowed_origins to \
    match or the served page is Origin-refused by the bus. Keep all four frame.toml ports \
    distinct: [frame].bind, [bus].listen_address, [bus].health_listen_address, and \
    [bus.websocket].listen_address. Or omit [frame].bind entirely to let the host prefer 4190 \
    and walk forward to a free port.";

/// The coherence rule for the three bus sockets: keep all four distinct and,
/// if the page server itself moves, mirror it into the allow-list.
const BUS_COHERENCE: &str = "Pick a free port and keep all four frame.toml ports distinct: \
    [frame].bind, [bus].listen_address, [bus].health_listen_address, and \
    [bus.websocket].listen_address. If you move [frame].bind, also change \
    [bus.websocket].allowed_origins to its new origin. Or omit the whole [bus] section to let \
    the host bind OS-assigned free ports and advertise them via /frame/config.json.";

/// One host socket: its human role, the frame.toml key that states it, the
/// address it binds, and the coherence rule for moving it.
struct HostSocket {
    role: &'static str,
    key: &'static str,
    addr: SocketAddr,
    coherence: &'static str,
}

/// The EXPLICITLY-stated host sockets in `config`, in bind order. A page
/// socket appears only when `[frame].bind` is stated; a bus socket appears
/// only when its port is non-zero (a `0` port is the OS-assigned sentinel of a
/// synthesized portless bus, chosen at boot and never a collision).
fn stated_sockets(config: &FrameConfig) -> Vec<HostSocket> {
    let mut sockets = Vec::new();
    if let Some(addr) = config.frame.bind {
        sockets.push(HostSocket {
            role: "page server",
            key: "[frame].bind",
            addr,
            coherence: PAGE_COHERENCE,
        });
    }
    if config.bus.listen_address.port() != 0 {
        sockets.push(HostSocket {
            role: "bus",
            key: "[bus].listen_address",
            addr: config.bus.listen_address,
            coherence: BUS_COHERENCE,
        });
    }
    if config.bus.health_listen_address.port() != 0 {
        sockets.push(HostSocket {
            role: "bus health",
            key: "[bus].health_listen_address",
            addr: config.bus.health_listen_address,
            coherence: BUS_COHERENCE,
        });
    }
    if let Some(websocket) = config.bus.websocket.as_ref()
        && websocket.listen_address.port() != 0
    {
        sockets.push(HostSocket {
            role: "websocket",
            key: "[bus.websocket].listen_address",
            addr: websocket.listen_address,
            coherence: BUS_COHERENCE,
        });
    }
    sockets
}

/// Probes each explicitly-stated host socket, failing with actionable guidance
/// the moment one is unavailable — before the host is spawned or booted. A
/// fully portless config probes nothing and passes cleanly.
///
/// # Errors
///
/// Returns [`CliError::PortUnavailable`] naming the socket role, the frame.toml
/// key, the address, and the coherence rule when a stated socket cannot be
/// bound.
pub fn ensure_ports_available(config: &FrameConfig) -> Result<(), CliError> {
    for socket in stated_sockets(config) {
        // A successful bind proves the port is free; the listener is dropped
        // immediately so the host can claim it a moment later.
        match TcpListener::bind(socket.addr) {
            Ok(listener) => drop(listener),
            Err(source) => {
                return Err(CliError::PortUnavailable {
                    role: socket.role,
                    key: socket.key,
                    addr: socket.addr,
                    coherence: socket.coherence,
                    source,
                });
            }
        }
    }
    Ok(())
}