Skip to content

Commit 428bf0e

Browse files
committed
feat(sandbox): add --json and --non-interactive to the sandbox root
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.
1 parent f29d034 commit 428bf0e

4 files changed

Lines changed: 178 additions & 19 deletions

File tree

sandbox/mod.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type VolumeId,
66
type VolumeSlug,
77
} from "@deno/sandbox";
8-
import { green, magenta, red, yellow } from "@std/fmt/colors";
8+
import { green, magenta, red, setColorEnabled, yellow } from "@std/fmt/colors";
99
import { pooledMap } from "@std/async";
1010
import { expandGlob } from "@std/fs";
1111
import { join } from "@std/path";
@@ -16,6 +16,7 @@ import {
1616
parseSize,
1717
renderTemporalTimestamp,
1818
tablePrinter,
19+
writeJsonResult,
1920
} from "../util.ts";
2021
import { createTrpcClient, getAuth, tokenStorage } from "../auth.ts";
2122
import { createSwitchCommand, type GlobalContext } from "../main.ts";
@@ -109,7 +110,9 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
109110
region: options.region as Region,
110111
root: options.root,
111112
});
112-
if (options.timeout === "session" || options.ssh) {
113+
if (
114+
(options.timeout === "session" || options.ssh) && !options.json
115+
) {
113116
console.log(`${green("✔")} Created sandbox with id '${sandbox.id}'`);
114117
}
115118

@@ -132,7 +135,12 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
132135

133136
if (options.exposeHttp) {
134137
const url = await sandbox.exposeHttp({ port: options.exposeHttp });
135-
console.log(`Exposed port ${options.exposeHttp} to ${url}`);
138+
// In JSON mode this is progress, not the final result; keep stdout clean.
139+
if (options.json) {
140+
console.error(`Exposed port ${options.exposeHttp} to ${url}`);
141+
} else {
142+
console.log(`Exposed port ${options.exposeHttp} to ${url}`);
143+
}
136144
}
137145

138146
const args = this.getLiteralArgs().length > 0
@@ -183,7 +191,11 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
183191
Deno.exit();
184192
});
185193
} else {
186-
console.log(sandbox.id);
194+
if (options.json) {
195+
writeJsonResult({ id: sandbox.id });
196+
} else {
197+
console.log(sandbox.id);
198+
}
187199

188200
Deno.exit();
189201
}
@@ -204,6 +216,20 @@ export const sandboxListCommand = new Command<SandboxContext>()
204216
cluster_hostname: string;
205217
}>;
206218

219+
if (options.json) {
220+
writeJsonResult({
221+
items: list.map((sandbox) => ({
222+
id: sandbox.id,
223+
status: sandbox.status,
224+
region: sandbox.cluster_hostname.split(".")[0],
225+
createdAt: sandbox.created_at,
226+
stoppedAt: sandbox.stopped_at,
227+
})),
228+
org,
229+
});
230+
return;
231+
}
232+
207233
tablePrinter(
208234
["ID", "CREATED", "REGION", "STATUS", "UPTIME"],
209235
list,
@@ -255,7 +281,9 @@ export const sandboxKillCommand = new Command<SandboxContext>()
255281
clusterHostname: cluster.hostname,
256282
}) as { success: boolean };
257283

258-
if (res.success) {
284+
if (options.json) {
285+
writeJsonResult({ id: sandboxId, killed: res.success });
286+
} else if (res.success) {
259287
console.log(`${green("✔")} Sandbox ${sandboxId} killed successfully.`);
260288
}
261289
}));
@@ -477,9 +505,14 @@ export const sandboxExtendCommand = new Command<SandboxContext>()
477505
.action(actionHandler(async (config, options, sandboxId, timeout) => {
478506
config.noCreate();
479507
await using sandbox = await connectToSandbox(options, config, sandboxId);
480-
console.log(
481-
await sandbox.extendTimeout(timeout as `${number}s` | `${number}m`),
508+
const result = await sandbox.extendTimeout(
509+
timeout as `${number}s` | `${number}m`,
482510
);
511+
if (options.json) {
512+
writeJsonResult({ id: sandboxId, timeout: result });
513+
} else {
514+
console.log(result);
515+
}
483516
}));
484517

485518
export const sandboxDeployCommand = new Command<SandboxContext>()
@@ -508,11 +541,15 @@ export const sandboxDeployCommand = new Command<SandboxContext>()
508541
},
509542
});
510543

511-
console.log(
512-
`${
513-
green("✔")
514-
} Successfully deployed sandbox '${sandboxId}' to app '${app}'.`,
515-
);
544+
if (options.json) {
545+
writeJsonResult({ id: sandboxId, app, deployed: true });
546+
} else {
547+
console.log(
548+
`${
549+
green("✔")
550+
} Successfully deployed sandbox '${sandboxId}' to app '${app}'.`,
551+
);
552+
}
516553
}));
517554

518555
function groupPathsBySandbox(paths: string[]): Record<string, string[]> {
@@ -603,6 +640,14 @@ export const sandboxCommand = new Command<GlobalContext>()
603640
.globalOption("--config <config:string>", "Path for the config file")
604641
.globalOption("--org <name:string>", "The name of the organization")
605642
.globalOption("-q, --quiet", "Suppress non-essential output")
643+
.globalOption(
644+
"-j, --json",
645+
"Emit JSON on stdout instead of human-readable output",
646+
)
647+
.globalOption(
648+
"-y, --non-interactive",
649+
"Fail fast instead of prompting; values must be supplied via flags or env vars (alias: -y)",
650+
)
606651
.globalAction((options) => {
607652
const endpoint = Deno.env.get("DENO_DEPLOY_ENDPOINT");
608653
if (endpoint) {
@@ -618,6 +663,12 @@ export const sandboxCommand = new Command<GlobalContext>()
618663
tokenStorage.set(tokenEnv, true);
619664
}
620665

666+
// `--json` implies machine-readable output: kill ANSI color so structured
667+
// payloads piped to `jq` don't carry escape sequences.
668+
if (options.json) {
669+
setColorEnabled(false);
670+
}
671+
621672
if (options.debug) {
622673
console.error(
623674
yellow(

sandbox/snapshot.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Command } from "@cliffy/command";
22
import type { SandboxContext } from "./mod.ts";
33
import { getAuth } from "../auth.ts";
44
import { Client } from "@deno/sandbox";
5-
import { formatSize, tablePrinter } from "../util.ts";
5+
import { formatSize, tablePrinter, writeJsonResult } from "../util.ts";
66
import { green } from "@std/fmt/colors";
77
import { actionHandler, getOrg } from "../config.ts";
88

@@ -24,7 +24,11 @@ export const snapshotsCreateCommand = new Command<SandboxContext>()
2424
const snapshot = await client.volumes.snapshot(volumeIdOrSlug, {
2525
slug: snapshotSlug,
2626
});
27-
console.log(snapshot.id);
27+
if (options.json) {
28+
writeJsonResult({ id: snapshot.id, slug: snapshotSlug });
29+
} else {
30+
console.log(snapshot.id);
31+
}
2832
}),
2933
);
3034

@@ -47,6 +51,22 @@ export const snapshotsListCommand = new Command<SandboxContext>()
4751
search,
4852
});
4953

54+
if (options.json) {
55+
writeJsonResult({
56+
items: list.items.map((snapshot) => ({
57+
id: snapshot.id,
58+
slug: snapshot.slug,
59+
region: snapshot.region,
60+
allocatedBytes: snapshot.allocatedSize,
61+
flattenedBytes: snapshot.flattenedSize,
62+
bootable: snapshot.isBootable,
63+
volume: snapshot.volume.slug,
64+
})),
65+
org,
66+
});
67+
return;
68+
}
69+
5070
tablePrinter(
5171
["ID", "SLUG", "REGION", "ALLOCATED", "FLATTENED", "BOOTABLE", "BASE"],
5272
list.items,
@@ -79,7 +99,11 @@ export const snapshotsDeleteCommand = new Command<SandboxContext>()
7999
});
80100

81101
await client.snapshots.delete(idOrSlug);
82-
console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`);
102+
if (options.json) {
103+
writeJsonResult({ id: idOrSlug, deleted: true });
104+
} else {
105+
console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`);
106+
}
83107
}));
84108

85109
export const snapshotsCommand = new Command<SandboxContext>()

sandbox/volumes.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { Command } from "@cliffy/command";
22
import type { SandboxContext } from "./mod.ts";
33
import { getAuth } from "../auth.ts";
44
import { Client } from "@deno/sandbox";
5-
import { formatSize, parseSize, tablePrinter } from "../util.ts";
5+
import {
6+
formatSize,
7+
parseSize,
8+
tablePrinter,
9+
writeJsonResult,
10+
} from "../util.ts";
611
import { green } from "@std/fmt/colors";
712
import { actionHandler, getOrg } from "../config.ts";
813

@@ -36,7 +41,11 @@ export const volumesCreateCommand = new Command<SandboxContext>()
3641
from: options.from,
3742
});
3843

39-
console.log(volume.id);
44+
if (options.json) {
45+
writeJsonResult({ id: volume.id, slug: name });
46+
} else {
47+
console.log(volume.id);
48+
}
4049
}));
4150

4251
export const volumesListCommand = new Command<SandboxContext>()
@@ -59,6 +68,21 @@ export const volumesListCommand = new Command<SandboxContext>()
5968
search,
6069
});
6170

71+
if (options.json) {
72+
writeJsonResult({
73+
items: list.items.map((volume) => ({
74+
id: volume.id,
75+
slug: volume.slug,
76+
region: volume.region,
77+
usedBytes: volume.estimatedFlattenedSize,
78+
capacityBytes: volume.capacity,
79+
baseSnapshot: volume.baseSnapshot ? volume.baseSnapshot.slug : null,
80+
})),
81+
org,
82+
});
83+
return;
84+
}
85+
6286
tablePrinter(
6387
["ID", "SLUG", "REGION", "USED", "TOTAL", "BASE"],
6488
list.items,
@@ -91,7 +115,11 @@ export const volumesDeleteCommand = new Command<SandboxContext>()
91115
});
92116

93117
await client.volumes.delete(idOrSlug);
94-
console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`);
118+
if (options.json) {
119+
writeJsonResult({ id: idOrSlug, deleted: true });
120+
} else {
121+
console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`);
122+
}
95123
}));
96124

97125
export const volumesSnapshotCommand = new Command<SandboxContext>()
@@ -113,7 +141,11 @@ export const volumesSnapshotCommand = new Command<SandboxContext>()
113141
const snapshot = await client.volumes.snapshot(volumeIdOrSlug, {
114142
slug: snapshotSlug,
115143
});
116-
console.log(snapshot.id);
144+
if (options.json) {
145+
writeJsonResult({ id: snapshot.id, slug: snapshotSlug });
146+
} else {
147+
console.log(snapshot.id);
148+
}
117149
}),
118150
);
119151

tests/agent.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,55 @@ Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", asyn
149149
assert(res.code !== 0);
150150
assertStringIncludes(res.stderr + res.stdout, "Invalid source");
151151
});
152+
153+
async function sandboxRaw(...args: string[]): Promise<
154+
{ code: number; stdout: string; stderr: string }
155+
> {
156+
const escaped = args.map((a) => $.escapeArg(a)).join(" ");
157+
const result = await $.raw`deno sandbox ${escaped}`.noThrow()
158+
.stdout("piped").stderr("piped");
159+
return {
160+
code: result.code,
161+
stdout: result.stdout,
162+
stderr: result.stderr,
163+
};
164+
}
165+
166+
Deno.test("sandbox --help advertises --json and --non-interactive", async () => {
167+
// The standalone `deno sandbox` root must expose the same agent flags as
168+
// `deno deploy`, otherwise agents can't drive it non-interactively.
169+
const res = await sandboxRaw("--help");
170+
assertEquals(res.code, 0, `stderr: ${res.stderr}`);
171+
assertStringIncludes(res.stdout, "--json");
172+
assertStringIncludes(res.stdout, "--non-interactive");
173+
});
174+
175+
Deno.test("sandbox list --json emits a structured error envelope, never a browser/hang", async () => {
176+
// Bad token + unreachable endpoint: the command must fail fast with a
177+
// machine-parseable envelope on stderr (and a clean stdout) rather than
178+
// attempting the OAuth browser flow or blocking on a prompt.
179+
const res = await sandboxRaw(
180+
"--json",
181+
"--token",
182+
"obviously-invalid-token",
183+
"--endpoint",
184+
"http://127.0.0.1:1",
185+
"list",
186+
"--org",
187+
"test",
188+
);
189+
assert(res.code !== 0, `expected non-zero exit; stderr: ${res.stderr}`);
190+
// The structured envelope is the last line of stderr (tRPC/network preamble
191+
// may precede it). Exact code is auth-vs-network dependent on the endpoint;
192+
// assert the agent-facing contract: a single error object with a string code.
193+
const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!);
194+
assert(
195+
typeof envelope.error?.code === "string",
196+
`expected an error envelope; got: ${JSON.stringify(envelope)}`,
197+
);
198+
assertEquals(
199+
res.stdout.trim(),
200+
"",
201+
`stdout should stay clean: ${res.stdout}`,
202+
);
203+
});

0 commit comments

Comments
 (0)