diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index b5196c385..76eb281b7 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -35,7 +35,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ## Safety Rules -- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `token delete`, `trial start` - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index eccf6d2b5..5d3a12d5a 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -87,6 +87,7 @@ toolkit/ │ │ │ ├── span/ # list, view │ │ │ ├── status/ # show │ │ │ ├── team/ # list +│ │ │ ├── token/ # create, delete, list │ │ │ ├── trace/ # list, logs, view │ │ │ ├── trial/ # list, start │ │ │ ├── api.ts # Make an authenticated API request diff --git a/apps/cli-docs/src/fragments/commands/token.md b/apps/cli-docs/src/fragments/commands/token.md new file mode 100644 index 000000000..8af1bb05a --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/token.md @@ -0,0 +1,23 @@ + + +## Permissions + +Sentry only lets a CLI login from `sentry auth login` manage org auth tokens. +Personal tokens, org auth tokens, and `SENTRY_AUTH_TOKEN` can't create, list, +or revoke them. + +The login also needs the `org:tokens` scope, which isn't part of the CLI's +default login. The first `sentry token` command asks you to re-authorize and +adds it. Creating a token also needs `org:ci`, because a login can only give a +new token scopes it holds itself, and revoking one needs `org:write` (org +owners and managers). + +Re-authorization only happens in interactive runs: with `--yes`, `--json`, or +`--dry-run` the CLI never starts the authorization flow, so run a +`sentry token` command once without those flags first. + +New tokens always get the `org:ci` scope. Sentry returns a token's value only +when it's created, so save it right away. + +Self-hosted Sentry needs a version that supports the `org:tokens` scope. +Older servers reject the authorization request with an unknown-scope error. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index d8afc02d2..4fdb9ce3d 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -118,7 +118,7 @@ Top-level layout: `dashboard`, `debug-files`, `docs`, `dsn`, `event`, `feedback`, `integration`, `issue`, `local`, `log`, `monitor`, `org`, `platform`, `proguard`, `project`, `react-native`, `release`, `replay`, `repo`, `snapshots`, `sourcemap`, `span`, `status`, - `team`, `trace`, `trial`) plus standalone command files (`api.ts`, + `team`, `token`, `trace`, `trial`) plus standalone command files (`api.ts`, `explore.ts`, `help.ts`, `info.ts`, `init.ts`, `mcp.ts`, `schema.ts`, `wasm-split.ts`, plus the hidden `bash-hook.ts`, `send-event.ts`, and `send-envelope.ts`). diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 763b1d50c..415232748 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -83,6 +83,8 @@ const BOUND_TARGET_TYPES = new Set([ "ReplayArguments", "SpanListArguments", "SpanViewArguments", + "TokenArguments", + "TokenCreateTarget", "TraceLogsArguments", "TraceViewArguments", "TrialStartArguments", 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 2b289e54b..1940cad78 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,7 +46,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ### Safety Rules -- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `token delete`, `trial start` - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -652,6 +652,16 @@ Work with Sentry teams → Full flags and examples: `references/team.md` +### Token + +Manage org auth tokens + +- `sentry token list []` — List org auth tokens +- `sentry token create ` — Create an org auth token +- `sentry token delete [/]` — Revoke an org auth token + +→ Full flags and examples: `references/token.md` + ### Integration Work with Sentry integrations diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/token.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/token.md new file mode 100644 index 000000000..7baed1382 --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/token.md @@ -0,0 +1,106 @@ +--- +name: sentry-cli-token +version: 0.48.0-dev.0 +description: Manage org auth tokens +requires: + bins: ["sentry"] + auth: true +--- + +# Token Commands + +Manage org auth tokens + +### `sentry token list []` + +List org auth tokens + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[]` | Organization slug (auto-detected if omitted) | + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Token ID | +| `name` | string | Token name | +| `scopes` | array | Scopes granted to the token | +| `tokenLastCharacters` | string \| null | Last 4 characters of the token | +| `dateCreated` | string | Creation timestamp | +| `dateLastUsed` | string \| null | Last time the token was used, if ever | +| `projectLastUsedId` | string \| null | ID of the project the token was last used for | +| `token` | string | Full token value (create response only) | + +**Examples:** + +```bash +# List tokens (auto-detect org) +sentry token list + +# List tokens in a specific org +sentry token list my-org + +# Output as JSON +sentry token list --json +``` + +### `sentry token create ` + +Create an org auth token + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | [<org>/]<name> - Name of the new token | + +**Flags:** +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Create a token (auto-detect org) +sentry token create github-actions + +# Create a token in a specific org +sentry token create my-org/github-actions + +# Output as JSON, including the token value +sentry token create my-org/github-actions --json +``` + +### `sentry token delete [/]` + +Revoke an org auth token + +**Destructive:** This command deletes data or makes changes that can't easily be undone. Use `--dry-run` to preview the changes first. Confirm with the user before running it. + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[/]` | [<org>/]<token-id> - Token to revoke | + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Revoke a token (auto-detect org) +sentry token delete 12345 + +# Preview revoking a token +sentry token delete my-org/12345 --dry-run + +# Revoke without the confirmation prompt +sentry token delete my-org/12345 --yes +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 5359ae2de..2220f4a46 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -66,6 +66,8 @@ import { listCommand as spanListCommand } from "./commands/span/list.js"; import { statusRoute } from "./commands/status/index.js"; import { teamRoute } from "./commands/team/index.js"; import { listCommand as teamListCommand } from "./commands/team/list.js"; +import { tokenRoute } from "./commands/token/index.js"; +import { listCommand as tokenListCommand } from "./commands/token/list.js"; import { traceRoute } from "./commands/trace/index.js"; import { listCommand as traceListCommand } from "./commands/trace/list.js"; import { trialRoute } from "./commands/trial/index.js"; @@ -112,6 +114,7 @@ const PLURAL_TO_SINGULAR: Record = { releases: "release", repos: "repo", teams: "team", + tokens: "token", logs: "log", monitors: "monitor", replays: "replay", @@ -146,6 +149,7 @@ export const routes = buildRouteMap({ release: releaseRoute, repo: repoRoute, team: teamRoute, + token: tokenRoute, integration: integrationRoute, issue: issueRoute, event: eventRoute, @@ -185,6 +189,7 @@ export const routes = buildRouteMap({ releases: releaseListCommand, repos: repoListCommand, teams: teamListCommand, + tokens: tokenListCommand, logs: logListCommand, monitors: monitorListCommand, spans: spanListCommand, @@ -213,6 +218,7 @@ export const routes = buildRouteMap({ releases: true, repos: true, teams: true, + tokens: true, logs: true, monitors: true, spans: true, diff --git a/packages/cli/src/commands/token/create.ts b/packages/cli/src/commands/token/create.ts new file mode 100644 index 000000000..e7f20b778 --- /dev/null +++ b/packages/cli/src/commands/token/create.ts @@ -0,0 +1,159 @@ +/** + * sentry token create + * + * Create an org auth token (`org:ci`) and print its value. Sentry only + * returns the value in the create response. + */ + +import type { SentryContext } from "../../context.js"; +import { createOrgAuthToken } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ORG_TARGET } from "../../lib/completion-target.js"; +import { selectAccountForOrg } from "../../lib/db/auth.js"; +import { + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { + type BoundPositional, + boundPositional, +} from "../../lib/positional-parse.js"; +import { resolveOrgInput } from "../../lib/target-positional.js"; +import type { OrgAuthToken } from "../../types/index.js"; +import { + CREATE_SCOPES, + ensureTokenScopes, + explainTokenForbidden, + parseOrgScopedArg, +} from "./shared.js"; + +const USAGE_HINT = "sentry token create [/]"; + +type CreateFlags = { + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = + | { dryRun: true; org: string; name: string } + | (OrgAuthToken & { dryRun?: false; org: string }); + +/** A token name bound to the organization where it will be created. */ +type TokenCreateTarget = BoundPositional< + ReturnType, + { readonly name: string; readonly org: string } +>; + +function formatTokenCreated(result: CreateResult): string { + if (result.dryRun) { + return renderMarkdown( + `Would create org auth token ${safeCodeSpan(result.name)} in **${escapeMarkdownInline(result.org)}**. (dry run)`, + ); + } + const rows: [string, string][] = [ + ["Name", escapeMarkdownInline(result.name)], + ["ID", result.id], + ["Scopes", result.scopes.join(", ")], + ]; + // Keep the one-time secret outside the table, which hard-wraps long cells. + const token = result.token + ? `\n\nToken:\n\n${safeCodeSpan(result.token)}` + : ""; + return renderMarkdown( + `## Org auth token created in ${escapeMarkdownInline(result.org)}\n\n${mdKvTable(rows)}${token}`, + ); +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create an org auth token", + fullDescription: + "Create an org auth token with the org:ci scope, for uploading source maps, " + + "debug files, and releases from CI.\n\n" + + "The token value is printed once. Sentry doesn't store it, so save it now.\n\n" + + "Use `org/name` to pick the org: everything before the first `/` is the org " + + "slug. Add the org when the name itself contains a `/`.\n\n" + + "Needs a CLI login (`sentry auth login`). The first run asks you to " + + "re-authorize so the login can manage org auth tokens (interactive runs " + + "only; skipped with --json).", + examples: [ + { + description: "Create a token (auto-detect org)", + command: "sentry token create github-actions", + }, + { + description: "Create a token in a specific org", + command: "sentry token create my-org/github-actions", + }, + { + description: "Output as JSON, including the token value", + command: "sentry token create my-org/github-actions --json", + }, + ], + }, + output: { + human: formatTokenCreated, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/name", + brief: "[/] - Name of the new token", + parse: boundPositional( + ORG_TARGET, + (raw) => parseOrgScopedArg(raw, "Token name", USAGE_HINT), + async ({ value: name, orgSlug }, context: SentryContext) => { + const { org } = await resolveOrgInput( + { org: orgSlug }, + context.cwd, + USAGE_HINT, + ); + return { name, org }; + }, + ), + }, + ], + }, + flags: { + "dry-run": DRY_RUN_FLAG, + }, + aliases: { ...DRY_RUN_ALIASES }, + }, + async *func( + this: SentryContext, + flags: CreateFlags, + target: TokenCreateTarget, + ) { + const { name, org } = await target.resolved; + + if (flags["dry-run"]) { + yield new CommandOutput({ dryRun: true, org, name }); + return { hint: "Dry run — no token was created." }; + } + + selectAccountForOrg(org); + const scopeMissing = await ensureTokenScopes( + CREATE_SCOPES, + "Creating an org auth token", + ); + + let token: OrgAuthToken; + try { + token = await createOrgAuthToken(org, name); + } catch (error) { + throw explainTokenForbidden(error, CREATE_SCOPES, scopeMissing); + } + + yield new CommandOutput({ ...token, org }); + return { + hint: "Save the token now. Sentry can't show it again.", + }; + }, +}); diff --git a/packages/cli/src/commands/token/delete.ts b/packages/cli/src/commands/token/delete.ts new file mode 100644 index 000000000..20256cdfc --- /dev/null +++ b/packages/cli/src/commands/token/delete.ts @@ -0,0 +1,226 @@ +/** + * sentry token delete + * + * Revoke an org auth token by ID. + * + * Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run` + * flags and enforces the non-interactive guard before `func()` runs. + */ + +import type { SentryContext } from "../../context.js"; +import { deleteOrgAuthToken, listOrgAuthTokens } from "../../lib/api-client.js"; +import { ORG_TARGET } from "../../lib/completion-target.js"; +import { selectAccountForOrg } from "../../lib/db/auth.js"; +import { ResolutionError, ValidationError } from "../../lib/errors.js"; +import { + escapeMarkdownInline, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { ID_KINDS, parseIdArguments } from "../../lib/id-arguments.js"; +import { logger } from "../../lib/logger.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../lib/mutate-command.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; +import { + type OrgInput, + parseOrgInput, + resolveOrgInput, +} from "../../lib/target-positional.js"; +import type { OrgAuthToken } from "../../types/index.js"; +import { + DELETE_SCOPES, + ensureTokenScopes, + explainTokenForbidden, +} from "./shared.js"; + +const USAGE = "[/]"; +const USAGE_HINT = `sentry token delete ${USAGE}`; +const log = logger.withTag("token.delete"); + +type DeleteFlags = { + readonly yes: boolean; + readonly force: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DeleteResult = { + deleted: boolean; + org: string; + id: string; + name: string; + dryRun?: boolean; +}; + +function formatTokenDeleted(result: DeleteResult): string { + const label = `org auth token ${safeCodeSpan(result.name)} (id ${result.id})`; + const org = `**${escapeMarkdownInline(result.org)}**`; + if (result.dryRun) { + return renderMarkdown(`Would revoke ${label} in ${org}. (dry run)`); + } + if (!result.deleted) { + return "Cancelled."; + } + return renderMarkdown(`Revoked ${label} in ${org}.`); +} + +/** Parsed token ID, organization and argument warnings. */ +export type TokenInput = { + /** Numeric ID of the token to revoke. */ + readonly id: string; + /** Organization input, including a pasted URL's server. */ + readonly org: OrgInput; + /** Parser warnings to show when resolving the target. */ + readonly warnings: readonly string[]; +}; + +/** A token ID bound to the organization that owns it. */ +type TokenArguments = BoundArguments< + TokenInput, + { readonly org: string; readonly id: string } +>; + +/** + * Parse one org-scoped token ID using the shared ID argument grammar. + * + * @throws {ContextError} When no ID is given + * @throws {ValidationError} When the ID or organization is invalid, or + * more than one ID is given + */ +export function parseTokenArguments(args: readonly string[]): TokenInput { + const { target, ids, baseUrl, warnings } = parseIdArguments( + args, + ID_KINDS.token, + { usageHint: USAGE_HINT, multiple: true }, + ); + // Never choose one of several tokens for a destructive action. + if (ids.length > 1) { + throw new ValidationError( + `Revoke one token at a time, not ${ids.length}: ${USAGE_HINT}`, + "token-id", + ); + } + const [id = ""] = ids; + const org = parseOrgInput(target, USAGE_HINT); + return { id, org: baseUrl ? { ...org, baseUrl } : org, warnings }; +} + +/** + * Find the token in the unpaginated list so confirmation can show its name. + * + * @throws {ResolutionError} When the org has no active token with this ID + */ +async function findToken(org: string, id: string): Promise { + const tokens = await listOrgAuthTokens(org); + const token = tokens.find((t) => t.id === id); + if (!token) { + throw new ResolutionError( + `Org auth token ${id}`, + `not found in ${org}`, + `sentry token list ${org}`, + ["It may already be revoked."], + ); + } + return token; +} + +export const deleteCommand = buildDeleteCommand({ + docs: { + brief: "Revoke an org auth token", + customUsage: [USAGE], + fullDescription: + "Revoke an org auth token. Anything still using it (CI jobs, build " + + "plugins) stops being able to upload right away.\n\n" + + "Find the token ID with `sentry token list`.\n\n" + + "Needs the org:write permission (org owners and managers). The first run " + + "asks you to re-authorize so your CLI login can manage org auth tokens " + + "(interactive runs only; skipped with --yes, --json, or --dry-run).", + examples: [ + { + description: "Revoke a token (auto-detect org)", + command: "sentry token delete 12345", + }, + { + description: "Preview revoking a token", + command: "sentry token delete my-org/12345 --dry-run", + }, + { + description: "Revoke without the confirmation prompt", + command: "sentry token delete my-org/12345 --yes", + }, + ], + }, + output: { + human: formatTokenDeleted, + }, + parameters: { + positional: boundArguments({ + placeholder: "org/token-id", + brief: "[/] - Token to revoke", + kind: ORG_TARGET, + parse: parseTokenArguments, + resolve: async (input, context: SentryContext) => { + for (const warning of input.warnings) { + log.warn(warning); + } + const { org } = await resolveOrgInput( + input.org, + context.cwd, + USAGE_HINT, + ); + return { org, id: input.id }; + }, + }), + }, + async *func(this: SentryContext, flags: DeleteFlags, target: TokenArguments) { + const { org, id } = await target.resolved; + + selectAccountForOrg(org); + // Elevate before looking the token up and confirming, so an older login + // authorizes once instead of failing with 403 after the prompt. + const scopeMissing = await ensureTokenScopes( + DELETE_SCOPES, + "Revoking an org auth token", + ); + + let token: OrgAuthToken; + try { + token = await findToken(org, id); + } catch (error) { + throw explainTokenForbidden(error, DELETE_SCOPES, scopeMissing); + } + const result: DeleteResult = { deleted: false, org, id, name: token.name }; + + if (flags["dry-run"]) { + yield new CommandOutput({ ...result, dryRun: true }); + return; + } + + if (!isConfirmationBypassed(flags)) { + const confirmed = await confirmByTyping( + token.name, + `Type '${token.name}' to revoke this org auth token:`, + ); + if (!confirmed) { + yield new CommandOutput(result); + return { hint: "Cancelled." }; + } + } + + try { + await deleteOrgAuthToken(org, id); + } catch (error) { + throw explainTokenForbidden(error, DELETE_SCOPES, scopeMissing); + } + yield new CommandOutput({ ...result, deleted: true }); + }, +}); diff --git a/packages/cli/src/commands/token/index.ts b/packages/cli/src/commands/token/index.ts new file mode 100644 index 000000000..8fd9c191c --- /dev/null +++ b/packages/cli/src/commands/token/index.ts @@ -0,0 +1,21 @@ +import { buildRouteMap } from "../../lib/route-map.js"; +import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; +import { listCommand } from "./list.js"; + +export const tokenRoute = buildRouteMap({ + routes: { + list: listCommand, + create: createCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage org auth tokens", + fullDescription: + "Create, list, and revoke org auth tokens: the org:ci tokens used to upload " + + "source maps, debug files, and releases from CI.\n\n" + + "Needs a CLI login from `sentry auth login`. The first token command asks " + + "you to re-authorize so the login can manage org auth tokens.\n\n" + + "Alias: `sentry tokens` → `sentry token list`", + }, +}); diff --git a/packages/cli/src/commands/token/list.ts b/packages/cli/src/commands/token/list.ts new file mode 100644 index 000000000..6eca17030 --- /dev/null +++ b/packages/cli/src/commands/token/list.ts @@ -0,0 +1,112 @@ +/** + * sentry token list + * + * List an organization's active org auth tokens. + */ + +import type { SentryContext } from "../../context.js"; +import { listOrgAuthTokens } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { selectAccountForOrg } from "../../lib/db/auth.js"; +import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { type Column, formatTable } from "../../lib/formatters/table.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { type OrgTarget, orgTarget } from "../../lib/target-positional.js"; +import { type OrgAuthToken, OrgAuthTokenSchema } from "../../types/index.js"; +import { + ensureTokenScopes, + explainTokenForbidden, + LIST_SCOPES, +} from "./shared.js"; + +type ListFlags = { + readonly json: boolean; + readonly fields?: string[]; +}; + +const TOKEN_COLUMNS: Column[] = [ + { header: "ID", value: (t) => t.id }, + { header: "NAME", value: (t) => escapeMarkdownCell(t.name) }, + { header: "SCOPES", value: (t) => t.scopes.join(", ") }, + { + header: "ENDS WITH", + value: (t) => (t.tokenLastCharacters ? `…${t.tokenLastCharacters}` : ""), + }, + { + header: "LAST USED", + value: (t) => + t.dateLastUsed + ? formatRelativeTime(t.dateLastUsed) + : colorTag("muted", "never"), + }, + { header: "CREATED", value: (t) => formatRelativeTime(t.dateCreated) }, +]; + +function formatTokenList(tokens: OrgAuthToken[]): string { + if (tokens.length === 0) { + return "No active org auth tokens."; + } + return formatTable(tokens, TOKEN_COLUMNS); +} + +export const listCommand = buildCommand({ + docs: { + brief: "List org auth tokens", + fullDescription: + "List an organization's active org auth tokens. Sentry never returns a " + + "token's value after creation, so the list only shows its last characters.\n\n" + + "Alias: `sentry tokens` → `sentry token list`", + examples: [ + { + description: "List tokens (auto-detect org)", + command: "sentry token list", + }, + { + description: "List tokens in a specific org", + command: "sentry token list my-org", + }, + { description: "Output as JSON", command: "sentry token list --json" }, + ], + }, + output: { + human: formatTokenList, + schema: OrgAuthTokenSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug (auto-detected if omitted)", + parse: orgTarget, + optional: true, + }, + ], + }, + }, + async *func(this: SentryContext, _flags: ListFlags, target: OrgTarget) { + const resolved = await target.resolved; + + selectAccountForOrg(resolved.org); + const scopeMissing = await ensureTokenScopes( + LIST_SCOPES, + "Listing org auth tokens", + ); + + let tokens: OrgAuthToken[]; + try { + tokens = await listOrgAuthTokens(resolved.org); + } catch (error) { + throw explainTokenForbidden(error, LIST_SCOPES, scopeMissing); + } + + yield new CommandOutput(tokens); + if (tokens.length === 0) { + return { + hint: `Create one with: sentry token create ${resolved.org}/`, + }; + } + }, +}); diff --git a/packages/cli/src/commands/token/shared.ts b/packages/cli/src/commands/token/shared.ts new file mode 100644 index 000000000..d2f9576fe --- /dev/null +++ b/packages/cli/src/commands/token/shared.ts @@ -0,0 +1,130 @@ +/** + * Shared scope handling for `sentry token` commands. + * + * Sentry only lets OAuth application tokens (a `sentry auth login` grant) + * manage org auth tokens, and only when the grant includes `org:tokens`. + * That scope isn't in the CLI's default login scopes, so each command asks + * for it (plus whatever the endpoint needs) on first use. + */ + +import { ApiError, AuthError, ContextError } from "../../lib/errors.js"; +import { runInteractiveLogin } from "../../lib/interactive-login.js"; +import { ensureScopesForAction } from "../../lib/scope-recovery.js"; + +/** Org slug: lowercase alphanumeric and hyphens, no leading/trailing hyphen. */ +const ORG_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +/** Consent scope Sentry requires for every org auth token request. */ +const TOKEN_MANAGEMENT_SCOPE = "org:tokens"; + +/** Scopes for `token list`. */ +export const LIST_SCOPES = [TOKEN_MANAGEMENT_SCOPE, "org:read"] as const; + +/** + * Scopes for `token create`. New tokens get `org:ci`, and an OAuth caller can + * only mint scopes it holds itself. + */ +export const CREATE_SCOPES = [ + TOKEN_MANAGEMENT_SCOPE, + "org:read", + "org:ci", +] as const; + +/** Scopes for `token delete`, which also lists tokens to confirm by name. */ +export const DELETE_SCOPES = [ + TOKEN_MANAGEMENT_SCOPE, + "org:read", + "org:write", +] as const; + +/** + * Make sure the stored login can run a token command, re-authorizing it if + * it lacks `required`. + * + * @param required - Scopes the command needs + * @param action - Human description for the prompt, e.g. "Creating an org auth token" + * @returns `true` when the login lacks `required` but this run can't + * re-authorize it (no interactive terminal, or `--yes`/`--json`); pass it to + * {@link explainTokenForbidden} + * @throws {AuthError} When the user didn't complete the authorization + */ +export async function ensureTokenScopes( + required: readonly string[], + action: string, +): Promise { + const elevation = await ensureScopesForAction( + required, + action, + runInteractiveLogin, + ); + if (elevation === "declined") { + throw new AuthError( + "invalid", + `Authorization was not completed, so the CLI can't manage org auth tokens.\n ` + + `If Sentry reported an unknown scope, your Sentry server doesn't support ${TOKEN_MANAGEMENT_SCOPE} yet.`, + { skipAutoAuth: true }, + ); + } + return elevation === "unavailable"; +} + +/** + * Explain a 403 from a token endpoint. Sentry's body doesn't say whether the + * scope, the auth method, or the user's org role is what's missing. + * + * @param error - Error thrown by the API call + * @param required - Scopes the command needs + * @param scopeMissing - The stored login lacks `required` and couldn't be + * re-authorized in this run (no interactive terminal, or `--yes`/`--json`/`--dry-run`, + * which must never block on the authorization flow) + */ +export function explainTokenForbidden( + error: unknown, + required: readonly string[], + scopeMissing: boolean, +): unknown { + if (!(error instanceof ApiError && error.status === 403)) { + return error; + } + const scopes = required.join(", "); + const detail = scopeMissing + ? `Your saved login doesn't include ${scopes}. Re-authorization needs an interactive terminal and is skipped with --yes, --json, or --dry-run.\n ` + + "Run this command once in a terminal without --yes, --json, or --dry-run to re-authorize." + : "Org auth tokens can only be managed with a CLI login from 'sentry auth login'. " + + "Personal tokens, org auth tokens, and SENTRY_AUTH_TOKEN can't manage them.\n " + + `The login also needs ${scopes}, and your org role must allow it.`; + return new ApiError( + "Not allowed to manage org auth tokens", + 403, + detail, + error.endpoint, + ); +} + +/** + * Parse `[/]`. + * + * Splits on the first `/` when the part before it looks like an org slug, so + * a token name can contain `/` when the org is given explicitly. + * + * @param arg - Raw positional, e.g. `my-org/github-actions` + * @param label - What the value is, for the error, e.g. "Token name" + * @param usageHint - Usage line for the error + * @throws {ContextError} When the value is empty + */ +export function parseOrgScopedArg( + arg: string | undefined, + label: string, + usageHint: string, +): { value: string; orgSlug?: string } { + const trimmed = arg?.trim() ?? ""; + const slash = trimmed.indexOf("/"); + const prefix = slash > 0 ? trimmed.slice(0, slash) : ""; + const parsed = ORG_SLUG_RE.test(prefix) + ? { orgSlug: prefix, value: trimmed.slice(slash + 1).trim() } + : { value: trimmed }; + if (!parsed.value) { + throw new ContextError(label, usageHint, []); + } + return parsed; +} diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index f652704a2..56de7b41d 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -198,6 +198,11 @@ export { listIntegrations, listIntegrationsPaginated, } from "./api/integrations.js"; +export { + createOrgAuthToken, + deleteOrgAuthToken, + listOrgAuthTokens, +} from "./api/org-auth-tokens.js"; export { listAllRepositories, listRepositories, diff --git a/packages/cli/src/lib/api-scope.ts b/packages/cli/src/lib/api-scope.ts index 3228669c0..a33931bb4 100644 --- a/packages/cli/src/lib/api-scope.ts +++ b/packages/cli/src/lib/api-scope.ts @@ -25,6 +25,7 @@ export const SENTRY_SCOPES = [ "org:admin", "org:integrations", "org:ci", + "org:tokens", "member:invite", "member:read", "member:write", diff --git a/packages/cli/src/lib/api/org-auth-tokens.ts b/packages/cli/src/lib/api/org-auth-tokens.ts new file mode 100644 index 000000000..bb4effcd6 --- /dev/null +++ b/packages/cli/src/lib/api/org-auth-tokens.ts @@ -0,0 +1,82 @@ +/** + * Org auth token API functions + * + * List, create, and revoke organization auth tokens: the `org:ci` tokens used + * for source map, debug file, and release uploads. + * + * The endpoints live on the control silo. They accept browser sessions and + * OAuth application tokens that hold `org:tokens`, so they work with a CLI + * login (`sentry auth login`) but reject personal and org auth tokens. + */ + +import { array } from "valibot"; + +import { type OrgAuthToken, OrgAuthTokenSchema } from "../../types/index.js"; +import { getControlSiloUrl } from "../sentry-client.js"; +import { + apiRequestToRegion, + apiRequestToRegionNoContent, +} from "./infrastructure.js"; + +function tokensPath(orgSlug: string): string { + return `/organizations/${encodeURIComponent(orgSlug)}/org-auth-tokens/`; +} + +/** + * List an organization's active org auth tokens. + * + * The endpoint isn't paginated. Sentry orders tokens by last use (unused + * tokens last), then by name. + * + * @param orgSlug - Organization slug + * @returns Active tokens, without their secret values + */ +export async function listOrgAuthTokens( + orgSlug: string, +): Promise { + const { data } = await apiRequestToRegion( + getControlSiloUrl(), + tokensPath(orgSlug), + { schema: array(OrgAuthTokenSchema) }, + ); + return data; +} + +/** + * Create an org auth token with Sentry's default scope (`org:ci`). + * + * An OAuth caller can only mint scopes its own token holds, so the CLI's + * grant needs `org:ci` too. + * + * @param orgSlug - Organization slug + * @param name - Token name (max 255 characters) + * @returns The new token, including its full `token` value + */ +export async function createOrgAuthToken( + orgSlug: string, + name: string, +): Promise { + const { data } = await apiRequestToRegion( + getControlSiloUrl(), + tokensPath(orgSlug), + { method: "POST", body: { name }, schema: OrgAuthTokenSchema }, + ); + return data; +} + +/** + * Revoke an org auth token. It stops working right away. + * + * @param orgSlug - Organization slug + * @param tokenId - Numeric token ID + */ +export async function deleteOrgAuthToken( + orgSlug: string, + tokenId: string, +): Promise { + await apiRequestToRegionNoContent( + getControlSiloUrl(), + `${tokensPath(orgSlug)}${encodeURIComponent(tokenId)}/`, + { method: "DELETE" }, + ); +} diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index 3c92b0193..6a3e5072f 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -276,6 +276,22 @@ export const ID_KINDS = { fromUrl: () => undefined, orgScoped: true, }, + token: { + label: "Token ID", + isId: isAllDigits, + mayBeId: () => true, + validate: (value) => { + if (!isAllDigits(value)) { + throw new ValidationError( + `Invalid token ID '${value}'. Use the numeric ID from 'sentry token list'.`, + "token-id", + ); + } + return value; + }, + fromUrl: () => undefined, + orgScoped: true, + }, } as const satisfies Record; /** How a command takes its IDs. */ diff --git a/packages/cli/src/lib/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts index 9717615fc..3cf3e0682 100644 --- a/packages/cli/src/lib/scope-recovery.ts +++ b/packages/cli/src/lib/scope-recovery.ts @@ -1,5 +1,6 @@ import { isatty } from "node:tty"; import { getCurrentAuthScopes } from "./api/auth.js"; +import { SENTRY_SCOPES } from "./api-scope.js"; import { assertAutoLoginHostTrusted } from "./auto-auth.js"; import { type AuthSource, @@ -58,9 +59,50 @@ const defaultRuntime: ScopeRecoveryRuntime = { function reauthorize( runInteractiveLogin: InteractiveLogin, runtime: ScopeRecoveryRuntime, + scope?: string, ): Promise { const accountId = runtime.getReauthorizationAccountId?.(); - return accountId ? runInteractiveLogin({ accountId }) : runInteractiveLogin(); + const options: InteractiveLoginOptions = { + ...(accountId && { accountId }), + ...(scope && { scope }), + }; + return Object.keys(options).length > 0 + ? runInteractiveLogin(options) + : runInteractiveLogin(); +} + +const DEFAULT_SCOPE_SET = new Set(OAUTH_SCOPES); +const KNOWN_SCOPE_SET = new Set(SENTRY_SCOPES); + +/** + * Scope string for an elevation that needs scopes outside the CLI's defaults. + * + * Opt-in scopes such as `org:tokens` stay out of {@link OAUTH_SCOPES} because + * Sentry rejects the whole device-flow request when it doesn't know one of the + * requested scopes, which would break `sentry auth login` on older self-hosted + * servers. Keeping the opt-in scopes the grant already holds means elevating + * for one action doesn't take away what an earlier elevation added. + * + * @returns `undefined` when `required` only names default scopes, so the + * device flow requests the defaults as usual + */ +function elevationScopeString( + granted: ReadonlySet, + required: readonly string[], +): string | undefined { + if (required.every((scope) => DEFAULT_SCOPE_SET.has(scope))) { + return; + } + const scopes = new Set(OAUTH_SCOPES); + for (const scope of granted) { + if (KNOWN_SCOPE_SET.has(scope)) { + scopes.add(scope); + } + } + for (const scope of required) { + scopes.add(scope); + } + return [...scopes].join(" "); } async function inspectOAuthScopes( @@ -202,8 +244,9 @@ export type ScopeElevationResult = * Commands that need a default scope older logins may lack call this before * any confirmation prompt, so the user authorizes once instead of hitting a * 403 and re-running the command (and its prompts) through - * {@link runWithScopeRecovery}. The device flow requests the CLI's default - * scopes, so `required` must be a subset of {@link OAUTH_SCOPES}. + * {@link runWithScopeRecovery}. When `required` only names default scopes, + * the device flow requests the defaults. Otherwise it requests the defaults, + * the opt-in scopes the grant already has, and `required`. * * @param required - Scopes the action needs * @param action - Human description for the prompt, e.g. "Removing an integration" @@ -215,11 +258,12 @@ export async function ensureScopesForAction( runtime: ScopeRecoveryRuntime = defaultRuntime, ): Promise { let missing: string[]; + let granted: Set; try { if (runtime.getAuthSource() !== "oauth") { return "ready"; } - const granted = new Set((await runtime.getAuthScopes()) ?? []); + granted = new Set((await runtime.getAuthScopes()) ?? []); missing = required.filter((scope) => !granted.has(scope)); } catch (error) { logger.debug("Could not inspect OAuth scopes before action", error); @@ -236,7 +280,8 @@ export async function ensureScopesForAction( runtime.write( `${action} needs elevated permissions (${missing.join(", ")}). Starting authorization...\n\n`, ); - return (await reauthorize(runInteractiveLogin, runtime)) + const scope = elevationScopeString(granted, required); + return (await reauthorize(runInteractiveLogin, runtime, scope)) ? "elevated" : "declined"; } diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 4ba1bb7b4..21087d6da 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -140,6 +140,7 @@ export type { LogsResponse, Mechanism, MonitorConfig, + OrgAuthToken, OsContext, ProductTrial, ProjectKey, @@ -185,6 +186,7 @@ export { IssueViewOutputSchema, LogsResponseSchema, MonitorConfigSchema, + OrgAuthTokenSchema, ProductTrialSchema, RegionSchema, RepositoryProviderSchema, diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index 0ee3f7b58..2988228a5 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -1304,6 +1304,43 @@ export const SentryIntegrationSchema = pipe( export type SentryIntegration = InferOutput; +// Org Auth Token + +/** + * Organization auth token from `/organizations/{org}/org-auth-tokens/`. + * + * The full `token` value is only present in the create response; Sentry + * stores a hash and can't return it again. + */ +export const OrgAuthTokenSchema = pipe( + looseObject({ + id: pipe(string(), description("Token ID")), + name: pipe(string(), description("Token name")), + scopes: pipe(array(string()), description("Scopes granted to the token")), + tokenLastCharacters: pipe( + nullable(string()), + description("Last 4 characters of the token"), + ), + dateCreated: pipe(string(), description("Creation timestamp")), + dateLastUsed: pipe( + nullable(string()), + description("Last time the token was used, if ever"), + ), + projectLastUsedId: optional( + pipe( + nullable(string()), + description("ID of the project the token was last used for"), + ), + ), + token: optional( + pipe(string(), description("Full token value (create response only)")), + ), + }), + description("Organization auth token"), +); + +export type OrgAuthToken = InferOutput; + // Cron Monitor /** diff --git a/packages/cli/test/commands/token/accounts.test.ts b/packages/cli/test/commands/token/accounts.test.ts new file mode 100644 index 000000000..2cd362f0c --- /dev/null +++ b/packages/cli/test/commands/token/accounts.test.ts @@ -0,0 +1,198 @@ +/** Token commands select an account before inspecting scopes or calling the API. */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createCommand } from "../../../src/commands/token/create.js"; +import { deleteCommand } from "../../../src/commands/token/delete.js"; +import { listCommand } from "../../../src/commands/token/list.js"; +import { + getActiveAccountId, + setActiveAccount, + setAuthToken, + withStoredAccount, +} from "../../../src/lib/db/auth.js"; +import { setOrgRegion, setOrgRegions } from "../../../src/lib/db/regions.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { OAUTH_SCOPES } from "../../../src/lib/oauth.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("token-accounts-", { + isolateProjectRoot: true, +}); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_ORG", + "SENTRY_PROJECT", +]); + +const WORK_HOST = "https://work.example.invalid"; +const TOKEN_PATH = "/api/0/organizations/work-org/org-auth-tokens/"; +const token = { + id: "7", + name: "ci", + scopes: ["org:ci"], + tokenLastCharacters: "abcd", + dateCreated: "2026-01-01T00:00:00Z", + dateLastUsed: null, +}; + +let activeAccountId: string; +let requests: { method: string; url: string; authorization: string | null }[]; + +function addAccount(credential: string, host: string): string { + const id = setAuthToken(credential, undefined, undefined, { + host, + newAccount: true, + }); + setUserInfo({ userId: credential }); + return id; +} + +beforeEach(async () => { + await resetHostScopingState(); + resetAuthenticatedFetch(); + addAccount("work-token", WORK_HOST); + setOrgRegion("work-org", WORK_HOST, WORK_HOST); + activeAccountId = addAccount("personal-token", "https://sentry.io"); + setOrgRegions([ + { + slug: "personal-org", + regionUrl: "https://us.sentry.io", + sourceOrigin: "https://sentry.io", + orgId: "12", + orgName: "personal-org", + }, + ]); + + requests = []; + vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const request = new Request(input, init); + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers.get("Authorization"), + }); + const path = new URL(request.url).pathname; + if (path === "/api/0/") { + return Promise.resolve( + Response.json({ + auth: { + scopes: [...OAUTH_SCOPES, "org:tokens", "org:ci", "org:write"], + }, + }), + ); + } + if (path === TOKEN_PATH) { + return Promise.resolve( + Response.json( + request.method === "POST" + ? { ...token, token: "sntrys_synthetic_created_token" } + : [token], + ), + ); + } + if (path === `${TOKEN_PATH}7/` && request.method === "DELETE") { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + Response.json({ detail: "Not found" }, { status: 404 }), + ); + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + resetAuthenticatedFetch(); + await resetHostScopingState(); +}); + +async function runCommand( + command: typeof listCommand, + target: string, + name = "list", +): Promise { + const func = await command.loader(); + await func.call( + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: getConfigDir(), + commandPrefix: ["sentry", "token", name], + }, + { json: true, yes: true, force: false, "dry-run": false }, + target, + ); +} + +describe("token command accounts", () => { + test.each([ + { + name: "list", + command: listCommand, + target: "work-org", + methods: ["GET"], + }, + { + name: "create", + command: createCommand, + target: "work-org/ci", + methods: ["POST"], + }, + { + name: "delete", + command: deleteCommand, + target: "work-org/7", + methods: ["GET", "DELETE"], + }, + ])( + "$name uses the target account for scopes and the action", + async ({ name, command, target, methods }) => { + await runCommand(command, target, name); + + expect(requests).toEqual( + [ + { method: "GET", url: `${WORK_HOST}/api/0/` }, + ...methods.map((method) => ({ + method, + url: `${WORK_HOST}${TOKEN_PATH}${method === "DELETE" ? "7/" : ""}`, + })), + ].map((request) => ({ + ...request, + authorization: "Bearer work-token", + })), + ); + expect(getActiveAccountId()).toBe(activeAccountId); + }, + ); + + test("keeps an explicitly pinned account on the same server", async () => { + const pinned = addAccount("pinned-token", WORK_HOST); + setActiveAccount(activeAccountId); + + await withStoredAccount({ id: pinned, host: WORK_HOST }, () => + runCommand(listCommand, "work-org"), + ); + + expect(requests).toEqual([ + { + method: "GET", + url: `${WORK_HOST}/api/0/`, + authorization: "Bearer pinned-token", + }, + { + method: "GET", + url: `${WORK_HOST}${TOKEN_PATH}`, + authorization: "Bearer pinned-token", + }, + ]); + expect(getActiveAccountId()).toBe(activeAccountId); + }); +}); diff --git a/packages/cli/test/commands/token/create.test.ts b/packages/cli/test/commands/token/create.test.ts new file mode 100644 index 000000000..199a4a52f --- /dev/null +++ b/packages/cli/test/commands/token/create.test.ts @@ -0,0 +1,187 @@ +/** + * Token Create Command Tests + */ + +import { stripVTControlCharacters } from "node:util"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createCommand } from "../../../src/commands/token/create.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError, AuthError } from "../../../src/lib/errors.js"; +import { + applyBoundPositionals, + BoundPositional, +} from "../../../src/lib/positional-parse.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +vi.mock("../../../src/lib/scope-recovery.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ensureScopesForAction: vi.fn() }; +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as scopeRecovery from "../../../src/lib/scope-recovery.js"; + +const created = { + id: "9", + name: "github-actions", + scopes: ["org:ci"], + tokenLastCharacters: "abcd", + dateCreated: "2026-01-01T00:00:00Z", + dateLastUsed: null, + token: `sntrys_${"abcdef0123456789".repeat(14)}`, +}; + +const flags = { "dry-run": false, json: false }; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +function output(write: ReturnType): string { + return write.mock.calls.map((c) => c[0]).join(""); +} + +describe("token create", () => { + let savedColumns: number; + let createSpy: ReturnType; + let resolveOrgSpy: ReturnType; + const ensureScopesSpy = vi.mocked(scopeRecovery.ensureScopesForAction); + + beforeEach(() => { + savedColumns = process.stdout.columns; + process.stdout.columns = 80; + ensureScopesSpy.mockResolvedValue("ready"); + createSpy = vi.spyOn(apiClient, "createOrgAuthToken"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + createSpy.mockResolvedValue(created); + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + }); + + afterEach(() => { + process.stdout.columns = savedColumns; + createSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + ensureScopesSpy.mockReset(); + }); + + test("prints a long token intact on its own line exactly once", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, flags, "my-org/github-actions"); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); + expect(ensureScopesSpy).toHaveBeenCalledWith( + ["org:tokens", "org:read", "org:ci"], + "Creating an org auth token", + expect.any(Function), + ); + expect(createSpy).toHaveBeenCalledWith("my-org", "github-actions"); + const lines = stripVTControlCharacters(output(stdoutWrite)).split("\n"); + expect(lines.filter((line) => line === created.token)).toEqual([ + created.token, + ]); + }); + + test("JSON output includes the token value", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { ...flags, json: true }, "github-actions"); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: undefined, cwd: "/tmp" }); + expect(JSON.parse(output(stdoutWrite))).toMatchObject({ + org: "my-org", + id: "9", + token: created.token, + }); + }); + + test("parses slash names without resolving the org until execution", async () => { + const { context } = createMockContext(); + const [target] = applyBoundPositionals( + ["my-org/deploy/prod"], + createCommand.parameters.positional, + context, + ); + + expect(target).toBeInstanceOf(BoundPositional); + expect((target as BoundPositional).parsed).toEqual({ + orgSlug: "my-org", + value: "deploy/prod", + }); + expect(resolveOrgSpy).not.toHaveBeenCalled(); + + const func = await createCommand.loader(); + await func.call(context, flags, target); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); + expect(createSpy).toHaveBeenCalledWith("my-org", "deploy/prod"); + }); + + test("dry-run neither elevates nor creates", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { ...flags, "dry-run": true }, "my-org/ci"); + + expect(ensureScopesSpy).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + expect(output(stdoutWrite)).toContain("Would create"); + }); + + test("does not create when authorization is declined", async () => { + ensureScopesSpy.mockResolvedValue("declined"); + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect(func.call(context, flags, "my-org/ci")).rejects.toThrow( + AuthError, + ); + expect(createSpy).not.toHaveBeenCalled(); + }); + + test("explains a 403", async () => { + createSpy.mockRejectedValue(new ApiError("Forbidden", 403)); + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const error = await func + .call(context, flags, "my-org/ci") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).detail).toContain("org:ci"); + }); +}); diff --git a/packages/cli/test/commands/token/delete.test.ts b/packages/cli/test/commands/token/delete.test.ts new file mode 100644 index 000000000..6b8ee1fe8 --- /dev/null +++ b/packages/cli/test/commands/token/delete.test.ts @@ -0,0 +1,181 @@ +/** + * Token Delete Command Tests + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + deleteCommand, + parseTokenArguments, +} from "../../../src/commands/token/delete.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError, ResolutionError } from "../../../src/lib/errors.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +vi.mock("../../../src/lib/scope-recovery.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ensureScopesForAction: vi.fn() }; +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as scopeRecovery from "../../../src/lib/scope-recovery.js"; + +const tokens = [ + { + id: "7", + name: "github-actions", + scopes: ["org:ci"], + tokenLastCharacters: "abcd", + dateCreated: "2026-01-01T00:00:00Z", + dateLastUsed: null, + }, +]; + +const yesFlags = { yes: true, force: false, "dry-run": false, json: false }; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +function output(write: ReturnType): string { + return write.mock.calls.map((c) => c[0]).join(""); +} + +describe("parseTokenArguments", () => { + // Shared org/ID forms and rejection cases live in argument-conventions.test.ts. + test("points non-numeric IDs to token list", () => { + expect(() => parseTokenArguments(["my-org/github-actions"])).toThrow( + "Use the numeric ID from 'sentry token list'", + ); + }); +}); + +describe("token delete", () => { + let listSpy: ReturnType; + let deleteSpy: ReturnType; + let resolveOrgSpy: ReturnType; + const ensureScopesSpy = vi.mocked(scopeRecovery.ensureScopesForAction); + + beforeEach(() => { + ensureScopesSpy.mockResolvedValue("ready"); + listSpy = vi.spyOn(apiClient, "listOrgAuthTokens"); + deleteSpy = vi.spyOn(apiClient, "deleteOrgAuthToken"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + listSpy.mockResolvedValue(tokens); + deleteSpy.mockResolvedValue(undefined); + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + }); + + afterEach(() => { + listSpy.mockRestore(); + deleteSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + ensureScopesSpy.mockReset(); + }); + + test("revokes the token with --yes", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, yesFlags, "my-org/7"); + + expect(ensureScopesSpy).toHaveBeenCalledWith( + ["org:tokens", "org:read", "org:write"], + "Revoking an org auth token", + expect.any(Function), + ); + expect(deleteSpy).toHaveBeenCalledWith("my-org", "7"); + expect(output(stdoutWrite)).toContain("Revoked org auth token"); + }); + + test("JSON output reports the revoked token", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, { ...yesFlags, json: true }, "my-org/7"); + + expect(JSON.parse(output(stdoutWrite))).toEqual({ + deleted: true, + org: "my-org", + id: "7", + name: "github-actions", + }); + }); + + test("dry-run does not delete", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, { ...yesFlags, "dry-run": true }, "my-org/7"); + + expect(deleteSpy).not.toHaveBeenCalled(); + expect(output(stdoutWrite)).toContain("Would revoke"); + }); + + test("dry-run explains the 403 when it can't re-authorize", async () => { + ensureScopesSpy.mockResolvedValue("unavailable"); + listSpy.mockRejectedValue(new ApiError("Forbidden", 403)); + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + const error = await func + .call(context, { ...yesFlags, "dry-run": true }, "my-org/7") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).detail).toContain("--dry-run"); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + test("fails before deleting when the token doesn't exist", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + await expect(func.call(context, yesFlags, "my-org/99")).rejects.toThrow( + ResolutionError, + ); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + test("explains a 403 from the delete", async () => { + deleteSpy.mockRejectedValue(new ApiError("Forbidden", 403)); + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + const error = await func + .call(context, yesFlags, "my-org/7") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).detail).toContain("org:write"); + }); +}); diff --git a/packages/cli/test/commands/token/list.test.ts b/packages/cli/test/commands/token/list.test.ts new file mode 100644 index 000000000..4b0624529 --- /dev/null +++ b/packages/cli/test/commands/token/list.test.ts @@ -0,0 +1,161 @@ +/** + * Token List Command Tests + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/token/list.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError, ContextError } from "../../../src/lib/errors.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +vi.mock("../../../src/lib/scope-recovery.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ensureScopesForAction: vi.fn() }; +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as scopeRecovery from "../../../src/lib/scope-recovery.js"; + +const tokens = [ + { + id: "7", + name: "github-actions", + scopes: ["org:ci"], + tokenLastCharacters: "abcd", + dateCreated: "2026-01-01T00:00:00Z", + dateLastUsed: "2026-02-01T00:00:00Z", + projectLastUsedId: "42", + }, + { + id: "8", + name: "unused", + scopes: ["org:ci"], + tokenLastCharacters: "wxyz", + dateCreated: "2026-01-02T00:00:00Z", + dateLastUsed: null, + projectLastUsedId: null, + }, +]; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + commandPrefix: ["sentry", "token", "list"], + }, + stdoutWrite, + }; +} + +function output(write: ReturnType): string { + return write.mock.calls.map((c) => c[0]).join(""); +} + +describe("token list", () => { + let listSpy: ReturnType; + let resolveOrgSpy: ReturnType; + const ensureScopesSpy = vi.mocked(scopeRecovery.ensureScopesForAction); + + beforeEach(() => { + ensureScopesSpy.mockResolvedValue("ready"); + listSpy = vi.spyOn(apiClient, "listOrgAuthTokens"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + listSpy.mockResolvedValue(tokens); + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + }); + + afterEach(() => { + listSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + ensureScopesSpy.mockReset(); + }); + + test("lists tokens without their values", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false }, "my-org/"); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); + expect(ensureScopesSpy).toHaveBeenCalledWith( + ["org:tokens", "org:read"], + "Listing org auth tokens", + expect.any(Function), + ); + expect(listSpy).toHaveBeenCalledWith("my-org"); + const text = output(stdoutWrite); + expect(text).toContain("github-actions"); + expect(text).toContain("abcd"); + expect(text).toContain("never"); + }); + + test("JSON output is the token array", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true }); + + expect(JSON.parse(output(stdoutWrite))).toEqual(tokens); + }); + + test("says when there are no tokens", async () => { + listSpy.mockResolvedValue([]); + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false }, "my-org"); + + expect(output(stdoutWrite)).toContain("No active org auth tokens"); + }); + + test("explains a 403", async () => { + ensureScopesSpy.mockResolvedValue("unavailable"); + listSpy.mockRejectedValue(new ApiError("Forbidden", 403)); + const { context } = createMockContext(); + const func = await listCommand.loader(); + + const error = await func + .call(context, { json: true }, "my-org") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).detail).toContain("org:tokens"); + }); + + test("throws ContextError when no org can be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect(func.call(context, { json: false })).rejects.toThrow( + ContextError, + ); + expect(ensureScopesSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/commands/token/shared.test.ts b/packages/cli/test/commands/token/shared.test.ts new file mode 100644 index 000000000..1823628ea --- /dev/null +++ b/packages/cli/test/commands/token/shared.test.ts @@ -0,0 +1,114 @@ +/** + * Token command shared helper tests + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/lib/scope-recovery.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ensureScopesForAction: vi.fn() }; +}); + +import { + CREATE_SCOPES, + ensureTokenScopes, + explainTokenForbidden, + parseOrgScopedArg, +} from "../../../src/commands/token/shared.js"; +import { ApiError, AuthError, ContextError } from "../../../src/lib/errors.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as scopeRecovery from "../../../src/lib/scope-recovery.js"; + +const USAGE = "sentry token create [/]"; + +describe("parseOrgScopedArg", () => { + test("splits an org prefix", () => { + expect(parseOrgScopedArg("my-org/ci", "Token name", USAGE)).toEqual({ + orgSlug: "my-org", + value: "ci", + }); + }); + + test("keeps a bare value", () => { + expect(parseOrgScopedArg("ci", "Token name", USAGE)).toEqual({ + value: "ci", + }); + }); + + test("only splits on the first slash, so names can contain slashes", () => { + expect( + parseOrgScopedArg("my-org/deploy/prod", "Token name", USAGE), + ).toEqual({ orgSlug: "my-org", value: "deploy/prod" }); + }); + + test("keeps a prefix that isn't an org slug in the value", () => { + expect(parseOrgScopedArg("My Token/prod", "Token name", USAGE)).toEqual({ + value: "My Token/prod", + }); + }); + + test("rejects an empty value", () => { + expect(() => parseOrgScopedArg("", "Token name", USAGE)).toThrow( + ContextError, + ); + expect(() => parseOrgScopedArg("my-org/", "Token name", USAGE)).toThrow( + ContextError, + ); + }); +}); + +describe("ensureTokenScopes", () => { + const ensureScopesSpy = vi.mocked(scopeRecovery.ensureScopesForAction); + + afterEach(() => ensureScopesSpy.mockReset()); + + test("reports whether the scopes are still missing", async () => { + ensureScopesSpy.mockResolvedValue("ready"); + await expect(ensureTokenScopes(CREATE_SCOPES, "x")).resolves.toBe(false); + + ensureScopesSpy.mockResolvedValue("elevated"); + await expect(ensureTokenScopes(CREATE_SCOPES, "x")).resolves.toBe(false); + + ensureScopesSpy.mockResolvedValue("unavailable"); + await expect(ensureTokenScopes(CREATE_SCOPES, "x")).resolves.toBe(true); + }); + + test("throws when the authorization is not completed", async () => { + ensureScopesSpy.mockResolvedValue("declined"); + await expect(ensureTokenScopes(CREATE_SCOPES, "x")).rejects.toThrow( + AuthError, + ); + }); +}); + +describe("explainTokenForbidden", () => { + test("passes through errors other than 403", () => { + const error = new ApiError("Not found", 404); + expect(explainTokenForbidden(error, CREATE_SCOPES, false)).toBe(error); + }); + + test("names the auth method, scopes, and role on a 403", () => { + const error = explainTokenForbidden( + new ApiError("Forbidden", 403), + CREATE_SCOPES, + false, + ); + expect(error).toBeInstanceOf(ApiError); + const detail = (error as ApiError).detail; + expect(detail).toContain("sentry auth login"); + expect(detail).toContain("org:tokens, org:read, org:ci"); + expect(detail).toContain("org role"); + }); + + test("says how to re-authorize when elevation was skipped", () => { + const error = explainTokenForbidden( + new ApiError("Forbidden", 403), + CREATE_SCOPES, + true, + ); + expect((error as ApiError).detail).toContain( + "skipped with --yes, --json, or --dry-run", + ); + }); +}); diff --git a/packages/cli/test/lib/api-scope.test.ts b/packages/cli/test/lib/api-scope.test.ts index 99e073cbd..d3c24743f 100644 --- a/packages/cli/test/lib/api-scope.test.ts +++ b/packages/cli/test/lib/api-scope.test.ts @@ -126,6 +126,7 @@ describe("extractRequiredScopes", () => { "org:admin", "org:integrations", "org:ci", + "org:tokens", "member:invite", "member:read", "member:write", diff --git a/packages/cli/test/lib/api/org-auth-tokens.test.ts b/packages/cli/test/lib/api/org-auth-tokens.test.ts new file mode 100644 index 000000000..be465646e --- /dev/null +++ b/packages/cli/test/lib/api/org-auth-tokens.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/lib/api/infrastructure.js", () => ({ + apiRequestToRegion: vi.fn(), + apiRequestToRegionNoContent: vi.fn(), +})); + +vi.mock("../../../src/lib/sentry-client.js", () => ({ + getControlSiloUrl: () => "https://sentry.io", +})); + +import { + apiRequestToRegion, + apiRequestToRegionNoContent, +} from "../../../src/lib/api/infrastructure.js"; +import { + createOrgAuthToken, + deleteOrgAuthToken, + listOrgAuthTokens, +} from "../../../src/lib/api/org-auth-tokens.js"; + +const token = { + id: "7", + name: "ci", + scopes: ["org:ci"], + tokenLastCharacters: "abcd", + dateCreated: "2026-01-01T00:00:00Z", + dateLastUsed: null, +}; + +describe("org auth token API", () => { + beforeEach(() => { + vi.mocked(apiRequestToRegion).mockReset(); + vi.mocked(apiRequestToRegionNoContent).mockReset(); + }); + + test("lists tokens from the control silo", async () => { + vi.mocked(apiRequestToRegion).mockResolvedValue({ + data: [token], + headers: new Headers(), + }); + + await expect(listOrgAuthTokens("my-org")).resolves.toEqual([token]); + expect(apiRequestToRegion).toHaveBeenCalledWith( + "https://sentry.io", + "/organizations/my-org/org-auth-tokens/", + expect.objectContaining({ schema: expect.any(Object) }), + ); + }); + + test("creates a token with only a name", async () => { + vi.mocked(apiRequestToRegion).mockResolvedValue({ + data: { ...token, token: "sntrys_secret" }, + headers: new Headers(), + }); + + const created = await createOrgAuthToken("my-org", "ci"); + expect(created.token).toBe("sntrys_secret"); + expect(apiRequestToRegion).toHaveBeenCalledWith( + "https://sentry.io", + "/organizations/my-org/org-auth-tokens/", + expect.objectContaining({ method: "POST", body: { name: "ci" } }), + ); + }); + + test("deletes a token by ID", async () => { + vi.mocked(apiRequestToRegionNoContent).mockResolvedValue(); + + await deleteOrgAuthToken("my-org", "7"); + expect(apiRequestToRegionNoContent).toHaveBeenCalledWith( + "https://sentry.io", + "/organizations/my-org/org-auth-tokens/7/", + { method: "DELETE" }, + ); + }); +}); diff --git a/packages/cli/test/lib/argument-conventions.test.ts b/packages/cli/test/lib/argument-conventions.test.ts index f8b9a8686..d778f878b 100644 --- a/packages/cli/test/lib/argument-conventions.test.ts +++ b/packages/cli/test/lib/argument-conventions.test.ts @@ -64,6 +64,8 @@ import { downloadCommand as replayDownloadCommand } from "../../src/commands/rep import { viewCommand as replayViewCommand } from "../../src/commands/replay/view.js"; import { listCommand as spanListCommand } from "../../src/commands/span/list.js"; import { viewCommand as spanViewCommand } from "../../src/commands/span/view.js"; +import { deleteCommand as tokenDeleteCommand } from "../../src/commands/token/delete.js"; +import { listCommand as tokenListCommand } from "../../src/commands/token/list.js"; import { logsCommand as traceLogsCommand } from "../../src/commands/trace/logs.js"; import { viewCommand as traceViewCommand } from "../../src/commands/trace/view.js"; import { listCommand as trialListCommand } from "../../src/commands/trial/list.js"; @@ -877,6 +879,7 @@ describe.each(RELEASE_COMMANDS)("%s", (_name, command) => { const ORG_ID_COMMANDS: readonly (readonly [string, unknown, string])[] = [ ["agent-conversation view", agentConversationViewCommand, "conv-1"], ["integration delete", integrationDeleteCommand, "42"], + ["token delete", tokenDeleteCommand, "7"], ]; /** The org and ID a command parsed from `[/]`. */ @@ -933,6 +936,7 @@ describe.each(ORG_ID_COMMANDS)("%s", (_name, command, id) => { const ORG_COMMANDS: readonly (readonly [string, unknown, readonly string[]])[] = [ ["org view", orgViewCommand, []], + ["token list", tokenListCommand, []], ["trial list", trialListCommand, []], // The trial name comes first ["trial start", trialStartCommand, ["seer"]], diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index 558a34ebc..8d55caf5f 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -439,7 +439,7 @@ describe("kinds with names", () => { }); describe("org-scoped kinds without hex IDs", () => { - const { conversation, integration } = ID_KINDS; + const { conversation } = ID_KINDS; test("any word may be a conversation ID; a bad one is rejected", () => { expect(parse(conversation, ["acme/conv_01H"])).toEqual({ @@ -456,15 +456,16 @@ describe("org-scoped kinds without hex IDs", () => { } }); - test("integration IDs are numeric", () => { - expect(parse(integration, ["acme", "42"])).toEqual({ + test.each(["integration", "token"] as const)("%s IDs are numeric", (name) => { + const kind = ID_KINDS[name]; + expect(parse(kind, ["acme", "42"])).toEqual({ target: "acme/", ids: ["42"], warnings: [], }); - expect(() => parse(integration, ["acme/github"])).toThrow( - "Use the numeric ID from 'sentry integration list'", + expect(() => parse(kind, ["acme/github"])).toThrow( + `Use the numeric ID from 'sentry ${name} list'`, ); - expect(() => parse(integration, ["acme/"])).toThrow(ContextError); + expect(() => parse(kind, ["acme/"])).toThrow(ContextError); }); }); diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts index 00220c225..229f77844 100644 --- a/packages/cli/test/lib/scope-recovery.test.ts +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -470,4 +470,59 @@ describe("ensureScopesForAction", () => { expect(testRuntime.getAuthScopes).not.toHaveBeenCalled(); expect(login).not.toHaveBeenCalled(); }); + + test("requests opt-in scopes on top of the defaults", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await expect( + ensureScopesForAction( + ["org:tokens", "org:read"], + "Listing org auth tokens", + login, + runtime(), + ), + ).resolves.toBe("elevated"); + const requested = login.mock.calls[0]?.[0]?.scope?.split(" "); + expect(requested).toEqual([...OAUTH_SCOPES, "org:tokens"]); + }); + + test("keeps opt-in scopes the grant already holds", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await ensureScopesForAction( + ["org:tokens"], + "x", + login, + runtime({ + getAuthScopes: vi + .fn() + .mockResolvedValue([...OAUTH_SCOPES, "org:admin", "not-a-scope"]), + getReauthorizationAccountId: () => "work-account", + }), + ); + const options = login.mock.calls[0]?.[0]; + expect(options?.accountId).toBe("work-account"); + const requested = options?.scope?.split(" "); + expect(requested).toContain("org:admin"); + expect(requested).toContain("org:tokens"); + expect(requested).not.toContain("not-a-scope"); + }); + + test("requests the defaults when only default scopes are missing", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await ensureScopesForAction( + ["org:integrations"], + "x", + login, + runtime({ + getAuthScopes: vi + .fn() + .mockResolvedValue( + OAUTH_SCOPES.filter((scope) => scope !== "org:integrations"), + ), + }), + ); + expect(login).toHaveBeenCalledWith(); + }); });