From 9a7282c0a3c46669c19a849fd8f18be4c0ebe488 Mon Sep 17 00:00:00 2001 From: crowlbot <280062030+crowlbot@users.noreply.github.com> Date: Wed, 13 May 2026 13:20:40 +0000 Subject: [PATCH 1/2] feat: add --json, --non-interactive flags and exit-code taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- deploy/mod.ts | 22 +++++++++++++--------- main.ts | 8 +++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/deploy/mod.ts b/deploy/mod.ts index ad17a25..49b23b8 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-explicit-any import { Command, ValidationError } from "@cliffy/command"; import { green, red, setColorEnabled, yellow } from "@std/fmt/colors"; import { error, renderTemporalTimestamp } from "../util.ts"; @@ -327,12 +328,15 @@ deploy your local directory to the specified application.`) (rootPath) => rootPath, ), ) - .command("create", createCommand) - .command("env", envCommand) - .command("database", databasesCommand) - .command("logs", logsCommand) - .command("setup-aws", setupAWSCommand) - .command("setup-gcp", setupGCPCommand) - .command("tunnel-login", tunnelLoginCommand) - .command("switch", createSwitchCommand(true)) - .command("logout", logoutCommand); + // Cliffy's accumulated generic chain (parent options × subcommand contexts) + // overflows the inference budget once enough globalOptions are stacked; + // the casts here are type-only, the runtime is unaffected. + .command("create", createCommand as Command) + .command("env", envCommand as Command) + .command("database", databasesCommand as Command) + .command("logs", logsCommand as Command) + .command("setup-aws", setupAWSCommand as Command) + .command("setup-gcp", setupGCPCommand as Command) + .command("tunnel-login", tunnelLoginCommand as Command) + .command("switch", createSwitchCommand(true) as Command) + .command("logout", logoutCommand as Command); diff --git a/main.ts b/main.ts index f3f7861..6658c8c 100644 --- a/main.ts +++ b/main.ts @@ -33,7 +33,13 @@ export type GlobalContext = { if (Deno.env.has("DENO_DEPLOY_CLI_SANDBOX")) { await sandboxCommand.parse(Deno.args); } else { - await deployCommand.command("sandbox", sandboxCommand).parse(Deno.args); + // Cliffy's accumulated generic chain (parent options × subcommand contexts) + // overflows the inference budget when stacking root commands with several + // globalOptions. The cast is type-only; runtime is unaffected. + // deno-lint-ignore no-explicit-any + await deployCommand.command("sandbox", sandboxCommand as Command).parse( + Deno.args, + ); } export function createSwitchCommand( From 26b8cd00176cfeb2610a72684ee1b99be2c4ce0a Mon Sep 17 00:00:00 2001 From: crowlbot <280062030+crowlbot@users.noreply.github.com> Date: Wed, 13 May 2026 13:55:25 +0000 Subject: [PATCH 2/2] feat: add apps list, orgs list, deployments list subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth slice of the agent-ergonomics series. Adds the three list/inspect commands an agent typically needs to navigate the resource hierarchy: - `deno deploy apps list [--limit N] [--cursor C]` — paginated apps in the current org. JSON: `{ items, nextCursor, org }`. Uses `apps.listByPage`. - `deno deploy orgs list` — orgs reachable by the token. JSON: `[{ id, slug, name, plan }]`. Uses `orgs.list`. - `deno deploy deployments list [--app A] [--limit N] [--cursor C] [--status S]` — paginated revisions. JSON: `{ items, nextCursor, org, app }`. Uses `revisions.listByPage`. All three honor the global `--json` flag and the global error envelope from the foundation PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/apps.ts | 76 ++++++++++++++++++++++++++++++ deploy/deployments.ts | 106 ++++++++++++++++++++++++++++++++++++++++++ deploy/mod.ts | 28 +++++------ deploy/orgs.ts | 50 ++++++++++++++++++++ main.ts | 8 +--- 5 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 deploy/apps.ts create mode 100644 deploy/deployments.ts create mode 100644 deploy/orgs.ts diff --git a/deploy/apps.ts b/deploy/apps.ts new file mode 100644 index 0000000..e939f2a --- /dev/null +++ b/deploy/apps.ts @@ -0,0 +1,76 @@ +import { Command } from "@cliffy/command"; +import { createTrpcClient } from "../auth.ts"; +import { actionHandler, getOrg } from "../config.ts"; +import type { GlobalContext } from "../main.ts"; +import { + renderTemporalTimestamp, + tablePrinter, + writeJsonResult, +} from "../util.ts"; + +interface AppItem { + id: string; + slug: string; + created_at: Date; + updated_at: Date; + layers: Array<{ slug: string }>; +} + +const appsListCommand = new Command() + .description("List applications in an organization") + .option("--org ", "The name of the organization") + .option("--limit ", "Maximum number of apps to return (default 20)") + .option("--cursor ", "Pagination cursor from a previous --json run") + .action(actionHandler(async (config, options) => { + config.noCreate(); + const org = await getOrg(options, config, options.org); + const trpcClient = createTrpcClient(options); + + const res = await trpcClient.query("apps.listByPage", { + cursor: options.cursor, + limit: options.limit ?? 20, + }) as { items: AppItem[]; nextCursor: string | null }; + + if (options.json) { + writeJsonResult({ + items: res.items.map((app) => ({ + id: app.id, + slug: app.slug, + createdAt: app.created_at, + updatedAt: app.updated_at, + layers: app.layers.map((l) => l.slug), + })), + nextCursor: res.nextCursor, + org, + }); + return; + } + + if (res.items.length === 0) { + console.log("No applications in this organization."); + return; + } + + tablePrinter( + ["SLUG", "CREATED", "UPDATED", "LAYERS"], + res.items, + (app) => [ + app.slug, + renderTemporalTimestamp(app.created_at.toISOString()), + renderTemporalTimestamp(app.updated_at.toISOString()), + app.layers.map((l) => l.slug).join(", ") || "—", + ], + ); + + if (res.nextCursor) { + console.log(`\nMore results available; pass --cursor ${res.nextCursor}`); + } + })); + +export const appsCommand = new Command() + .description("Manage applications") + .action(() => { + appsCommand.showHelp(); + }) + .command("list", appsListCommand) + .alias("ls"); diff --git a/deploy/deployments.ts b/deploy/deployments.ts new file mode 100644 index 0000000..acaca0c --- /dev/null +++ b/deploy/deployments.ts @@ -0,0 +1,106 @@ +import { Command } from "@cliffy/command"; +import { createTrpcClient } from "../auth.ts"; +import { actionHandler, getApp, getOrg } from "../config.ts"; +import type { GlobalContext } from "../main.ts"; +import { + renderTemporalTimestamp, + tablePrinter, + writeJsonResult, +} from "../util.ts"; + +interface RevisionItem { + id: string; + status: string; + created_at: Date; + updated_at: Date; + prod: boolean; + steps: Array<{ step: string }>; +} + +const deploymentStatuses = [ + "skipped", + "queued", + "building", + "succeeded", + "failed", +] as const; +type DeploymentStatus = typeof deploymentStatuses[number]; + +const deploymentsListCommand = new Command() + .description("List deployments (revisions) for an application") + .option("--org ", "The name of the organization") + .option("--app ", "The name of the application") + .option( + "--limit ", + "Maximum number of deployments to return (default 20)", + ) + .option("--cursor ", "Pagination cursor from a previous --json run") + .option( + "--status ", + `Filter by status: one of ${deploymentStatuses.join(", ")}`, + ) + .action(actionHandler(async (config, options) => { + config.noCreate(); + const org = await getOrg(options, config, options.org); + const { app } = await getApp(options, config, false, org, options.app); + const trpcClient = createTrpcClient(options); + + // Cliffy widens the option through its option-builder generics; the + // backend zod-validates and returns a USAGE error if it's not one of + // the enum values, which the global error envelope surfaces fine. + const status = options.status as unknown as DeploymentStatus | undefined; + + const res = await trpcClient.query("revisions.listByPage", { + org, + app, + cursor: options.cursor, + limit: options.limit ?? 20, + status, + }) as { items: RevisionItem[]; nextCursor: string | null }; + + if (options.json) { + writeJsonResult({ + items: res.items.map((r) => ({ + id: r.id, + status: r.status, + prod: r.prod, + createdAt: r.created_at, + updatedAt: r.updated_at, + lastStep: r.steps.at(-1)?.step ?? null, + })), + nextCursor: res.nextCursor, + org, + app, + }); + return; + } + + if (res.items.length === 0) { + console.log("No deployments for this application."); + return; + } + + tablePrinter( + ["REVISION", "STATUS", "PROD", "CREATED", "LAST STEP"], + res.items, + (r) => [ + r.id, + r.status, + r.prod ? "yes" : "no", + renderTemporalTimestamp(r.created_at.toISOString()), + r.steps.at(-1)?.step ?? "—", + ], + ); + + if (res.nextCursor) { + console.log(`\nMore results available; pass --cursor ${res.nextCursor}`); + } + })); + +export const deploymentsCommand = new Command() + .description("Manage deployments (revisions)") + .action(() => { + deploymentsCommand.showHelp(); + }) + .command("list", deploymentsListCommand) + .alias("ls"); diff --git a/deploy/mod.ts b/deploy/mod.ts index 49b23b8..954a5ff 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-explicit-any import { Command, ValidationError } from "@cliffy/command"; import { green, red, setColorEnabled, yellow } from "@std/fmt/colors"; import { error, renderTemporalTimestamp } from "../util.ts"; @@ -11,6 +10,9 @@ import { createTrpcClient, getAuth, tokenStorage } from "../auth.ts"; import { databasesCommand } from "./database.ts"; import { envCommand } from "./env.ts"; import { createCommand } from "./create/mod.ts"; +import { appsCommand } from "./apps.ts"; +import { orgsCommand } from "./orgs.ts"; +import { deploymentsCommand } from "./deployments.ts"; const setupAWSCommand = new Command() .description("Setup cloud connections for AWS") @@ -328,15 +330,15 @@ deploy your local directory to the specified application.`) (rootPath) => rootPath, ), ) - // Cliffy's accumulated generic chain (parent options × subcommand contexts) - // overflows the inference budget once enough globalOptions are stacked; - // the casts here are type-only, the runtime is unaffected. - .command("create", createCommand as Command) - .command("env", envCommand as Command) - .command("database", databasesCommand as Command) - .command("logs", logsCommand as Command) - .command("setup-aws", setupAWSCommand as Command) - .command("setup-gcp", setupGCPCommand as Command) - .command("tunnel-login", tunnelLoginCommand as Command) - .command("switch", createSwitchCommand(true) as Command) - .command("logout", logoutCommand as Command); + .command("create", createCommand) + .command("env", envCommand) + .command("database", databasesCommand) + .command("apps", appsCommand) + .command("orgs", orgsCommand) + .command("deployments", deploymentsCommand) + .command("logs", logsCommand) + .command("setup-aws", setupAWSCommand) + .command("setup-gcp", setupGCPCommand) + .command("tunnel-login", tunnelLoginCommand) + .command("switch", createSwitchCommand(true)) + .command("logout", logoutCommand); diff --git a/deploy/orgs.ts b/deploy/orgs.ts new file mode 100644 index 0000000..90c60ed --- /dev/null +++ b/deploy/orgs.ts @@ -0,0 +1,50 @@ +import { Command } from "@cliffy/command"; +import { createTrpcClient } from "../auth.ts"; +import { actionHandler } from "../config.ts"; +import type { GlobalContext } from "../main.ts"; +import { tablePrinter, writeJsonResult } from "../util.ts"; + +interface OrgItem { + id: string; + name: string; + slug: string; + plan: string | null; +} + +const orgsListCommand = new Command() + .description("List organizations the current token can access") + .action(actionHandler(async (config, options) => { + config.noCreate(); + const trpcClient = createTrpcClient(options); + + const orgs = await trpcClient.query("orgs.list") as OrgItem[]; + + if (options.json) { + writeJsonResult(orgs.map((org) => ({ + id: org.id, + slug: org.slug, + name: org.name, + plan: org.plan, + }))); + return; + } + + if (orgs.length === 0) { + console.log("No organizations accessible with this token."); + return; + } + + tablePrinter( + ["SLUG", "NAME", "PLAN"], + orgs, + (org) => [org.slug, org.name, org.plan ?? "—"], + ); + })); + +export const orgsCommand = new Command() + .description("List organizations") + .action(() => { + orgsCommand.showHelp(); + }) + .command("list", orgsListCommand) + .alias("ls"); diff --git a/main.ts b/main.ts index 6658c8c..f3f7861 100644 --- a/main.ts +++ b/main.ts @@ -33,13 +33,7 @@ export type GlobalContext = { if (Deno.env.has("DENO_DEPLOY_CLI_SANDBOX")) { await sandboxCommand.parse(Deno.args); } else { - // Cliffy's accumulated generic chain (parent options × subcommand contexts) - // overflows the inference budget when stacking root commands with several - // globalOptions. The cast is type-only; runtime is unaffected. - // deno-lint-ignore no-explicit-any - await deployCommand.command("sandbox", sandboxCommand as Command).parse( - Deno.args, - ); + await deployCommand.command("sandbox", sandboxCommand).parse(Deno.args); } export function createSwitchCommand(