Compare commits
2 Commits
cb1f6134f6
...
6160d957f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6160d957f5 | |||
| d448314e5e |
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# vbox-new.sh — create + boot a FRESH Ubuntu VirtualBox VM, pre-provisioned with
|
||||
# the hack-house dev toolchain via cloud-init.
|
||||
#
|
||||
# VirtualBox guests can't be `docker exec`/`multipass exec`'d into, so the
|
||||
# launch-time sandbox-bootstrap.sh path doesn't reach them. Instead we hand the
|
||||
# guest a cloud-init NoCloud seed ISO whose user-data installs the same package
|
||||
# set (scripts/sandbox-tools.json) and creates a sudo login user on first boot.
|
||||
#
|
||||
# Detect-first, never silent: if the named VM already exists this refuses rather
|
||||
# than clobber it. --plan prints exactly what it would download/create and
|
||||
# changes nothing.
|
||||
#
|
||||
# usage:
|
||||
# ./vbox-new.sh [--name hh-vbox] [--user dell] [--cpus 2] [--mem 2048]
|
||||
# [--disk 15] [--release 24.04] [--pass <pw>] [--pubkey <file>]
|
||||
# [--no-boot] [--plan] [--yes]
|
||||
#
|
||||
# Deps: VBoxManage, qemu-img (convert the cloud image to VDI), and one of
|
||||
# cloud-localds | genisoimage | mkisofs | xorriso (build the seed ISO).
|
||||
set -uo pipefail
|
||||
|
||||
NAME="hh-vbox"
|
||||
VM_USER="${USER:-hacker}"
|
||||
CPUS=2
|
||||
MEM=2048 # MiB
|
||||
DISK=15 # GiB (cloud image grows to fill it on first boot via growpart)
|
||||
RELEASE="24.04"
|
||||
PASSWORD="" # empty → generate a random one and print it once
|
||||
PUBKEY="" # path to an SSH public key to authorize (auto-detects if empty)
|
||||
DO_BOOT=1
|
||||
PLAN_ONLY=0
|
||||
ASSUME_YES=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--name=*) NAME="${1#*=}"; shift ;;
|
||||
--user|--name-user) VM_USER="$2"; shift 2 ;;
|
||||
--user=*) VM_USER="${1#*=}"; shift ;;
|
||||
--cpus) CPUS="$2"; shift 2 ;;
|
||||
--cpus=*) CPUS="${1#*=}"; shift ;;
|
||||
--mem) MEM="$2"; shift 2 ;;
|
||||
--mem=*) MEM="${1#*=}"; shift ;;
|
||||
--disk) DISK="$2"; shift 2 ;;
|
||||
--disk=*) DISK="${1#*=}"; shift ;;
|
||||
--release) RELEASE="$2"; shift 2 ;;
|
||||
--release=*) RELEASE="${1#*=}"; shift ;;
|
||||
--pass) PASSWORD="$2"; shift 2 ;;
|
||||
--pass=*) PASSWORD="${1#*=}"; shift ;;
|
||||
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||
--pubkey=*) PUBKEY="${1#*=}"; shift ;;
|
||||
--no-boot) DO_BOOT=0; shift ;;
|
||||
--plan|--dry-run) PLAN_ONLY=1; shift ;;
|
||||
-y|--yes) ASSUME_YES=1; shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "✖ unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOLS_JSON="$SCRIPT_DIR/sandbox-tools.json"
|
||||
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/hh/vbox"
|
||||
IMG_NAME="ubuntu-${RELEASE}-server-cloudimg-amd64.img"
|
||||
IMG_URL="https://cloud-images.ubuntu.com/releases/${RELEASE}/release/${IMG_NAME}"
|
||||
IMG_PATH="$CACHE_DIR/$IMG_NAME"
|
||||
|
||||
# ── dependency probes ────────────────────────────────────────────────────────
|
||||
need() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
missing=()
|
||||
need VBoxManage || missing+=("VBoxManage (install VirtualBox; see ensure-vbox.sh)")
|
||||
need qemu-img || missing+=("qemu-img (apt install qemu-utils)")
|
||||
|
||||
# Seed-ISO builder: prefer cloud-localds, fall back to a generic ISO maker.
|
||||
SEED_TOOL=""
|
||||
for t in cloud-localds genisoimage mkisofs xorriso; do
|
||||
if need "$t"; then SEED_TOOL="$t"; break; fi
|
||||
done
|
||||
[[ -z "$SEED_TOOL" ]] && missing+=("cloud-localds OR genisoimage/mkisofs/xorriso (apt install cloud-image-utils genisoimage)")
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo "✖ missing required tools:" >&2
|
||||
printf ' - %s\n' "${missing[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── package list (mirrors the Rust guarantee: vim + curl always present) ─────
|
||||
read_pkgs() {
|
||||
if need jq && [[ -f "$TOOLS_JSON" ]]; then
|
||||
jq -r '.packages[]?' "$TOOLS_JSON" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
# Collect, force vim+curl, dedupe while preserving order.
|
||||
declare -A seen
|
||||
PKG_LIST=()
|
||||
for p in vim curl $(read_pkgs); do
|
||||
[[ -n "$p" && -z "${seen[$p]:-}" ]] || continue
|
||||
seen[$p]=1
|
||||
PKG_LIST+=("$p")
|
||||
done
|
||||
|
||||
# ── refuse to clobber an existing VM ─────────────────────────────────────────
|
||||
if VBoxManage showvminfo "$NAME" >/dev/null 2>&1; then
|
||||
echo "✖ a VM named '$NAME' already exists — pick another --name, or boot it with /sbx gui $NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── plan mode: describe, change nothing ──────────────────────────────────────
|
||||
if [[ $PLAN_ONLY -eq 1 ]]; then
|
||||
echo "plan (no changes will be made):" >&2
|
||||
echo " base image : $IMG_URL" >&2
|
||||
echo " → cache $IMG_PATH $( [[ -f "$IMG_PATH" ]] && echo '(already downloaded)' || echo '(~600MB download)' )" >&2
|
||||
echo " VM : $NAME · ${CPUS} cpu · ${MEM}MiB · ${DISK}GiB disk · NAT" >&2
|
||||
echo " login user : $VM_USER (sudo, NOPASSWD)" >&2
|
||||
echo " seed tool : $SEED_TOOL" >&2
|
||||
echo " packages : ${PKG_LIST[*]}" >&2
|
||||
echo " boot GUI : $( [[ $DO_BOOT -eq 1 ]] && echo yes || echo no )" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── confirmation (skipped with --yes) ────────────────────────────────────────
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
if [[ ! -f "$IMG_PATH" ]]; then
|
||||
printf 'Create VM "%s" (downloads ~600MB Ubuntu %s cloud image first)? [y/N] ' "$NAME" "$RELEASE" >&2
|
||||
else
|
||||
printf 'Create VM "%s" from cached Ubuntu %s image? [y/N] ' "$NAME" "$RELEASE" >&2
|
||||
fi
|
||||
read -r reply
|
||||
case "$reply" in y|Y|yes|YES) ;; *) echo "✖ aborted" >&2; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
# ── 1. fetch the cloud image (cached) ────────────────────────────────────────
|
||||
if [[ ! -f "$IMG_PATH" ]]; then
|
||||
echo "↓ downloading $IMG_NAME …" >&2
|
||||
if need curl; then
|
||||
curl -fL --retry 3 -o "$IMG_PATH.partial" "$IMG_URL" || { echo "✖ download failed" >&2; rm -f "$IMG_PATH.partial"; exit 1; }
|
||||
elif need wget; then
|
||||
wget -O "$IMG_PATH.partial" "$IMG_URL" || { echo "✖ download failed" >&2; rm -f "$IMG_PATH.partial"; exit 1; }
|
||||
else
|
||||
echo "✖ neither curl nor wget available to fetch the image" >&2; exit 1
|
||||
fi
|
||||
mv "$IMG_PATH.partial" "$IMG_PATH"
|
||||
fi
|
||||
|
||||
# ── 2. register the VM (creates its folder) ──────────────────────────────────
|
||||
echo "⛧ creating VM '$NAME' …" >&2
|
||||
VBoxManage createvm --name "$NAME" --ostype Ubuntu_64 --register >/dev/null
|
||||
MACHINE_FOLDER="$(VBoxManage list systemproperties | sed -n 's/^Default machine folder: *//p')"
|
||||
VMDIR="$MACHINE_FOLDER/$NAME"
|
||||
VDI="$VMDIR/$NAME.vdi"
|
||||
SEED_ISO="$VMDIR/seed.iso"
|
||||
|
||||
# Clean up a half-built VM if anything below fails.
|
||||
cleanup() { VBoxManage unregistervm "$NAME" --delete >/dev/null 2>&1 || true; }
|
||||
trap 'echo "✖ build failed — rolling back VM" >&2; cleanup' ERR
|
||||
set -e
|
||||
|
||||
# ── 3. cloud-image → VDI, grown to the requested size ────────────────────────
|
||||
echo "⛧ converting cloud image → VDI …" >&2
|
||||
qemu-img convert -O vdi "$IMG_PATH" "$VDI"
|
||||
VBoxManage modifymedium disk "$VDI" --resize "$((DISK * 1024))" >/dev/null
|
||||
|
||||
# ── 4. build the cloud-init NoCloud seed ─────────────────────────────────────
|
||||
# Credentials: a random password is generated unless --pass was given, and any
|
||||
# available SSH public key is authorized so password login isn't the only door.
|
||||
if [[ -z "$PASSWORD" ]]; then
|
||||
if need openssl; then PASSWORD="$(openssl rand -base64 12)"; else PASSWORD="hackhouse-$RANDOM"; fi
|
||||
GENERATED_PW=1
|
||||
else
|
||||
GENERATED_PW=0
|
||||
fi
|
||||
if [[ -z "$PUBKEY" ]]; then
|
||||
for k in "$HOME"/.ssh/id_ed25519.pub "$HOME"/.ssh/id_rsa.pub; do
|
||||
[[ -f "$k" ]] && { PUBKEY="$k"; break; }
|
||||
done
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'echo "✖ build failed — rolling back VM" >&2; cleanup; rm -rf "$WORK"' ERR
|
||||
{
|
||||
echo "instance-id: $NAME"
|
||||
echo "local-hostname: $NAME"
|
||||
} > "$WORK/meta-data"
|
||||
|
||||
{
|
||||
echo "#cloud-config"
|
||||
echo "hostname: $NAME"
|
||||
echo "ssh_pwauth: true"
|
||||
echo "users:"
|
||||
echo " - name: $VM_USER"
|
||||
echo " groups: [sudo]"
|
||||
echo " sudo: 'ALL=(ALL) NOPASSWD:ALL'"
|
||||
echo " shell: /bin/bash"
|
||||
echo " lock_passwd: false"
|
||||
if [[ -n "$PUBKEY" ]]; then
|
||||
echo " ssh_authorized_keys:"
|
||||
echo " - $(cat "$PUBKEY")"
|
||||
fi
|
||||
echo "chpasswd:"
|
||||
echo " expire: false"
|
||||
echo " users:"
|
||||
echo " - {name: $VM_USER, password: '$PASSWORD', type: text}"
|
||||
echo "package_update: true"
|
||||
echo "packages:"
|
||||
for p in "${PKG_LIST[@]}"; do echo " - $p"; done
|
||||
} > "$WORK/user-data"
|
||||
|
||||
echo "⛧ building cloud-init seed ($SEED_TOOL) …" >&2
|
||||
case "$SEED_TOOL" in
|
||||
cloud-localds)
|
||||
cloud-localds "$SEED_ISO" "$WORK/user-data" "$WORK/meta-data"
|
||||
;;
|
||||
genisoimage|mkisofs)
|
||||
"$SEED_TOOL" -output "$SEED_ISO" -volid cidata -joliet -rock \
|
||||
"$WORK/user-data" "$WORK/meta-data" >/dev/null 2>&1
|
||||
;;
|
||||
xorriso)
|
||||
xorriso -as mkisofs -output "$SEED_ISO" -volid cidata -joliet -rock \
|
||||
"$WORK/user-data" "$WORK/meta-data" >/dev/null 2>&1
|
||||
;;
|
||||
esac
|
||||
rm -rf "$WORK"
|
||||
trap 'echo "✖ build failed — rolling back VM" >&2; cleanup' ERR
|
||||
|
||||
# ── 5. wire up storage + hardware ────────────────────────────────────────────
|
||||
VBoxManage modifyvm "$NAME" \
|
||||
--cpus "$CPUS" --memory "$MEM" \
|
||||
--nic1 nat --graphicscontroller vmsvga --vram 32 \
|
||||
--ioapic on --audio-driver none --boot1 disk --boot2 dvd >/dev/null
|
||||
VBoxManage storagectl "$NAME" --name SATA --add sata --controller IntelAhci --portcount 2 >/dev/null
|
||||
VBoxManage storageattach "$NAME" --storagectl SATA --port 0 --device 0 --type hdd --medium "$VDI" >/dev/null
|
||||
VBoxManage storageattach "$NAME" --storagectl SATA --port 1 --device 0 --type dvddrive --medium "$SEED_ISO" >/dev/null
|
||||
|
||||
trap - ERR # past the point of automatic rollback
|
||||
|
||||
echo "✓ VM '$NAME' created (user '$VM_USER')" >&2
|
||||
if [[ "${GENERATED_PW:-0}" -eq 1 ]]; then
|
||||
echo " login password (generated, shown once): $PASSWORD" >&2
|
||||
fi
|
||||
[[ -n "$PUBKEY" ]] && echo " authorized SSH key: $PUBKEY" >&2
|
||||
echo " cloud-init runs the toolchain install on first boot (give it a minute)." >&2
|
||||
|
||||
# ── 6. boot ──────────────────────────────────────────────────────────────────
|
||||
if [[ $DO_BOOT -eq 1 ]]; then
|
||||
echo "⛧ booting '$NAME' (GUI) …" >&2
|
||||
VBoxManage startvm "$NAME" --type gui >/dev/null
|
||||
echo "✓ launched $NAME" >&2
|
||||
else
|
||||
echo "ⓘ not booting (--no-boot); start later with: /sbx gui $NAME" >&2
|
||||
fi
|
||||
+154
-19
@@ -1585,16 +1585,24 @@ fn handle_command(
|
||||
// guard. Grammar: `/sbx launch vbox [gui] <vm> [yes]`; a bare
|
||||
// `/sbx launch vbox` opens the arrow-navigable VM picker.
|
||||
let mut rest = pos.peekable();
|
||||
if rest.peek() == Some(&"gui") {
|
||||
rest.next(); // optional `gui` keyword (sugar)
|
||||
}
|
||||
match rest.next() {
|
||||
Some(vm) => {
|
||||
let confirmed = rest
|
||||
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
|
||||
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
|
||||
if rest.peek() == Some(&"new") {
|
||||
// `/sbx launch vbox new [name]` — build a brand-new VM from
|
||||
// a cloud image (cloud-init toolchain), not an existing one.
|
||||
rest.next();
|
||||
let name = rest.next().unwrap_or("hh-vbox").to_string();
|
||||
launch_vbox_new(app, name, app.me.clone(), app_tx);
|
||||
} else {
|
||||
if rest.peek() == Some(&"gui") {
|
||||
rest.next(); // optional `gui` keyword (sugar)
|
||||
}
|
||||
match rest.next() {
|
||||
Some(vm) => {
|
||||
let confirmed = rest
|
||||
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
|
||||
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
|
||||
}
|
||||
None => open_vbox_picker(app),
|
||||
}
|
||||
None => open_vbox_picker(app),
|
||||
}
|
||||
} else if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||
app.sys("a sandbox is already running");
|
||||
@@ -1680,27 +1688,80 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||
}
|
||||
Some(label) => {
|
||||
if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||
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 {
|
||||
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 (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||
*launching = true;
|
||||
let members: Vec<String> =
|
||||
app.users.iter().map(|u| u.username.clone()).collect();
|
||||
app.sys(format!("loading sandbox from {image}…"));
|
||||
spawn_launch(
|
||||
sbx::Backend::Docker, image, app.me.clone(), members, rows, cols,
|
||||
false, pty_tx.clone(), broker_tx.clone(), app_tx.clone(),
|
||||
);
|
||||
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(
|
||||
sbx::Backend::Docker, image, owner, members, rows,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1802,6 +1863,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") => {
|
||||
// Convenience alias for `/sbx launch vbox gui <vm> [yes]` — opens a
|
||||
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
|
||||
@@ -1817,7 +1917,7 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
_ => 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 launch vbox new [name] (fresh VM) · /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") {
|
||||
@@ -2100,6 +2200,41 @@ fn open_vbox_picker(app: &mut App) {
|
||||
/// VM image — or who'd have to stop another VM first — must re-issue with a
|
||||
/// trailing `yes`, which then installs VirtualBox, imports the shared appliance,
|
||||
/// and/or frees VT-x before booting.
|
||||
/// Build + boot a brand-new VirtualBox VM from a cloud image (the only path that
|
||||
/// creates a VM from scratch — `launch_vbox_gui` opens existing ones). The heavy
|
||||
/// lifting (image download, cloud-init seed, VBox create/boot) is in
|
||||
/// scripts/vbox-new.sh; we run it off-thread and report through `app_tx` so the
|
||||
/// first-run ~600MB download never stalls the TUI.
|
||||
fn launch_vbox_new(app: &mut App, name: String, user: String, app_tx: &UnboundedSender<Net>) {
|
||||
if !is_snap_label(&name) {
|
||||
app.sys("VM name must be alphanumerics, '.', '_' or '-'");
|
||||
return;
|
||||
}
|
||||
if !sbx::vbox_installed() {
|
||||
app.sys("VirtualBox isn't installed — `/sbx gui <vm> --install` or run ./scripts/ensure-vbox.sh first");
|
||||
return;
|
||||
}
|
||||
if sbx::vm_registered(&name) {
|
||||
app.sys(format!(
|
||||
"a VM named ‘{name}’ already exists — pick another name, or boot it with `/sbx gui {name}`"
|
||||
));
|
||||
return;
|
||||
}
|
||||
app.sys(format!(
|
||||
"building fresh VirtualBox VM ‘{name}’ (first run downloads a ~600MB Ubuntu cloud image; cloud-init installs the toolchain on boot)…"
|
||||
));
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let (n, u) = (name.clone(), user);
|
||||
let res = tokio::task::spawn_blocking(move || sbx::vbox_new(&n, &u)).await;
|
||||
let _ = match res {
|
||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
||||
Ok(Err(e)) => tx.send(Net::Err(format!("vbox new failed: {e}"))),
|
||||
Err(e) => tx.send(Net::Err(format!("vbox new task: {e}"))),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn launch_vbox_gui(
|
||||
app: &mut App,
|
||||
vm: String,
|
||||
|
||||
+216
-5
@@ -16,6 +16,12 @@ use std::sync::mpsc;
|
||||
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh");
|
||||
/// Detect-first VirtualBox installer (ships in hh/scripts/).
|
||||
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");
|
||||
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
||||
|
||||
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
|
||||
pub fn docker_daemon_up() -> bool {
|
||||
@@ -182,6 +188,43 @@ pub fn gui_launch(name: &str) -> Result<String> {
|
||||
Ok(format!("launched {name} (GUI)"))
|
||||
}
|
||||
|
||||
/// Create + boot a *fresh* Ubuntu VirtualBox VM pre-provisioned with the dev
|
||||
/// toolchain, by driving `scripts/vbox-new.sh` (downloads a cloud image, builds
|
||||
/// a cloud-init NoCloud seed installing scripts/sandbox-tools.json, then boots
|
||||
/// the GUI). Unlike `/sbx launch vbox <vm>`, which opens an *existing* image,
|
||||
/// this builds one from scratch — the only path that doesn't need a pre-made VM.
|
||||
///
|
||||
/// Blocking and slow on first run (~600MB image download). Runs the script
|
||||
/// non-interactively (`--yes`); returns its final status line(s) — including the
|
||||
/// generated login password, which is shown exactly once.
|
||||
pub fn vbox_new(name: &str, user: &str) -> Result<String> {
|
||||
let out = Command::new("bash")
|
||||
.arg(VBOX_NEW)
|
||||
.args(["--yes", "--name", name, "--user", user])
|
||||
.output()
|
||||
.context("running vbox-new.sh (is bash available?)")?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"vbox-new failed: {}",
|
||||
stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim()
|
||||
);
|
||||
}
|
||||
// The script narrates progress on stderr. Surface the final success line and,
|
||||
// if one was generated, the one-time login password.
|
||||
let last = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('✓'))
|
||||
.unwrap_or("VM created")
|
||||
.trim();
|
||||
let pw = stderr.lines().find(|l| l.contains("login password"));
|
||||
Ok(match pw {
|
||||
Some(p) => format!("{last} · {}", p.trim()),
|
||||
None => last.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
||||
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
||||
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
||||
@@ -446,8 +489,21 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
|
||||
pub fn teardown(backend: Backend, name: &str) {
|
||||
match backend {
|
||||
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")
|
||||
.args(["delete", name, "--purge"])
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
@@ -559,6 +615,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
|
||||
/// GUI runs locally, never relayed), so it has its own save path. Blocking.
|
||||
///
|
||||
@@ -600,6 +680,35 @@ pub fn vm_save_state(vm: &str, label: &str, local: bool) -> Result<String> {
|
||||
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
|
||||
/// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list
|
||||
/// (exit 1 "does not have any snapshots") is reported as no snapshots, not an
|
||||
@@ -667,6 +776,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`
|
||||
/// (empty = backend default). The container/VM is already up (see `prepare`).
|
||||
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||||
@@ -730,6 +870,74 @@ fn dk(name: &str, args: &[&str]) {
|
||||
.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)
|
||||
/// so they're a usable superuser non-interactively. `u` is already unix-safe.
|
||||
fn mp_grant_sudo(name: &str, u: &str) {
|
||||
@@ -759,13 +967,16 @@ pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String])
|
||||
if !run.is_empty() {
|
||||
mp_grant_sudo(name, &run); // owner = passwordless superuser
|
||||
}
|
||||
mp_bootstrap(name); // same baseline dev toolchain as the docker sandbox
|
||||
run
|
||||
}
|
||||
Backend::Docker => {
|
||||
// Refresh the apt index once so `apt-get install <pkg>` just works —
|
||||
// base images ship without /var/lib/apt/lists, so installs otherwise
|
||||
// fail with "Unable to locate package" until the user runs update.
|
||||
dk(name, &["apt-get", "update"]);
|
||||
// Install the baseline dev toolchain (editable list in
|
||||
// scripts/sandbox-tools.json; vim+curl guaranteed) so a fresh
|
||||
// sandbox comes up usable instead of bare. The script also refreshes
|
||||
// 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 {
|
||||
let u = unix_name(m);
|
||||
if !u.is_empty() {
|
||||
|
||||
+33
-30
@@ -150,53 +150,56 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
);
|
||||
vec![
|
||||
HelpCluster {
|
||||
title: "SANDBOX",
|
||||
title: "VIRTUAL MACHINES",
|
||||
items: vec![
|
||||
// ── launch (one verb per backend) ──
|
||||
kv(
|
||||
"/sbx launch <docker|multipass|vbox>",
|
||||
"summon a sandbox on YOUR machine — docker/multipass relay a shared shell; vbox <vm> opens a local GUI (or local)",
|
||||
),
|
||||
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)",
|
||||
"/sbx launch docker [image]",
|
||||
"Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain)",
|
||||
),
|
||||
kv(
|
||||
"/sbx load <label>",
|
||||
"launch a fresh sandbox from a saved snapshot",
|
||||
"/sbx launch multipass [image]",
|
||||
"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(
|
||||
"/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(
|
||||
"/sbx launch vbox [gui] <vm>",
|
||||
"boot a VM's GUI on YOUR machine — host with the VM already imported launches instantly",
|
||||
"/sbx launch vbox new [name]",
|
||||
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
|
||||
),
|
||||
kv(
|
||||
"/sbx launch vbox gui <vm> yes",
|
||||
"non-host opt-in: append yes to install VirtualBox and/or import the shared .ova, then boot",
|
||||
"/sbx launch vbox [gui] <vm> [yes]",
|
||||
"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(
|
||||
"/sbx gui <vm> [yes]",
|
||||
"alias of /sbx launch vbox gui <vm> [yes]",
|
||||
"/sbx save [label] [--local]",
|
||||
"save docker/multipass state (--local on docker also writes a portable .tar)",
|
||||
),
|
||||
kv("/sbx vms", "detect VirtualBox + list local VMs"),
|
||||
kv(
|
||||
"/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 {
|
||||
|
||||
Reference in New Issue
Block a user