@@ -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
225223function 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
237231async 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