diff --git a/apps/cli-docs/src/components/Terminal.astro b/apps/cli-docs/src/components/Terminal.astro index 1cb79c0a88..e2a694e5c0 100644 --- a/apps/cli-docs/src/components/Terminal.astro +++ b/apps/cli-docs/src/components/Terminal.astro @@ -31,7 +31,7 @@ if (background) {
$ - sentry auth login + sentry auth
diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index ee60060583..603bb42f4d 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -106,7 +106,7 @@ Or run directly without installing: The easiest way to authenticate is via OAuth device flow: ```bash -sentry auth login +sentry auth ``` You'll be given a URL and a code to enter. Once you authorize the application @@ -120,7 +120,7 @@ Sentry CLI configuration directory (`~/.sentry/` by default, overridable with Alternatively, you can use an API token directly: ```bash -sentry auth login --token YOUR_SENTRY_API_TOKEN +sentry auth --token YOUR_SENTRY_API_TOKEN ``` You can create API tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). @@ -149,7 +149,7 @@ sentry auth logout Using a self-hosted Sentry instance? Set `SENTRY_URL` to point at it: ```bash -SENTRY_URL=https://sentry.example.com sentry auth login +SENTRY_URL=https://sentry.example.com sentry auth ``` See the [Self-Hosted](../self-hosted/) guide for full setup details. diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 51395146eb..9157ccb78f 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -5,9 +5,12 @@ ### OAuth login (recommended) ```bash -sentry auth login +sentry auth ``` +Bare `sentry auth` logs in when you're logged out and shows status when you're +already authenticated. `sentry auth login` always starts the login flow. + 1. A URL and device code will be displayed 2. Open the URL in your browser 3. Enter the code when prompted @@ -18,7 +21,7 @@ sentry auth login ### Token login ```bash -sentry auth login --token YOUR_SENTRY_API_TOKEN +sentry auth --token YOUR_SENTRY_API_TOKEN ``` ### Read-only OAuth login @@ -27,7 +30,7 @@ Request only read-only scopes — useful for tokens handed to AI agents or CI jobs that should not mutate Sentry state: ```bash -sentry auth login --read-only +sentry auth --read-only ``` ### Custom OAuth scopes @@ -35,8 +38,8 @@ sentry auth login --read-only Request specific scopes (repeatable, comma-separated): ```bash -sentry auth login --scope project:read --scope org:read -sentry auth login --scope project:read,event:read +sentry auth --scope project:read --scope org:read +sentry auth --scope project:read,event:read ``` ### Self-hosted Sentry @@ -44,14 +47,14 @@ sentry auth login --scope project:read,event:read Use `--url` (recommended) or the `SENTRY_URL` environment variable: ```bash -sentry auth login --url https://sentry.example.com -SENTRY_URL=https://sentry.example.com sentry auth login +sentry auth --url https://sentry.example.com +SENTRY_URL=https://sentry.example.com sentry auth ``` For token-based auth with self-hosted: ```bash -sentry auth login --token YOUR_TOKEN --url https://sentry.example.com +sentry auth --token YOUR_TOKEN --url https://sentry.example.com ``` See [Self-Hosted Sentry](../self-hosted/) for details. diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index e7e80d9d74..adc6e6f95c 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -2,7 +2,7 @@ > **Experimental:** `sentry init` is experimental and may modify your source files. Always review changes before committing. -**Prerequisites:** You must be authenticated first. Run `sentry auth login` if you haven't already. +**Prerequisites:** You must be authenticated first. Run `sentry auth` if you haven't already. ## Examples diff --git a/packages/cli/README.md b/packages/cli/README.md index 052c1d534a..86a1d85082 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -52,7 +52,7 @@ bunx sentry --help ```bash # Authenticate with Sentry -sentry auth login +sentry auth # List issues (auto-detects project from your codebase) sentry issue list 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 2e2ac49f34..c1294f269c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -284,8 +284,8 @@ npm install -g sentry ### Authentication ```bash -sentry auth login -sentry auth login --token YOUR_SENTRY_API_TOKEN +sentry auth +sentry auth --token YOUR_SENTRY_API_TOKEN sentry auth status sentry auth logout ``` 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 26b90c01ae..766e0f9034 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 @@ -26,19 +26,19 @@ Authenticate with Sentry **Examples:** ```bash -sentry auth login +sentry auth -sentry auth login --token YOUR_SENTRY_API_TOKEN +sentry auth --token YOUR_SENTRY_API_TOKEN -sentry auth login --read-only +sentry auth --read-only -sentry auth login --scope project:read --scope org:read -sentry auth login --scope project:read,event:read +sentry auth --scope project:read --scope org:read +sentry auth --scope project:read,event:read -sentry auth login --url https://sentry.example.com -SENTRY_URL=https://sentry.example.com sentry auth login +sentry auth --url https://sentry.example.com +SENTRY_URL=https://sentry.example.com sentry auth -sentry auth login --token YOUR_TOKEN --url https://sentry.example.com +sentry auth --token YOUR_TOKEN --url https://sentry.example.com ``` ### `sentry auth logout` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 2324b3b93d..99f49ca221 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -236,6 +236,10 @@ Mark an issue as resolved **Flags:** - `-i, --in - Resolve in a release, next release, or commit ('' | '@next' | '@commit' | '@commit:@')` +### `sentry issue unresolve ` + +Reopen a resolved issue + **Examples:** ```bash @@ -265,10 +269,6 @@ sentry issue unresolve CLI-G5 sentry issue reopen CLI-G5 # alias ``` -### `sentry issue unresolve ` - -Reopen a resolved issue - ### `sentry issue archive ` Archive (ignore) an issue diff --git a/packages/cli/script/generate-skill-markdown.ts b/packages/cli/script/generate-skill-markdown.ts index fd2f22b696..eb19074f46 100644 --- a/packages/cli/script/generate-skill-markdown.ts +++ b/packages/cli/script/generate-skill-markdown.ts @@ -18,10 +18,20 @@ export function extractCommandPathFromHeading( export function matchExampleToCommand( code: string, commandPaths: readonly string[], - groupFallback: string + groupFallback: string, + defaultCommandPath?: string ): string | undefined { - return ( - commandPaths.find((path) => code.includes(path)) ?? - (code.includes(groupFallback) ? groupFallback : undefined) - ); + // Prefer the longest path so `sentry auth login` wins over bare `sentry auth` + // when both would otherwise match via includes(). + const byLengthDesc = [...commandPaths].sort((a, b) => b.length - a.length); + const matched = byLengthDesc.find((path) => code.includes(path)); + if (matched) { + return matched; + } + if (!code.includes(groupFallback)) { + return; + } + // Bare group examples (`sentry auth`) belong on the default subcommand when + // one exists (login), not on a synthetic group-only path. + return defaultCommandPath ?? groupFallback; } diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index fc7df43dfe..e87189dbb6 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -60,6 +60,34 @@ import { isRouteMap, } from "../src/lib/introspect.js"; +/** + * Resolve the default subcommand name for a route map, if configured. + * + * Hidden dispatcher routes (e.g. auth's `default`) map bare group examples + * onto the user-facing command they primarily document (login). + */ +function findDefaultCommandName(routeMap: RouteMap): string | undefined { + if (!routeMap.getDefaultCommand) { + return; + } + const defaultCmd = routeMap.getDefaultCommand(); + if (!defaultCmd) { + return; + } + for (const sub of routeMap.getAllEntries()) { + if (sub.target === defaultCmd) { + const name = sub.name.original; + // Auth uses a hidden "default" dispatcher; bare `sentry auth` examples + // document the login path. + if (name === "default") { + return "login"; + } + return name; + } + } + return; +} + const SKILL_DIR = "plugins/sentry-cli/skills/sentry-cli"; const OUTPUT_PATH = `${SKILL_DIR}/SKILL.md`; const REFERENCES_DIR = `${SKILL_DIR}/references`; @@ -290,10 +318,10 @@ npm install -g sentry \`\`\`bash # OAuth login (recommended) -sentry auth login +sentry auth # Or use an API token -sentry auth login --token YOUR_SENTRY_API_TOKEN +sentry auth --token YOUR_SENTRY_API_TOKEN # Check auth status sentry auth status @@ -341,10 +369,14 @@ function collectCommandPaths( */ function associateCodeBlocks( tokens: Token[], - commandPaths: string[], - commandGroup: string, - examples: Map + context: { + commandPaths: string[]; + commandGroup: string; + examples: Map; + defaultCommandPath?: string; + } ): void { + const { commandPaths, commandGroup, examples, defaultCommandPath } = context; const groupFallback = `sentry ${commandGroup}`; let currentCmd: string | null = null; @@ -359,7 +391,12 @@ function associateCodeBlocks( if (currentCmd && examples.has(currentCmd)) { appendExample(examples, currentCmd, code); } else { - const target = matchExampleToCommand(code, commandPaths, groupFallback); + const target = matchExampleToCommand( + code, + commandPaths, + groupFallback, + defaultCommandPath + ); if (target) { appendExample(examples, target, code); } @@ -377,7 +414,8 @@ function associateCodeBlocks( * by matching code blocks to commands via heading context or content analysis. */ async function loadCommandExamples( - commandGroup: string + commandGroup: string, + defaultCommandName?: string ): Promise> { const docContent = await loadDoc(`commands/${commandGroup}.md`); if (!docContent) { @@ -387,7 +425,15 @@ async function loadCommandExamples( const tokens = marked.lexer(docContent); const examples = new Map(); const commandPaths = collectCommandPaths(tokens, examples); - associateCodeBlocks(tokens, commandPaths, commandGroup, examples); + const defaultCommandPath = defaultCommandName + ? `sentry ${commandGroup} ${defaultCommandName}` + : undefined; + associateCodeBlocks(tokens, { + commandPaths, + commandGroup, + examples, + defaultCommandPath, + }); return examples; } @@ -435,7 +481,13 @@ async function extractRoutes(routeMap: RouteMap): Promise { } const routeName = entry.name.original; const target = entry.target; - const docExamples = await loadCommandExamples(routeName); + const defaultCommandName = isRouteMap(target) + ? findDefaultCommandName(target) + : undefined; + const docExamples = await loadCommandExamples( + routeName, + defaultCommandName + ); if (isRouteMap(target)) { result.push({ name: routeName, diff --git a/packages/cli/src/commands/auth/default.ts b/packages/cli/src/commands/auth/default.ts new file mode 100644 index 0000000000..aba8ed2122 --- /dev/null +++ b/packages/cli/src/commands/auth/default.ts @@ -0,0 +1,191 @@ +/** + * Bare `sentry auth` dispatcher. + * + * Routes to login when logged out (or when login-only flags are present), + * and to status when already authenticated. + * + * Dispatches into the *unwrapped* login/status generators so the command + * pipeline (auth guard, rc-URL trust check, telemetry, output rendering) + * runs exactly once under this command's settings. + */ + +import type { Command } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { isAuthenticated } from "../../lib/db/auth.js"; +import { formatAuthStatus } from "../../lib/formatters/human.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import type { LoginResult } from "../../lib/interactive-login.js"; +import { FRESH_ALIASES, FRESH_FLAG } from "../../lib/list-command.js"; +import { formatLoginResult, loginCommand, parseLoginUrl } from "./login.js"; +import { type AuthStatusData, statusCommand } from "./status.js"; + +type AuthDefaultFlags = { + readonly token?: string; + readonly timeout: number; + readonly force: boolean; + readonly url?: string; + readonly "read-only": boolean; + readonly scope?: readonly string[]; + readonly "show-token": boolean; + readonly fresh: boolean; +}; + +/** Login-only flags that force the bare command into the login path. */ +const LOGIN_ONLY_FLAGS = [ + "token", + "force", + "url", + "read-only", + "scope", +] as const; + +/** + * Choose login vs status for bare `sentry auth`. + * + * - Login-only flags always select login (even when already authenticated). + * - Otherwise: logged out → login, logged in → status. + */ +export function resolveAuthDefaultTarget( + flags: Readonly>, + authenticated = isAuthenticated() +): "login" | "status" { + for (const name of LOGIN_ONLY_FLAGS) { + const value = flags[name]; + if (value === undefined || value === false) { + continue; + } + if (Array.isArray(value) && value.length === 0) { + continue; + } + return "login"; + } + return authenticated ? "status" : "login"; +} + +type RawCommandFunc = ( + this: SentryContext, + flags: Record, + ...args: unknown[] +) => AsyncGenerator; + +/** Read the unwrapped generator attached by {@link buildCommand}. */ +function getRawFunc(command: Command): RawCommandFunc { + const raw = (command as unknown as { __rawFunc?: RawCommandFunc }).__rawFunc; + if (!raw) { + throw new Error( + "Command is missing __rawFunc; expected buildCommand output" + ); + } + return raw; +} + +function isLoginResult(data: unknown): data is LoginResult { + return ( + !!data && + typeof data === "object" && + "method" in data && + "configPath" in data + ); +} + +function formatAuthDefaultOutput(data: LoginResult | AuthStatusData): string { + if (isLoginResult(data)) { + return formatLoginResult(data); + } + return formatAuthStatus(data); +} + +/** + * Hidden default for bare `sentry auth`. + * + * Accepts the union of login and status flags so either path works without + * an explicit subcommand. Renders through this command's single pipeline. + */ +export const authDefaultCommand = buildCommand({ + auth: false, + // Login must run even with a poisoned .sentryclirc URL. Status is also + // reached through this path when already authenticated — matching the + // onboarding intent of bare `sentry auth` rather than re-applying the + // stricter explicit-status rc gate via a nested wrapper. + skipRcUrlCheck: true, + docs: { + brief: "Authenticate with Sentry or show auth status", + fullDescription: + "When not authenticated, starts the login flow. When already authenticated, shows auth status. " + + "Equivalent to `sentry auth login` or `sentry auth status` depending on current credentials. " + + "Login-only flags (e.g. --token, --url) force the login path.", + }, + parameters: { + flags: { + token: { + kind: "parsed", + parse: String, + brief: "Authenticate using an API token instead of OAuth", + optional: true, + }, + timeout: { + kind: "parsed", + parse: numberParser, + brief: "Timeout for OAuth flow in seconds (default: 900)", + default: "900", + }, + force: { + kind: "boolean", + brief: "Re-authenticate without prompting", + default: false, + }, + url: { + kind: "parsed", + parse: parseLoginUrl, + brief: + "Sentry instance URL to authenticate against (e.g. https://sentry.example.com). " + + "Required for self-hosted; defaults to SaaS (https://sentry.io).", + optional: true, + }, + "read-only": { + kind: "boolean", + brief: + "Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). " + + "Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.", + default: false, + }, + scope: { + kind: "parsed", + parse: String, + brief: + "Request specific OAuth scopes (repeatable, comma-separated). " + + "E.g. --scope project:read --scope org:read. Overrides the default scope set.", + variadic: true, + optional: true, + }, + "show-token": { + kind: "boolean", + brief: "Show the stored token (masked by default)", + default: false, + }, + fresh: FRESH_FLAG, + }, + aliases: { s: "scope", ...FRESH_ALIASES }, + }, + output: { human: formatAuthDefaultOutput }, + async *func(this: SentryContext, flags: AuthDefaultFlags) { + const target = resolveAuthDefaultTarget(flags); + const command = target === "login" ? loginCommand : statusCommand; + const raw = getRawFunc(command as Command); + const generator = raw.call( + this, + flags as unknown as Record + ); + + // Re-yield CommandOutput values so this command's wrapper renders once. + let step = await generator.next(); + while (!step.done) { + if (step.value instanceof CommandOutput) { + yield step.value; + } + step = await generator.next(); + } + return step.value as { hint?: string } | undefined; + }, +}); diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index 21dcd3a1a1..56fb3ed49f 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { authDefaultCommand } from "./default.js"; import { loginCommand } from "./login.js"; import { logoutCommand } from "./logout.js"; import { refreshCommand } from "./refresh.js"; @@ -8,6 +9,8 @@ import { whoamiCommand } from "./whoami.js"; export const authRoute = buildRouteMap({ routes: { + // Hidden smart default: login when logged out, status when logged in. + default: authDefaultCommand, login: loginCommand, logout: logoutCommand, refresh: refreshCommand, @@ -15,14 +18,16 @@ export const authRoute = buildRouteMap({ token: tokenCommand, whoami: whoamiCommand, }, - defaultCommand: "status", + defaultCommand: "default", docs: { brief: "Authenticate with Sentry", fullDescription: - "Manage authentication with Sentry. Use 'sentry auth login' to authenticate, " + - "'sentry auth logout' to remove credentials, 'sentry auth refresh' to manually refresh your OAuth access token, " + - "'sentry auth status' to check your authentication status, " + - "'sentry auth whoami' to show your current user identity, " + - "and 'sentry auth token' to print your token for use in scripts.", + "Manage authentication with Sentry. Use 'sentry auth' to log in when logged out " + + "or show status when logged in. Explicit subcommands: 'sentry auth login', " + + "'sentry auth logout', 'sentry auth refresh', 'sentry auth status', " + + "'sentry auth whoami', and 'sentry auth token'.", + hideRoute: { + default: true, + }, }, }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 133da8791f..a2621886c4 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -54,7 +54,7 @@ import { registerLoginTrustAnchor } from "../../lib/token-host.js"; const log = logger.withTag("auth.login"); /** Format a {@link LoginResult} for human-readable terminal output. */ -function formatLoginResult(result: LoginResult): string { +export function formatLoginResult(result: LoginResult): string { const lines: string[] = []; lines.push( success( diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index f930a97d92..c7a7bf1747 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -315,8 +315,8 @@ function printWelcomeMessage( emit(`Installed sentry v${version} to ${binaryPath}`); emit(""); emit("Get started:"); - emit(" sentry auth login Authenticate with Sentry"); - emit(" sentry --help See all available commands"); + emit(" sentry auth Authenticate with Sentry"); + emit(" sentry --help See all available commands"); emit(""); emit("https://cli.sentry.dev"); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 956fb53ab2..9b5efea99f 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -847,5 +847,10 @@ export function buildCommand< outputConfig.schema; } + // Expose the unwrapped generator for internal dispatchers (e.g. bare + // `sentry auth`) that must run a subcommand without re-entering the + // telemetry / auth-guard / rc-trust / output pipeline. + (cmd as unknown as Record).__rawFunc = originalFunc; + return cmd; } diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index 53a429466f..5be8830266 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -135,7 +135,7 @@ function generateCommands(): HelpCommand[] { }); } -const EXAMPLE_LOGGED_OUT = "sentry auth login"; +const EXAMPLE_LOGGED_OUT = "sentry auth"; const EXAMPLE_LOGGED_IN = "sentry issue list"; const DOCS_URL = "https://cli.sentry.dev/getting-started/"; diff --git a/packages/cli/test/commands/auth/default.test.ts b/packages/cli/test/commands/auth/default.test.ts new file mode 100644 index 0000000000..2f7448667f --- /dev/null +++ b/packages/cli/test/commands/auth/default.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for bare `sentry auth` smart default routing. + */ + +import { describe, expect, test, vi } from "vitest"; +import { + authDefaultCommand, + resolveAuthDefaultTarget, +} from "../../../src/commands/auth/default.js"; +import { loginCommand } from "../../../src/commands/auth/login.js"; +import { statusCommand } from "../../../src/commands/auth/status.js"; +import { CommandOutput } from "../../../src/lib/formatters/output.js"; + +describe("resolveAuthDefaultTarget", () => { + test("logged out → login", () => { + expect(resolveAuthDefaultTarget({}, false)).toBe("login"); + }); + + test("logged in → status", () => { + expect(resolveAuthDefaultTarget({}, true)).toBe("status"); + }); + + test("login-only flags force login even when authenticated", () => { + expect(resolveAuthDefaultTarget({ token: "t" }, true)).toBe("login"); + expect(resolveAuthDefaultTarget({ force: true }, true)).toBe("login"); + expect( + resolveAuthDefaultTarget({ url: "https://sentry.example.com" }, true) + ).toBe("login"); + expect(resolveAuthDefaultTarget({ "read-only": true }, true)).toBe("login"); + expect(resolveAuthDefaultTarget({ scope: ["org:read"] }, true)).toBe( + "login" + ); + }); + + test("status-only flags keep status when authenticated", () => { + expect( + resolveAuthDefaultTarget({ "show-token": true, fresh: true }, true) + ).toBe("status"); + }); + + test("empty scope array does not force login", () => { + expect(resolveAuthDefaultTarget({ scope: [] }, true)).toBe("status"); + }); +}); + +describe("authDefaultCommand", () => { + test("dispatches to login raw func when logged out", async () => { + const isAuthenticated = vi.spyOn( + await import("../../../src/lib/db/auth.js"), + "isAuthenticated" + ); + isAuthenticated.mockReturnValue(false); + + const loginRaw = vi.fn(async function* () { + yield new CommandOutput({ + method: "token", + configPath: "/tmp/sentry.db", + }); + }); + const statusRaw = vi.fn(async function* () { + yield new CommandOutput({ authenticated: true, source: "oauth" }); + }); + + const loginCmd = loginCommand as unknown as { __rawFunc: unknown }; + const statusCmd = statusCommand as unknown as { __rawFunc: unknown }; + const prevLogin = loginCmd.__rawFunc; + const prevStatus = statusCmd.__rawFunc; + loginCmd.__rawFunc = loginRaw; + statusCmd.__rawFunc = statusRaw; + + const context = { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + cwd: "/tmp", + env: process.env, + }; + + try { + const func = await authDefaultCommand.loader(); + await func.call(context as never, { + timeout: 900, + force: false, + "read-only": false, + "show-token": false, + fresh: false, + json: false, + }); + + expect(loginRaw).toHaveBeenCalledOnce(); + expect(statusRaw).not.toHaveBeenCalled(); + } finally { + loginCmd.__rawFunc = prevLogin; + statusCmd.__rawFunc = prevStatus; + isAuthenticated.mockRestore(); + } + }); + + test("dispatches to status raw func when logged in", async () => { + const isAuthenticated = vi.spyOn( + await import("../../../src/lib/db/auth.js"), + "isAuthenticated" + ); + isAuthenticated.mockReturnValue(true); + + const loginRaw = vi.fn(async function* () { + yield new CommandOutput({ + method: "token", + configPath: "/tmp/sentry.db", + }); + }); + const statusRaw = vi.fn(async function* () { + yield new CommandOutput({ authenticated: true, source: "oauth" }); + }); + + const loginCmd = loginCommand as unknown as { __rawFunc: unknown }; + const statusCmd = statusCommand as unknown as { __rawFunc: unknown }; + const prevLogin = loginCmd.__rawFunc; + const prevStatus = statusCmd.__rawFunc; + loginCmd.__rawFunc = loginRaw; + statusCmd.__rawFunc = statusRaw; + + const context = { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + cwd: "/tmp", + env: process.env, + }; + + try { + const func = await authDefaultCommand.loader(); + await func.call(context as never, { + timeout: 900, + force: false, + "read-only": false, + "show-token": false, + fresh: false, + json: false, + }); + + expect(statusRaw).toHaveBeenCalledOnce(); + expect(loginRaw).not.toHaveBeenCalled(); + } finally { + loginCmd.__rawFunc = prevLogin; + statusCmd.__rawFunc = prevStatus; + isAuthenticated.mockRestore(); + } + }); +}); diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index 2127f3c40a..6591a67fed 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -549,7 +549,7 @@ describe("sentry cli setup", () => { // Should show welcome message, not "Setup complete!" expect(combined).toContain("Installed sentry v"); expect(combined).toContain("Get started:"); - expect(combined).toContain("sentry auth login"); + expect(combined).toContain("sentry auth"); expect(combined).toContain("sentry --help"); expect(combined).toContain("cli.sentry.dev"); expect(combined).not.toContain("Setup complete!"); @@ -642,7 +642,7 @@ describe("sentry cli setup", () => { expect(combined).toContain("Binary: Installed to"); // But welcome/getting-started is suppressed for upgrades expect(combined).not.toContain("Get started:"); - expect(combined).not.toContain("sentry auth login"); + expect(combined).not.toContain("Authenticate with Sentry"); }); test("--install with --quiet suppresses all output", async () => { diff --git a/packages/cli/test/lib/command-suggestions.test.ts b/packages/cli/test/lib/command-suggestions.test.ts index ff7d511956..8dc8c067c9 100644 --- a/packages/cli/test/lib/command-suggestions.test.ts +++ b/packages/cli/test/lib/command-suggestions.test.ts @@ -144,8 +144,18 @@ describe("routes with defaultCommand", () => { } }); - test("auth route group has defaultCommand (status)", () => { + test("auth route group has defaultCommand (smart default)", () => { expect(routesWithDefault.has("auth")).toBe(true); + const authEntry = routes + .getAllEntries() + .find((e) => e.name.original === "auth"); + const authMap = authEntry?.target as unknown as RouteMap; + const defaultCmd = authMap.getDefaultCommand?.(); + const defaultEntry = authMap + .getAllEntries() + .find((e) => e.target === defaultCmd); + expect(defaultEntry?.name.original).toBe("default"); + expect(defaultEntry?.hidden).toBe(true); }); test("route groups without defaultCommand", () => { diff --git a/packages/cli/test/lib/help.test.ts b/packages/cli/test/lib/help.test.ts index a94cf0c057..37955e7ac9 100644 --- a/packages/cli/test/lib/help.test.ts +++ b/packages/cli/test/lib/help.test.ts @@ -112,7 +112,7 @@ describe("printCustomHelp", () => { test("shows login example when not authenticated", async () => { // useTestConfigDir provides a clean env with no auth token const output = stripAnsi(printCustomHelp()); - expect(output).toContain("sentry auth login"); + expect(output).toContain("sentry auth"); }); test("includes an Environment Variables section with top-level vars", () => { diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 6bce18c8b3..374ee8d5bc 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -40,6 +40,28 @@ describe("matchExampleToCommand", () => { ).toBe("sentry project create"); }); + test("maps bare group examples to the default subcommand", () => { + expect( + matchExampleToCommand( + "sentry auth\nsentry auth --token YOUR_SENTRY_API_TOKEN", + ["sentry auth login", "sentry auth logout", "sentry auth status"], + "sentry auth", + "sentry auth login" + ) + ).toBe("sentry auth login"); + }); + + test("prefers longer command paths over shorter prefixes", () => { + expect( + matchExampleToCommand( + "sentry auth login --token TOKEN", + ["sentry auth login", "sentry auth status"], + "sentry auth", + "sentry auth login" + ) + ).toBe("sentry auth login"); + }); + test("the generated project reference retains create examples", async () => { const reference = await readFile( "plugins/sentry-cli/skills/sentry-cli/references/project.md",