From c8b97f07885bba89814f528fb80721a1305f8f2b Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Sun, 8 Feb 2026 21:13:44 -0500 Subject: [PATCH 01/10] feat: add sandbox quickstart command Add `deno sandbox quickstart` to create pre-configured snapshots from popular tools. Supports 5 presets (Python, Node.js, Data Science, Web Tools, System Tools) plus a custom multi-select flow. Includes non-interactive mode via --preset flag for scripting. --- sandbox/mod.ts | 4 + sandbox/quickstart.ts | 488 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 sandbox/quickstart.ts diff --git a/sandbox/mod.ts b/sandbox/mod.ts index 0a05664..6cbc4e0 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -22,6 +22,7 @@ import { createSwitchCommand, type GlobalContext } from "../main.ts"; import { volumesCommand } from "./volumes.ts"; import { snapshotsCommand } from "./snapshot.ts"; +import { quickstartCommand } from "./quickstart.ts"; import { actionHandler, type ConfigContext, getOrg } from "../config.ts"; export type SandboxContext = GlobalContext & { @@ -616,4 +617,7 @@ export const sandboxCommand = new Command() .command("deploy", sandboxDeployCommand) .command("volumes", volumesCommand) .command("snapshots", snapshotsCommand) + .command("quickstart", quickstartCommand) + .alias("quick") + .alias("init") .command("switch", createSwitchCommand(false)); diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts new file mode 100644 index 0000000..93eae68 --- /dev/null +++ b/sandbox/quickstart.ts @@ -0,0 +1,488 @@ +import { Command, ValidationError } from "@cliffy/command"; +import { Client, type Region, Sandbox } from "@deno/sandbox"; +import { green, yellow } from "@std/fmt/colors"; +import { Spinner } from "@std/cli/unstable-spinner"; +import { + type PromptEntry, + promptSelect, +} from "@std/cli/unstable-prompt-select"; +import { promptMultipleSelect } from "@std/cli/unstable-prompt-multiple-select"; + +import type { SandboxContext } from "./mod.ts"; +import { actionHandler, getOrg } from "../config.ts"; +import { getAuth } from "../auth.ts"; +import { error, parseSize } from "../util.ts"; + +// --- Preset & Category Definitions --- +// Each preset describes a ready-made configuration: a name for the menu, +// a slug for the --preset flag, apt packages to install, and optional +// extra commands to run after installation (like pip installs). + +interface Preset { + slug: string; + name: string; + description: string; + packages: string[]; + setupCommands: string[]; +} + +const PRESETS: Preset[] = [ + { + slug: "python", + name: "Python", + description: "Python 3 with pip and venv", + packages: ["python3", "python3-pip", "python3-venv"], + setupCommands: [], + }, + { + slug: "nodejs", + name: "Node.js", + description: "Node.js with npm", + packages: ["nodejs", "npm"], + setupCommands: [], + }, + { + slug: "data-science", + name: "Data Science", + description: "Python with NumPy, Pandas, Matplotlib, SciPy", + packages: ["python3", "python3-pip", "python3-venv"], + setupCommands: [ + "pip3 install --break-system-packages numpy pandas matplotlib scipy", + ], + }, + { + slug: "web-tools", + name: "Web Tools", + description: "curl, wget, jq, git, headless Chromium", + packages: ["curl", "wget", "jq", "git", "chromium"], + setupCommands: [], + }, + { + slug: "system-tools", + name: "System Tools", + description: "build-essential, git, curl, wget, jq, sqlite3", + packages: ["build-essential", "git", "curl", "wget", "jq", "sqlite3"], + setupCommands: [], + }, +]; + +// Categories for the "Custom" flow. Each item maps a friendly label +// to the apt packages and optional setup commands it needs. + +interface CategoryItem { + label: string; + packages: string[]; + setupCommands: string[]; +} + +interface Category { + name: string; + items: CategoryItem[]; +} + +const CUSTOM_CATEGORIES: Category[] = [ + { + name: "Languages", + items: [ + { + label: "Python", + packages: ["python3", "python3-pip", "python3-venv"], + setupCommands: [], + }, + { + label: "Node.js", + packages: ["nodejs", "npm"], + setupCommands: [], + }, + ], + }, + { + name: "Data & Analysis", + items: [ + { + label: "NumPy", + packages: ["python3", "python3-pip"], + setupCommands: ["pip3 install --break-system-packages numpy"], + }, + { + label: "Pandas", + packages: ["python3", "python3-pip"], + setupCommands: ["pip3 install --break-system-packages pandas"], + }, + { + label: "Matplotlib", + packages: ["python3", "python3-pip"], + setupCommands: ["pip3 install --break-system-packages matplotlib"], + }, + { + label: "SciPy", + packages: ["python3", "python3-pip"], + setupCommands: ["pip3 install --break-system-packages scipy"], + }, + ], + }, + { + name: "Web & Network", + items: [ + { label: "curl", packages: ["curl"], setupCommands: [] }, + { label: "wget", packages: ["wget"], setupCommands: [] }, + { label: "jq", packages: ["jq"], setupCommands: [] }, + { label: "git", packages: ["git"], setupCommands: [] }, + { label: "Chromium", packages: ["chromium"], setupCommands: [] }, + ], + }, + { + name: "System", + items: [ + { + label: "build-essential", + packages: ["build-essential"], + setupCommands: [], + }, + { label: "sqlite3", packages: ["sqlite3"], setupCommands: [] }, + { label: "htop", packages: ["htop"], setupCommands: [] }, + ], + }, +]; + +// --- Interactive Prompts --- +// These functions handle the step-by-step menu the user sees +// when they run the command without flags. + +function promptPresetSelection(): Preset | "custom" | null { + const choices: PromptEntry[] = PRESETS.map((preset) => ({ + label: `${preset.name} — ${preset.description}`, + value: preset, + })); + + choices.push({ + label: "Custom — Choose individual tools", + value: "custom", + }); + + const selected = promptSelect("Select a preset:", choices, { clear: true }); + if (!selected) return null; + return selected.value; +} + +function promptCustomSelection(): { + packages: string[]; + setupCommands: string[]; +} | null { + // Collect all selected packages and setup commands across categories. + // We use a Set for packages so duplicates are removed automatically + // (e.g. picking both "Python" and "NumPy" won't install python3 twice). + const allPackages = new Set(); + const allSetupCommands: string[] = []; + + for (const category of CUSTOM_CATEGORIES) { + const choices = category.items.map((item) => ({ + label: item.label, + value: item, + })); + + const selected = promptMultipleSelect( + `Select ${category.name} to install:`, + choices, + { clear: true }, + ); + + if (selected === null) return null; + + for (const entry of selected) { + for (const pkg of entry.value.packages) { + allPackages.add(pkg); + } + for (const cmd of entry.value.setupCommands) { + if (!allSetupCommands.includes(cmd)) { + allSetupCommands.push(cmd); + } + } + } + } + + if (allPackages.size === 0 && allSetupCommands.length === 0) { + return null; + } + + return { + packages: [...allPackages], + setupCommands: allSetupCommands, + }; +} + +function promptRegion(): Region | null { + const choices: PromptEntry[] = [ + { label: "Chicago (ord)", value: "ord" }, + { label: "Amsterdam (ams)", value: "ams" }, + ]; + + const selected = promptSelect("Select a region:", choices, { clear: true }); + if (!selected) return null; + return selected.value; +} + +function promptSnapshotName(): string | null { + const name = prompt( + "Enter a name for this snapshot:", + `quickstart-${Date.now()}`, + ); + return name; +} + +// --- Build Logic --- +// This is the core of the feature. It creates a temporary volume, +// boots a sandbox, installs everything, then snapshots the result. + +async function buildSnapshot( + context: SandboxContext, + client: Client, + options: { + packages: string[]; + setupCommands: string[]; + region: Region; + snapshotSlug: string; + capacity: number; + token: string; + org: string; + }, +): Promise { + // A unique name for the temporary volume so it doesn't clash with anything + const volumeSlug = `qs-temp-${Date.now()}`; + + const spinner = new Spinner({ color: "yellow" }); + + // Step 1: Create a temporary volume based on Debian 13 + spinner.message = "Creating temporary volume..."; + spinner.start(); + const volume = await client.volumes.create({ + slug: volumeSlug, + capacity: options.capacity, + region: options.region, + from: "builtin:debian-13", + }); + spinner.stop(); + console.log(`${green("✔")} Volume created`); + + try { + // Step 2: Boot a sandbox using this volume as its root filesystem. + // The sandbox is short-lived (10m timeout) — just long enough to install. + spinner.message = "Booting sandbox..."; + spinner.start(); + const sandbox = await Sandbox.create({ + token: options.token, + org: options.org, + timeout: "10m", + region: options.region, + root: volume.id, + }); + spinner.stop(); + console.log(`${green("✔")} Sandbox booted`); + + try { + // Step 3: Update the package list so apt knows what's available + spinner.message = "Updating package lists..."; + spinner.start(); + const updateChild = await sandbox.spawn("bash", { + args: ["-c", "apt-get update"], + stdout: "null", + stderr: "null", + }); + const updateStatus = await updateChild.status; + spinner.stop(); + if (!updateStatus.success) { + error(context, "Failed to update package lists"); + } + console.log(`${green("✔")} Package lists updated`); + + // Step 4: Install the apt packages. + // DEBIAN_FRONTEND=noninteractive prevents apt from asking questions. + if (options.packages.length > 0) { + const packageList = options.packages.join(", "); + spinner.message = `Installing packages: ${packageList}`; + spinner.start(); + const installCmd = `DEBIAN_FRONTEND=noninteractive apt-get install -y ${ + options.packages.join(" ") + }`; + const installChild = await sandbox.spawn("bash", { + args: ["-c", installCmd], + stdout: "null", + stderr: "null", + }); + const installStatus = await installChild.status; + spinner.stop(); + if (!installStatus.success) { + error(context, "Package installation failed"); + } + console.log(`${green("✔")} Packages installed`); + } + + // Step 5: Run any extra setup commands (like pip installs). + // These are optional — if one fails we warn but keep going. + for (const cmd of options.setupCommands) { + spinner.message = `Running: ${cmd}`; + spinner.start(); + const setupChild = await sandbox.spawn("bash", { + args: ["-c", cmd], + stdout: "null", + stderr: "null", + }); + const setupStatus = await setupChild.status; + spinner.stop(); + if (!setupStatus.success) { + console.log(`${yellow("⚠")} Setup command failed: ${cmd}`); + } else { + console.log(`${green("✔")} ${cmd}`); + } + } + } finally { + // Always close the sandbox before snapshotting — snapshots can't + // be created while a volume is attached to a running sandbox. + await sandbox.close(); + } + + // Step 6: Snapshot the volume to create a reusable image + spinner.message = "Creating snapshot..."; + spinner.start(); + await client.volumes.snapshot(volume.id, { + slug: options.snapshotSlug, + }); + spinner.stop(); + console.log(`${green("✔")} Snapshot created`); + } finally { + // Step 7: Delete the temporary volume — the snapshot is independent now. + // If this fails, warn the user so they can clean it up manually. + spinner.message = "Cleaning up temporary volume..."; + spinner.start(); + try { + await client.volumes.delete(volume.id); + spinner.stop(); + console.log(`${green("✔")} Cleanup complete`); + } catch { + spinner.stop(); + console.log( + `${yellow("⚠")} Could not delete temporary volume '${volumeSlug}'.`, + ); + console.log(" Please delete it manually to avoid charges:"); + console.log(` deno sandbox volumes delete ${volumeSlug}`); + } + } + + // Done! Show the user how to use their new snapshot. + console.log(); + console.log( + `${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`, + ); + console.log(); + console.log("To create a sandbox with this snapshot:"); + console.log(` deno sandbox create --root ${options.snapshotSlug}`); +} + +// --- The Command --- + +export const quickstartCommand = new Command() + .description( + "Create a pre-configured snapshot from popular tools and languages", + ) + .option("--preset ", "Use a named preset (skip the menu)", { + value: (name: string): string => { + const valid = PRESETS.map((p) => p.slug); + if (!valid.includes(name)) { + throw new ValidationError( + `Unknown preset '${name}'. Available presets: ${valid.join(", ")}`, + ); + } + return name; + }, + }) + .option("--name ", "Name for the snapshot") + .option("--region ", "Region (ord or ams)") + .option("--capacity ", "Volume capacity", { default: "10GB" }) + .example( + "Interactive mode", + "quickstart", + ) + .example( + "Using a preset", + "quickstart --preset python --name my-python --region ord", + ) + .action(actionHandler(async (config, options) => { + config.noCreate(); + const org = await getOrg(options, config, options.org); + const token = await getAuth(options, true); + + const client = new Client({ + apiEndpoint: options.endpoint, + token, + org, + }); + + // Determine what to install — either from a preset flag or interactive menu + let packages: string[]; + let setupCommands: string[]; + + if (options.preset) { + const preset = PRESETS.find((p) => p.slug === options.preset)!; + packages = preset.packages; + setupCommands = preset.setupCommands; + } else { + const selection = promptPresetSelection(); + if (selection === null) { + error(options, "No preset was selected."); + } + + if (selection === "custom") { + const custom = promptCustomSelection(); + if ( + custom === null || (custom.packages.length === 0 && + custom.setupCommands.length === 0) + ) { + error(options, "No tools were selected."); + } + packages = custom.packages; + setupCommands = custom.setupCommands; + } else { + packages = selection.packages; + setupCommands = selection.setupCommands; + } + } + + // Determine region — from flag or interactive prompt + let region: Region; + if (options.region) { + if (options.region !== "ord" && options.region !== "ams") { + throw new ValidationError( + "Region must be 'ord' (Chicago) or 'ams' (Amsterdam)", + ); + } + region = options.region; + } else { + const selected = promptRegion(); + if (selected === null) { + error(options, "No region was selected."); + } + region = selected; + } + + // Determine snapshot name — from flag or interactive prompt + let snapshotSlug: string; + if (options.name) { + snapshotSlug = options.name; + } else { + const name = promptSnapshotName(); + if (name === null) { + error(options, "No snapshot name was provided."); + } + snapshotSlug = name; + } + + await buildSnapshot(options, client, { + packages, + setupCommands, + region, + snapshotSlug, + capacity: Math.floor(parseSize(options, options.capacity)), + token, + org, + }); + })); From ec0d360b50175b7e38d9d08f031416c922d04345 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 9 Feb 2026 11:50:15 -0500 Subject: [PATCH 02/10] add optional verbose output --- deno.json | 5 ++- sandbox/quickstart.ts | 71 ++++++++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/deno.json b/deno.json index ac3b55e..5bd38e9 100644 --- a/deno.json +++ b/deno.json @@ -43,5 +43,8 @@ }, "exclude": [ "astro-demo" - ] + ], + "deploy": { + "org": "donjo" + } } diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index 93eae68..1aed94c 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -47,7 +47,7 @@ const PRESETS: Preset[] = [ description: "Python with NumPy, Pandas, Matplotlib, SciPy", packages: ["python3", "python3-pip", "python3-venv"], setupCommands: [ - "pip3 install --break-system-packages numpy pandas matplotlib scipy", + "sudo pip3 install --break-system-packages numpy pandas matplotlib scipy", ], }, { @@ -102,22 +102,22 @@ const CUSTOM_CATEGORIES: Category[] = [ { label: "NumPy", packages: ["python3", "python3-pip"], - setupCommands: ["pip3 install --break-system-packages numpy"], + setupCommands: ["sudo pip3 install --break-system-packages numpy"], }, { label: "Pandas", packages: ["python3", "python3-pip"], - setupCommands: ["pip3 install --break-system-packages pandas"], + setupCommands: ["sudo pip3 install --break-system-packages pandas"], }, { label: "Matplotlib", packages: ["python3", "python3-pip"], - setupCommands: ["pip3 install --break-system-packages matplotlib"], + setupCommands: ["sudo pip3 install --break-system-packages matplotlib"], }, { label: "SciPy", packages: ["python3", "python3-pip"], - setupCommands: ["pip3 install --break-system-packages scipy"], + setupCommands: ["sudo pip3 install --break-system-packages scipy"], }, ], }, @@ -245,13 +245,25 @@ async function buildSnapshot( capacity: number; token: string; org: string; + verbose: boolean; }, ): Promise { // A unique name for the temporary volume so it doesn't clash with anything const volumeSlug = `qs-temp-${Date.now()}`; + // In verbose mode, command output goes straight to the terminal. + // In normal mode, output is hidden and we show friendly progress instead. + const out = options.verbose ? "inherit" : "null" as const; + const spinner = new Spinner({ color: "yellow" }); + const totalSteps = 2 + options.packages.length + options.setupCommands.length; + let currentStep = 0; + const step = (label: string) => { + currentStep++; + return `[${currentStep}/${totalSteps}] ${label}`; + }; + // Step 1: Create a temporary volume based on Debian 13 spinner.message = "Creating temporary volume..."; spinner.start(); @@ -279,14 +291,23 @@ async function buildSnapshot( spinner.stop(); console.log(`${green("✔")} Sandbox booted`); + console.log(); + console.log( + `Installing ${options.packages.length} package${options.packages.length === 1 ? "" : "s"}` + + (options.setupCommands.length > 0 + ? ` + ${options.setupCommands.length} setup command${options.setupCommands.length === 1 ? "" : "s"}` + : ""), + ); + console.log(); + try { // Step 3: Update the package list so apt knows what's available - spinner.message = "Updating package lists..."; + spinner.message = step("Updating package lists..."); spinner.start(); const updateChild = await sandbox.spawn("bash", { - args: ["-c", "apt-get update"], - stdout: "null", - stderr: "null", + args: ["-c", "sudo apt update"], + stdout: out, + stderr: out, }); const updateStatus = await updateChild.status; spinner.stop(); @@ -295,37 +316,37 @@ async function buildSnapshot( } console.log(`${green("✔")} Package lists updated`); - // Step 4: Install the apt packages. - // DEBIAN_FRONTEND=noninteractive prevents apt from asking questions. - if (options.packages.length > 0) { - const packageList = options.packages.join(", "); - spinner.message = `Installing packages: ${packageList}`; + // Step 4: Install each apt package individually so we can show + // per-package progress. DEBIAN_FRONTEND=noninteractive prevents + // apt from asking questions. + for (let i = 0; i < options.packages.length; i++) { + const pkg = options.packages[i]; + spinner.message = step(`Installing ${pkg}...`); spinner.start(); - const installCmd = `DEBIAN_FRONTEND=noninteractive apt-get install -y ${ - options.packages.join(" ") - }`; + const installCmd = + `sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`; const installChild = await sandbox.spawn("bash", { args: ["-c", installCmd], - stdout: "null", - stderr: "null", + stdout: out, + stderr: out, }); const installStatus = await installChild.status; spinner.stop(); if (!installStatus.success) { - error(context, "Package installation failed"); + error(context, `Failed to install ${pkg}`); } - console.log(`${green("✔")} Packages installed`); + console.log(`${green("✔")} Installed ${pkg}`); } // Step 5: Run any extra setup commands (like pip installs). // These are optional — if one fails we warn but keep going. for (const cmd of options.setupCommands) { - spinner.message = `Running: ${cmd}`; + spinner.message = step(`Running: ${cmd}`); spinner.start(); const setupChild = await sandbox.spawn("bash", { args: ["-c", cmd], - stdout: "null", - stderr: "null", + stdout: out, + stderr: out, }); const setupStatus = await setupChild.status; spinner.stop(); @@ -398,6 +419,7 @@ export const quickstartCommand = new Command() .option("--name ", "Name for the snapshot") .option("--region ", "Region (ord or ams)") .option("--capacity ", "Volume capacity", { default: "10GB" }) + .option("--verbose", "Show full command output") .example( "Interactive mode", "quickstart", @@ -484,5 +506,6 @@ export const quickstartCommand = new Command() capacity: Math.floor(parseSize(options, options.capacity)), token, org, + verbose: options.verbose ?? false, }); })); From 34aacf58eceba904e7a7592412d01f27d876d227 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 9 Feb 2026 14:00:01 -0500 Subject: [PATCH 03/10] snapshot working --- deno.json | 14 ++--- deno.lock | 20 +++---- sandbox/quickstart.ts | 135 +++++++++++++++++++++++++++++++----------- 3 files changed, 115 insertions(+), 54 deletions(-) diff --git a/deno.json b/deno.json index 5bd38e9..154c7b0 100644 --- a/deno.json +++ b/deno.json @@ -1,23 +1,19 @@ { "nodeModulesDir": "auto", - "workspace": [ - "astro-demo" - ], + "workspace": ["astro-demo"], "name": "@deno/deploy", "license": "MIT", "tasks": { "build": "deno run -A jsr:@deno/wasmbuild@0.19.2" }, "publish": { - "exclude": [ - "!./lib" - ] + "exclude": ["!./lib"] }, "exports": "./main.ts", "imports": { "@cfa/gitignore-parser": "jsr:@cfa/gitignore-parser@^0.1.4", "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", - "@deno/sandbox": "jsr:@deno/sandbox@^0.10.0", + "@deno/sandbox": "jsr:@deno/sandbox@^0.11.0", "@std/assert": "jsr:@std/assert@^1.0.16", "@std/async": "jsr:@std/async@^1.1.0", "@std/cli": "jsr:@std/cli@1.0.27", @@ -41,9 +37,7 @@ "pg-connection-string": "npm:pg-connection-string@^2.9.1", "dax": "jsr:@david/dax@^0.44.2" }, - "exclude": [ - "astro-demo" - ], + "exclude": ["astro-demo"], "deploy": { "org": "donjo" } diff --git a/deno.lock b/deno.lock index 993e070..883e378 100644 --- a/deno.lock +++ b/deno.lock @@ -11,8 +11,8 @@ "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@deno/framework-detect@0": "0.3.0", - "jsr:@deno/sandbox@0.10": "0.10.0", - "jsr:@std/assert@^1.0.16": "1.0.17", + "jsr:@deno/sandbox@0.11": "0.11.0", + "jsr:@std/assert@^1.0.16": "1.0.18", "jsr:@std/async@^1.1.0": "1.1.0", "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/cli@1.0.27": "1.0.27", @@ -50,7 +50,7 @@ "npm:superjson@^2.2.2": "2.2.6", "npm:temporal-polyfill@0.3": "0.3.0", "npm:ws@^8.18.3": "8.19.0", - "npm:zod@^4.1.5": "4.3.5" + "npm:zod@^4.1.5": "4.3.6" }, "jsr": { "@cfa/gitignore-parser@0.1.4": { @@ -113,8 +113,8 @@ "jsr:@std/path@^1.1.2" ] }, - "@deno/sandbox@0.10.0": { - "integrity": "4157bd2ff55b2fe47dcfb4fd180a47f47cf7d5c93fe6724e1eee41a170b51865", + "@deno/sandbox@0.11.0": { + "integrity": "598b902db1eaf747cf16dce5b752154db567bc563b555c3ce32d02b27f650432", "dependencies": [ "jsr:@std/fs@^1.0.19", "jsr:@std/path@^1.1.2", @@ -123,8 +123,8 @@ "npm:zod" ] }, - "@std/assert@1.0.17": { - "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", + "@std/assert@1.0.18": { + "integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78", "dependencies": [ "jsr:@std/internal" ] @@ -2562,8 +2562,8 @@ "zod@3.25.76": { "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, - "zod@4.3.5": { - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==" + "zod@4.3.6": { + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" @@ -2575,7 +2575,7 @@ "jsr:@cliffy/command@^1.0.0-rc.8", "jsr:@david/dax@~0.44.2", "jsr:@deno/framework-detect@0", - "jsr:@deno/sandbox@0.10", + "jsr:@deno/sandbox@0.11", "jsr:@std/assert@^1.0.16", "jsr:@std/async@^1.1.0", "jsr:@std/cli@1.0.27", diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index 1aed94c..e8d1603 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -276,6 +276,8 @@ async function buildSnapshot( spinner.stop(); console.log(`${green("✔")} Volume created`); + let snapshotCreated = false; + try { // Step 2: Boot a sandbox using this volume as its root filesystem. // The sandbox is short-lived (10m timeout) — just long enough to install. @@ -357,46 +359,111 @@ async function buildSnapshot( } } } finally { - // Always close the sandbox before snapshotting — snapshots can't - // be created while a volume is attached to a running sandbox. - await sandbox.close(); + // We must kill() the sandbox, not just close(). + // close() only disconnects the WebSocket — the sandbox keeps + // running on the server with the volume still mounted. + // kill() sends a DELETE to the server which actually terminates + // the sandbox and releases the volume. + spinner.message = "Stopping sandbox and detaching volume..."; + spinner.start(); + try { + await sandbox.kill(); + } catch { + // kill() may time out (10s limit), but the server is still + // processing the termination. Wait for the WebSocket to + // confirm the sandbox is gone. + try { + await Promise.race([ + sandbox.closed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out")), 30_000) + ), + ]); + } catch { + // Sandbox may have already timed out and stopped on its own + } + } + // Brief pause to let the volume fully detach after sandbox termination + await new Promise((resolve) => setTimeout(resolve, 5_000)); + spinner.stop(); + console.log(`${green("✔")} Sandbox stopped`); } - // Step 6: Snapshot the volume to create a reusable image - spinner.message = "Creating snapshot..."; - spinner.start(); - await client.volumes.snapshot(volume.id, { - slug: options.snapshotSlug, - }); - spinner.stop(); - console.log(`${green("✔")} Snapshot created`); + // Step 6: Snapshot the volume to create a reusable image. + // The volume may not be fully detached from the sandbox yet, + // so we retry a few times with increasing delays. + const maxAttempts = 3; + const retryDelays = [10_000, 15_000, 15_000]; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + spinner.message = attempt === 1 + ? "Creating snapshot..." + : `Creating snapshot (attempt ${attempt}/${maxAttempts})...`; + spinner.start(); + try { + await client.volumes.snapshot(volume.id, { + slug: options.snapshotSlug, + }); + spinner.stop(); + console.log(`${green("✔")} Snapshot created`); + snapshotCreated = true; + break; + } catch (e) { + spinner.stop(); + if (attempt < maxAttempts) { + const delaySec = retryDelays[attempt - 1] / 1000; + console.log( + `${yellow("⚠")} Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`, + ); + await new Promise((resolve) => + setTimeout(resolve, retryDelays[attempt - 1]) + ); + } else { + console.log(`${yellow("⚠")} Snapshot creation failed: ${e}`); + console.log( + " You can try creating it manually once the volume is ready:", + ); + console.log( + ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, + ); + } + } + } } finally { - // Step 7: Delete the temporary volume — the snapshot is independent now. - // If this fails, warn the user so they can clean it up manually. - spinner.message = "Cleaning up temporary volume..."; - spinner.start(); - try { - await client.volumes.delete(volume.id); - spinner.stop(); - console.log(`${green("✔")} Cleanup complete`); - } catch { - spinner.stop(); - console.log( - `${yellow("⚠")} Could not delete temporary volume '${volumeSlug}'.`, - ); - console.log(" Please delete it manually to avoid charges:"); - console.log(` deno sandbox volumes delete ${volumeSlug}`); + // Only delete the temporary volume if the snapshot was created. + // If it wasn't, keep the volume so the user can snapshot it manually. + if (snapshotCreated) { + spinner.message = "Cleaning up temporary volume..."; + spinner.start(); + try { + await Promise.race([ + client.volumes.delete(volume.id), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out")), 30_000) + ), + ]); + spinner.stop(); + console.log(`${green("✔")} Cleanup complete`); + } catch { + spinner.stop(); + console.log( + `${yellow("⚠")} Could not delete temporary volume '${volumeSlug}'.`, + ); + console.log(" Please delete it manually to avoid charges:"); + console.log(` deno sandbox volumes delete ${volumeSlug}`); + } } } - // Done! Show the user how to use their new snapshot. - console.log(); - console.log( - `${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`, - ); - console.log(); - console.log("To create a sandbox with this snapshot:"); - console.log(` deno sandbox create --root ${options.snapshotSlug}`); + if (snapshotCreated) { + console.log(); + console.log( + `${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`, + ); + console.log(); + console.log("To create a sandbox with this snapshot:"); + console.log(` deno sandbox create --root ${options.snapshotSlug}`); + } } // --- The Command --- From e304521c45240dc459e21f7d7e87c13b68855e59 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 9 Feb 2026 17:30:56 -0500 Subject: [PATCH 04/10] remove volume cleanup --- sandbox/quickstart.ts | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index e8d1603..f7c713d 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -430,29 +430,8 @@ async function buildSnapshot( } } } finally { - // Only delete the temporary volume if the snapshot was created. - // If it wasn't, keep the volume so the user can snapshot it manually. - if (snapshotCreated) { - spinner.message = "Cleaning up temporary volume..."; - spinner.start(); - try { - await Promise.race([ - client.volumes.delete(volume.id), - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out")), 30_000) - ), - ]); - spinner.stop(); - console.log(`${green("✔")} Cleanup complete`); - } catch { - spinner.stop(); - console.log( - `${yellow("⚠")} Could not delete temporary volume '${volumeSlug}'.`, - ); - console.log(" Please delete it manually to avoid charges:"); - console.log(` deno sandbox volumes delete ${volumeSlug}`); - } - } + // The volume is kept because the snapshot depends on it. + // It cannot be deleted while the snapshot exists. } if (snapshotCreated) { From 27a6f814ba5ed22a60ebbd7126c36504035c483d Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Tue, 10 Feb 2026 09:54:33 -0500 Subject: [PATCH 05/10] add SSH example to quickstart success output --- sandbox/quickstart.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index f7c713d..c7b99ab 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -442,6 +442,9 @@ async function buildSnapshot( console.log(); console.log("To create a sandbox with this snapshot:"); console.log(` deno sandbox create --root ${options.snapshotSlug}`); + console.log(); + console.log("To create a sandbox and SSH into it:"); + console.log(` deno sandbox create --root ${options.snapshotSlug} --ssh`); } } From aee88fdbddc055e40e9bb82832e0de31434efc7b Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 12:07:35 -0500 Subject: [PATCH 06/10] fmt --- sandbox/quickstart.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index c7b99ab..0512893 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -295,9 +295,13 @@ async function buildSnapshot( console.log(); console.log( - `Installing ${options.packages.length} package${options.packages.length === 1 ? "" : "s"}` + + `Installing ${options.packages.length} package${ + options.packages.length === 1 ? "" : "s" + }` + (options.setupCommands.length > 0 - ? ` + ${options.setupCommands.length} setup command${options.setupCommands.length === 1 ? "" : "s"}` + ? ` + ${options.setupCommands.length} setup command${ + options.setupCommands.length === 1 ? "" : "s" + }` : ""), ); console.log(); @@ -413,7 +417,9 @@ async function buildSnapshot( if (attempt < maxAttempts) { const delaySec = retryDelays[attempt - 1] / 1000; console.log( - `${yellow("⚠")} Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`, + `${ + yellow("⚠") + } Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`, ); await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt - 1]) From 391326c3cfa996bd5412650d6a0682895540ec9d Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 12:27:07 -0500 Subject: [PATCH 07/10] fix: prevent resource leaks and silent failures in quickstart - Use throw instead of error() inside try blocks so the finally block runs sandbox cleanup instead of exiting immediately - Make snapshot failure exit non-zero instead of silently succeeding - Log errors in sandbox teardown catch blocks instead of swallowing them --- sandbox/quickstart.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index 0512893..de001fa 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -318,7 +318,7 @@ async function buildSnapshot( const updateStatus = await updateChild.status; spinner.stop(); if (!updateStatus.success) { - error(context, "Failed to update package lists"); + throw new Error("Failed to update package lists"); } console.log(`${green("✔")} Package lists updated`); @@ -339,7 +339,7 @@ async function buildSnapshot( const installStatus = await installChild.status; spinner.stop(); if (!installStatus.success) { - error(context, `Failed to install ${pkg}`); + throw new Error(`Failed to install ${pkg}`); } console.log(`${green("✔")} Installed ${pkg}`); } @@ -372,10 +372,13 @@ async function buildSnapshot( spinner.start(); try { await sandbox.kill(); - } catch { + } catch (killError) { // kill() may time out (10s limit), but the server is still // processing the termination. Wait for the WebSocket to // confirm the sandbox is gone. + if (options.verbose) { + console.log(`${yellow("⚠")} sandbox.kill() failed: ${killError}`); + } try { await Promise.race([ sandbox.closed, @@ -383,8 +386,13 @@ async function buildSnapshot( setTimeout(() => reject(new Error("timed out")), 30_000) ), ]); - } catch { - // Sandbox may have already timed out and stopped on its own + } catch (closedError) { + console.log( + `${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`, + ); + console.log( + " The sandbox may still be running. Check your dashboard.", + ); } } // Brief pause to let the volume fully detach after sandbox termination @@ -425,11 +433,9 @@ async function buildSnapshot( setTimeout(resolve, retryDelays[attempt - 1]) ); } else { - console.log(`${yellow("⚠")} Snapshot creation failed: ${e}`); - console.log( - " You can try creating it manually once the volume is ready:", - ); - console.log( + throw new Error( + `Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` + + ` The volume '${volumeSlug}' still exists. You can try manually:\n` + ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, ); } From 15c177328b0daea278f6aa005cfbe5c8285dc8e6 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 12:31:20 -0500 Subject: [PATCH 08/10] refactor: simplify quickstart command - Extract runInSandbox helper for repeated spawn/status pattern - Fix step counter off-by-one (totalSteps counted 2 base but only 1 used) - Simplify install summary, prompt, and package loop - Use Set for setup command dedup to match package dedup - Remove empty try/finally and unnecessary snapshotCreated flag - Remove unused context parameter from buildSnapshot --- sandbox/quickstart.ts | 321 +++++++++++++++++++----------------------- 1 file changed, 144 insertions(+), 177 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index de001fa..19f1493 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -173,7 +173,7 @@ function promptCustomSelection(): { // We use a Set for packages so duplicates are removed automatically // (e.g. picking both "Python" and "NumPy" won't install python3 twice). const allPackages = new Set(); - const allSetupCommands: string[] = []; + const allSetupCommands = new Set(); for (const category of CUSTOM_CATEGORIES) { const choices = category.items.map((item) => ({ @@ -194,20 +194,18 @@ function promptCustomSelection(): { allPackages.add(pkg); } for (const cmd of entry.value.setupCommands) { - if (!allSetupCommands.includes(cmd)) { - allSetupCommands.push(cmd); - } + allSetupCommands.add(cmd); } } } - if (allPackages.size === 0 && allSetupCommands.length === 0) { + if (allPackages.size === 0 && allSetupCommands.size === 0) { return null; } return { packages: [...allPackages], - setupCommands: allSetupCommands, + setupCommands: [...allSetupCommands], }; } @@ -223,11 +221,7 @@ function promptRegion(): Region | null { } function promptSnapshotName(): string | null { - const name = prompt( - "Enter a name for this snapshot:", - `quickstart-${Date.now()}`, - ); - return name; + return prompt("Enter a name for this snapshot:", `quickstart-${Date.now()}`); } // --- Build Logic --- @@ -235,7 +229,6 @@ function promptSnapshotName(): string | null { // boots a sandbox, installs everything, then snapshots the result. async function buildSnapshot( - context: SandboxContext, client: Client, options: { packages: string[]; @@ -257,7 +250,18 @@ async function buildSnapshot( const spinner = new Spinner({ color: "yellow" }); - const totalSteps = 2 + options.packages.length + options.setupCommands.length; + // Runs a shell command inside the sandbox and returns whether it succeeded + async function runInSandbox(sandbox: Sandbox, command: string): Promise { + const child = await sandbox.spawn("bash", { + args: ["-c", command], + stdout: out, + stderr: out, + }); + const status = await child.status; + return status.success; + } + + const totalSteps = 1 + options.packages.length + options.setupCommands.length; let currentStep = 0; const step = (label: string) => { currentStep++; @@ -276,188 +280,151 @@ async function buildSnapshot( spinner.stop(); console.log(`${green("✔")} Volume created`); - let snapshotCreated = false; + // Boot a sandbox using this volume as its root filesystem. + // The sandbox is short-lived (10m timeout) — just long enough to install. + spinner.message = "Booting sandbox..."; + spinner.start(); + const sandbox = await Sandbox.create({ + token: options.token, + org: options.org, + timeout: "10m", + region: options.region, + root: volume.id, + }); + spinner.stop(); + console.log(`${green("✔")} Sandbox booted`); + + console.log(); + const pkgCount = options.packages.length; + const cmdCount = options.setupCommands.length; + let summary = `Installing ${pkgCount} package${pkgCount === 1 ? "" : "s"}`; + if (cmdCount > 0) { + summary += ` + ${cmdCount} setup command${cmdCount === 1 ? "" : "s"}`; + } + console.log(summary); + console.log(); try { - // Step 2: Boot a sandbox using this volume as its root filesystem. - // The sandbox is short-lived (10m timeout) — just long enough to install. - spinner.message = "Booting sandbox..."; + spinner.message = step("Updating package lists..."); spinner.start(); - const sandbox = await Sandbox.create({ - token: options.token, - org: options.org, - timeout: "10m", - region: options.region, - root: volume.id, - }); + const updateOk = await runInSandbox(sandbox, "sudo apt update"); spinner.stop(); - console.log(`${green("✔")} Sandbox booted`); - - console.log(); - console.log( - `Installing ${options.packages.length} package${ - options.packages.length === 1 ? "" : "s" - }` + - (options.setupCommands.length > 0 - ? ` + ${options.setupCommands.length} setup command${ - options.setupCommands.length === 1 ? "" : "s" - }` - : ""), - ); - console.log(); + if (!updateOk) { + throw new Error("Failed to update package lists"); + } + console.log(`${green("✔")} Package lists updated`); - try { - // Step 3: Update the package list so apt knows what's available - spinner.message = step("Updating package lists..."); + // DEBIAN_FRONTEND=noninteractive prevents apt from asking questions + for (const pkg of options.packages) { + spinner.message = step(`Installing ${pkg}...`); spinner.start(); - const updateChild = await sandbox.spawn("bash", { - args: ["-c", "sudo apt update"], - stdout: out, - stderr: out, - }); - const updateStatus = await updateChild.status; + const installOk = await runInSandbox( + sandbox, + `sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`, + ); spinner.stop(); - if (!updateStatus.success) { - throw new Error("Failed to update package lists"); - } - console.log(`${green("✔")} Package lists updated`); - - // Step 4: Install each apt package individually so we can show - // per-package progress. DEBIAN_FRONTEND=noninteractive prevents - // apt from asking questions. - for (let i = 0; i < options.packages.length; i++) { - const pkg = options.packages[i]; - spinner.message = step(`Installing ${pkg}...`); - spinner.start(); - const installCmd = - `sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`; - const installChild = await sandbox.spawn("bash", { - args: ["-c", installCmd], - stdout: out, - stderr: out, - }); - const installStatus = await installChild.status; - spinner.stop(); - if (!installStatus.success) { - throw new Error(`Failed to install ${pkg}`); - } - console.log(`${green("✔")} Installed ${pkg}`); + if (!installOk) { + throw new Error(`Failed to install ${pkg}`); } + console.log(`${green("✔")} Installed ${pkg}`); + } - // Step 5: Run any extra setup commands (like pip installs). - // These are optional — if one fails we warn but keep going. - for (const cmd of options.setupCommands) { - spinner.message = step(`Running: ${cmd}`); - spinner.start(); - const setupChild = await sandbox.spawn("bash", { - args: ["-c", cmd], - stdout: out, - stderr: out, - }); - const setupStatus = await setupChild.status; - spinner.stop(); - if (!setupStatus.success) { - console.log(`${yellow("⚠")} Setup command failed: ${cmd}`); - } else { - console.log(`${green("✔")} ${cmd}`); - } - } - } finally { - // We must kill() the sandbox, not just close(). - // close() only disconnects the WebSocket — the sandbox keeps - // running on the server with the volume still mounted. - // kill() sends a DELETE to the server which actually terminates - // the sandbox and releases the volume. - spinner.message = "Stopping sandbox and detaching volume..."; + // Setup commands are optional — if one fails we warn but keep going + for (const cmd of options.setupCommands) { + spinner.message = step(`Running: ${cmd}`); spinner.start(); + const setupOk = await runInSandbox(sandbox, cmd); + spinner.stop(); + if (!setupOk) { + console.log(`${yellow("⚠")} Setup command failed: ${cmd}`); + } else { + console.log(`${green("✔")} ${cmd}`); + } + } + } finally { + // We use kill() instead of close() because close() only disconnects + // the client while the sandbox continues running server-side with + // the volume mounted. kill() terminates the sandbox on the server, + // which is required to release the volume for snapshotting. + spinner.message = "Stopping sandbox and detaching volume..."; + spinner.start(); + try { + await sandbox.kill(); + } catch (killError) { + if (options.verbose) { + console.log(`${yellow("⚠")} sandbox.kill() failed: ${killError}`); + } try { - await sandbox.kill(); - } catch (killError) { - // kill() may time out (10s limit), but the server is still - // processing the termination. Wait for the WebSocket to - // confirm the sandbox is gone. - if (options.verbose) { - console.log(`${yellow("⚠")} sandbox.kill() failed: ${killError}`); - } - try { - await Promise.race([ - sandbox.closed, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out")), 30_000) - ), - ]); - } catch (closedError) { - console.log( - `${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`, - ); - console.log( - " The sandbox may still be running. Check your dashboard.", - ); - } + await Promise.race([ + sandbox.closed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out")), 30_000) + ), + ]); + } catch (closedError) { + console.log( + `${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`, + ); + console.log( + " The sandbox may still be running. Check your dashboard.", + ); } - // Brief pause to let the volume fully detach after sandbox termination - await new Promise((resolve) => setTimeout(resolve, 5_000)); - spinner.stop(); - console.log(`${green("✔")} Sandbox stopped`); } + // Brief pause to let the volume fully detach after sandbox termination + await new Promise((resolve) => setTimeout(resolve, 5_000)); + spinner.stop(); + console.log(`${green("✔")} Sandbox stopped`); + } - // Step 6: Snapshot the volume to create a reusable image. - // The volume may not be fully detached from the sandbox yet, - // so we retry a few times with increasing delays. - const maxAttempts = 3; - const retryDelays = [10_000, 15_000, 15_000]; + // Snapshot the volume to create a reusable image. + // The volume may not be fully detached yet, so we retry a few times. + const maxAttempts = 3; + const retryDelays = [10_000, 15_000, 15_000]; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - spinner.message = attempt === 1 - ? "Creating snapshot..." - : `Creating snapshot (attempt ${attempt}/${maxAttempts})...`; - spinner.start(); - try { - await client.volumes.snapshot(volume.id, { - slug: options.snapshotSlug, - }); - spinner.stop(); - console.log(`${green("✔")} Snapshot created`); - snapshotCreated = true; - break; - } catch (e) { - spinner.stop(); - if (attempt < maxAttempts) { - const delaySec = retryDelays[attempt - 1] / 1000; - console.log( - `${ - yellow("⚠") - } Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`, - ); - await new Promise((resolve) => - setTimeout(resolve, retryDelays[attempt - 1]) - ); - } else { - throw new Error( - `Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` + - ` The volume '${volumeSlug}' still exists. You can try manually:\n` + - ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, - ); - } + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + spinner.message = attempt === 1 + ? "Creating snapshot..." + : `Creating snapshot (attempt ${attempt}/${maxAttempts})...`; + spinner.start(); + try { + await client.volumes.snapshot(volume.id, { + slug: options.snapshotSlug, + }); + spinner.stop(); + console.log(`${green("✔")} Snapshot created`); + break; + } catch (e) { + spinner.stop(); + if (attempt < maxAttempts) { + const delaySec = retryDelays[attempt - 1] / 1000; + console.log( + `${ + yellow("⚠") + } Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`, + ); + await new Promise((resolve) => + setTimeout(resolve, retryDelays[attempt - 1]) + ); + } else { + throw new Error( + `Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` + + ` The volume '${volumeSlug}' still exists. You can try manually:\n` + + ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, + ); } } - } finally { - // The volume is kept because the snapshot depends on it. - // It cannot be deleted while the snapshot exists. } - if (snapshotCreated) { - console.log(); - console.log( - `${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`, - ); - console.log(); - console.log("To create a sandbox with this snapshot:"); - console.log(` deno sandbox create --root ${options.snapshotSlug}`); - console.log(); - console.log("To create a sandbox and SSH into it:"); - console.log(` deno sandbox create --root ${options.snapshotSlug} --ssh`); - } + console.log(); + console.log( + `${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`, + ); + console.log(); + console.log("To create a sandbox with this snapshot:"); + console.log(` deno sandbox create --root ${options.snapshotSlug}`); + console.log(); + console.log("To create a sandbox and SSH into it:"); + console.log(` deno sandbox create --root ${options.snapshotSlug} --ssh`); } // --- The Command --- @@ -559,7 +526,7 @@ export const quickstartCommand = new Command() snapshotSlug = name; } - await buildSnapshot(options, client, { + await buildSnapshot(client, { packages, setupCommands, region, From 267c534a1c27926eda0ef532b6573ba0f622c33b Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 12:35:11 -0500 Subject: [PATCH 09/10] fix: improve error handling and comment accuracy in quickstart - Stop spinner on volume/sandbox creation failures for clean output - Include orphaned volume cleanup instructions when sandbox boot fails - Track failed setup commands and warn that snapshot will be incomplete - Fix misleading "temporary volume" comments (volume is kept permanently) --- sandbox/quickstart.ts | 61 ++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index 19f1493..3a9537c 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -225,8 +225,9 @@ function promptSnapshotName(): string | null { } // --- Build Logic --- -// This is the core of the feature. It creates a temporary volume, -// boots a sandbox, installs everything, then snapshots the result. +// This is the core of the feature. It creates a volume, boots a +// sandbox, installs everything, then snapshots the result. +// The volume is kept because the snapshot depends on it. async function buildSnapshot( client: Client, @@ -241,7 +242,7 @@ async function buildSnapshot( verbose: boolean; }, ): Promise { - // A unique name for the temporary volume so it doesn't clash with anything + // A unique name for the build volume so it doesn't clash with anything const volumeSlug = `qs-temp-${Date.now()}`; // In verbose mode, command output goes straight to the terminal. @@ -268,15 +269,20 @@ async function buildSnapshot( return `[${currentStep}/${totalSteps}] ${label}`; }; - // Step 1: Create a temporary volume based on Debian 13 - spinner.message = "Creating temporary volume..."; + spinner.message = "Creating volume..."; spinner.start(); - const volume = await client.volumes.create({ - slug: volumeSlug, - capacity: options.capacity, - region: options.region, - from: "builtin:debian-13", - }); + let volume; + try { + volume = await client.volumes.create({ + slug: volumeSlug, + capacity: options.capacity, + region: options.region, + from: "builtin:debian-13", + }); + } catch (e) { + spinner.stop(); + throw new Error(`Failed to create volume: ${e}`); + } spinner.stop(); console.log(`${green("✔")} Volume created`); @@ -284,13 +290,23 @@ async function buildSnapshot( // The sandbox is short-lived (10m timeout) — just long enough to install. spinner.message = "Booting sandbox..."; spinner.start(); - const sandbox = await Sandbox.create({ - token: options.token, - org: options.org, - timeout: "10m", - region: options.region, - root: volume.id, - }); + let sandbox; + try { + sandbox = await Sandbox.create({ + token: options.token, + org: options.org, + timeout: "10m", + region: options.region, + root: volume.id, + }); + } catch (e) { + spinner.stop(); + throw new Error( + `Failed to boot sandbox: ${e}\n` + + ` Volume '${volumeSlug}' was created but is now unused.\n` + + ` You can delete it with: deno sandbox volumes delete ${volumeSlug}`, + ); + } spinner.stop(); console.log(`${green("✔")} Sandbox booted`); @@ -330,6 +346,7 @@ async function buildSnapshot( } // Setup commands are optional — if one fails we warn but keep going + const failedCommands: string[] = []; for (const cmd of options.setupCommands) { spinner.message = step(`Running: ${cmd}`); spinner.start(); @@ -337,10 +354,18 @@ async function buildSnapshot( spinner.stop(); if (!setupOk) { console.log(`${yellow("⚠")} Setup command failed: ${cmd}`); + failedCommands.push(cmd); } else { console.log(`${green("✔")} ${cmd}`); } } + if (failedCommands.length > 0) { + console.log(); + console.log( + `${yellow("⚠")} ${failedCommands.length} setup command(s) failed. ` + + "The snapshot will be incomplete.", + ); + } } finally { // We use kill() instead of close() because close() only disconnects // the client while the sandbox continues running server-side with From 7a3b8816bd8020411e00562500439bce1d89f399 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 12:55:50 -0500 Subject: [PATCH 10/10] fmt --- sandbox/quickstart.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/sandbox/quickstart.ts b/sandbox/quickstart.ts index 3a9537c..8ad1215 100644 --- a/sandbox/quickstart.ts +++ b/sandbox/quickstart.ts @@ -252,7 +252,10 @@ async function buildSnapshot( const spinner = new Spinner({ color: "yellow" }); // Runs a shell command inside the sandbox and returns whether it succeeded - async function runInSandbox(sandbox: Sandbox, command: string): Promise { + async function runInSandbox( + sandbox: Sandbox, + command: string, + ): Promise { const child = await sandbox.spawn("bash", { args: ["-c", command], stdout: out, @@ -303,8 +306,8 @@ async function buildSnapshot( spinner.stop(); throw new Error( `Failed to boot sandbox: ${e}\n` + - ` Volume '${volumeSlug}' was created but is now unused.\n` + - ` You can delete it with: deno sandbox volumes delete ${volumeSlug}`, + ` Volume '${volumeSlug}' was created but is now unused.\n` + + ` You can delete it with: deno sandbox volumes delete ${volumeSlug}`, ); } spinner.stop(); @@ -363,7 +366,7 @@ async function buildSnapshot( console.log(); console.log( `${yellow("⚠")} ${failedCommands.length} setup command(s) failed. ` + - "The snapshot will be incomplete.", + "The snapshot will be incomplete.", ); } } finally { @@ -388,7 +391,9 @@ async function buildSnapshot( ]); } catch (closedError) { console.log( - `${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`, + `${ + yellow("⚠") + } Could not confirm sandbox termination: ${closedError}`, ); console.log( " The sandbox may still be running. Check your dashboard.", @@ -433,8 +438,8 @@ async function buildSnapshot( } else { throw new Error( `Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` + - ` The volume '${volumeSlug}' still exists. You can try manually:\n` + - ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, + ` The volume '${volumeSlug}' still exists. You can try manually:\n` + + ` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`, ); } }