Skip to content

Commit fa27fdd

Browse files
crowlbotclaude
andcommitted
feat: add deno deploy whoami + conflict hint in tRPC error mapping
Fifth slice of the agent-ergonomics series. `deno deploy whoami` lets an agent verify the current token without side effects: - Calls `orgs.list` (one round-trip to the backend). - Human mode prints "Authenticated. N reachable organizations:" plus a table of slug / name / plan. - `--json` mode emits `{ authenticated: true, user: null, orgs: [...] }`. `user` is currently `null` because the deployng tRPC router does not expose user identity; when that 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 tiny idempotency upgrade in `mapTrpcError()`: when the backend returns `SLUG_ALREADY_IN_USE` (the shared "name in use" error across apps and databases), the global error envelope now carries a hint pointing at "use a different name, or run the update/publish command for the existing resource". The exit code stays CONFLICT=5 either way. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b79e96 commit fa27fdd

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";
@@ -236,6 +241,61 @@ const logoutCommand = new Command()
236241
console.log(`${green("✔")} Successfully logged out`);
237242
});
238243

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