Skip to content

Commit 15c1773

Browse files
author
John Donmoyer
committed
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
1 parent 391326c commit 15c1773

1 file changed

Lines changed: 144 additions & 177 deletions

File tree

sandbox/quickstart.ts

Lines changed: 144 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ function promptCustomSelection(): {
173173
// We use a Set for packages so duplicates are removed automatically
174174
// (e.g. picking both "Python" and "NumPy" won't install python3 twice).
175175
const allPackages = new Set<string>();
176-
const allSetupCommands: string[] = [];
176+
const allSetupCommands = new Set<string>();
177177

178178
for (const category of CUSTOM_CATEGORIES) {
179179
const choices = category.items.map((item) => ({
@@ -194,20 +194,18 @@ function promptCustomSelection(): {
194194
allPackages.add(pkg);
195195
}
196196
for (const cmd of entry.value.setupCommands) {
197-
if (!allSetupCommands.includes(cmd)) {
198-
allSetupCommands.push(cmd);
199-
}
197+
allSetupCommands.add(cmd);
200198
}
201199
}
202200
}
203201

204-
if (allPackages.size === 0 && allSetupCommands.length === 0) {
202+
if (allPackages.size === 0 && allSetupCommands.size === 0) {
205203
return null;
206204
}
207205

208206
return {
209207
packages: [...allPackages],
210-
setupCommands: allSetupCommands,
208+
setupCommands: [...allSetupCommands],
211209
};
212210
}
213211

@@ -223,19 +221,14 @@ function promptRegion(): Region | null {
223221
}
224222

225223
function promptSnapshotName(): string | null {
226-
const name = prompt(
227-
"Enter a name for this snapshot:",
228-
`quickstart-${Date.now()}`,
229-
);
230-
return name;
224+
return prompt("Enter a name for this snapshot:", `quickstart-${Date.now()}`);
231225
}
232226

233227
// --- Build Logic ---
234228
// This is the core of the feature. It creates a temporary volume,
235229
// boots a sandbox, installs everything, then snapshots the result.
236230

237231
async function buildSnapshot(
238-
context: SandboxContext,
239232
client: Client,
240233
options: {
241234
packages: string[];
@@ -257,7 +250,18 @@ async function buildSnapshot(
257250

258251
const spinner = new Spinner({ color: "yellow" });
259252

260-
const totalSteps = 2 + options.packages.length + options.setupCommands.length;
253+
// Runs a shell command inside the sandbox and returns whether it succeeded
254+
async function runInSandbox(sandbox: Sandbox, command: string): Promise<boolean> {
255+
const child = await sandbox.spawn("bash", {
256+
args: ["-c", command],
257+
stdout: out,
258+
stderr: out,
259+
});
260+
const status = await child.status;
261+
return status.success;
262+
}
263+
264+
const totalSteps = 1 + options.packages.length + options.setupCommands.length;
261265
let currentStep = 0;
262266
const step = (label: string) => {
263267
currentStep++;
@@ -276,188 +280,151 @@ async function buildSnapshot(
276280
spinner.stop();
277281
console.log(`${green("✔")} Volume created`);
278282

279-
let snapshotCreated = false;
283+
// Boot a sandbox using this volume as its root filesystem.
284+
// The sandbox is short-lived (10m timeout) — just long enough to install.
285+
spinner.message = "Booting sandbox...";
286+
spinner.start();
287+
const sandbox = await Sandbox.create({
288+
token: options.token,
289+
org: options.org,
290+
timeout: "10m",
291+
region: options.region,
292+
root: volume.id,
293+
});
294+
spinner.stop();
295+
console.log(`${green("✔")} Sandbox booted`);
296+
297+
console.log();
298+
const pkgCount = options.packages.length;
299+
const cmdCount = options.setupCommands.length;
300+
let summary = `Installing ${pkgCount} package${pkgCount === 1 ? "" : "s"}`;
301+
if (cmdCount > 0) {
302+
summary += ` + ${cmdCount} setup command${cmdCount === 1 ? "" : "s"}`;
303+
}
304+
console.log(summary);
305+
console.log();
280306

281307
try {
282-
// Step 2: Boot a sandbox using this volume as its root filesystem.
283-
// The sandbox is short-lived (10m timeout) — just long enough to install.
284-
spinner.message = "Booting sandbox...";
308+
spinner.message = step("Updating package lists...");
285309
spinner.start();
286-
const sandbox = await Sandbox.create({
287-
token: options.token,
288-
org: options.org,
289-
timeout: "10m",
290-
region: options.region,
291-
root: volume.id,
292-
});
310+
const updateOk = await runInSandbox(sandbox, "sudo apt update");
293311
spinner.stop();
294-
console.log(`${green("✔")} Sandbox booted`);
295-
296-
console.log();
297-
console.log(
298-
`Installing ${options.packages.length} package${
299-
options.packages.length === 1 ? "" : "s"
300-
}` +
301-
(options.setupCommands.length > 0
302-
? ` + ${options.setupCommands.length} setup command${
303-
options.setupCommands.length === 1 ? "" : "s"
304-
}`
305-
: ""),
306-
);
307-
console.log();
312+
if (!updateOk) {
313+
throw new Error("Failed to update package lists");
314+
}
315+
console.log(`${green("✔")} Package lists updated`);
308316

309-
try {
310-
// Step 3: Update the package list so apt knows what's available
311-
spinner.message = step("Updating package lists...");
317+
// DEBIAN_FRONTEND=noninteractive prevents apt from asking questions
318+
for (const pkg of options.packages) {
319+
spinner.message = step(`Installing ${pkg}...`);
312320
spinner.start();
313-
const updateChild = await sandbox.spawn("bash", {
314-
args: ["-c", "sudo apt update"],
315-
stdout: out,
316-
stderr: out,
317-
});
318-
const updateStatus = await updateChild.status;
321+
const installOk = await runInSandbox(
322+
sandbox,
323+
`sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`,
324+
);
319325
spinner.stop();
320-
if (!updateStatus.success) {
321-
throw new Error("Failed to update package lists");
322-
}
323-
console.log(`${green("✔")} Package lists updated`);
324-
325-
// Step 4: Install each apt package individually so we can show
326-
// per-package progress. DEBIAN_FRONTEND=noninteractive prevents
327-
// apt from asking questions.
328-
for (let i = 0; i < options.packages.length; i++) {
329-
const pkg = options.packages[i];
330-
spinner.message = step(`Installing ${pkg}...`);
331-
spinner.start();
332-
const installCmd =
333-
`sudo DEBIAN_FRONTEND=noninteractive apt install -y ${pkg}`;
334-
const installChild = await sandbox.spawn("bash", {
335-
args: ["-c", installCmd],
336-
stdout: out,
337-
stderr: out,
338-
});
339-
const installStatus = await installChild.status;
340-
spinner.stop();
341-
if (!installStatus.success) {
342-
throw new Error(`Failed to install ${pkg}`);
343-
}
344-
console.log(`${green("✔")} Installed ${pkg}`);
326+
if (!installOk) {
327+
throw new Error(`Failed to install ${pkg}`);
345328
}
329+
console.log(`${green("✔")} Installed ${pkg}`);
330+
}
346331

347-
// Step 5: Run any extra setup commands (like pip installs).
348-
// These are optional — if one fails we warn but keep going.
349-
for (const cmd of options.setupCommands) {
350-
spinner.message = step(`Running: ${cmd}`);
351-
spinner.start();
352-
const setupChild = await sandbox.spawn("bash", {
353-
args: ["-c", cmd],
354-
stdout: out,
355-
stderr: out,
356-
});
357-
const setupStatus = await setupChild.status;
358-
spinner.stop();
359-
if (!setupStatus.success) {
360-
console.log(`${yellow("⚠")} Setup command failed: ${cmd}`);
361-
} else {
362-
console.log(`${green("✔")} ${cmd}`);
363-
}
364-
}
365-
} finally {
366-
// We must kill() the sandbox, not just close().
367-
// close() only disconnects the WebSocket — the sandbox keeps
368-
// running on the server with the volume still mounted.
369-
// kill() sends a DELETE to the server which actually terminates
370-
// the sandbox and releases the volume.
371-
spinner.message = "Stopping sandbox and detaching volume...";
332+
// Setup commands are optional — if one fails we warn but keep going
333+
for (const cmd of options.setupCommands) {
334+
spinner.message = step(`Running: ${cmd}`);
372335
spinner.start();
336+
const setupOk = await runInSandbox(sandbox, cmd);
337+
spinner.stop();
338+
if (!setupOk) {
339+
console.log(`${yellow("⚠")} Setup command failed: ${cmd}`);
340+
} else {
341+
console.log(`${green("✔")} ${cmd}`);
342+
}
343+
}
344+
} finally {
345+
// We use kill() instead of close() because close() only disconnects
346+
// the client while the sandbox continues running server-side with
347+
// the volume mounted. kill() terminates the sandbox on the server,
348+
// which is required to release the volume for snapshotting.
349+
spinner.message = "Stopping sandbox and detaching volume...";
350+
spinner.start();
351+
try {
352+
await sandbox.kill();
353+
} catch (killError) {
354+
if (options.verbose) {
355+
console.log(`${yellow("⚠")} sandbox.kill() failed: ${killError}`);
356+
}
373357
try {
374-
await sandbox.kill();
375-
} catch (killError) {
376-
// kill() may time out (10s limit), but the server is still
377-
// processing the termination. Wait for the WebSocket to
378-
// confirm the sandbox is gone.
379-
if (options.verbose) {
380-
console.log(`${yellow("⚠")} sandbox.kill() failed: ${killError}`);
381-
}
382-
try {
383-
await Promise.race([
384-
sandbox.closed,
385-
new Promise((_, reject) =>
386-
setTimeout(() => reject(new Error("timed out")), 30_000)
387-
),
388-
]);
389-
} catch (closedError) {
390-
console.log(
391-
`${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`,
392-
);
393-
console.log(
394-
" The sandbox may still be running. Check your dashboard.",
395-
);
396-
}
358+
await Promise.race([
359+
sandbox.closed,
360+
new Promise((_, reject) =>
361+
setTimeout(() => reject(new Error("timed out")), 30_000)
362+
),
363+
]);
364+
} catch (closedError) {
365+
console.log(
366+
`${yellow("⚠")} Could not confirm sandbox termination: ${closedError}`,
367+
);
368+
console.log(
369+
" The sandbox may still be running. Check your dashboard.",
370+
);
397371
}
398-
// Brief pause to let the volume fully detach after sandbox termination
399-
await new Promise((resolve) => setTimeout(resolve, 5_000));
400-
spinner.stop();
401-
console.log(`${green("✔")} Sandbox stopped`);
402372
}
373+
// Brief pause to let the volume fully detach after sandbox termination
374+
await new Promise((resolve) => setTimeout(resolve, 5_000));
375+
spinner.stop();
376+
console.log(`${green("✔")} Sandbox stopped`);
377+
}
403378

404-
// Step 6: Snapshot the volume to create a reusable image.
405-
// The volume may not be fully detached from the sandbox yet,
406-
// so we retry a few times with increasing delays.
407-
const maxAttempts = 3;
408-
const retryDelays = [10_000, 15_000, 15_000];
379+
// Snapshot the volume to create a reusable image.
380+
// The volume may not be fully detached yet, so we retry a few times.
381+
const maxAttempts = 3;
382+
const retryDelays = [10_000, 15_000, 15_000];
409383

410-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
411-
spinner.message = attempt === 1
412-
? "Creating snapshot..."
413-
: `Creating snapshot (attempt ${attempt}/${maxAttempts})...`;
414-
spinner.start();
415-
try {
416-
await client.volumes.snapshot(volume.id, {
417-
slug: options.snapshotSlug,
418-
});
419-
spinner.stop();
420-
console.log(`${green("✔")} Snapshot created`);
421-
snapshotCreated = true;
422-
break;
423-
} catch (e) {
424-
spinner.stop();
425-
if (attempt < maxAttempts) {
426-
const delaySec = retryDelays[attempt - 1] / 1000;
427-
console.log(
428-
`${
429-
yellow("⚠")
430-
} Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`,
431-
);
432-
await new Promise((resolve) =>
433-
setTimeout(resolve, retryDelays[attempt - 1])
434-
);
435-
} else {
436-
throw new Error(
437-
`Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` +
438-
` The volume '${volumeSlug}' still exists. You can try manually:\n` +
439-
` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`,
440-
);
441-
}
384+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
385+
spinner.message = attempt === 1
386+
? "Creating snapshot..."
387+
: `Creating snapshot (attempt ${attempt}/${maxAttempts})...`;
388+
spinner.start();
389+
try {
390+
await client.volumes.snapshot(volume.id, {
391+
slug: options.snapshotSlug,
392+
});
393+
spinner.stop();
394+
console.log(`${green("✔")} Snapshot created`);
395+
break;
396+
} catch (e) {
397+
spinner.stop();
398+
if (attempt < maxAttempts) {
399+
const delaySec = retryDelays[attempt - 1] / 1000;
400+
console.log(
401+
`${
402+
yellow("⚠")
403+
} Snapshot attempt ${attempt} failed, retrying in ${delaySec}s...`,
404+
);
405+
await new Promise((resolve) =>
406+
setTimeout(resolve, retryDelays[attempt - 1])
407+
);
408+
} else {
409+
throw new Error(
410+
`Snapshot creation failed after ${maxAttempts} attempts: ${e}\n` +
411+
` The volume '${volumeSlug}' still exists. You can try manually:\n` +
412+
` deno sandbox volumes snapshot ${volumeSlug} ${options.snapshotSlug}`,
413+
);
442414
}
443415
}
444-
} finally {
445-
// The volume is kept because the snapshot depends on it.
446-
// It cannot be deleted while the snapshot exists.
447416
}
448417

449-
if (snapshotCreated) {
450-
console.log();
451-
console.log(
452-
`${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`,
453-
);
454-
console.log();
455-
console.log("To create a sandbox with this snapshot:");
456-
console.log(` deno sandbox create --root ${options.snapshotSlug}`);
457-
console.log();
458-
console.log("To create a sandbox and SSH into it:");
459-
console.log(` deno sandbox create --root ${options.snapshotSlug} --ssh`);
460-
}
418+
console.log();
419+
console.log(
420+
`${green("✔")} Snapshot '${options.snapshotSlug}' is ready to use.`,
421+
);
422+
console.log();
423+
console.log("To create a sandbox with this snapshot:");
424+
console.log(` deno sandbox create --root ${options.snapshotSlug}`);
425+
console.log();
426+
console.log("To create a sandbox and SSH into it:");
427+
console.log(` deno sandbox create --root ${options.snapshotSlug} --ssh`);
461428
}
462429

463430
// --- The Command ---
@@ -559,7 +526,7 @@ export const quickstartCommand = new Command<SandboxContext>()
559526
snapshotSlug = name;
560527
}
561528

562-
await buildSnapshot(options, client, {
529+
await buildSnapshot(client, {
563530
packages,
564531
setupCommands,
565532
region,

0 commit comments

Comments
 (0)