Skip to content

Commit e520178

Browse files
crowlbotclaude
andcommitted
feat: add --json, --non-interactive flags and exit-code taxonomy
Make the CLI usable by AI agents and CI bots: - Global `--json` flag emits a single JSON object/array on stdout and a structured `{ error: { code, message, hint, traceId } }` envelope on stderr. Suppresses spinners, progress bars, and ANSI color so output pipes cleanly into `jq`. Wired into `publish` (final result with revisionId, URL, status, timelines) and `create` (including `--dry-run`, which now emits the resolved build config as JSON). - Global `-y, --non-interactive, --yes` flag makes `requireInteractive()` fail fast with a clear `NON_INTERACTIVE_REQUIRED` error instead of hanging on stdin, even on a TTY. Same flag on both `deploy` and `sandbox` roots. - `ExitCode` enum (OK=0, GENERIC=1, USAGE=2, AUTH=3, NOT_FOUND=4, CONFLICT=5, NETWORK=6). `error()` accepts `{ code, errorCode, hint, response }` and exits with the matching numeric code. tRPC errors are mapped via `mapTrpcError()`: 401/403/NOT_AUTHENTICATED/TOKEN_EXPIRED → AUTH, 404 → NOT_FOUND, 409 → CONFLICT, 5xx → NETWORK. Invalid-token path emits `AUTH_INVALID_TOKEN` with an explicit hint pointing at `DENO_DEPLOY_TOKEN` rather than retrying through a browser. - Move the keychain-unavailable warning from stdout to stderr so it doesn't pollute machine-readable output. Adds `tests/agent.test.ts` covering JSON dry-run, non-interactive short-circuit, `AUTH_INVALID_TOKEN` envelope, and the `-y` alias. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34794b5 commit e520178

7 files changed

Lines changed: 403 additions & 45 deletions

File tree

auth.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,40 @@ import {
1515
} from "@trpc/client";
1616
import { observable } from "@trpc/server/observable";
1717
import { Spinner } from "@std/cli/unstable-spinner";
18-
import { error, requireInteractive } from "./util.ts";
18+
import { error, ExitCode, requireInteractive } from "./util.ts";
1919
import { EventSourcePolyfill } from "event-source-polyfill";
2020
import type { GlobalContext } from "./main.ts";
2121

22+
/** Map a tRPC error envelope (with `data.httpStatus` / `data.code`) to our taxonomy. */
23+
function mapTrpcError(
24+
err: unknown,
25+
): { code: ExitCode; errorCode: string; hint?: string } {
26+
// deno-lint-ignore no-explicit-any
27+
const data = (err as any)?.data;
28+
const httpStatus: number | undefined = data?.httpStatus;
29+
const backendCode: string | undefined = data?.code;
30+
if (
31+
backendCode === "NOT_AUTHENTICATED" || backendCode === "TOKEN_EXPIRED" ||
32+
httpStatus === 401 || httpStatus === 403
33+
) {
34+
return {
35+
code: ExitCode.AUTH,
36+
errorCode: backendCode ?? "AUTH",
37+
hint: "Set DENO_DEPLOY_TOKEN or run `deno deploy login`.",
38+
};
39+
}
40+
if (httpStatus === 404) {
41+
return { code: ExitCode.NOT_FOUND, errorCode: backendCode ?? "NOT_FOUND" };
42+
}
43+
if (httpStatus === 409) {
44+
return { code: ExitCode.CONFLICT, errorCode: backendCode ?? "CONFLICT" };
45+
}
46+
if (httpStatus !== undefined && httpStatus >= 500) {
47+
return { code: ExitCode.NETWORK, errorCode: backendCode ?? "BACKEND" };
48+
}
49+
return { code: ExitCode.GENERIC, errorCode: backendCode ?? "GENERIC" };
50+
}
51+
2252
// deno-lint-ignore no-explicit-any
2353
export type TRPCClient = TRPCUntypedClient<any>;
2454

@@ -40,11 +70,13 @@ export function createTrpcClient(
4070
if (context.debug) {
4171
console.error(err);
4272
}
43-
error(
44-
context,
45-
err.message || Deno.inspect(err),
46-
err.meta?.response as Response | undefined,
47-
);
73+
const mapped = mapTrpcError(err);
74+
error(context, err.message || Deno.inspect(err), {
75+
code: mapped.code,
76+
errorCode: mapped.errorCode,
77+
hint: mapped.hint,
78+
response: err.meta?.response as Response | undefined,
79+
});
4880
},
4981
complete() {
5082
observer.complete();
@@ -85,7 +117,13 @@ export function createTrpcClient(
85117
if (tokenIsTemp) {
86118
error(
87119
context,
88-
"The token specified via 'DENO_DEPLOY_TOKEN' or the '--token' flag is invalid.",
120+
"The token specified via 'DENO_DEPLOY_TOKEN' or the '--token' flag is invalid or expired.",
121+
{
122+
code: ExitCode.AUTH,
123+
errorCode: "AUTH_INVALID_TOKEN",
124+
hint:
125+
`Generate a new token at ${context.endpoint}/account/tokens and re-export DENO_DEPLOY_TOKEN.`,
126+
},
89127
);
90128
}
91129

@@ -94,6 +132,10 @@ export function createTrpcClient(
94132
error(
95133
context,
96134
"Already re-attempted authorization, please re-run this command",
135+
{
136+
code: ExitCode.AUTH,
137+
errorCode: "AUTH_RETRY_EXHAUSTED",
138+
},
97139
);
98140
}
99141

@@ -362,7 +404,7 @@ export const tokenStorage = {
362404
} catch {
363405
if (!cannotInteractWithKeychain) {
364406
cannotInteractWithKeychain = true;
365-
console.log(KEYCHAIN_WARNING);
407+
console.error(KEYCHAIN_WARNING);
366408
}
367409
return null;
368410
}
@@ -377,7 +419,7 @@ export const tokenStorage = {
377419
} catch {
378420
if (!cannotInteractWithKeychain) {
379421
cannotInteractWithKeychain = true;
380-
console.log(KEYCHAIN_WARNING);
422+
console.error(KEYCHAIN_WARNING);
381423
}
382424
}
383425
} else {

deploy/create/mod.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020

2121
import { publish, waitForRevision } from "../publish.ts";
2222
import { resolve } from "@std/path";
23-
import { error } from "../../util.ts";
23+
import { error, writeJsonResult } from "../../util.ts";
2424
import { green } from "@std/fmt/colors";
2525

2626
export const createCommand = new Command<GlobalContext>()
@@ -296,8 +296,10 @@ export const createCommand = new Command<GlobalContext>()
296296

297297
const region = required(options.region, "region");
298298

299-
console.log("Using the following build configuration:");
300-
console.log(renderBuildConfig(buildConfig satisfies BuildConfig));
299+
if (!options.json) {
300+
console.log("Using the following build configuration:");
301+
console.log(renderBuildConfig(buildConfig satisfies BuildConfig));
302+
}
301303

302304
data = {
303305
org,
@@ -312,7 +314,21 @@ export const createCommand = new Command<GlobalContext>()
312314
} else {
313315
data = await createFlow(options, rootPath);
314316
}
315-
if (!options.dryRun) {
317+
if (options.dryRun) {
318+
if (options.json) {
319+
writeJsonResult({
320+
dryRun: true,
321+
org: data.org,
322+
app: data.app,
323+
repo: data.repo,
324+
buildDirectory: data.buildDirectory,
325+
buildConfig: data.buildConfig,
326+
buildTimeout: data.buildTimeout,
327+
buildMemoryLimit: data.buildMemoryLimit,
328+
region: data.region,
329+
});
330+
}
331+
} else {
316332
await createApp(
317333
options,
318334
config,
@@ -399,10 +415,42 @@ export async function createApp(
399415
deviceCreation,
400416
});
401417

418+
const appUrl = `${context.endpoint}/${data.org}/${data.app}`;
419+
if (context.json) {
420+
// Local-source path will call publish() which emits its own JSON envelope;
421+
// the github path emits the create-only envelope here.
422+
if (data.repo !== undefined) {
423+
const revisionId = await trpcClient.mutation("apps.triggerGitHubBuild", {
424+
org: data.org,
425+
app: data.app,
426+
branch: null,
427+
}) as string;
428+
writeJsonResult({
429+
org: data.org,
430+
app: data.app,
431+
url: appUrl,
432+
revisionId,
433+
source: "github",
434+
});
435+
if (wait) {
436+
await waitForRevision(context, data.org, data.app, revisionId);
437+
}
438+
return;
439+
}
440+
await publish(
441+
context,
442+
configContext,
443+
rootPath,
444+
data.org,
445+
data.app,
446+
true,
447+
wait ?? false,
448+
);
449+
return;
450+
}
451+
402452
console.log(
403-
`${
404-
green("✔")
405-
} Created app, view it at ${context.endpoint}/${data.org}/${data.app}`,
453+
`${green("✔")} Created app, view it at ${appUrl}`,
406454
);
407455

408456
if (data.repo === undefined) {

deploy/mod.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
// deno-lint-ignore-file no-explicit-any
12
import { Command, ValidationError } from "@cliffy/command";
2-
import { green, red, yellow } from "@std/fmt/colors";
3+
import { green, red, setColorEnabled, yellow } from "@std/fmt/colors";
34
import { error, renderTemporalTimestamp } from "../util.ts";
45
import { createSwitchCommand, type GlobalContext } from "../main.ts";
56
import { VERSION } from "../version.ts";
@@ -201,6 +202,14 @@ deploy your local directory to the specified application.`)
201202
collect: true,
202203
})
203204
.globalOption("-q, --quiet", "Suppress non-essential output")
205+
.globalOption(
206+
"-j, --json",
207+
"Emit JSON on stdout instead of human-readable output",
208+
)
209+
.globalOption(
210+
"-y, --non-interactive",
211+
"Fail fast instead of prompting; values must be supplied via flags or env vars (alias: -y)",
212+
)
204213
.option("--org <name:string>", "The name of the organization")
205214
.option("--app <name:string>", "The name of the application")
206215
.option("--prod", "Deploy directly to production")
@@ -226,6 +235,12 @@ deploy your local directory to the specified application.`)
226235
tokenStorage.set(tokenEnv, true);
227236
}
228237

238+
// `--json` implies machine-readable output: kill ANSI color so structured
239+
// payloads piped to `jq` don't carry escape sequences.
240+
if (options.json) {
241+
setColorEnabled(false);
242+
}
243+
229244
if (options.debug) {
230245
console.error(
231246
yellow(
@@ -262,12 +277,15 @@ deploy your local directory to the specified application.`)
262277
(rootPath) => rootPath,
263278
),
264279
)
265-
.command("create", createCommand)
266-
.command("env", envCommand)
267-
.command("database", databasesCommand)
268-
.command("logs", logsCommand)
269-
.command("setup-aws", setupAWSCommand)
270-
.command("setup-gcp", setupGCPCommand)
271-
.command("tunnel-login", tunnelLoginCommand)
272-
.command("switch", createSwitchCommand(true))
273-
.command("logout", logoutCommand);
280+
// Cliffy's accumulated generic chain (parent options × subcommand contexts)
281+
// overflows the inference budget once enough globalOptions are stacked;
282+
// the casts here are type-only, the runtime is unaffected.
283+
.command("create", createCommand as Command<any>)
284+
.command("env", envCommand as Command<any>)
285+
.command("database", databasesCommand as Command<any>)
286+
.command("logs", logsCommand as Command<any>)
287+
.command("setup-aws", setupAWSCommand as Command<any>)
288+
.command("setup-gcp", setupGCPCommand as Command<any>)
289+
.command("tunnel-login", tunnelLoginCommand as Command<any>)
290+
.command("switch", createSwitchCommand(true) as Command<any>)
291+
.command("logout", logoutCommand as Command<any>);

deploy/publish.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Spinner } from "@std/cli/unstable-spinner";
44
import { join, relative, resolve, SEPARATOR } from "@std/path";
55
import { green, red, yellow } from "@std/fmt/colors";
66
import { authedFetch, createTrpcClient } from "../auth.ts";
7-
import { error } from "../util.ts";
7+
import { error, shouldUseSpinner, writeJsonResult } from "../util.ts";
88
import type { GlobalContext } from "../main.ts";
99
import type { ConfigContext } from "../config.ts";
1010

@@ -30,15 +30,15 @@ export async function publish(
3030
prod: boolean,
3131
wait: boolean,
3232
) {
33-
const quiet = context.quiet;
33+
const quiet = context.quiet || context.json;
3434
const log: typeof console.log = quiet
3535
? () => {}
3636
// deno-lint-ignore no-explicit-any
3737
: console.log.bind(console) as any;
3838

3939
function startSpinner(message: string): Spinner {
4040
const spinner = new Spinner({ message, color: "yellow" });
41-
if (!quiet) spinner.start();
41+
if (shouldUseSpinner(context)) spinner.start();
4242
return spinner;
4343
}
4444

@@ -161,6 +161,7 @@ export async function publish(
161161
console.log("Missing hashes", missingHashes);
162162
}
163163

164+
const useProgress = shouldUseSpinner(context);
164165
const progress = new ProgressBar({
165166
max: missingHashes.length,
166167
emptyChar: " ",
@@ -189,7 +190,7 @@ export async function publish(
189190
new TransformStream({
190191
transform({ internalPath, data, hash }, controller) {
191192
if (missingHashes.includes(hash)) {
192-
if (!quiet) progress.value += 1;
193+
if (useProgress) progress.value += 1;
193194

194195
controller.enqueue(
195196
{
@@ -238,7 +239,7 @@ export async function publish(
238239
},
239240
);
240241

241-
if (!quiet) await progress.stop();
242+
if (useProgress) await progress.stop();
242243

243244
log();
244245

@@ -270,7 +271,7 @@ export async function waitForRevision(
270271
revisionId: string,
271272
revision?: Revision,
272273
) {
273-
const quiet = context.quiet;
274+
const quiet = context.quiet || context.json;
274275
const log: typeof console.log = quiet
275276
? () => {}
276277
// deno-lint-ignore no-explicit-any
@@ -285,7 +286,7 @@ export async function waitForRevision(
285286
message: "Awaiting revision to complete...",
286287
color: "yellow",
287288
});
288-
if (!quiet) completionSpinner.start();
289+
if (shouldUseSpinner(context)) completionSpinner.start();
289290

290291
const completionPromise = Promise.withResolvers<void>();
291292

@@ -324,6 +325,16 @@ export async function waitForRevision(
324325

325326
completionSpinner.stop();
326327
if (revision?.status === "cancelled" || revision?.status === "failed") {
328+
if (context.json) {
329+
error(context, `The revision ${revision.status}.`, {
330+
code: 1,
331+
errorCode: revision.status === "cancelled"
332+
? "REVISION_CANCELLED"
333+
: "REVISION_FAILED",
334+
hint:
335+
`View ${context.endpoint}/${org}/${app}/builds/${revisionId} for details.`,
336+
});
337+
}
327338
console.log(
328339
`\n${red("✗")} The revision ${
329340
revision.status === "cancelled" ? "was " : ""
@@ -338,6 +349,21 @@ export async function waitForRevision(
338349
revision: revisionId,
339350
}) as Array<{ partition_config_name: string; domains: string[] }>;
340351

352+
if (context.json) {
353+
writeJsonResult({
354+
org,
355+
app,
356+
revisionId,
357+
url: `${context.endpoint}/${org}/${app}/builds/${revisionId}`,
358+
status: revision?.status ?? "ready",
359+
timelines: timelines.map((t) => ({
360+
partition: t.partition_config_name,
361+
domains: t.domains.map((d) => `https://${d}`),
362+
})),
363+
});
364+
return;
365+
}
366+
341367
console.log(`\n${green("✔")} Successfully deployed your application!`);
342368

343369
for (const timeline of timelines) {

main.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,22 @@ export type GlobalContext = {
2424
ignore?: string[];
2525
allowNodeModules?: boolean;
2626
quiet?: true;
27+
/** Emit JSON to stdout (single object/array per command) and structured errors to stderr. */
28+
json?: true;
29+
/** Refuse interactive prompts; missing inputs must come from flags/env. */
30+
nonInteractive?: true;
2731
};
2832

2933
if (Deno.env.has("DENO_DEPLOY_CLI_SANDBOX")) {
3034
await sandboxCommand.parse(Deno.args);
3135
} else {
32-
await deployCommand.command("sandbox", sandboxCommand).parse(Deno.args);
36+
// Cliffy's accumulated generic chain (parent options × subcommand contexts)
37+
// overflows the inference budget when stacking root commands with several
38+
// globalOptions. The cast is type-only; runtime is unaffected.
39+
// deno-lint-ignore no-explicit-any
40+
await deployCommand.command("sandbox", sandboxCommand as Command<any>).parse(
41+
Deno.args,
42+
);
3343
}
3444

3545
export function createSwitchCommand(

0 commit comments

Comments
 (0)