From 428bf0e539c062838d83b2037d5bfad65d848771 Mon Sep 17 00:00:00 2001 From: crowlbot <280062030+crowlbot@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:49:33 +0000 Subject: [PATCH] feat(sandbox): add --json and --non-interactive to the sandbox root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `deno deploy` root exposes global `-j, --json` and `-y, --non-interactive` flags, but the standalone `deno sandbox` root did not, so agents driving `deno sandbox` couldn't get machine-readable output or fail-fast prompt refusal — they'd hit color codes, table output, and (without a token) the OAuth browser flow. - Register `--json` / `--non-interactive` as globalOptions on sandboxCommand and disable ANSI color in JSON mode, mirroring the deploy root. - Emit a single JSON object/array on stdout for the data-producing subcommands: `sandbox list`, `sandbox create` (detached), `extend`, `deploy`, `kill`, and the `volumes`/`snapshots` create/list/delete/ snapshot commands. Progress stays on stderr; stdout is jq-clean. - `--non-interactive` threads through the shared getOrg(), which already refuses prompts and names the `--org` flag to pass. - Tests: assert the sandbox root advertises both flags and that a sandbox command in `--json` mode emits a structured error envelope (never a browser/hang) with a clean stdout. --- sandbox/mod.ts | 75 +++++++++++++++++++++++++++++++++++++-------- sandbox/snapshot.ts | 30 ++++++++++++++++-- sandbox/volumes.ts | 40 +++++++++++++++++++++--- tests/agent.test.ts | 52 +++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 19 deletions(-) diff --git a/sandbox/mod.ts b/sandbox/mod.ts index cf2b8c3..5a27e0e 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -5,7 +5,7 @@ import { type VolumeId, type VolumeSlug, } from "@deno/sandbox"; -import { green, magenta, red, yellow } from "@std/fmt/colors"; +import { green, magenta, red, setColorEnabled, yellow } from "@std/fmt/colors"; import { pooledMap } from "@std/async"; import { expandGlob } from "@std/fs"; import { join } from "@std/path"; @@ -16,6 +16,7 @@ import { parseSize, renderTemporalTimestamp, tablePrinter, + writeJsonResult, } from "../util.ts"; import { createTrpcClient, getAuth, tokenStorage } from "../auth.ts"; import { createSwitchCommand, type GlobalContext } from "../main.ts"; @@ -109,7 +110,9 @@ export const sandboxCreateCommand = new Command() region: options.region as Region, root: options.root, }); - if (options.timeout === "session" || options.ssh) { + if ( + (options.timeout === "session" || options.ssh) && !options.json + ) { console.log(`${green("✔")} Created sandbox with id '${sandbox.id}'`); } @@ -132,7 +135,12 @@ export const sandboxCreateCommand = new Command() if (options.exposeHttp) { const url = await sandbox.exposeHttp({ port: options.exposeHttp }); - console.log(`Exposed port ${options.exposeHttp} to ${url}`); + // 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}`); + } } const args = this.getLiteralArgs().length > 0 @@ -183,7 +191,11 @@ export const sandboxCreateCommand = new Command() Deno.exit(); }); } else { - console.log(sandbox.id); + if (options.json) { + writeJsonResult({ id: sandbox.id }); + } else { + console.log(sandbox.id); + } Deno.exit(); } @@ -204,6 +216,20 @@ export const sandboxListCommand = new Command() cluster_hostname: string; }>; + if (options.json) { + writeJsonResult({ + items: list.map((sandbox) => ({ + id: sandbox.id, + status: sandbox.status, + region: sandbox.cluster_hostname.split(".")[0], + createdAt: sandbox.created_at, + stoppedAt: sandbox.stopped_at, + })), + org, + }); + return; + } + tablePrinter( ["ID", "CREATED", "REGION", "STATUS", "UPTIME"], list, @@ -255,7 +281,9 @@ export const sandboxKillCommand = new Command() clusterHostname: cluster.hostname, }) as { success: boolean }; - if (res.success) { + if (options.json) { + writeJsonResult({ id: sandboxId, killed: res.success }); + } else if (res.success) { console.log(`${green("✔")} Sandbox ${sandboxId} killed successfully.`); } })); @@ -477,9 +505,14 @@ export const sandboxExtendCommand = new Command() .action(actionHandler(async (config, options, sandboxId, timeout) => { config.noCreate(); await using sandbox = await connectToSandbox(options, config, sandboxId); - console.log( - await sandbox.extendTimeout(timeout as `${number}s` | `${number}m`), + const result = await sandbox.extendTimeout( + timeout as `${number}s` | `${number}m`, ); + if (options.json) { + writeJsonResult({ id: sandboxId, timeout: result }); + } else { + console.log(result); + } })); export const sandboxDeployCommand = new Command() @@ -508,11 +541,15 @@ export const sandboxDeployCommand = new Command() }, }); - console.log( - `${ - green("✔") - } Successfully deployed sandbox '${sandboxId}' to app '${app}'.`, - ); + if (options.json) { + writeJsonResult({ id: sandboxId, app, deployed: true }); + } else { + console.log( + `${ + green("✔") + } Successfully deployed sandbox '${sandboxId}' to app '${app}'.`, + ); + } })); function groupPathsBySandbox(paths: string[]): Record { @@ -603,6 +640,14 @@ export const sandboxCommand = new Command() .globalOption("--config ", "Path for the config file") .globalOption("--org ", "The name of the organization") .globalOption("-q, --quiet", "Suppress non-essential output") + .globalOption( + "-j, --json", + "Emit JSON on stdout instead of human-readable output", + ) + .globalOption( + "-y, --non-interactive", + "Fail fast instead of prompting; values must be supplied via flags or env vars (alias: -y)", + ) .globalAction((options) => { const endpoint = Deno.env.get("DENO_DEPLOY_ENDPOINT"); if (endpoint) { @@ -618,6 +663,12 @@ export const sandboxCommand = new Command() tokenStorage.set(tokenEnv, true); } + // `--json` implies machine-readable output: kill ANSI color so structured + // payloads piped to `jq` don't carry escape sequences. + if (options.json) { + setColorEnabled(false); + } + if (options.debug) { console.error( yellow( diff --git a/sandbox/snapshot.ts b/sandbox/snapshot.ts index 8a1dbe3..06acfa6 100644 --- a/sandbox/snapshot.ts +++ b/sandbox/snapshot.ts @@ -2,7 +2,7 @@ import { Command } from "@cliffy/command"; import type { SandboxContext } from "./mod.ts"; import { getAuth } from "../auth.ts"; import { Client } from "@deno/sandbox"; -import { formatSize, tablePrinter } from "../util.ts"; +import { formatSize, tablePrinter, writeJsonResult } from "../util.ts"; import { green } from "@std/fmt/colors"; import { actionHandler, getOrg } from "../config.ts"; @@ -24,7 +24,11 @@ export const snapshotsCreateCommand = new Command() const snapshot = await client.volumes.snapshot(volumeIdOrSlug, { slug: snapshotSlug, }); - console.log(snapshot.id); + if (options.json) { + writeJsonResult({ id: snapshot.id, slug: snapshotSlug }); + } else { + console.log(snapshot.id); + } }), ); @@ -47,6 +51,22 @@ export const snapshotsListCommand = new Command() search, }); + if (options.json) { + writeJsonResult({ + items: list.items.map((snapshot) => ({ + id: snapshot.id, + slug: snapshot.slug, + region: snapshot.region, + allocatedBytes: snapshot.allocatedSize, + flattenedBytes: snapshot.flattenedSize, + bootable: snapshot.isBootable, + volume: snapshot.volume.slug, + })), + org, + }); + return; + } + tablePrinter( ["ID", "SLUG", "REGION", "ALLOCATED", "FLATTENED", "BOOTABLE", "BASE"], list.items, @@ -79,7 +99,11 @@ export const snapshotsDeleteCommand = new Command() }); await client.snapshots.delete(idOrSlug); - console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`); + if (options.json) { + writeJsonResult({ id: idOrSlug, deleted: true }); + } else { + console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`); + } })); export const snapshotsCommand = new Command() diff --git a/sandbox/volumes.ts b/sandbox/volumes.ts index 7a36ded..6abc6f5 100644 --- a/sandbox/volumes.ts +++ b/sandbox/volumes.ts @@ -2,7 +2,12 @@ import { Command } from "@cliffy/command"; import type { SandboxContext } from "./mod.ts"; import { getAuth } from "../auth.ts"; import { Client } from "@deno/sandbox"; -import { formatSize, parseSize, tablePrinter } from "../util.ts"; +import { + formatSize, + parseSize, + tablePrinter, + writeJsonResult, +} from "../util.ts"; import { green } from "@std/fmt/colors"; import { actionHandler, getOrg } from "../config.ts"; @@ -36,7 +41,11 @@ export const volumesCreateCommand = new Command() from: options.from, }); - console.log(volume.id); + if (options.json) { + writeJsonResult({ id: volume.id, slug: name }); + } else { + console.log(volume.id); + } })); export const volumesListCommand = new Command() @@ -59,6 +68,21 @@ export const volumesListCommand = new Command() search, }); + if (options.json) { + writeJsonResult({ + items: list.items.map((volume) => ({ + id: volume.id, + slug: volume.slug, + region: volume.region, + usedBytes: volume.estimatedFlattenedSize, + capacityBytes: volume.capacity, + baseSnapshot: volume.baseSnapshot ? volume.baseSnapshot.slug : null, + })), + org, + }); + return; + } + tablePrinter( ["ID", "SLUG", "REGION", "USED", "TOTAL", "BASE"], list.items, @@ -91,7 +115,11 @@ export const volumesDeleteCommand = new Command() }); await client.volumes.delete(idOrSlug); - console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); + if (options.json) { + writeJsonResult({ id: idOrSlug, deleted: true }); + } else { + console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); + } })); export const volumesSnapshotCommand = new Command() @@ -113,7 +141,11 @@ export const volumesSnapshotCommand = new Command() const snapshot = await client.volumes.snapshot(volumeIdOrSlug, { slug: snapshotSlug, }); - console.log(snapshot.id); + if (options.json) { + writeJsonResult({ id: snapshot.id, slug: snapshotSlug }); + } else { + console.log(snapshot.id); + } }), ); diff --git a/tests/agent.test.ts b/tests/agent.test.ts index 15b9c24..95bc848 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -149,3 +149,55 @@ Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", asyn assert(res.code !== 0); assertStringIncludes(res.stderr + res.stdout, "Invalid source"); }); + +async function sandboxRaw(...args: string[]): Promise< + { code: number; stdout: string; stderr: string } +> { + const escaped = args.map((a) => $.escapeArg(a)).join(" "); + const result = await $.raw`deno sandbox ${escaped}`.noThrow() + .stdout("piped").stderr("piped"); + return { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +Deno.test("sandbox --help advertises --json and --non-interactive", async () => { + // The standalone `deno sandbox` root must expose the same agent flags as + // `deno deploy`, otherwise agents can't drive it non-interactively. + const res = await sandboxRaw("--help"); + assertEquals(res.code, 0, `stderr: ${res.stderr}`); + assertStringIncludes(res.stdout, "--json"); + assertStringIncludes(res.stdout, "--non-interactive"); +}); + +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 + // attempting the OAuth browser flow or blocking on a prompt. + const res = await sandboxRaw( + "--json", + "--token", + "obviously-invalid-token", + "--endpoint", + "http://127.0.0.1:1", + "list", + "--org", + "test", + ); + assert(res.code !== 0, `expected non-zero exit; stderr: ${res.stderr}`); + // The structured envelope is the last line of stderr (tRPC/network preamble + // may precede it). Exact code is auth-vs-network dependent on the endpoint; + // assert the agent-facing contract: a single error object with a string code. + const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!); + assert( + typeof envelope.error?.code === "string", + `expected an error envelope; got: ${JSON.stringify(envelope)}`, + ); + assertEquals( + res.stdout.trim(), + "", + `stdout should stay clean: ${res.stdout}`, + ); +});