Skip to content

Commit dc1e6bd

Browse files
crowlbotclaude
authored 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 3e1ca76 commit dc1e6bd

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,6 +1,11 @@
11
import { Command, ValidationError } from "@cliffy/command";
22
import { green, red, setColorEnabled, yellow } from "@std/fmt/colors";
3-
import { error, renderTemporalTimestamp } from "../util.ts";
3+
import {
4+
error,
5+
renderTemporalTimestamp,
6+
tablePrinter,
7+
writeJsonResult,
8+
} from "../util.ts";
49
import { createSwitchCommand, type GlobalContext } from "../main.ts";
510
import { VERSION } from "../version.ts";
611
import { actionHandler, getApp, getOrg } from "../config.ts";
@@ -234,6 +239,61 @@ const logoutCommand = new Command()
234239
console.log(`${green("✔")} Successfully logged out`);
235240
});
236241

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

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)