use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tempfile::NamedTempFile;
use tokio::fs;
use tokio::process::Command;
use crate::config;
use crate::engine;
use crate::model;
use crate::preset;
use crate::ui;
use clap::ValueEnum;
#[derive(Debug, Clone, PartialEq, ValueEnum)]
pub enum ProvisionProfile {
Base,
Opencode,
}
impl std::fmt::Display for ProvisionProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProvisionProfile::Base => write!(f, "base"),
ProvisionProfile::Opencode => write!(f, "opencode"),
}
}
}
pub fn resolve_workspace_context() -> Result<(String, PathBuf, PathBuf), color_eyre::Report> {
let current_dir = std::env::current_dir()?;
let canonical_current = std::fs::canonicalize(¤t_dir)?;
let home = std::env::var("HOME")?;
let raw_workspace_root = if let Ok(v) = std::env::var("MUTHR_WORKSPACE_ROOT") {
v
} else if let Ok(cfg) = config::load() {
cfg.workspace_root
.unwrap_or_else(|| format!("{}/src", home))
} else {
format!("{}/src", home)
};
let canonical_workspace = std::fs::canonicalize(Path::new(&raw_workspace_root))
.unwrap_or_else(|_| PathBuf::from(&raw_workspace_root));
let mut inside_muthr_config = false;
if let Ok(muthr_config_dir) = std::fs::canonicalize(PathBuf::from(&home).join(".config/muthr"))
{
if canonical_current.starts_with(&muthr_config_dir) {
inside_muthr_config = true;
}
}
if inside_muthr_config {
return Ok((
"muthr-config-sandbox".to_string(),
PathBuf::from(&home).join(".config/muthr"),
current_dir,
));
}
if canonical_current.starts_with(&canonical_workspace) {
if canonical_current == canonical_workspace {
return Err(color_eyre::eyre::eyre!(
"Navigate into a project directory first."
));
}
let relative = canonical_current.strip_prefix(&canonical_workspace)?;
let project_folder = relative
.components()
.next()
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid workspace path"))?
.as_os_str()
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid project name"))?
.to_string();
let sanitized: String = project_folder
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if sanitized.is_empty() {
return Err(color_eyre::eyre::eyre!("Sanitized project name is empty"));
}
let vm_name = format!("{}-sandbox", sanitized);
let mount_point = PathBuf::from(&raw_workspace_root).join(&project_folder);
Ok((vm_name, mount_point, current_dir))
} else {
Err(color_eyre::eyre::eyre!(
"Sandbox tasks are restricted to project workspaces or the muthr configuration directory."
))
}
}
pub async fn vm_exists(vm_name: &str) -> bool {
let output = Command::new("limactl")
.args(["ls", "-q"])
.output()
.await
.ok()
.filter(|o| o.status.success());
if let Some(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line == vm_name {
return true;
}
}
}
false
}
pub async fn vm_is_running(vm_name: &str) -> bool {
let output = Command::new("limactl")
.args(["ls", "-f", "'{{.Status}}'", vm_name])
.output()
.await
.ok();
if let Some(out) = output {
if out.status.success() {
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
return status == "Running";
}
}
false
}
pub async fn vm_stop(vm_name: &str) -> Result<(), color_eyre::Report> {
println!("\n[PROC] Stopping sandbox VM ({})...", vm_name);
let output = Command::new("limactl")
.arg("stop")
.arg(vm_name)
.output()
.await
.ok();
match output {
Some(out) if out.status.success() => {
println!("[ OK ] VM stopped cleanly. System memory reclaimed.");
}
_ => {
eprintln!("[WARN] ACPI stop sequence sent.");
}
}
Ok(())
}
async fn vm_create(
vm_name: &str,
workspace_root: &Path,
mount_point: &Path,
) -> Result<(), color_eyre::Report> {
let home = std::env::var("HOME")?;
let template_path = PathBuf::from(&home).join(".config/muthr/manifests/dev-sandbox.yaml");
if !template_path.exists() {
return Err(color_eyre::eyre::eyre!(
"Template not found: {:?}",
template_path
));
}
let content = fs::read_to_string(&template_path).await?;
let expanded = content
.replace(
"__WORKSPACE_ROOT__",
workspace_root.to_str().unwrap_or_default(),
)
.replace("__MOUNT_POINT__", mount_point.to_str().unwrap_or_default());
println!(
"[PROC] VM '{}' not found. Creating and starting...",
vm_name
);
let mut tmp_yaml = NamedTempFile::new()?;
tmp_yaml.write_all(expanded.as_bytes())?;
let create_status = Command::new("limactl")
.args(["create", "--name", vm_name])
.arg(tmp_yaml.path())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !create_status.success() {
return Err(color_eyre::eyre::eyre!("Failed to create VM: {}", vm_name));
}
let start_status = Command::new("limactl")
.arg("start")
.arg(vm_name)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !start_status.success() {
return Err(color_eyre::eyre::eyre!("Failed to start VM: {}", vm_name));
}
Ok(())
}
async fn vm_start(vm_name: &str) -> Result<(), color_eyre::Report> {
println!("[PROC] Starting sandbox VM ({})...", vm_name);
let status = Command::new("limactl")
.arg("start")
.arg(vm_name)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!("Failed to start VM: {}", vm_name));
}
Ok(())
}
async fn is_vm_provisioned(vm_name: &str) -> bool {
let output = Command::new("limactl")
.args(["shell", "--workdir", "/tmp", vm_name])
.arg("bash")
.arg("-c")
.arg("test -f /var/log/opencode_provision.lock")
.output()
.await
.ok();
match output {
Some(out) => out.status.success(),
None => false,
}
}
async fn dpkg_lock_free(vm_name: &str) -> bool {
let output = Command::new("limactl")
.args(["shell", "--workdir", "/tmp", vm_name])
.arg("bash")
.arg("-c")
.arg(
"fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \
pgrep -x apt-get >/dev/null 2>&1 || \
pgrep -x dpkg >/dev/null 2>&1; \
exit $?",
)
.output()
.await
.ok();
match output {
Some(out) => !out.status.success(),
None => true,
}
}
async fn wait_for_dpkg(vm_name: &str, timeout_secs: u64) -> Result<(), color_eyre::Report> {
let start = std::time::Instant::now();
loop {
if dpkg_lock_free(vm_name).await {
return Ok(());
}
if start.elapsed() > std::time::Duration::from_secs(timeout_secs) {
return Err(color_eyre::eyre::eyre!(
"Timed out waiting for dpkg/apt lock to be released"
));
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
async fn run_provision(vm_name: &str, script_name: &str) -> Result<(), color_eyre::Report> {
let home = std::env::var("HOME")?;
let host_script =
PathBuf::from(&home).join(format!(".config/muthr/provision.d/{}.sh", script_name));
if !host_script.exists() {
return Err(color_eyre::eyre::eyre!(
"Provision script not found: {:?}",
host_script
));
}
println!("[PROC] Running provision: {}...", script_name);
let script_str = host_script
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in provision script path"))?;
println!("[PROC] Waiting for dpkg/apt lock to be free...");
wait_for_dpkg(vm_name, 120).await?;
let status = Command::new("bash")
.arg(script_str)
.arg(vm_name)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!("Provision failed: {}", script_name));
}
println!("[ OK ] Provision complete: {}", script_name);
Ok(())
}
async fn handle_provisioning(
vm_name: &str,
profile: &ProvisionProfile,
) -> Result<(), color_eyre::Report> {
if is_vm_provisioned(vm_name).await {
return Ok(());
}
match profile {
ProvisionProfile::Opencode => {
run_provision(vm_name, "opencode").await?;
}
ProvisionProfile::Base => {
println!("[INFO] Base provision only — no extra installs.");
}
}
Ok(())
}
pub async fn up(port: u16, profile: ProvisionProfile) -> Result<(), color_eyre::Report> {
let (vm_name, mount_point, workdir) = resolve_workspace_context()?;
println!("[INFO] Target Virtual Environment Context: {}", vm_name);
if !engine::verify_health(port).await {
return Err(color_eyre::eyre::eyre!(
"Inference pipeline unreachable at 127.0.0.1:{}. Run 'muthr serve' first.",
port
));
}
let home = std::env::var("HOME")?;
let presets = preset::list_presets()?;
if !vm_exists(&vm_name).await {
vm_create(&vm_name, &mount_point, &mount_point).await?;
} else if !vm_is_running(&vm_name).await {
vm_start(&vm_name).await?;
} else {
println!("[ OK ] VM already running");
}
handle_provisioning(&vm_name, &profile).await?;
let loaded_model = model::poll_loaded_model("127.0.0.1", port, 20, 1.5).await?;
println!("[INFO] Model detected: {}", loaded_model);
let ctx_window = model::get_ctx_window("127.0.0.1", port).await?;
println!("[INFO] Context window: {}", ctx_window);
let preset_name =
fs::read_to_string(PathBuf::from(&home).join(".cache/muthr/active-preset-name"))
.await
.unwrap_or_default();
let selected_preset = if !preset_name.is_empty() {
presets.iter().find(|p| p.name == preset_name)
} else {
None
}
.or(presets.first());
let runtime_config = match selected_preset {
Some(p) => crate::runtime_config::generate_runtime_config(p, port, &mount_point)?,
None => {
return Err(color_eyre::eyre::eyre!(
"No presets available for config generation"
))
}
};
println!("[PROC] Injecting runtime configuration mapping...");
let cp_status = Command::new("limactl")
.args([
"cp",
runtime_config
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("Non-UTF-8 path in runtime config"))?,
&format!("{}:/tmp/opencode-config.json", vm_name),
])
.status()
.await?;
if !cp_status.success() {
return Err(color_eyre::eyre::eyre!("Failed to copy config into VM."));
}
println!("[PROC] Launching opencode session...");
let status = Command::new("limactl")
.args([
"shell",
"--workdir",
workdir.to_str().unwrap_or("/tmp"),
&vm_name,
"--",
"env",
"PATH=/home/user.guest/.opencode/bin:/home/user.guest/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
"OPENCODE_CONFIG=/tmp/opencode-config.json",
"opencode",
])
.stdin(Stdio::inherit())
.status()
.await?;
vm_stop(&vm_name).await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"opencode session exited with error"
));
}
Ok(())
}
pub async fn down() -> Result<(), color_eyre::Report> {
let (vm_name, _, _) = resolve_workspace_context()?;
if !vm_exists(&vm_name).await {
println!("[WARN] VM '{}' does not exist", vm_name);
return Ok(());
}
vm_stop(&vm_name).await?;
Ok(())
}
pub async fn list() -> Result<(), color_eyre::Report> {
let sandbox_suffix = "-sandbox";
println!("[INFO] Sandbox VMs:");
println!("===============================================================================");
let output = Command::new("limactl")
.args(["ls", "-q"])
.output()
.await
.ok();
let vms: Vec<String> = match output {
Some(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|v| v.ends_with(sandbox_suffix))
.map(|v| v.to_string())
.collect(),
_ => Vec::new(),
};
if vms.is_empty() {
println!("[WARN] No sandbox VMs found");
return Ok(());
}
let is_tty = std::io::stdout().is_terminal();
if !is_tty {
for vm in &vms {
let status = Command::new("limactl")
.args(["ls", "-f", "'{{.Status}}'", vm])
.output()
.await
.ok()
.and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.trim()
.to_string()
.split_whitespace()
.next()
.map(|s| s.to_string())
})
.unwrap_or_else(|| "Unknown".to_string());
let project = vm.strip_suffix(sandbox_suffix).unwrap_or(vm);
let mount_point = format!("/sandbox-{}", project);
println!(" {:<30} {} Mount: {}", vm, status, mount_point);
}
} else {
let mut rows: Vec<Vec<String>> = Vec::new();
for vm in &vms {
let status = Command::new("limactl")
.args(["ls", "-f", "'{{.Status}}'", vm])
.output()
.await
.ok()
.and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.trim()
.to_string()
.split_whitespace()
.next()
.map(|s| s.to_string())
})
.unwrap_or_else(|| "Unknown".to_string());
let project = vm.strip_suffix(sandbox_suffix).unwrap_or(vm);
let mount_point = format!("/sandbox-{}", project);
rows.push(vec![vm.clone(), status, mount_point.to_string()]);
}
let headers = vec!["VM Name", "Status", "Mount Point"];
ui::select_table(&headers, rows);
}
println!("===============================================================================");
Ok(())
}