From fd686fc27b5082413698e04b06979fd07e39e8df Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 3 Nov 2026 13:36:33 -0500 Subject: [PATCH] feat(cli): Add headless authentication bootstrap command --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/auth.md | 6 +++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/auth.md | 18 +++++++ packages/cli/src/commands/auth/bootstrap.ts | 47 +++++++++++++++++++ packages/cli/src/commands/auth/index.ts | 2 + packages/cli/src/lib/setup-code.ts | 36 ++------------ packages/cli/src/output-schemas/auth.ts | 30 ++++++++++++ packages/cli/test/lib/help.test.ts | 4 +- 9 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/commands/auth/bootstrap.ts create mode 100644 packages/cli/src/output-schemas/auth.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index eccf6d2b5..e926b0ac9 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -58,7 +58,7 @@ toolkit/ │ │ │ ├── agent-conversation/# list, view │ │ │ ├── alert/ # create, delete, edit, list, view │ │ │ ├── attribute/ # list -│ │ │ ├── auth/ # list, login, logout, refresh, status, switch, token, whoami +│ │ │ ├── auth/ # bootstrap, list, login, logout, refresh, status, switch, token, whoami │ │ │ ├── build/ # download, upload │ │ │ ├── cli/ # completion, defaults, feedback, fix, import, setup, uninstall, upgrade │ │ │ ├── code-mappings/# upload diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 3543c9861..7e1833930 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -16,6 +16,12 @@ Use `--read-only` to request only read-only scopes — useful for tokens handed to AI agents or CI jobs that should not mutate Sentry state. Use `--scope` to request specific scopes (repeatable, comma-separated). +## Setup codes + +`SENTRY_SETUP_CODE=... sentry auth bootstrap` exchanges a short-lived, single-use +code supplied by Sentry for stored OAuth credentials. Use `--url` to confirm an +HTTP instance and `--force` to replace the stored login for the issuing server. + ## Self-hosted Sentry Pass `--url` to log in to a self-hosted instance. Setting `SENTRY_HOST` or diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ac0aa3623..5d4a88958 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -397,6 +397,7 @@ Authenticate with Sentry - `sentry auth list` — List stored accounts - `sentry auth login []` — Authenticate with Sentry +- `sentry auth bootstrap` — Authenticate using a one-time setup code - `sentry auth logout ` — Log out of Sentry - `sentry auth refresh` — Refresh your OAuth access token - `sentry auth status` — View authentication status diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md index d25c002a8..03a9901f2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -86,6 +86,24 @@ sentry auth login jane@example.com --url https://sentry.example.com sentry auth login jane@example.com:sentry.example.com ``` +### `sentry auth bootstrap` + +Authenticate using a one-time setup code + +**Flags:** +- `--force - Replace the stored login` +- `--url - Explicit issuing Sentry instance URL` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `method` | string | | +| `configPath` | string | Path to the stored CLI credentials | +| `user` | object | | +| `expiresIn` | number | | +| `refreshEnabled` | boolean | | + ### `sentry auth logout ` Log out of Sentry diff --git a/packages/cli/src/commands/auth/bootstrap.ts b/packages/cli/src/commands/auth/bootstrap.ts new file mode 100644 index 000000000..b7b97d202 --- /dev/null +++ b/packages/cli/src/commands/auth/bootstrap.ts @@ -0,0 +1,47 @@ +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { + ACCOUNT_SERVER_TARGET, + completingParser, +} from "../../lib/completion-target.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { bootstrapAuthentication } from "../../lib/setup-code.js"; +import type { BootstrapResult } from "../../output-schemas/auth.js"; +import { BootstrapResultSchema } from "../../generated/output-schemas/auth.js"; +import { applyLoginUrl, formatLoginResult, parseLoginUrl } from "./login.js"; + +/** Authenticate without opening a browser using a private, one-use setup code. */ +export const bootstrapCommand = buildCommand({ + auth: false, + skipRcUrlCheck: true, + docs: { + brief: "Authenticate using a one-time setup code", + fullDescription: + "Redeem SENTRY_SETUP_CODE for OAuth credentials without opening a browser.\n\n" + + "The code is short-lived and can be redeemed only once. Get a new code from Sentry if it has expired or already been used.\n" + + "Use --force to replace an existing stored login.", + }, + parameters: { + flags: { + force: { + kind: "boolean", + brief: "Replace the stored login", + default: false, + }, + url: { + kind: "parsed", + parse: completingParser(ACCOUNT_SERVER_TARGET, parseLoginUrl), + brief: "Explicit issuing Sentry instance URL", + optional: true, + }, + }, + }, + output: { + human: (result: BootstrapResult) => formatLoginResult(result), + schema: BootstrapResultSchema, + }, + async *func(this: SentryContext, flags: { force: boolean; url?: string }) { + applyLoginUrl(flags.url); + yield new CommandOutput(await bootstrapAuthentication(flags)); + }, +}); diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index 3ffe7eab1..1d985b301 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,5 +1,6 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { topLevelBrief } from "../../top-level-briefs.js"; +import { bootstrapCommand } from "./bootstrap.js"; import { authDefaultCommand } from "./default.js"; import { listCommand } from "./list.js"; import { loginCommand } from "./login.js"; @@ -16,6 +17,7 @@ export const authRoute = buildRouteMap({ default: authDefaultCommand, list: listCommand, login: loginCommand, + bootstrap: bootstrapCommand, logout: logoutCommand, refresh: refreshCommand, status: statusCommand, diff --git a/packages/cli/src/lib/setup-code.ts b/packages/cli/src/lib/setup-code.ts index b59f9b46d..0170e7161 100644 --- a/packages/cli/src/lib/setup-code.ts +++ b/packages/cli/src/lib/setup-code.ts @@ -1,18 +1,7 @@ /** Read the issuing Sentry instance from a private setup code. */ -import { - boolean, - description, - type InferOutput, - literal, - looseObject, - number, - object, - optional, - pipe, - safeParse, - string, -} from "valibot"; +import { looseObject, safeParse, string } from "valibot"; +import type { BootstrapResult } from "../output-schemas/auth.js"; import { pointDefaultUrlAtAccount } from "./account-selection.js"; import { normalizeAuthToken } from "./auth-header.js"; import { @@ -33,6 +22,8 @@ import { registerLoginTrustAnchor } from "./token-host.js"; import { ValidationError } from "./errors.js"; import { normalizeHttpOrigin } from "./sentry-urls.js"; +export type { BootstrapResult } from "../output-schemas/auth.js"; + const setupCodePattern = /^sntryc_([A-Za-z0-9+/=]+)_([A-Za-z0-9+/]{43})$/; const claimSchema = looseObject({ url: string() }); @@ -64,25 +55,6 @@ export function getSetupCodeHost(code: string): string { return host; } -/** Safe authentication result exposed to command output and field discovery. */ -export const BootstrapResultSchema = object({ - method: literal("oauth"), - configPath: pipe(string(), description("Path to the stored CLI credentials")), - user: optional( - object({ - id: optional(string()), - name: optional(string()), - email: optional(string()), - username: optional(string()), - }), - ), - expiresIn: optional(number()), - refreshEnabled: boolean(), -}); - -/** Public result of successful setup-code authentication. */ -export type BootstrapResult = InferOutput; - /** Redeem a private setup code, replacing only the issuing server's selected login. */ export async function bootstrapAuthentication( options: { diff --git a/packages/cli/src/output-schemas/auth.ts b/packages/cli/src/output-schemas/auth.ts new file mode 100644 index 000000000..cbd78555d --- /dev/null +++ b/packages/cli/src/output-schemas/auth.ts @@ -0,0 +1,30 @@ +import { + boolean, + description, + type InferOutput, + literal, + number, + object, + optional, + pipe, + string, +} from "valibot"; + +/** Safe authentication result exposed to command output and field discovery. */ +export const BootstrapResultSchema = object({ + method: literal("oauth"), + configPath: pipe(string(), description("Path to the stored CLI credentials")), + user: optional( + object({ + id: optional(string()), + name: optional(string()), + email: optional(string()), + username: optional(string()), + }), + ), + expiresIn: optional(number()), + refreshEnabled: boolean(), +}); + +/** Public result of successful setup-code authentication. */ +export type BootstrapResult = InferOutput; diff --git a/packages/cli/test/lib/help.test.ts b/packages/cli/test/lib/help.test.ts index d2b8e6907..3fb261dcc 100644 --- a/packages/cli/test/lib/help.test.ts +++ b/packages/cli/test/lib/help.test.ts @@ -206,7 +206,7 @@ describe("printCustomHelp overview", () => { test("lists every command and subcommand for explicit help", () => { const output = stripAnsi(humanHelp(false)); - expect(output).toContain("sentry auth list | login | logout"); + expect(output).toContain("sentry auth list | login | bootstrap | logout"); expect(output).toContain("sentry dashboard"); expect(output).not.toContain("all commands:"); expect(commandLines(output).length).toBeGreaterThan( @@ -220,7 +220,7 @@ describe("printCustomHelp overview", () => { printCustomHelp({ overview: true }), ), ); - expect(output).toContain("sentry auth list | login | logout"); + expect(output).toContain("sentry auth list | login | bootstrap | logout"); expect(output).toContain("sentry dashboard"); expect(output).not.toContain("all commands:"); });