diff --git a/sandbox/mod.ts b/sandbox/mod.ts index 289ed61..9be8027 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 & { @@ -626,4 +627,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..8ad1215 --- /dev/null +++ b/sandbox/quickstart.ts @@ -0,0 +1,569 @@ +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: [ + "sudo 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: ["sudo pip3 install --break-system-packages numpy"], + }, + { + label: "Pandas", + packages: ["python3", "python3-pip"], + setupCommands: ["sudo pip3 install --break-system-packages pandas"], + }, + { + label: "Matplotlib", + packages: ["python3", "python3-pip"], + setupCommands: ["sudo pip3 install --break-system-packages matplotlib"], + }, + { + label: "SciPy", + packages: ["python3", "python3-pip"], + setupCommands: ["sudo 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 = new Set(); + + 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) { + allSetupCommands.add(cmd); + } + } + } + + if (allPackages.size === 0 && allSetupCommands.size === 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 { + return prompt("Enter a name for this snapshot:", `quickstart-${Date.now()}`); +} + +// --- Build Logic --- +// 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, + options: { + packages: string[]; + setupCommands: string[]; + region: Region; + snapshotSlug: string; + capacity: number; + token: string; + org: string; + verbose: boolean; + }, +): Promise { + // 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. + // 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" }); + + // 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++; + return `[${currentStep}/${totalSteps}] ${label}`; + }; + + spinner.message = "Creating volume..."; + spinner.start(); + 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`); + + // 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(); + 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`); + + 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 { + spinner.message = step("Updating package lists..."); + spinner.start(); + const updateOk = await runInSandbox(sandbox, "sudo apt update"); + spinner.stop(); + if (!updateOk) { + throw new Error("Failed to update package lists"); + } + console.log(`${green("✔")} Package lists updated`); + + // DEBIAN_FRONTEND=noninteractive prevents apt from asking questions + for (const pkg of options.packages) { + spinner.message = step(`Installing ${pkg}...`); + spinner.start(); + const installOk = await runInSandbox( + sandbox, + `sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`, + ); + spinner.stop(); + if (!installOk) { + throw new Error(`Failed to install ${pkg}`); + } + console.log(`${green("✔")} Installed ${pkg}`); + } + + // 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(); + const setupOk = await runInSandbox(sandbox, cmd); + 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 + // 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 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`); + } + + // 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`); + 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}`, + ); + } + } + } + + 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 --- + +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" }) + .option("--verbose", "Show full command output") + .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(client, { + packages, + setupCommands, + region, + snapshotSlug, + capacity: Math.floor(parseSize(options, options.capacity)), + token, + org, + verbose: options.verbose ?? false, + }); + }));