diff --git a/config.ts b/config.ts index b885639..0581f69 100644 --- a/config.ts +++ b/config.ts @@ -58,7 +58,7 @@ export async function getOrg( } org = selectedOrg.value.slug; - console.log(`Selected organization '${selectedOrg.value.name}'`); + console.error(`Selected organization '${selectedOrg.value.name}'`); } } @@ -144,7 +144,7 @@ export async function getApp( created = true; } else { app = selectedApp.value.slug; - console.log(`Selected application '${selectedApp.value.slug}'`); + console.error(`Selected application '${selectedApp.value.slug}'`); } } @@ -314,7 +314,7 @@ async function writeConfig( ); if (!configContent.config) { - console.log( + console.error( `Created configuration file at '${join(Deno.cwd(), "deno.jsonc")}'`, ); } diff --git a/sandbox/mod.ts b/sandbox/mod.ts index f76c3da..e8e9f36 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -12,7 +12,10 @@ import { join } from "@std/path"; import { Spinner } from "@std/cli/unstable-spinner"; import { + error, + ExitCode, formatDuration, + isNonInteractive, parseSize, renderTemporalTimestamp, tablePrinter, @@ -91,6 +94,26 @@ export const sandboxCreateCommand = new Command() config.noCreate(); const org = await getOrg(options, config, options.org); + // A "session" timeout (the default) keeps the sandbox alive only for as long + // as this process — the *primary* client — stays connected, blocking on + // SIGINT. That is an inherently interactive construct: in non-interactive / + // CI mode the process must return promptly, and doing so would immediately + // destroy a session-scoped sandbox. Reject it up front (before creating an + // orphan) and steer the caller to an explicit, self-sufficient timeout. + const nonInteractive = isNonInteractive(options); + if (nonInteractive && options.timeout === "session") { + error( + options, + "Cannot create a sandbox with the default 'session' timeout in non-interactive mode: a session-scoped sandbox is destroyed as soon as this command exits.", + { + code: ExitCode.USAGE, + errorCode: "NON_INTERACTIVE_REQUIRED", + hint: + "Pass an explicit --timeout (e.g. --timeout 15m) so the sandbox outlives this command, then manage it with `sandbox kill`.", + }, + ); + } + const quiet = options.timeout === "session"; const token = await getAuth(options, quiet); @@ -113,7 +136,7 @@ export const sandboxCreateCommand = new Command() if ( (options.timeout === "session" || options.ssh) && !options.json ) { - console.log(`${green("✔")} Created sandbox with id '${sandbox.id}'`); + console.error(`${green("✔")} Created sandbox with id '${sandbox.id}'`); } if (options.copy) { @@ -135,12 +158,8 @@ export const sandboxCreateCommand = new Command() if (options.exposeHttp) { const url = await sandbox.exposeHttp({ port: options.exposeHttp }); - // In JSON mode this is progress, not the final result; keep stdout clean. - if (options.json) { - console.error(`Exposed port ${options.exposeHttp} to ${url}`); - } else { - console.log(`Exposed port ${options.exposeHttp} to ${url}`); - } + // Progress/status, not the command's data payload — keep stdout clean. + console.error(`Exposed port ${options.exposeHttp} to ${url}`); } const args = this.getLiteralArgs().length > 0 @@ -167,36 +186,45 @@ export const sandboxCreateCommand = new Command() await config.save(); const stopMessage = "Stopping the sandbox..."; + + // Status chrome belongs on stderr so `--json` stdout stays a single payload. + const installKeepAlive = () => { + console.error("\nCtrl+C to stop the sandbox."); + Deno.addSignalListener("SIGINT", async () => { + console.error("\n" + stopMessage); + await sandbox.close(); + Deno.exit(); + }); + }; + + const emitResult = () => { + if (options.json) { + writeJsonResult({ id: sandbox.id, org, timeout: options.timeout }); + } else { + console.log(sandbox.id); + } + }; + if (options.ssh) { const success = await sshIntoSandbox(sandbox); if (success) { // Closes the sandbox only when ssh session was established and finished successfully - console.log("Disconnecting from the sandbox..."); + console.error("Disconnecting from the sandbox..."); await sandbox.close(); + } else if (nonInteractive) { + // No TTY to attach an ssh session to: return the sandbox info and let + // its (explicit) timeout govern its lifetime instead of blocking. + emitResult(); + Deno.exit(); } else { // Otherwise, keep the sandbox running and wait for Ctrl+C - console.log("\nCtrl+C to stop the sandbox."); - Deno.addSignalListener("SIGINT", async () => { - console.log("\n" + stopMessage); - await sandbox.close(); - Deno.exit(); - }); + installKeepAlive(); } } else if (options.timeout === "session") { - // Otherwise, keep the sandbox running and wait for Ctrl+C - console.log("\nCtrl+C to stop the sandbox."); - Deno.addSignalListener("SIGINT", async () => { - console.log("\n" + stopMessage); - await sandbox.close(); - Deno.exit(); - }); + // Interactive only — non-interactive session timeouts are rejected above. + installKeepAlive(); } else { - if (options.json) { - writeJsonResult({ id: sandbox.id }); - } else { - console.log(sandbox.id); - } - + emitResult(); Deno.exit(); } })); @@ -284,7 +312,7 @@ export const sandboxKillCommand = new Command() if (options.json) { writeJsonResult({ id: sandboxId, killed: res.success }); } else if (res.success) { - console.log(`${green("✔")} Sandbox ${sandboxId} killed successfully.`); + console.error(`${green("✔")} Sandbox ${sandboxId} killed successfully.`); } })); @@ -544,7 +572,7 @@ export const sandboxDeployCommand = new Command() if (options.json) { writeJsonResult({ id: sandboxId, app, deployed: true }); } else { - console.log( + console.error( `${ green("✔") } Successfully deployed sandbox '${sandboxId}' to app '${app}'.`, @@ -602,7 +630,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise { stderr: "null", }).output(); if (which.success) { - console.log(`ssh ${connectInfo}`); + console.error(`ssh ${connectInfo}`); const command = new Deno.Command("ssh", { args: [connectInfo], stdin: "inherit", @@ -614,7 +642,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise { await sandbox.close(); return true; } else { - console.log( + console.error( `Started ssh session. You can now connect to ${magenta(connectInfo)} Example: diff --git a/sandbox/snapshot.ts b/sandbox/snapshot.ts index 06acfa6..2dd55ae 100644 --- a/sandbox/snapshot.ts +++ b/sandbox/snapshot.ts @@ -102,7 +102,9 @@ export const snapshotsDeleteCommand = new Command() if (options.json) { writeJsonResult({ id: idOrSlug, deleted: true }); } else { - console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`); + console.error( + `${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`, + ); } })); diff --git a/sandbox/volumes.ts b/sandbox/volumes.ts index 6abc6f5..3207004 100644 --- a/sandbox/volumes.ts +++ b/sandbox/volumes.ts @@ -118,7 +118,7 @@ export const volumesDeleteCommand = new Command() if (options.json) { writeJsonResult({ id: idOrSlug, deleted: true }); } else { - console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); + console.error(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); } })); diff --git a/tests/agent.test.ts b/tests/agent.test.ts index 64b3db8..a44b4cc 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -197,6 +197,33 @@ Deno.test("sandbox --help advertises --json and --non-interactive", async () => assertStringIncludes(res.stdout, "--non-interactive"); }); +Deno.test("sandbox create --json --non-interactive with default session timeout fails fast (no hang)", async () => { + // Regression for the create hang: the default `timeout=session` installs an + // interactive SIGINT keep-alive. In non-interactive mode that blocked forever + // instead of emitting JSON or exiting. It must now refuse up front with a + // USAGE envelope (exit 2) and a clean stdout, before any backend round-trip. + const res = await sandboxRaw( + "--json", + "--non-interactive", + "--token", + "obviously-invalid-token", + "--endpoint", + "http://127.0.0.1:1", + "create", + "--org", + "test", + ); + assertEquals(res.code, 2, `unexpected exit; stderr: ${res.stderr}`); + assertEquals( + res.stdout.trim(), + "", + `stdout should stay clean: ${res.stdout}`, + ); + const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!); + assertEquals(envelope.error.code, "NON_INTERACTIVE_REQUIRED"); + assertStringIncludes(envelope.error.hint, "--timeout"); +}); + Deno.test("sandbox list --json emits a structured error envelope, never a browser/hang", async () => { // Bad token + unreachable endpoint: the command must fail fast with a // machine-parseable envelope on stderr (and a clean stdout) rather than