diff --git a/auth.ts b/auth.ts index 7921813..8a286e2 100644 --- a/auth.ts +++ b/auth.ts @@ -41,7 +41,15 @@ function mapTrpcError( return { code: ExitCode.NOT_FOUND, errorCode: backendCode ?? "NOT_FOUND" }; } if (httpStatus === 409) { - return { code: ExitCode.CONFLICT, errorCode: backendCode ?? "CONFLICT" }; + const hint = backendCode === "SLUG_ALREADY_IN_USE" + ? "A resource with that name already exists. Use a different name, " + + "or run the corresponding update/publish command against the existing one." + : undefined; + return { + code: ExitCode.CONFLICT, + errorCode: backendCode ?? "CONFLICT", + hint, + }; } if (httpStatus !== undefined && httpStatus >= 500) { return { code: ExitCode.NETWORK, errorCode: backendCode ?? "BACKEND" }; diff --git a/deploy/mod.ts b/deploy/mod.ts index 954a5ff..a409722 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -1,6 +1,11 @@ import { Command, ValidationError } from "@cliffy/command"; import { green, red, setColorEnabled, yellow } from "@std/fmt/colors"; -import { error, renderTemporalTimestamp } from "../util.ts"; +import { + error, + renderTemporalTimestamp, + tablePrinter, + writeJsonResult, +} from "../util.ts"; import { createSwitchCommand, type GlobalContext } from "../main.ts"; import { VERSION } from "../version.ts"; import { actionHandler, getApp, getOrg } from "../config.ts"; @@ -234,6 +239,61 @@ const logoutCommand = new Command() console.log(`${green("✔")} Successfully logged out`); }); +interface WhoamiOrg { + id: string; + name: string; + slug: string; + plan: string | null; +} + +const whoamiCommand = new Command() + .description( + "Verify the current Deno Deploy token and list reachable organizations", + ) + .example( + "Check that DENO_DEPLOY_TOKEN works", + "whoami --json", + ) + .action(actionHandler(async (config, options) => { + config.noCreate(); + // Touch tokenStorage via the tRPC client; this will surface a clean + // AUTH_INVALID_TOKEN envelope from the errorLink if the token is bad, + // without ever calling `requireInteractive()` or opening a browser. + const trpcClient = createTrpcClient(options); + const orgs = await trpcClient.query("orgs.list") as WhoamiOrg[]; + + if (options.json) { + writeJsonResult({ + authenticated: true, + // The deployng tRPC router does not currently expose user identity, + // so we surface what we can (orgs the token can reach). When that + // procedure lands, this output will gain a `user` field; existing + // consumers reading `authenticated` / `orgs[]` keep working. + user: null, + orgs: orgs.map((org) => ({ + id: org.id, + slug: org.slug, + name: org.name, + plan: org.plan, + })), + }); + return; + } + + console.log( + `${green("✔")} Authenticated. ${orgs.length} reachable organization${ + orgs.length === 1 ? "" : "s" + }:`, + ); + if (orgs.length > 0) { + tablePrinter( + ["SLUG", "NAME", "PLAN"], + orgs, + (org) => [org.slug, org.name, org.plan ?? "—"], + ); + } + })); + export const deployCommand = new Command() .name("deno deploy") .version(VERSION) @@ -341,4 +401,5 @@ deploy your local directory to the specified application.`) .command("setup-gcp", setupGCPCommand) .command("tunnel-login", tunnelLoginCommand) .command("switch", createSwitchCommand(true)) - .command("logout", logoutCommand); + .command("logout", logoutCommand) + .command("whoami", whoamiCommand); diff --git a/tests/agent.test.ts b/tests/agent.test.ts index d83a0f2..15b9c24 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -119,6 +119,20 @@ Deno.test("setup-aws --non-interactive without --policies surfaces MISSING_FLAG" ); }); +Deno.test("whoami --json with bad token emits AUTH envelope (exit 3, no browser)", async () => { + const res = await deployRaw( + "--json", + "--token", + "obviously-invalid-token", + "--endpoint", + "http://127.0.0.1:1", + "whoami", + ); + assertEquals(res.code, 3, `stderr: ${res.stderr}`); + const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!); + assertEquals(envelope.error.code, "AUTH_INVALID_TOKEN"); +}); + Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", async () => { // Cliffy's ValidationError handler exits with code 1 by default; // verify the agent can pattern-match on stderr text either way.