Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`);
}
}

Expand Down Expand Up @@ -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}'`);
}
}

Expand Down Expand Up @@ -314,7 +314,7 @@ async function writeConfig(
);

if (!configContent.config) {
console.log(
console.error(
`Created configuration file at '${join(Deno.cwd(), "deno.jsonc")}'`,
);
}
Expand Down
90 changes: 59 additions & 31 deletions sandbox/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { join } from "@std/path";
import { Spinner } from "@std/cli/unstable-spinner";

import {
error,
ExitCode,
formatDuration,
isNonInteractive,
parseSize,
renderTemporalTimestamp,
tablePrinter,
Expand Down Expand Up @@ -91,6 +94,26 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
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);

Expand All @@ -113,7 +136,7 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
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) {
Expand All @@ -135,12 +158,8 @@ export const sandboxCreateCommand = new Command<SandboxContext>()

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
Expand All @@ -167,36 +186,45 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
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();
}
}));
Expand Down Expand Up @@ -284,7 +312,7 @@ export const sandboxKillCommand = new Command<SandboxContext>()
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.`);
}
}));

Expand Down Expand Up @@ -544,7 +572,7 @@ export const sandboxDeployCommand = new Command<SandboxContext>()
if (options.json) {
writeJsonResult({ id: sandboxId, app, deployed: true });
} else {
console.log(
console.error(
`${
green("✔")
} Successfully deployed sandbox '${sandboxId}' to app '${app}'.`,
Expand Down Expand Up @@ -602,7 +630,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise<boolean> {
stderr: "null",
}).output();
if (which.success) {
console.log(`ssh ${connectInfo}`);
console.error(`ssh ${connectInfo}`);
const command = new Deno.Command("ssh", {
args: [connectInfo],
stdin: "inherit",
Expand All @@ -614,7 +642,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise<boolean> {
await sandbox.close();
return true;
} else {
console.log(
console.error(
`Started ssh session. You can now connect to ${magenta(connectInfo)}

Example:
Expand Down
4 changes: 3 additions & 1 deletion sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export const snapshotsDeleteCommand = new Command<SandboxContext>()
if (options.json) {
writeJsonResult({ id: idOrSlug, deleted: true });
} else {
console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`);
console.error(
`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`,
);
}
}));

Expand Down
2 changes: 1 addition & 1 deletion sandbox/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const volumesDeleteCommand = new Command<SandboxContext>()
if (options.json) {
writeJsonResult({ id: idOrSlug, deleted: true });
} else {
console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`);
console.error(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`);
}
}));

Expand Down
27 changes: 27 additions & 0 deletions tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading