feat(sbx): VM dev-toolchain bootstrap + save/load parity across backends

Reorganize the help menu into one VIRTUAL MACHINES cluster covering all
backends, and bring docker/multipass/vbox to save+load parity:

- Launch-time dev toolchain: sandbox-bootstrap.sh + editable
  sandbox-tools.json (vim/curl guaranteed), installed in docker AND
  multipass sandboxes at provision time.
- Vbox load: vm_restore + `/sbx vmload <vm> [label]` (restore snapshot
  then boot the GUI).
- Multipass load: `/sbx load` is now backend-aware (locate_snapshot +
  SnapKind), mp_restore re-attaches the shared shell; teardown stops
  (not purges) an instance that still has snapshots so they survive
  `/sbx stop`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-06 22:29:24 -07:00
parent cb1f6134f6
commit d448314e5e
5 changed files with 385 additions and 47 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# sandbox-bootstrap.sh — install a baseline dev toolchain inside a hack-house
# Docker sandbox.
#
# It is piped to `bash -s` (as root) inside the container at provision time, so
# fresh sandboxes come up usable instead of bare. Idempotent + sentinel-guarded:
# a re-provision (new member joins) or a snapshot load is a fast no-op.
#
# When launched by hh, the package list comes from scripts/sandbox-tools.json
# (the canonical, editable schema) via $HH_SBX_PKGS. DEFAULT_PKGS below is only
# the fallback for running this script standalone. Per-launch override:
# HH_SBX_PKGS="vim tmux ripgrep" ./host-house.sh ...
set -uo pipefail
SENTINEL=/var/lib/hh-bootstrap.done
[[ -f "$SENTINEL" ]] && exit 0 # this container is already provisioned
# Baseline dev tools: editors, fetchers, vcs, net + inspect utilities, json,
# archives. Keep names valid for the base image (ubuntu:24.04) — an unknown
# package would otherwise abort the whole batch install.
DEFAULT_PKGS="vim nano less curl wget ca-certificates git \
build-essential pkg-config \
procps iproute2 iputils-ping net-tools \
jq unzip zip tree htop file ripgrep \
python3 python3-pip python3-venv"
PKGS="${HH_SBX_PKGS:-$DEFAULT_PKGS}"
export DEBIAN_FRONTEND=noninteractive
# Refresh the index first (base images ship without /var/lib/apt/lists). If the
# update fails (e.g. no network) we bail WITHOUT writing the sentinel, so the
# next launch retries instead of leaving a half-provisioned shell.
apt-get update -qq || exit 0
# --no-install-recommends keeps the image lean. apt is atomic on resolution, so
# if one name is unavailable the batch aborts — fall back to one-by-one so a
# single bad/missing package can't deprive the shell of everything else.
# shellcheck disable=SC2086
if ! apt-get install -y --no-install-recommends $PKGS; then
for p in $PKGS; do
apt-get install -y --no-install-recommends "$p" || true
done
fi
mkdir -p "$(dirname "$SENTINEL")"
date -u +%FT%TZ > "$SENTINEL" # mark done; skip on the next provision pass
+28
View File
@@ -0,0 +1,28 @@
{
"_comment": "Packages apt-get installs in every hack-house Docker sandbox at launch. Edit `packages` to add dev tools, then relaunch the sandbox. `vim` and `curl` are ALWAYS installed even if absent here. Names must be valid for the base image (ubuntu:24.04); unknown names are skipped, not fatal. Per-launch override: HH_SBX_PKGS=\"vim tmux\".",
"packages": [
"vim",
"curl",
"wget",
"ca-certificates",
"git",
"less",
"nano",
"build-essential",
"pkg-config",
"procps",
"iproute2",
"iputils-ping",
"net-tools",
"jq",
"unzip",
"zip",
"tree",
"htop",
"file",
"ripgrep",
"python3",
"python3-pip",
"python3-venv"
]
}
+100 -8
View File
@@ -1680,28 +1680,81 @@ fn handle_command(
} }
} }
Some("load") => match p.next() { Some("load") => match p.next() {
None => app.sys("usage: /sbx load <label> (a docker snapshot saved via /sbx save)"), None => app.sys("usage: /sbx load <label> (a docker or multipass snapshot saved via /sbx save)"),
Some(label) if !is_snap_label(label) => { Some(label) if !is_snap_label(label) => {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'"); app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} }
Some(label) => { Some(label) => {
if app.sandbox.is_some() || broker.is_some() || *launching { if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("stop the current sandbox first (`/sbx stop`) before loading a snapshot"); app.sys("stop the current sandbox first (`/sbx stop`) before loading a snapshot");
} else if !sbx::docker_daemon_up() {
app.err("docker daemon is not running — `/sbx launch docker --start` once to boot it, then retry");
} else { } else {
let image = format!("{}:{}", sbx::SNAP_REPO, label); // A bare label is backend-ambiguous, so probe which backend
// actually holds the snapshot, then restore through it:
// docker reruns a committed image; multipass restores the
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1); let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true; *launching = true;
let members: Vec<String> = let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect(); app.users.iter().map(|u| u.username.clone()).collect();
app.sys(format!("loading sandbox from {image}")); let owner = app.me.clone();
app.sys(format!("loading snapshot '{label}'…"));
let (pty, btx, atx) =
(pty_tx.clone(), broker_tx.clone(), app_tx.clone());
tokio::spawn(async move {
let (n, lbl) = (SBX_NAME.to_string(), label.clone());
let kind = tokio::task::spawn_blocking(move || {
sbx::locate_snapshot(&n, &lbl)
})
.await
.unwrap_or(sbx::SnapKind::None);
match kind {
sbx::SnapKind::Docker => {
if !sbx::docker_daemon_up() {
let _ = atx.send(Net::Err("docker daemon is not running — `/sbx launch docker --start` once to boot it, then retry".into()));
let _ = btx.send(BrokerMsg::Failed);
return;
}
let image = format!("{}:{}", sbx::SNAP_REPO, label);
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}")));
spawn_launch( spawn_launch(
sbx::Backend::Docker, image, app.me.clone(), members, rows, cols, sbx::Backend::Docker, image, owner, members, rows,
false, pty_tx.clone(), broker_tx.clone(), app_tx.clone(), cols, false, pty, btx, atx,
); );
} }
sbx::SnapKind::Multipass => {
let lbl = label.clone();
let res = tokio::task::spawn_blocking(move || {
sbx::mp_restore(SBX_NAME, &lbl)
})
.await;
match res {
Ok(Ok(desc)) => {
let _ = atx.send(Net::Sys(format!("{desc} · booting…")));
spawn_launch(
sbx::Backend::Multipass,
sbx::Backend::Multipass.default_image().to_string(),
owner, members, rows, cols, false, pty, btx, atx,
);
}
Ok(Err(e)) => {
let _ = atx.send(Net::Err(format!("load failed: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
Err(e) => {
let _ = atx.send(Net::Err(format!("load task: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
}
}
sbx::SnapKind::None => {
let _ = atx.send(Net::Err(format!("no saved snapshot '{label}' — list with `/sbx snaps`")));
let _ = btx.send(BrokerMsg::Failed);
}
}
});
}
} }
}, },
Some("snaps") | Some("snapshots") => { Some("snaps") | Some("snapshots") => {
@@ -1802,6 +1855,45 @@ fn handle_command(
} }
} }
} }
Some("vmload") => {
// Restore a VirtualBox VM to a snapshot (inverse of /sbx vmsave)
// then boot its GUI locally. `<label>` picks a named snapshot;
// omit it to restore the VM's current snapshot.
let mut vpos = p.filter(|a| !a.starts_with('-'));
match vpos.next() {
None => app.sys(
"usage: /sbx vmload <vm> [label] (restore a VirtualBox snapshot + boot; list with /sbx vmsnaps <vm>)",
),
Some(vm) => {
let label = vpos.next().map(str::to_string);
if label.as_deref().is_some_and(|l| !is_snap_label(l)) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"restoring VM '{vm}'{} then booting…",
label
.as_deref()
.map(|l| format!(" to '{l}'"))
.unwrap_or_default()
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
let desc = sbx::vm_restore(&vm, label.as_deref())?;
let boot = sbx::gui_launch(&vm)?;
Ok::<_, anyhow::Error>(format!("{desc} · {boot}"))
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("{desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmload failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmload task: {e}"))),
};
});
}
}
}
}
Some("gui") => { Some("gui") => {
// Convenience alias for `/sbx launch vbox gui <vm> [yes]` — opens a // Convenience alias for `/sbx launch vbox gui <vm> [yes]` — opens a
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui` // local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
@@ -1817,7 +1909,7 @@ fn handle_command(
} }
} }
_ => app.sys( _ => app.sys(
"usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmsnaps <vm>", "usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>",
), ),
} }
} else if let Some(rest) = line.strip_prefix("/unsudo") { } else if let Some(rest) = line.strip_prefix("/unsudo") {
+177 -5
View File
@@ -16,6 +16,10 @@ use std::sync::mpsc;
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh"); const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh");
/// Detect-first VirtualBox installer (ships in hh/scripts/). /// Detect-first VirtualBox installer (ships in hh/scripts/).
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh"); const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh");
/// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/).
const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-bootstrap.sh");
/// Editable package schema the bootstrap installs — vim+curl always added.
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
/// Is the Docker daemon accepting connections? (`docker info` succeeds.) /// Is the Docker daemon accepting connections? (`docker info` succeeds.)
pub fn docker_daemon_up() -> bool { pub fn docker_daemon_up() -> bool {
@@ -446,8 +450,21 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
pub fn teardown(backend: Backend, name: &str) { pub fn teardown(backend: Backend, name: &str) {
match backend { match backend {
Backend::Multipass => { Backend::Multipass => {
// Multipass snapshots live *inside* the instance, so `delete --purge`
// would destroy any saved state. If the user has snapshots worth
// keeping (so `/sbx load <label>` can restore them), just *stop* the
// instance — it persists, powered off, ready to be restored. With no
// snapshots there's nothing to preserve, so purge to stay clean.
let has_snaps = list_snapshots(Backend::Multipass, name)
.map(|s| !s.is_empty())
.unwrap_or(false);
let args: &[&str] = if has_snaps {
&["stop", name]
} else {
&["delete", name, "--purge"]
};
let _ = Command::new("multipass") let _ = Command::new("multipass")
.args(["delete", name, "--purge"]) .args(args)
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.status(); .status();
@@ -559,6 +576,30 @@ pub fn save_state(backend: Backend, name: &str, label: &str, local: bool) -> Res
} }
} }
/// Restore a Multipass instance to a saved snapshot — the load-side inverse of
/// the multipass branch of `save_state`. `multipass restore <name>.<label>`
/// requires the instance to exist and be **stopped**, which is exactly the
/// state `teardown` leaves it in when snapshots are present. Blocking; the
/// caller boots it afterwards via the normal `prepare` (existing instance →
/// `multipass start`) path. The leading `--destructive` skips the interactive
/// "overwrite current state?" prompt — safe here because restoring *is* the
/// intent and any unsaved drift since the snapshot is what the user wants gone.
pub fn mp_restore(name: &str, label: &str) -> Result<String> {
let target = format!("{name}.{label}");
let out = Command::new("multipass")
.args(["restore", "--destructive", &target])
.output()
.context("multipass restore (is multipass installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"multipass restore failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
Ok(format!("restored multipass instance to snapshot '{label}'"))
}
/// Snapshot a VirtualBox VM's state. VirtualBox isn't a brokered `Backend` (its /// Snapshot a VirtualBox VM's state. VirtualBox isn't a brokered `Backend` (its
/// GUI runs locally, never relayed), so it has its own save path. Blocking. /// GUI runs locally, never relayed), so it has its own save path. Blocking.
/// ///
@@ -600,6 +641,35 @@ pub fn vm_save_state(vm: &str, label: &str, local: bool) -> Result<String> {
Ok(format!("snapshot '{label}' of {vm}")) Ok(format!("snapshot '{label}' of {vm}"))
} }
/// Restore a VirtualBox VM to a snapshot — the inverse of `vm_save_state`.
/// `label` picks a named snapshot; `None` restores the VM's *current* snapshot.
/// VirtualBox refuses to restore a live machine, so the VM must be powered off.
/// Blocking; the caller boots the GUI afterwards.
pub fn vm_restore(vm: &str, label: Option<&str>) -> Result<String> {
if vm_running(vm) {
anyhow::bail!("{vm} is running — power it off first, then restore the snapshot");
}
let args: Vec<&str> = match label {
Some(l) => vec!["snapshot", vm, "restore", l],
None => vec!["snapshot", vm, "restorecurrent"],
};
let out = Command::new("VBoxManage")
.args(&args)
.output()
.context("VBoxManage snapshot restore (is VirtualBox installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"VBoxManage snapshot restore failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
Ok(match label {
Some(l) => format!("restored {vm} to snapshot '{l}'"),
None => format!("restored {vm} to its current snapshot"),
})
}
/// Snapshot names for a VirtualBox VM (`VBoxManage snapshot <vm> list /// Snapshot names for a VirtualBox VM (`VBoxManage snapshot <vm> list
/// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list /// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list
/// (exit 1 "does not have any snapshots") is reported as no snapshots, not an /// (exit 1 "does not have any snapshots") is reported as no snapshots, not an
@@ -667,6 +737,37 @@ pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
} }
} }
/// Where a `/sbx load <label>` snapshot lives, so the loader knows which backend
/// to restore through. A bare label is ambiguous (a docker image tag *and* a
/// multipass snapshot could share it), so we probe and prefer whichever exists.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SnapKind {
Docker,
Multipass,
None,
}
/// Resolve a snapshot label to the backend that holds it. Probes multipass first
/// (its snapshots are instance-scoped and rarer), then docker's `hh-snap` repo.
/// Blocking — run off the UI thread. A backend whose CLI is absent simply
/// reports no match rather than erroring, so a missing multipass doesn't block a
/// docker load and vice-versa.
pub fn locate_snapshot(name: &str, label: &str) -> SnapKind {
if list_snapshots(Backend::Multipass, name)
.map(|s| s.iter().any(|l| l == label))
.unwrap_or(false)
{
return SnapKind::Multipass;
}
if list_snapshots(Backend::Docker, name)
.map(|s| s.iter().any(|l| l == label))
.unwrap_or(false)
{
return SnapKind::Docker;
}
SnapKind::None
}
/// Build the shell command for a backend, running as unix user `run_user` /// Build the shell command for a backend, running as unix user `run_user`
/// (empty = backend default). The container/VM is already up (see `prepare`). /// (empty = backend default). The container/VM is already up (see `prepare`).
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder { fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
@@ -730,6 +831,74 @@ fn dk(name: &str, args: &[&str]) {
.status(); .status();
} }
#[derive(serde::Deserialize)]
struct ToolsCfg {
packages: Vec<String>,
}
/// The space-joined apt package list every Docker sandbox installs at launch.
/// Read from `scripts/sandbox-tools.json` so it's editable without code changes;
/// `vim`+`curl` are always present, names are sanitized to a safe charset, and
/// duplicates collapse. Missing/invalid JSON degrades gracefully to vim+curl.
fn sandbox_pkgs() -> String {
let mut out: Vec<String> = vec!["vim".into(), "curl".into()];
if let Ok(raw) = std::fs::read_to_string(SBX_TOOLS_JSON) {
if let Ok(cfg) = serde_json::from_str::<ToolsCfg>(&raw) {
for p in cfg.packages {
// Keep only valid apt package-name chars so a stray edit can't
// smuggle shell metacharacters into the install command.
let p: String = p
.trim()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || "._+-".contains(*c))
.collect();
if !p.is_empty() && !out.contains(&p) {
out.push(p);
}
}
}
}
out.join(" ")
}
/// Run the sandbox bootstrap inside the container, piping the script to `bash -s`
/// on stdin (keeps it out of argv) with the resolved package list in the env.
/// Blocking — provision() is already off the UI thread.
fn dk_bootstrap(name: &str) {
let script = std::fs::read_to_string(SBX_BOOTSTRAP).unwrap_or_else(|_| {
// Degraded fallback if the script file is missing: still refresh the
// index and install whatever the env carries (at minimum vim+curl).
"apt-get update -qq && apt-get install -y --no-install-recommends $HH_SBX_PKGS".into()
});
let env = format!("HH_SBX_PKGS={}", sandbox_pkgs());
let child = Command::new("docker")
.args(["exec", "-i", "-e", &env, name, "bash", "-s"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Ok(mut c) = child {
if let Some(mut stdin) = c.stdin.take() {
let _ = stdin.write_all(script.as_bytes());
}
let _ = c.wait();
}
}
/// Install the baseline dev toolchain (scripts/sandbox-tools.json; vim+curl
/// guaranteed) inside a Multipass VM. It already ships apt + sudo with a
/// populated index, so this is a direct install. The package list is sanitized
/// by `sandbox_pkgs`, so interpolating it into the shell command is safe.
/// Blocking — provision() is off the UI thread.
fn mp_bootstrap(name: &str) {
let cmd = format!(
"export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && \
apt-get install -y --no-install-recommends {} || true",
sandbox_pkgs()
);
mp(name, &["sudo", "bash", "-c", &cmd]);
}
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) /// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
/// so they're a usable superuser non-interactively. `u` is already unix-safe. /// so they're a usable superuser non-interactively. `u` is already unix-safe.
fn mp_grant_sudo(name: &str, u: &str) { fn mp_grant_sudo(name: &str, u: &str) {
@@ -759,13 +928,16 @@ pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String])
if !run.is_empty() { if !run.is_empty() {
mp_grant_sudo(name, &run); // owner = passwordless superuser mp_grant_sudo(name, &run); // owner = passwordless superuser
} }
mp_bootstrap(name); // same baseline dev toolchain as the docker sandbox
run run
} }
Backend::Docker => { Backend::Docker => {
// Refresh the apt index once so `apt-get install <pkg>` just works — // Install the baseline dev toolchain (editable list in
// base images ship without /var/lib/apt/lists, so installs otherwise // scripts/sandbox-tools.json; vim+curl guaranteed) so a fresh
// fail with "Unable to locate package" until the user runs update. // sandbox comes up usable instead of bare. The script also refreshes
dk(name, &["apt-get", "update"]); // the apt index (base images ship without /var/lib/apt/lists) and is
// idempotent + sentinel-guarded, so re-provisions stay fast.
dk_bootstrap(name);
for m in members { for m in members {
let u = unix_name(m); let u = unix_name(m);
if !u.is_empty() { if !u.is_empty() {
+31 -32
View File
@@ -150,53 +150,52 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
); );
vec![ vec![
HelpCluster { HelpCluster {
title: "SANDBOX", title: "VIRTUAL MACHINES",
items: vec![ items: vec![
// ── launch (one verb per backend) ──
kv( kv(
"/sbx launch <docker|multipass|vbox>", "/sbx launch docker [image]",
"summon a sandbox on YOUR machine — docker/multipass relay a shared shell; vbox <vm> opens a local GUI (or local)", "Linux containershared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain)",
),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv(
"/sbx save [label] [--local]",
"snapshot state (docker image; --local also writes a portable .tar)",
), ),
kv( kv(
"/sbx load <label>", "/sbx launch multipass [image]",
"launch a fresh sandbox from a saved snapshot", "full Ubuntu VM — shared shell relayed to the room (same dev toolchain)",
), ),
kv("/sbx snaps", "list saved snapshots"),
kv(
"/drive · F2",
"type into the shared shell (Esc releases)",
),
],
},
HelpCluster {
title: "VIRTUALBOX (local GUI VM)",
items: vec![
kv( kv(
"/sbx launch vbox", "/sbx launch vbox",
"open the arrow-navigable VM picker (↑↓ move · Enter/Tab boot · Esc dismiss)", "VirtualBox VM picker — local GUI (↑↓ move · Enter/Tab boot · Esc dismiss)",
), ),
kv( kv(
"/sbx launch vbox [gui] <vm>", "/sbx launch vbox [gui] <vm> [yes]",
"boot a VM's GUI on YOUR machine — host with the VM already imported launches instantly", "boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",
), ),
kv("/sbx launch local", "a plain shell on your own machine — no VM"),
kv("/sbx stop", "tear down the running sandbox (purges the VM/container)"),
// ── save (docker/multipass share a verb; vbox has its own) ──
kv( kv(
"/sbx launch vbox gui <vm> yes", "/sbx save [label] [--local]",
"non-host opt-in: append yes to install VirtualBox and/or import the shared .ova, then boot", "save docker/multipass state (--local on docker also writes a portable .tar)",
), ),
kv(
"/sbx gui <vm> [yes]",
"alias of /sbx launch vbox gui <vm> [yes]",
),
kv("/sbx vms", "detect VirtualBox + list local VMs"),
kv( kv(
"/sbx vmsave <vm> [label] [--local]", "/sbx vmsave <vm> [label] [--local]",
"snapshot a VM (--local also exports a portable .ova to /send)", "save a VirtualBox VM snapshot (--local also exports a portable .ova to /send)",
), ),
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"), // ── load + list ──
kv(
"/sbx load <label>",
"relaunch a saved docker/multipass snapshot (auto-detects backend)",
),
kv(
"/sbx vmload <vm> [label]",
"restore a VirtualBox snapshot + boot (omit label for the VM's current snapshot)",
),
kv("/sbx snaps", "list saved docker/multipass snapshots"),
kv(
"/sbx vms · /sbx vmsnaps <vm>",
"list local VirtualBox VMs · a VM's snapshots",
),
kv("/sbx gui <vm> [yes]", "alias of /sbx launch vbox gui <vm> [yes]"),
kv("/drive · F2", "type into the shared shell (Esc releases)"),
], ],
}, },
HelpCluster { HelpCluster {