909 lines
30 KiB
Rust
909 lines
30 KiB
Rust
use anyhow::{Result, bail};
|
|
use std::path::Path;
|
|
use tokio::process::Command;
|
|
use uuid::Uuid;
|
|
|
|
const CONTAINER_NAME: &str = "sandlot";
|
|
const USER: &str = "ubuntu";
|
|
const CLAUDE_BIN: &str = "/home/ubuntu/.local/bin/claude";
|
|
const CONTAINER_PATH: &str = "/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
|
|
const CONTAINER_ENV: &[(&str, &str)] = &[
|
|
("RUSTUP_HOME", "/sandlot/.rustup"),
|
|
("CARGO_HOME", "/sandlot/.cargo"),
|
|
("GOROOT", "/sandlot/.go"),
|
|
("GOPATH", "/sandlot/.gopath"),
|
|
("RUSTC_WRAPPER", "/sandlot/.cargo/bin/sccache"),
|
|
("SCCACHE_DIR", "/sandlot/.sccache"),
|
|
];
|
|
|
|
fn debug_mode() -> bool {
|
|
std::env::var("DEBUG").is_ok_and(|v| !v.is_empty())
|
|
}
|
|
|
|
fn home_dir() -> String {
|
|
dirs::home_dir()
|
|
.expect("cannot find home directory")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
fn cache_dir() -> String {
|
|
format!("{}/.sandlot/.cache", home_dir())
|
|
}
|
|
|
|
/// Translate a host path to its corresponding container path.
|
|
pub fn container_path(host_path: &str) -> String {
|
|
let home = home_dir();
|
|
let sandlot_prefix = format!("{home}/.sandlot");
|
|
let dev_prefix = format!("{home}/dev");
|
|
let code_prefix = format!("{home}/code");
|
|
if host_path.starts_with(&sandlot_prefix) {
|
|
return format!("/sandlot{}", &host_path[sandlot_prefix.len()..]);
|
|
}
|
|
if host_path.starts_with(&dev_prefix) {
|
|
return format!("/host/dev{}", &host_path[dev_prefix.len()..]);
|
|
}
|
|
if host_path.starts_with(&code_prefix) {
|
|
return format!("/host/code{}", &host_path[code_prefix.len()..]);
|
|
}
|
|
host_path.to_string()
|
|
}
|
|
|
|
fn require_container() {
|
|
if which::which("container").is_err() {
|
|
eprintln!("\u{2716} Apple Container is not installed. Install it with: brew install container");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Run a shell command, returning error on failure.
|
|
async fn run(args: &[&str], step: &str) -> Result<()> {
|
|
let mut cmd = Command::new(args[0]);
|
|
cmd.args(&args[1..]);
|
|
if !debug_mode() {
|
|
cmd.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
}
|
|
let output = cmd.output().await?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let detail = if !stderr.is_empty() {
|
|
stderr
|
|
} else if !stdout.is_empty() {
|
|
stdout
|
|
} else {
|
|
"(no output)".to_string()
|
|
};
|
|
bail!("{step} failed (exit {}):\n{detail}", output.status.code().unwrap_or(1));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Check which host source directories exist.
|
|
fn host_mounts(home: &str) -> (bool, bool) {
|
|
let dev = Path::new(&format!("{home}/dev")).exists();
|
|
let code = Path::new(&format!("{home}/code")).exists();
|
|
(dev, code)
|
|
}
|
|
|
|
/// Check whether the package cache is populated.
|
|
async fn has_cached_tooling() -> bool {
|
|
let cache = cache_dir();
|
|
for f in &["bun", "claude", "neofetch", "nvim.tar.gz"] {
|
|
if !Path::new(&format!("{cache}/{f}")).exists() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
async fn create_container(home: &str) -> Result<()> {
|
|
let (dev, code) = host_mounts(home);
|
|
let memory = match crate::config::get_memory().await {
|
|
Some(m) => match crate::config::validate_memory(&m) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
crate::fmt::info(&format!("Invalid memory config, using default: {e}"));
|
|
crate::config::DEFAULTS_MEMORY.to_string()
|
|
}
|
|
},
|
|
None => crate::config::DEFAULTS_MEMORY.to_string(),
|
|
};
|
|
let mut args: Vec<String> = vec![
|
|
"container".into(), "run".into(), "-d".into(),
|
|
"--name".into(), CONTAINER_NAME.into(),
|
|
"-m".into(), memory,
|
|
];
|
|
if dev {
|
|
args.push("--mount".into());
|
|
args.push(format!(
|
|
"type=bind,source={home}/dev,target=/host/dev,readonly"
|
|
));
|
|
}
|
|
if code {
|
|
args.push("--mount".into());
|
|
args.push(format!(
|
|
"type=bind,source={home}/code,target=/host/code,readonly"
|
|
));
|
|
}
|
|
args.push("-v".into());
|
|
args.push(format!("{home}/.sandlot:/sandlot"));
|
|
args.push("ubuntu:24.04".into());
|
|
args.push("sleep".into());
|
|
args.push("infinity".into());
|
|
|
|
let mut cmd = Command::new(&args[0]);
|
|
cmd.args(&args[1..]);
|
|
if !debug_mode() {
|
|
cmd.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
}
|
|
let output = cmd.output().await?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
bail!(
|
|
"Container creation failed (exit {}):\n{}",
|
|
output.status.code().unwrap_or(1),
|
|
if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() }
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_packages(cached: bool) -> Result<()> {
|
|
let packages = if cached {
|
|
"curl git fish build-essential"
|
|
} else {
|
|
"curl git fish unzip build-essential"
|
|
};
|
|
run(
|
|
&[
|
|
"container", "exec", CONTAINER_NAME, "bash", "-c",
|
|
&format!("apt update && apt install -y {packages}"),
|
|
],
|
|
"Package installation",
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn create_host_symlinks(home: &str) -> Result<()> {
|
|
let (dev, code) = host_mounts(home);
|
|
let mut cmds = vec![
|
|
format!("mkdir -p '{home}'"),
|
|
format!("ln -s /sandlot '{home}/.sandlot'"),
|
|
];
|
|
if dev {
|
|
cmds.push(format!("ln -s /host/dev '{home}/dev'"));
|
|
}
|
|
if code {
|
|
cmds.push(format!("ln -s /host/code '{home}/code'"));
|
|
}
|
|
run(
|
|
&[
|
|
"container", "exec", CONTAINER_NAME, "bash", "-c",
|
|
&cmds.join(" && "),
|
|
],
|
|
"Symlink creation",
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn install_tooling(cached: bool, log: &dyn Fn(&str)) -> Result<()> {
|
|
// Ensure cache directory exists
|
|
tokio::fs::create_dir_all(cache_dir()).await.ok();
|
|
|
|
if cached {
|
|
log("Installing packages (cached)");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c", "mkdir -p ~/.local/bin",
|
|
],
|
|
"Create bin directory",
|
|
)
|
|
.await?;
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx",
|
|
],
|
|
"Install cached binaries",
|
|
)
|
|
.await?;
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1",
|
|
],
|
|
"Install cached Neovim",
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
log("Installing Bun");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"env", &format!("BUN_INSTALL=/home/{USER}/.local"),
|
|
"bash", "-c", "curl -fsSL https://bun.sh/install | bash",
|
|
],
|
|
"Bun installation",
|
|
)
|
|
.await?;
|
|
|
|
log("Installing Claude Code");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash",
|
|
],
|
|
"Claude Code installation",
|
|
)
|
|
.await?;
|
|
|
|
log("Installing neofetch");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch",
|
|
],
|
|
"neofetch installation",
|
|
)
|
|
.await?;
|
|
|
|
log("Installing Neovim");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1",
|
|
],
|
|
"Neovim installation",
|
|
)
|
|
.await?;
|
|
|
|
// Cache binaries
|
|
let _ = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz",
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
|
|
install_persistent_tooling(log).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_persistent_tooling(log: &dyn Fn(&str)) -> Result<()> {
|
|
// Rust
|
|
let has_rust = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/rustc"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.await?;
|
|
if !has_rust.success() {
|
|
log("Installing Rust");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"env",
|
|
&format!("RUSTUP_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1),
|
|
&format!("CARGO_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1),
|
|
"bash", "-c",
|
|
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
|
|
],
|
|
"Rust installation",
|
|
)
|
|
.await?;
|
|
// Add musl target
|
|
let cargo_home = CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1;
|
|
let rustup_home = CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1;
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"env",
|
|
&format!("RUSTUP_HOME={rustup_home}"),
|
|
&format!("CARGO_HOME={cargo_home}"),
|
|
&format!("PATH={cargo_home}/bin:$PATH"),
|
|
"rustup", "target", "add", "aarch64-unknown-linux-musl",
|
|
],
|
|
"Rust musl target",
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Cargo config
|
|
let has_cargo_config = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-f", "/sandlot/.cargo/config.toml"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.await?;
|
|
if !has_cargo_config.success() {
|
|
let cargo_config = r#"[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n"#;
|
|
let _ = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
&format!("echo -e '{cargo_config}' > /sandlot/.cargo/config.toml"),
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
// sccache
|
|
let has_sccache = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/sccache"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.await?;
|
|
if !has_sccache.success() {
|
|
log("Installing sccache");
|
|
let sccache_version = "v0.14.0";
|
|
let sccache_archive = format!("sccache-{sccache_version}-aarch64-unknown-linux-musl.tar.gz");
|
|
let sccache_url = format!("https://github.com/mozilla/sccache/releases/download/{sccache_version}/{sccache_archive}");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
&format!(
|
|
"curl -fsSL {sccache_url} | tar xz -C /tmp && cp /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl/sccache /sandlot/.cargo/bin/sccache && chmod +x /sandlot/.cargo/bin/sccache && rm -rf /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl"
|
|
),
|
|
],
|
|
"sccache installation",
|
|
)
|
|
.await?;
|
|
let _ = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.sccache"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
// Go
|
|
let has_go = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.go/bin/go"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.await?;
|
|
if !has_go.success() {
|
|
log("Installing Go");
|
|
run(
|
|
&[
|
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1",
|
|
],
|
|
"Go installation",
|
|
)
|
|
.await?;
|
|
let _ = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.gopath"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_script(home: &str, name: &str, content: &str) -> Result<()> {
|
|
let tmp = format!("{home}/.sandlot/.{name}.tmp");
|
|
tokio::fs::write(&tmp, content).await?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
|
|
}
|
|
let _ = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
&format!("cp /sandlot/.{name}.tmp ~/.local/bin/{name}"),
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
tokio::fs::remove_file(&tmp).await.ok();
|
|
Ok(())
|
|
}
|
|
|
|
async fn configure_environment(home: &str, api_key: &str) -> Result<()> {
|
|
// Git identity
|
|
let git_name = Command::new("git")
|
|
.args(["config", "user.name"])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.unwrap_or_default();
|
|
let git_email = Command::new("git")
|
|
.args(["config", "user.email"])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.unwrap_or_default();
|
|
if !git_name.is_empty() {
|
|
let _ = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.name", &git_name])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
}
|
|
if !git_email.is_empty() {
|
|
let _ = Command::new("container")
|
|
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.email", &git_email])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
// Claude settings
|
|
let activity_bin = format!("/home/{USER}/.local/bin/sandlot-activity");
|
|
let hooks = serde_json::json!({
|
|
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
|
|
"PreToolUse": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
|
|
});
|
|
let status_line = serde_json::json!({
|
|
"type": "command",
|
|
"command": format!("/home/{USER}/.local/bin/sandlot-statusline"),
|
|
});
|
|
let settings = serde_json::json!({
|
|
"apiKeyHelper": "~/.claude/api-key-helper.sh",
|
|
"skipDangerousModePermissionPrompt": true,
|
|
"hooks": hooks,
|
|
"statusLine": status_line,
|
|
});
|
|
let claude_json = serde_json::json!({
|
|
"hasCompletedOnboarding": true,
|
|
"effortCalloutDismissed": true,
|
|
"projects": { "/": { "hasTrustDialogAccepted": true } },
|
|
});
|
|
let settings_json = serde_json::to_string(&settings)?;
|
|
let claude_json_str = serde_json::to_string(&claude_json)?;
|
|
|
|
// API key helper (write to temp file so key never appears in ps)
|
|
let escaped_key = api_key.replace('\'', "'\\''");
|
|
let tmp = format!("{home}/.sandlot/.api-key-helper.tmp");
|
|
tokio::fs::write(&tmp, format!("#!/bin/sh\necho '{escaped_key}'\n")).await?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
|
|
}
|
|
let _ = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh",
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
tokio::fs::remove_file(&tmp).await.ok();
|
|
|
|
// Activity hook script
|
|
install_script(
|
|
home,
|
|
"sandlot-activity",
|
|
"#!/bin/bash\nP=\"${CLAUDE_PROJECT_DIR%/}\"\necho \"$1\" > \"$(dirname \"$P\")/.activity-$(basename \"$P\")\"\n",
|
|
)
|
|
.await?;
|
|
|
|
// Statusline script
|
|
install_script(
|
|
home,
|
|
"sandlot-statusline",
|
|
"#!/bin/bash\ninput=$(cat)\ncwd=$(echo \"$input\" | grep -oP '\"cwd\"\\s*:\\s*\"\\K[^\"]+' | head -1)\n[ -n \"$cwd\" ] && printf '\\033[36m\u{2387} %s\\033[0m\\n' \"$(basename \"$cwd\")\"\n",
|
|
)
|
|
.await?;
|
|
|
|
// Write Claude settings
|
|
let _ = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER, CONTAINER_NAME,
|
|
"bash", "-c",
|
|
&format!(
|
|
"mkdir -p ~/.claude\necho '{settings_json}' > ~/.claude/settings.json\necho '{claude_json_str}' > ~/.claude.json\n"
|
|
),
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────────────────
|
|
|
|
/// Create and provision the container from scratch.
|
|
pub async fn create(log: &dyn Fn(&str)) -> Result<()> {
|
|
require_container();
|
|
let api_key = crate::env::require_api_key().await;
|
|
|
|
let s = status().await;
|
|
if s != "missing" {
|
|
bail!("Container already exists. Use 'sandlot vm destroy' first to recreate it.");
|
|
}
|
|
|
|
let home = home_dir();
|
|
let cached = has_cached_tooling().await;
|
|
|
|
log("Pulling image & creating container");
|
|
create_container(&home).await?;
|
|
|
|
log("Installing packages");
|
|
install_packages(cached).await?;
|
|
create_host_symlinks(&home).await?;
|
|
|
|
install_tooling(cached, log).await?;
|
|
|
|
log("Configuring environment");
|
|
configure_environment(&home, &api_key).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Start a stopped container.
|
|
pub async fn start() -> Result<()> {
|
|
require_container();
|
|
let s = status().await;
|
|
if s == "running" {
|
|
return Ok(());
|
|
}
|
|
if s == "missing" {
|
|
bail!("Container does not exist. Use 'sandlot vm create' first.");
|
|
}
|
|
run(&["container", "start", CONTAINER_NAME], "Container start").await
|
|
}
|
|
|
|
/// Ensure the sandlot container exists and is running.
|
|
pub async fn ensure(log: &dyn Fn(&str)) -> Result<()> {
|
|
require_container();
|
|
crate::env::require_api_key().await;
|
|
|
|
// Ensure container daemon is running
|
|
let mut cmd = Command::new("container");
|
|
cmd.args(["system", "start", "--enable-kernel-install"]);
|
|
if !debug_mode() {
|
|
cmd.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
}
|
|
let _ = cmd.output().await;
|
|
|
|
let s = status().await;
|
|
if s == "running" {
|
|
return Ok(());
|
|
}
|
|
if s == "stopped" {
|
|
return start().await;
|
|
}
|
|
|
|
create(log).await
|
|
}
|
|
|
|
/// Check container status.
|
|
pub async fn status() -> &'static str {
|
|
let output = Command::new("container")
|
|
.args(["list", "--format", "json", "--all"])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
|
|
let output = match output {
|
|
Ok(o) => o,
|
|
Err(_) => return "missing",
|
|
};
|
|
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
let containers: Vec<serde_json::Value> = match serde_json::from_str(text.trim()) {
|
|
Ok(v) => v,
|
|
Err(_) => return "missing",
|
|
};
|
|
|
|
for c in &containers {
|
|
if c.get("configuration")
|
|
.and_then(|cfg| cfg.get("id"))
|
|
.and_then(|id| id.as_str())
|
|
== Some(CONTAINER_NAME)
|
|
{
|
|
let status_str = c
|
|
.get("status")
|
|
.and_then(|s| s.as_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
return if status_str == "running" {
|
|
"running"
|
|
} else {
|
|
"stopped"
|
|
};
|
|
}
|
|
}
|
|
|
|
"missing"
|
|
}
|
|
|
|
/// Launch claude in the container at the given workdir.
|
|
pub fn claude<'a>(
|
|
workdir: &'a str,
|
|
prompt: Option<&'a str>,
|
|
print: Option<&'a str>,
|
|
continue_session: bool,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(i32, Option<String>)>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
let cwd = container_path(workdir);
|
|
let home = home_dir();
|
|
let (dev, code) = host_mounts(&home);
|
|
let mut system_prompt_lines = vec![
|
|
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).".to_string(),
|
|
format!("Your working directory is {cwd}, a git worktree managed by sandlot."),
|
|
];
|
|
if dev {
|
|
system_prompt_lines.push("The host's ~/dev is mounted read-only at /host/dev.".to_string());
|
|
}
|
|
if code {
|
|
system_prompt_lines.push("The host's ~/code is mounted read-only at /host/code.".to_string());
|
|
}
|
|
system_prompt_lines.push("The host's ~/.sandlot is mounted at /sandlot.".to_string());
|
|
system_prompt_lines.push("Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.".to_string());
|
|
system_prompt_lines.push("Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/. sccache is configured as RUSTC_WRAPPER for build caching.".to_string());
|
|
if print.is_some() {
|
|
system_prompt_lines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.".to_string());
|
|
}
|
|
let system_prompt = system_prompt_lines.join("\n");
|
|
|
|
let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
|
|
let mut env_args: Vec<String> = vec![
|
|
format!("TERM={term}"),
|
|
format!("PATH={CONTAINER_PATH}"),
|
|
];
|
|
for (k, v) in CONTAINER_ENV {
|
|
env_args.push(format!("{k}={v}"));
|
|
}
|
|
|
|
let mut args: Vec<String> = vec![
|
|
"container".into(), "exec".into(), "-it".into(),
|
|
"--user".into(), USER.into(),
|
|
"--workdir".into(), cwd.clone(),
|
|
CONTAINER_NAME.into(), "env".into(),
|
|
];
|
|
args.extend(env_args);
|
|
args.extend([
|
|
CLAUDE_BIN.into(),
|
|
"--dangerously-skip-permissions".into(),
|
|
"--model".into(), "claude-opus-4-6".into(),
|
|
"--effort".into(), "max".into(),
|
|
"--append-system-prompt".into(), system_prompt,
|
|
]);
|
|
if continue_session {
|
|
args.push("--continue".into());
|
|
}
|
|
if let Some(p) = print {
|
|
args.push("-p".into());
|
|
args.push(p.into());
|
|
} else if let Some(p) = prompt {
|
|
args.push(p.into());
|
|
}
|
|
|
|
if print.is_some() {
|
|
let mut cmd = std::process::Command::new(&args[0]);
|
|
cmd.args(&args[1..])
|
|
.stdin(std::process::Stdio::inherit())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::inherit());
|
|
let child = cmd.spawn()?;
|
|
let output = child.wait_with_output()?;
|
|
let exit_code = output.status.code().unwrap_or(1);
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
|
|
if exit_code != 0 && continue_session {
|
|
crate::fmt::info("Retrying without --continue");
|
|
return claude(workdir, prompt, print, false).await;
|
|
}
|
|
return Ok((exit_code, Some(stdout)));
|
|
}
|
|
|
|
let mut cmd = std::process::Command::new(&args[0]);
|
|
cmd.args(&args[1..])
|
|
.stdin(std::process::Stdio::inherit())
|
|
.stdout(std::process::Stdio::inherit())
|
|
.stderr(std::process::Stdio::inherit());
|
|
let status = cmd.spawn()?.wait()?;
|
|
let exit_code = status.code().unwrap_or(1);
|
|
|
|
if exit_code != 0 && continue_session {
|
|
crate::fmt::info("Retrying without --continue");
|
|
return claude(workdir, prompt, print, false).await;
|
|
}
|
|
Ok((exit_code, None))
|
|
})
|
|
}
|
|
|
|
/// Open an interactive fish shell in the container.
|
|
pub async fn shell(workdir: Option<&str>) -> Result<()> {
|
|
let mut args: Vec<String> = vec![
|
|
"container".into(), "exec".into(), "-it".into(),
|
|
"--user".into(), USER.into(),
|
|
];
|
|
if let Some(wd) = workdir {
|
|
args.push("--workdir".into());
|
|
args.push(container_path(wd));
|
|
}
|
|
let mut env_args: Vec<String> = vec![
|
|
"TERM=xterm-256color".into(),
|
|
format!("PATH={CONTAINER_PATH}"),
|
|
];
|
|
for (k, v) in CONTAINER_ENV {
|
|
env_args.push(format!("{k}={v}"));
|
|
}
|
|
args.push(CONTAINER_NAME.into());
|
|
args.push("env".into());
|
|
args.extend(env_args);
|
|
args.push("fish".into());
|
|
args.push("--login".into());
|
|
|
|
let status = std::process::Command::new(&args[0])
|
|
.args(&args[1..])
|
|
.stdin(std::process::Stdio::inherit())
|
|
.stdout(std::process::Stdio::inherit())
|
|
.stderr(std::process::Stdio::inherit())
|
|
.spawn()?
|
|
.wait()?;
|
|
let _ = status;
|
|
Ok(())
|
|
}
|
|
|
|
/// Run neofetch in the container.
|
|
pub async fn neofetch() -> Result<()> {
|
|
let mut env_args: Vec<String> = vec![format!("PATH={CONTAINER_PATH}")];
|
|
for (k, v) in CONTAINER_ENV {
|
|
env_args.push(format!("{k}={v}"));
|
|
}
|
|
let mut args: Vec<String> = vec![
|
|
"container".into(), "exec".into(),
|
|
"--user".into(), USER.into(),
|
|
CONTAINER_NAME.into(), "env".into(),
|
|
];
|
|
args.extend(env_args);
|
|
args.push("neofetch".into());
|
|
|
|
let status = std::process::Command::new(&args[0])
|
|
.args(&args[1..])
|
|
.stdin(std::process::Stdio::inherit())
|
|
.stdout(std::process::Stdio::inherit())
|
|
.stderr(std::process::Stdio::inherit())
|
|
.spawn()?
|
|
.wait()?;
|
|
let _ = status;
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a bash command in the container at the given workdir, capturing output.
|
|
pub async fn exec(workdir: &str, command: &str) -> (i32, String, String) {
|
|
let env_exports: String = CONTAINER_ENV
|
|
.iter()
|
|
.map(|(k, v)| format!("export {k}={v}"))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
let full_cmd = format!("export PATH={CONTAINER_PATH}; {env_exports}; {command}");
|
|
let output = Command::new("container")
|
|
.args([
|
|
"exec", "--user", USER,
|
|
"--workdir", &container_path(workdir),
|
|
CONTAINER_NAME, "bash", "-c", &full_cmd,
|
|
])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
.await;
|
|
|
|
match output {
|
|
Ok(o) => (
|
|
o.status.code().unwrap_or(1),
|
|
String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
|
String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
|
),
|
|
Err(_) => (1, String::new(), String::new()),
|
|
}
|
|
}
|
|
|
|
/// Pipe input text to Claude in the container with a prompt, returning the output.
|
|
pub async fn claude_pipe(input: &str, prompt: &str) -> (i32, String, String) {
|
|
let tmp_name = format!(".claude-pipe-{}", Uuid::new_v4());
|
|
let home = home_dir();
|
|
let tmp_path = format!("{home}/.sandlot/{tmp_name}");
|
|
tokio::fs::write(&tmp_path, input).await.ok();
|
|
let escaped_prompt = prompt.replace('"', "\\\"");
|
|
let result = exec(
|
|
&format!("{home}/.sandlot"),
|
|
&format!(
|
|
"cat /sandlot/{tmp_name} | claude --model claude-opus-4-6 --effort max -p \"{escaped_prompt}\""
|
|
),
|
|
)
|
|
.await;
|
|
tokio::fs::remove_file(&tmp_path).await.ok();
|
|
result
|
|
}
|
|
|
|
/// Check if Claude is actively working in the given worktree.
|
|
pub async fn is_claude_active(worktree: &str, branch: &str) -> bool {
|
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
|
let file = parent.join(format!(".activity-{branch}"));
|
|
match tokio::fs::read_to_string(&file).await {
|
|
Ok(content) => content.trim() == "active",
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Set the activity marker for a worktree.
|
|
pub async fn set_activity(worktree: &str, branch: &str) {
|
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
|
let file = parent.join(format!(".activity-{branch}"));
|
|
tokio::fs::write(&file, "active\n").await.ok();
|
|
}
|
|
|
|
/// Remove the activity marker file for a worktree.
|
|
pub async fn clear_activity(worktree: &str, branch: &str) {
|
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
|
let file = parent.join(format!(".activity-{branch}"));
|
|
tokio::fs::remove_file(&file).await.ok();
|
|
}
|
|
|
|
/// Stop the container.
|
|
pub async fn stop() -> Result<()> {
|
|
let _ = Command::new("container")
|
|
.args(["stop", CONTAINER_NAME])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop and delete the container.
|
|
pub async fn destroy() -> Result<()> {
|
|
stop().await?;
|
|
let _ = Command::new("container")
|
|
.args(["delete", CONTAINER_NAME])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Clear the package cache.
|
|
pub async fn clear_cache() -> bool {
|
|
let cache = cache_dir();
|
|
let existed = Path::new(&format!("{cache}/bun")).exists();
|
|
tokio::fs::remove_dir_all(&cache).await.ok();
|
|
existed
|
|
}
|