moor 0.9.4

Build software with an AI agent inside a locked-down container
pub mod ask_cmd;
pub mod audit_cmd;
pub mod bundle_cmd;
pub mod doctor_cmd;
pub mod down;
pub mod flow_cmd;
pub mod import_cmd;
pub mod keel_cmd;
pub mod logs;
pub mod new_cmd;
pub mod next_cmd;
pub mod pull_cmd;
pub mod recipe;
pub mod run_cmd;
pub mod secrets_status;
pub mod selftest;
pub mod shell;
pub mod ship_cmd;
pub mod spec_cmd;
pub mod status;
pub mod studio_cmd;
pub mod ui_cmd;
pub mod up;
pub mod use_cmd;
pub mod view;

use crate::{compose, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use std::io::{self, Write};
use std::path::Path;

/// The host's own GitHub credential, used for every credentialed git
/// operation moor itself performs (`moor new --github`'s clone, `moor
/// ship`'s push, `moor pull`'s fetch) — see ADR-0009. Resolved via `gh
/// auth token`: `gh` already authenticates the operator's account, and
/// `gh pr create` already assumes it's logged in. `None` means there's
/// nothing to ship/clone/pull with; callers fail loudly rather than
/// falling back to any in-sandbox value.
pub(crate) fn host_github_token() -> Option<String> {
    let (status, out) = proc::run_capture("gh", &["auth", "token"]).ok()?;
    let token = out.trim().to_string();
    (status.success() && !token.is_empty()).then_some(token)
}

/// Run a host `git` invocation authenticated by `token`, via a credential
/// helper that reads it from an env var set only for this one call — never
/// in `argv` (so never in `docker inspect`/`ps`), and never exported into
/// the sandbox. The same pattern the sandbox's own (now-removed) in-container
/// helper used, moved to the host and sourced from the host's credential
/// instead of a sandbox secret.
pub(crate) fn run_git_as_host(token: &str, argv: &[&str]) -> Result<(bool, String)> {
    std::env::set_var("MOOR_HOST_GH_TOKEN", token);
    let helper = "credential.helper=!f() { echo username=x-access-token; echo password=$MOOR_HOST_GH_TOKEN; }; f";
    let mut args: Vec<&str> = vec!["-c", helper];
    args.extend_from_slice(argv);
    let result = proc::run_capture_combined("git", &args);
    std::env::remove_var("MOOR_HOST_GH_TOKEN");
    let (status, out) = result?;
    Ok((status.success(), out))
}

/// Stream a local bundle file into the sandbox at `dest`, the same
/// stdin-only mechanism `moor import` uses: the read-only rootfs defeats
/// `docker cp` even onto a writable tmpfs path, but a byte stream through
/// `docker exec -i`'s stdin only ever touches `dest`.
pub(crate) fn stream_into_sandbox(container: &str, bundle_path: &Path, dest: &str) -> Result<()> {
    let cmd = format!("cat > {dest}");
    let status = proc::run_with_stdin_file(
        "docker",
        &["exec", "-i", container, "sh", "-c", &cmd],
        bundle_path,
    )?;
    proc::require_success("streaming bundle into the sandbox", status)
}

/// Of all known projects, the ones whose sandbox container is currently
/// running (`moor up`'d). A single `docker ps` covers every project at
/// once rather than shelling out per project.
pub(crate) fn running_projects(names: &[String]) -> Result<Vec<String>> {
    let (_status, out) = crate::proc::run_capture("docker", &["ps", "--format", "{{.Names}}"])?;
    let running: std::collections::HashSet<&str> = out.lines().collect();
    let mut matches = vec![];
    for name in names {
        let Ok(manifest_path) = paths::manifest_path(name) else {
            continue;
        };
        if !manifest_path.exists() {
            continue;
        }
        if let Ok(m) = Manifest::load(&manifest_path) {
            if running.contains(m.sandbox_container().as_str()) {
                matches.push(name.clone());
            }
        }
    }
    Ok(matches)
}

/// How a project name was arrived at, when the operator didn't spell it
/// out as a plain command-line argument — shown alongside the resolved
/// name so a command can never silently act on the wrong container.
#[derive(Debug)]
pub enum ProjectSource {
    Sticky,
    RunningUnique,
    SoleProject,
}

impl ProjectSource {
    fn describe(&self) -> &'static str {
        match self {
            ProjectSource::Sticky => "set via `moor use`",
            ProjectSource::RunningUnique => "only sandbox currently up",
            ProjectSource::SoleProject => "only project you have",
        }
    }
}

/// The auto-detection half of `resolve_project`'s precedence rule,
/// pulled out as a pure function of already-gathered inputs (no disk or
/// docker access) so "the one sandbox that's up wins, else the one
/// project you have, else ask" can be unit tested directly. Only reached
/// once an explicit `--project` and a `moor use` default have both come
/// up empty.
fn pick_running_or_sole_project(
    all: &[String],
    running: &[String],
) -> Result<(String, ProjectSource)> {
    if running.len() == 1 {
        return Ok((running[0].clone(), ProjectSource::RunningUnique));
    }
    match all.len() {
        0 => anyhow::bail!("no projects yet — try `moor new <name>`"),
        1 => Ok((all[0].clone(), ProjectSource::SoleProject)),
        _ if running.len() > 1 => anyhow::bail!(
            "more than one project is up ({}) — pass `--project <name>` or run `moor use <name>`",
            running.join(", ")
        ),
        _ => anyhow::bail!(
            "more than one project exists ({}) and none is up — run `moor up <name>`, `moor use <name>`, or pass `--project <name>`",
            all.join(", ")
        ),
    }
}

/// Resolve the project a command should act on when the operator didn't
/// name one explicitly: `moor keel`/`moor view` are meant to be used
/// without repeating the project on every call. Precedence: an explicit
/// `--project` flag; the sticky default set by `moor use`; the one
/// project whose sandbox is actually running right now (keel ships
/// natively in every sandbox, so "the project you have up" is the
/// obvious target — no setup step needed); and finally, if there's only
/// one project at all, that one. Short-circuits before touching disk or
/// docker once an explicit or sticky project answers the question.
pub fn resolve_project(explicit: Option<String>) -> Result<(String, Option<ProjectSource>)> {
    if let Some(name) = explicit {
        return Ok((name, None));
    }
    if let Some(name) = paths::read_current_project()? {
        return Ok((name, Some(ProjectSource::Sticky)));
    }
    let all = paths::all_project_names()?;
    let running = running_projects(&all)?;
    let (name, source) = pick_running_or_sole_project(&all, &running)?;
    Ok((name, Some(source)))
}

/// `resolve_project`, but announces the result — used by commands (like
/// `moor keel`/`moor view`) that can act on a project without it ever
/// appearing in the command line, so it's always visible which container
/// is about to be touched, not just inferred silently.
pub fn resolve_project_announced(explicit: Option<String>) -> Result<String> {
    let (name, source) = resolve_project(explicit)?;
    match source {
        Some(source) => println!("==> project: {name} ({})", source.describe()),
        None => println!("==> project: {name}"),
    }
    Ok(name)
}

/// Interactively confirm and create a private GitHub repo named `name`,
/// returning its "owner/repo" slug. `Ok(None)` means the operator
/// declined the confirmation prompt — callers should treat that as "stop
/// here, don't scaffold anything," not as an error. Shared by `new` and
/// `import` so the confirm-then-create-then-report flow can't drift
/// between the two.
pub fn create_github_repo_interactive(name: &str) -> Result<Option<String>> {
    print!(
        "About to run `gh repo create {name} --private` on your GitHub account. Continue? [y/N] "
    );
    io::stdout().flush().ok();
    let mut answer = String::new();
    io::stdin().read_line(&mut answer)?;
    if !answer.trim().eq_ignore_ascii_case("y") {
        return Ok(None);
    }

    let (status, whoami) = crate::proc::run_capture("gh", &["api", "user", "-q", ".login"])?;
    crate::proc::require_success("gh api user", status)?;
    let owner = whoami.trim();
    let repo_slug = format!("{owner}/{name}");

    let status = crate::proc::run_inherit("gh", &["repo", "create", &repo_slug, "--private"])?;
    crate::proc::require_success("gh repo create", status)?;
    Ok(Some(repo_slug))
}

/// Re-render compose.yml from the current manifest and bring the pair up.
/// Called by both `new` and `up` so a hand-edited moor.yaml always
/// takes effect on the next start, not just at creation time.
pub fn compose_up(name: &str) -> Result<()> {
    let m = Manifest::load(&paths::manifest_path(name)?)?;
    // Anything not already in this process's own environment gets a
    // chance to resolve from the Keychain before we hand off to `docker
    // compose`, which reads ${VAR} substitutions from its parent
    // process's environment — see secrets.rs.
    crate::secrets::resolve_into_env(name, &m.secrets);
    let rendered = compose::render(&m);
    let compose_path = paths::compose_path(name)?;
    std::fs::write(&compose_path, rendered)
        .with_context(|| format!("writing {}", compose_path.display()))?;

    let compose_path_str = compose_path.to_string_lossy().to_string();
    let status = crate::proc::run_inherit(
        "docker",
        &["compose", "-p", name, "-f", &compose_path_str, "up", "-d"],
    )?;
    crate::proc::require_success("docker compose up", status)?;
    sync_git_identity(&m);
    // A warning, not a failure: keel already blocks any run whose required
    // posture has no attestation, so refusing to come up would add nothing.
    if let Err(e) = crate::posture::attest(name, &m) {
        eprintln!("warning: could not attest the sandbox's posture: {e:#}");
    }
    Ok(())
}

/// Give the sandbox's own git identity a real name, sourced from the
/// *host's* own `git config`, instead of leaving it unset and letting
/// keel's own identity lookup fall all the way through to "unknown" in
/// things like `keel approve`'s audit record (see
/// docs/decisions/0007-git-identity.md). Always local (never
/// `--global`): the container's rootfs is read-only outside its mounted
/// volumes, so only the repo-local `.git/config` — on the writable
/// workspace volume — can actually be written to. Best-effort and
/// silent: does nothing if there's no repo yet (called too early, before
/// `keel init`/clone/import has created one — callers that know a repo
/// now exists call this again afterward) or if the host itself has no
/// git identity configured.
pub fn sync_git_identity(m: &Manifest) {
    let container = m.sandbox_container();
    for key in ["user.name", "user.email"] {
        if let Ok((status, out)) = crate::proc::run_capture("git", &["config", "--get", key]) {
            let value = out.trim();
            if status.success() && !value.is_empty() {
                let _ = crate::proc::run_capture(
                    "docker",
                    &["exec", &container, "git", "config", key, value],
                );
            }
        }
    }
}

pub fn compose_down(name: &str) -> Result<()> {
    let compose_path = paths::compose_path(name)?;
    if !compose_path.exists() {
        anyhow::bail!("no compose.yml for project '{name}' — has `moor new` been run?");
    }
    let compose_path_str = compose_path.to_string_lossy().to_string();
    let status = crate::proc::run_inherit(
        "docker",
        &["compose", "-p", name, "-f", &compose_path_str, "down"],
    )?;
    crate::proc::require_success("docker compose down", status)?;
    Ok(())
}

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

    fn names(items: &[&str]) -> Vec<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn one_running_project_wins_even_with_others_present() {
        let all = names(&["banner-demo", "isolator-sample-app", "keel"]);
        let running = names(&["keel"]);
        let (name, source) = pick_running_or_sole_project(&all, &running).unwrap();
        assert_eq!(name, "keel");
        assert!(matches!(source, ProjectSource::RunningUnique));
    }

    #[test]
    fn sole_project_wins_when_none_are_running() {
        let all = names(&["only-project"]);
        let running = names(&[]);
        let (name, source) = pick_running_or_sole_project(&all, &running).unwrap();
        assert_eq!(name, "only-project");
        assert!(matches!(source, ProjectSource::SoleProject));
    }

    #[test]
    fn no_projects_at_all_is_an_error() {
        let err = pick_running_or_sole_project(&[], &[]).unwrap_err();
        assert!(err.to_string().contains("moor new"));
    }

    #[test]
    fn multiple_running_projects_is_ambiguous() {
        let all = names(&["banner-demo", "keel"]);
        let running = names(&["banner-demo", "keel"]);
        let err = pick_running_or_sole_project(&all, &running).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("banner-demo") && msg.contains("keel"));
        assert!(msg.contains("--project") || msg.contains("moor use"));
    }

    #[test]
    fn multiple_projects_none_running_is_ambiguous() {
        let all = names(&["banner-demo", "isolator-sample-app", "keel"]);
        let err = pick_running_or_sole_project(&all, &[]).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("none is up"));
        assert!(msg.contains("moor up"));
    }

    #[test]
    fn describes_each_source_distinctly() {
        let sticky = ProjectSource::Sticky.describe();
        let running = ProjectSource::RunningUnique.describe();
        let sole = ProjectSource::SoleProject.describe();
        assert_ne!(sticky, running);
        assert_ne!(running, sole);
        assert_ne!(sticky, sole);
    }
}