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
75 changes: 63 additions & 12 deletions sandbox/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -109,7 +110,9 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
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}'`);
}

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

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
Expand Down Expand Up @@ -183,7 +191,11 @@ export const sandboxCreateCommand = new Command<SandboxContext>()
Deno.exit();
});
} else {
console.log(sandbox.id);
if (options.json) {
writeJsonResult({ id: sandbox.id });
} else {
console.log(sandbox.id);
}

Deno.exit();
}
Expand All @@ -204,6 +216,20 @@ export const sandboxListCommand = new Command<SandboxContext>()
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,
Expand Down Expand Up @@ -255,7 +281,9 @@ export const sandboxKillCommand = new Command<SandboxContext>()
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.`);
}
}));
Expand Down Expand Up @@ -477,9 +505,14 @@ export const sandboxExtendCommand = new Command<SandboxContext>()
.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<SandboxContext>()
Expand Down Expand Up @@ -508,11 +541,15 @@ export const sandboxDeployCommand = new Command<SandboxContext>()
},
});

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<string, string[]> {
Expand Down Expand Up @@ -608,6 +645,14 @@ full reference.`)
.globalOption("--config <config:string>", "Path for the config file")
.globalOption("--org <name:string>", "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) {
Expand All @@ -623,6 +668,12 @@ full reference.`)
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(
Expand Down
30 changes: 27 additions & 3 deletions sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -24,7 +24,11 @@ export const snapshotsCreateCommand = new Command<SandboxContext>()
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);
}
}),
);

Expand All @@ -47,6 +51,22 @@ export const snapshotsListCommand = new Command<SandboxContext>()
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,
Expand Down Expand Up @@ -79,7 +99,11 @@ export const snapshotsDeleteCommand = new Command<SandboxContext>()
});

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<SandboxContext>()
Expand Down
40 changes: 36 additions & 4 deletions sandbox/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -36,7 +41,11 @@ export const volumesCreateCommand = new Command<SandboxContext>()
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<SandboxContext>()
Expand All @@ -59,6 +68,21 @@ export const volumesListCommand = new Command<SandboxContext>()
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,
Expand Down Expand Up @@ -91,7 +115,11 @@ export const volumesDeleteCommand = new Command<SandboxContext>()
});

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<SandboxContext>()
Expand All @@ -113,7 +141,11 @@ export const volumesSnapshotCommand = new Command<SandboxContext>()
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);
}
}),
);

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