microsandbox-cli 0.3.14

CLI binary for managing microsandbox environments.
//! `msb install` command — create an executable alias for `msb run`.

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use clap::Args;
use microsandbox::config;

use crate::ui;

//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

/// Marker comment used to identify msb-generated alias scripts.
pub(super) const MARKER: &str = "# generated by msb install";

//--------------------------------------------------------------------------------------------------
// Types
//--------------------------------------------------------------------------------------------------

/// Install a sandbox as a system command in ~/.microsandbox/bin.
#[derive(Debug, Args)]
pub struct InstallArgs {
    /// Image to install (e.g. python, ubuntu).
    #[arg(required_unless_present = "list")]
    pub image: Option<String>,

    /// Command name for the alias (defaults to image name).
    #[arg(short, long)]
    pub name: Option<String>,

    /// Number of virtual CPUs to allocate.
    #[arg(short = 'c', long)]
    pub cpus: Option<u8>,

    /// Amount of memory to allocate (e.g. 512M, 1G).
    #[arg(short, long)]
    pub memory: Option<String>,

    /// Mount a host path or named volume into the sandbox (SOURCE:DEST).
    #[arg(short, long)]
    pub volume: Vec<String>,

    /// Set the default working directory for commands.
    #[arg(short, long)]
    pub workdir: Option<String>,

    /// Shell to use for interactive sessions (default: /bin/sh).
    #[arg(long)]
    pub shell: Option<String>,

    /// Set an environment variable (KEY=value).
    #[arg(short, long)]
    pub env: Vec<String>,

    /// Overwrite an existing alias with the same name.
    #[arg(short, long)]
    pub force: bool,

    /// Don't pull the image before installing.
    #[arg(long)]
    pub no_pull: bool,

    /// Create a fresh sandbox on every invocation (no persistent state).
    #[arg(long)]
    pub tmp: bool,

    /// List all installed sandbox commands.
    #[arg(short, long)]
    pub list: bool,

    /// Default command to run in the sandbox (after --).
    #[arg(last = true)]
    pub command: Vec<String>,
}

//--------------------------------------------------------------------------------------------------
// Functions
//--------------------------------------------------------------------------------------------------

/// Execute the `msb install` command.
pub async fn run(args: InstallArgs) -> anyhow::Result<()> {
    let bin_dir = resolve_bin_dir();

    if args.list {
        return list_aliases(&bin_dir);
    }

    let image = args.image.as_deref().unwrap();
    let no_pull = args.no_pull;
    let alias_name = args.name.as_deref().unwrap_or_else(|| derive_name(image));

    validate_alias_name(alias_name)?;

    let alias_path = bin_dir.join(alias_name);

    // Check for conflicts.
    if alias_path.exists() && !args.force {
        anyhow::bail!("alias '{alias_name}' already exists (use --force to overwrite)");
    }

    // Pre-pull the image so the alias is ready to use immediately.
    if !no_pull {
        super::image::pull_if_missing(image, false).await?;
    }

    // Ensure bin dir exists.
    fs::create_dir_all(&bin_dir)?;

    // Build the script.
    let script = build_script(image, alias_name, &args);

    // Write and make executable.
    fs::write(&alias_path, &script)?;
    fs::set_permissions(&alias_path, fs::Permissions::from_mode(0o755))?;

    ui::success("Installed", alias_name);

    // PATH hint.
    if !is_in_path(&bin_dir) {
        eprintln!(
            "  Add to your shell profile:\n    export PATH=\"{}:$PATH\"",
            bin_dir.display()
        );
    }

    Ok(())
}

/// Resolve the bin directory for installed aliases.
fn resolve_bin_dir() -> PathBuf {
    config::config().home().join("bin")
}

/// Validate that an alias name is safe to use as a filename in the bin directory.
fn validate_alias_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() {
        anyhow::bail!("alias name cannot be empty");
    }
    if name.contains('/') || name.contains("..") {
        anyhow::bail!("alias name must not contain '/' or '..'");
    }

    const RESERVED: &[&str] = &["msb", "agentd"];
    if RESERVED.contains(&name) {
        anyhow::bail!("alias name '{name}' would shadow a microsandbox binary");
    }

    Ok(())
}

/// Derive an alias name from an image reference.
///
/// Strips the digest, tag, and registry/namespace prefix:
/// - `ubuntu` → `ubuntu`
/// - `ghcr.io/foo/bar:latest` → `bar`
/// - `library/alpine` → `alpine`
/// - `ubuntu@sha256:abc` → `ubuntu`
fn derive_name(image: &str) -> &str {
    let without_digest = image.split('@').next().unwrap_or(image);
    let without_tag = without_digest.split(':').next().unwrap_or(without_digest);
    without_tag.rsplit('/').next().unwrap_or(without_tag)
}

/// Shell-quote a string, using single quotes only when necessary.
fn shell_quote(s: &str) -> String {
    if s.is_empty() {
        return "''".to_string();
    }
    if s.chars()
        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+'))
    {
        s.to_string()
    } else {
        format!("'{}'", s.replace('\'', "'\\''"))
    }
}

/// Strip control characters for safe embedding in shell comments.
fn sanitize_comment(s: &str) -> String {
    s.chars().filter(|c| !c.is_control()).collect()
}

/// Build the shell script content for an alias.
fn build_script(image: &str, alias_name: &str, args: &InstallArgs) -> String {
    if args.tmp {
        build_tmp_script(image, args)
    } else {
        build_persisted_script(image, alias_name, args)
    }
}

/// Build an ephemeral (--tmp) alias script.
///
/// Each invocation creates a fresh sandbox that is removed on exit.
fn build_tmp_script(image: &str, args: &InstallArgs) -> String {
    let mut parts = vec!["exec".to_string(), "msb".to_string(), "run".to_string()];
    parts.push(shell_quote(image));
    append_resource_options(&mut parts, args);

    if !args.command.is_empty() {
        parts.push("--".into());
        for c in &args.command {
            parts.push(shell_quote(c));
        }
    }

    let mut script = format!("#!/bin/sh\n{MARKER}\n");
    script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
    script.push_str("# mode: tmp\n");
    if !args.command.is_empty() {
        script.push_str(&format!(
            "# command: {}\n",
            sanitize_comment(&args.command.join(" "))
        ));
    }
    script.push_str(&parts.join(" "));
    script.push('\n');
    script
}

/// Build a persisted alias script.
///
/// Uses `msb run -n` which creates on first use and reuses on subsequent
/// invocations. State (installed packages, files, etc.) persists across calls.
fn build_persisted_script(image: &str, sandbox_name: &str, args: &InstallArgs) -> String {
    let mut parts = vec![
        "exec".to_string(),
        "msb".to_string(),
        "run".to_string(),
        "-n".to_string(),
        shell_quote(sandbox_name),
        shell_quote(image),
    ];
    append_resource_options(&mut parts, args);

    if !args.command.is_empty() {
        parts.push("--".into());
        for c in &args.command {
            parts.push(shell_quote(c));
        }
    }

    let mut script = format!("#!/bin/sh\n{MARKER}\n");
    script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
    script.push_str("# mode: persisted\n");
    if !args.command.is_empty() {
        script.push_str(&format!(
            "# command: {}\n",
            sanitize_comment(&args.command.join(" "))
        ));
    }
    script.push_str(&parts.join(" "));
    script.push('\n');
    script
}

/// Append resource/environment options to a command parts list.
fn append_resource_options(parts: &mut Vec<String>, args: &InstallArgs) {
    if let Some(cpus) = args.cpus {
        parts.push("-c".into());
        parts.push(cpus.to_string());
    }
    if let Some(ref mem) = args.memory {
        parts.push("-m".into());
        parts.push(shell_quote(mem));
    }
    for vol in &args.volume {
        parts.push("-v".into());
        parts.push(shell_quote(vol));
    }
    if let Some(ref workdir) = args.workdir {
        parts.push("-w".into());
        parts.push(shell_quote(workdir));
    }
    if let Some(ref shell) = args.shell {
        parts.push("--shell".into());
        parts.push(shell_quote(shell));
    }
    for env_str in &args.env {
        parts.push("-e".into());
        parts.push(shell_quote(env_str));
    }
}

/// List all msb-installed aliases in the bin directory.
fn list_aliases(bin_dir: &Path) -> anyhow::Result<()> {
    let mut table = ui::Table::new(&["NAME", "IMAGE", "MODE", "COMMAND"]);

    if bin_dir.is_dir() {
        let mut entries: Vec<_> = fs::read_dir(bin_dir)?.filter_map(|e| e.ok()).collect();
        entries.sort_by_key(|e| e.file_name());

        for entry in entries {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }

            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            if content.lines().nth(1) != Some(MARKER) {
                continue;
            }

            let name = entry.file_name().to_string_lossy().to_string();
            let image = content
                .lines()
                .find_map(|l| l.strip_prefix("# image: "))
                .unwrap_or("-")
                .to_string();
            let mode = content
                .lines()
                .find_map(|l| l.strip_prefix("# mode: "))
                .unwrap_or("-")
                .to_string();
            let command = content
                .lines()
                .find_map(|l| l.strip_prefix("# command: "))
                .unwrap_or("")
                .to_string();

            table.add_row(vec![name, image, mode, command]);
        }
    }

    table.print();

    Ok(())
}

/// Check whether a directory is already in the PATH.
fn is_in_path(dir: &Path) -> bool {
    std::env::var_os("PATH")
        .map(|path| std::env::split_paths(&path).any(|p| p == dir))
        .unwrap_or(false)
}