Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ Authenticate with Sentry

- `sentry auth list` — List stored accounts
- `sentry auth login [<email>]` — Authenticate with Sentry
- `sentry auth bootstrap` — Authenticate using a one-time setup code
- `sentry auth logout <account...>` — Log out of Sentry
- `sentry auth refresh` — Refresh your OAuth access token
- `sentry auth status` — View authentication status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value> - 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 <account...>`

Log out of Sentry
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/commands/auth/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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));
},
});
2 changes: 2 additions & 0 deletions packages/cli/src/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +17,7 @@ export const authRoute = buildRouteMap({
default: authDefaultCommand,
list: listCommand,
login: loginCommand,
bootstrap: bootstrapCommand,
logout: logoutCommand,
refresh: refreshCommand,
status: statusCommand,
Expand Down
36 changes: 4 additions & 32 deletions packages/cli/src/lib/setup-code.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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() });

Expand Down Expand Up @@ -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<typeof BootstrapResultSchema>;

/** Redeem a private setup code, replacing only the issuing server's selected login. */
export async function bootstrapAuthentication(
options: {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/output-schemas/auth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof BootstrapResultSchema>;
4 changes: 2 additions & 2 deletions packages/cli/test/lib/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:");
});
Expand Down
Loading