Skip to content

Commit 2ae29f3

Browse files
crowlbotclaude
andcommitted
feat: add deno deploy whoami + conflict hint in tRPC error mapping
`deno deploy whoami` lets an agent verify the current token without side effects: calls `orgs.list`, prints a slug/name/plan table in human mode, and emits `{ authenticated, user, orgs }` in `--json` mode. `user` is currently `null` because the deployng tRPC router does not expose user identity; when an `account.me` procedure lands, the field will gain `{ id, name, email, ... }` and existing consumers reading `authenticated` / `orgs[]` keep working. A bad token surfaces the global `AUTH_INVALID_TOKEN` envelope on stderr with exit 3 — no browser is opened. Also a small idempotency upgrade in `mapTrpcError()`: when the backend returns `SLUG_ALREADY_IN_USE`, the global error envelope now carries a hint pointing at the recovery path. Exit code stays `CONFLICT=5`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 83f452e commit 2ae29f3

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

auth.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@ function mapTrpcError(
4141
return { code: ExitCode.NOT_FOUND, errorCode: backendCode ?? "NOT_FOUND" };
4242
}
4343
if (httpStatus === 409) {
44-
return { code: ExitCode.CONFLICT, errorCode: backendCode ?? "CONFLICT" };
44+
const hint = backendCode === "SLUG_ALREADY_IN_USE"
45+
? "A resource with that name already exists. Use a different name, " +
46+
"or run the corresponding update/publish command against the existing one."
47+
: undefined;
48+
return {
49+
code: ExitCode.CONFLICT,
50+
errorCode: backendCode ?? "CONFLICT",
51+
hint,
52+
};
4553
}
4654
if (httpStatus !== undefined && httpStatus >= 500) {
4755
return { code: ExitCode.NETWORK, errorCode: backendCode ?? "BACKEND" };

deploy/mod.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// deno-lint-ignore-file no-explicit-any
22
import { Command, ValidationError } from "@cliffy/command";
33
import { green, red, setColorEnabled, yellow } from "@std/fmt/colors";
4-
import { error, renderTemporalTimestamp } from "../util.ts";
4+
import {
5+
error,
6+
renderTemporalTimestamp,
7+
tablePrinter,
8+
writeJsonResult,
9+
} from "../util.ts";
510
import { createSwitchCommand, type GlobalContext } from "../main.ts";
611
import { VERSION } from "../version.ts";
712
import { actionHandler, getApp, getOrg } from "../config.ts";
@@ -237,6 +242,61 @@ const logoutCommand = new Command()
237242
console.log(`${green("✔")} Successfully logged out`);
238243
});
239244

245+
interface WhoamiOrg {
246+
id: string;
247+
name: string;
248+
slug: string;
249+
plan: string | null;
250+
}
251+
252+
const whoamiCommand = new Command<GlobalContext>()
253+
.description(
254+
"Verify the current Deno Deploy token and list reachable organizations",
255+
)
256+
.example(
257+
"Check that DENO_DEPLOY_TOKEN works",
258+
"whoami --json",
259+
)
260+
.action(actionHandler(async (config, options) => {
261+
config.noCreate();
262+
// Touch tokenStorage via the tRPC client; this will surface a clean
263+
// AUTH_INVALID_TOKEN envelope from the errorLink if the token is bad,
264+
// without ever calling `requireInteractive()` or opening a browser.
265+
const trpcClient = createTrpcClient(options);
266+
const orgs = await trpcClient.query("orgs.list") as WhoamiOrg[];
267+
268+
if (options.json) {
269+
writeJsonResult({
270+
authenticated: true,
271+
// The deployng tRPC router does not currently expose user identity,
272+
// so we surface what we can (orgs the token can reach). When that
273+
// procedure lands, this output will gain a `user` field; existing
274+
// consumers reading `authenticated` / `orgs[]` keep working.
275+
user: null,
276+
orgs: orgs.map((org) => ({
277+
id: org.id,
278+
slug: org.slug,
279+
name: org.name,
280+
plan: org.plan,
281+
})),
282+
});
283+
return;
284+
}
285+
286+
console.log(
287+
`${green("✔")} Authenticated. ${orgs.length} reachable organization${
288+
orgs.length === 1 ? "" : "s"
289+
}:`,
290+
);
291+
if (orgs.length > 0) {
292+
tablePrinter(
293+
["SLUG", "NAME", "PLAN"],
294+
orgs,
295+
(org) => [org.slug, org.name, org.plan ?? "—"],
296+
);
297+
}
298+
}));
299+
240300
export const deployCommand = new Command()
241301
.name("deno deploy")
242302
.version(VERSION)
@@ -347,4 +407,5 @@ deploy your local directory to the specified application.`)
347407
.command("setup-gcp", setupGCPCommand as Command<any>)
348408
.command("tunnel-login", tunnelLoginCommand as Command<any>)
349409
.command("switch", createSwitchCommand(true) as Command<any>)
350-
.command("logout", logoutCommand as Command<any>);
410+
.command("logout", logoutCommand as Command<any>)
411+
.command("whoami", whoamiCommand as Command<any>);

tests/agent.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ Deno.test("setup-aws --non-interactive without --policies surfaces MISSING_FLAG"
119119
);
120120
});
121121

122+
Deno.test("whoami --json with bad token emits AUTH envelope (exit 3, no browser)", async () => {
123+
const res = await deployRaw(
124+
"--json",
125+
"--token",
126+
"obviously-invalid-token",
127+
"--endpoint",
128+
"http://127.0.0.1:1",
129+
"whoami",
130+
);
131+
assertEquals(res.code, 3, `stderr: ${res.stderr}`);
132+
const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!);
133+
assertEquals(envelope.error.code, "AUTH_INVALID_TOKEN");
134+
});
135+
122136
Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", async () => {
123137
// Cliffy's ValidationError handler exits with code 1 by default;
124138
// verify the agent can pattern-match on stderr text either way.

0 commit comments

Comments
 (0)