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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app
1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`)
2. Create a new token with the following scopes:
<!-- GENERATED:START oauth-scopes -->
`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `alerts:read`, `alerts:write`
`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write`
<!-- GENERATED:END oauth-scopes -->
3. Pass it to the CLI:

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ When creating your Sentry OAuth application:
- `org:read`
- `event:read`, `event:write`
- `member:read`
- `team:read`, `team:write`
- `team:read`, `team:write`, `team:admin`
- `alerts:read`, `alerts:write`
<!-- GENERATED:END oauth-scopes -->

Expand Down
27 changes: 14 additions & 13 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
import { CLI_VERSION } from "./lib/constants.js";
import { reportCliError } from "./lib/error-reporting.js";
import {
ApiError,
AuthError,
CliError,
getExitCode,
Expand Down Expand Up @@ -280,6 +281,16 @@ function formatSynonymError(
return `${prefix} ${exc.format()}\n${tip}`;
}

function escapesToOuterMiddleware(exc: unknown): boolean {
if (exc instanceof OutputError) {
return true;
}
if (exc instanceof AuthError) {
return exc.reason === "not_authenticated" || exc.reason === "expired";
}
return exc instanceof ApiError && (exc.status === 401 || exc.status === 403);
}

/**
* Custom error formatting for CLI errors.
*
Expand Down Expand Up @@ -353,19 +364,9 @@ const customText: ApplicationText = {
return base;
},
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
// OutputError: data was already rendered to stdout — just re-throw
// so the exit code propagates without Stricli printing an error message.
if (exc instanceof OutputError) {
throw exc;
}

// Re-throw AuthError for auto-login flow in bin.ts
// Don't capture to Sentry - it's an expected state (user not logged in or token expired), not an error
// Note: skipAutoAuth is checked in bin.ts, not here — all auth errors must escape Sentry capture
if (
exc instanceof AuthError &&
(exc.reason === "not_authenticated" || exc.reason === "expired")
) {
// These errors are handled outside Stricli: OutputError has already been
// rendered, while auth errors may trigger login and a single retry.
if (escapesToOuterMiddleware(exc)) {
throw exc;
}

Expand Down
95 changes: 6 additions & 89 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
);
const { error } = await import("./lib/formatters/colors.js");
const { runInteractiveLogin } = await import("./lib/interactive-login.js");
const { assertAutoLoginHostTrusted, recoverWithAutoLogin } = await import(
"./lib/auto-auth.js"
);
const { recoverWithAutoLogin } = await import("./lib/auto-auth.js");
const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js");
const { scheduleInitForceExitIfRequested } = await import(
"./lib/init/force-exit.js"
Expand Down Expand Up @@ -438,97 +436,16 @@ export async function runCli(cliArgs: string[]): Promise<void> {
}
};

/**
* Check whether a caught error is a recoverable 403 missing-scope error.
*
* Returns the extracted scope names when all conditions are met:
* - Interactive TTY (stdin)
* - Error is an `ApiError` with status 403
* - Token is an OAuth token (not env-var — those can't be re-scoped via CLI)
* - The 403 detail mentions specific missing scopes
*
* Returns `null` when recovery is not possible, signaling the caller to
* re-throw.
*/
async function extractRecoverableScopes(
err: unknown
): Promise<string[] | null> {
if (!isatty(0)) {
return null;
}
const { ApiError } = await import("./lib/errors.js");
if (!(err instanceof ApiError) || err.status !== 403) {
return null;
}
const { isEnvTokenActive } = await import("./lib/db/auth.js");
if (isEnvTokenActive()) {
return null;
}
const { extractRequiredScopes } = await import("./lib/api-scope.js");
const scopes = extractRequiredScopes(err.detail);
return scopes.length > 0 ? scopes : null;
}

/**
* Scope recovery middleware.
*
* Catches 403 Forbidden errors for OAuth tokens (not env-var tokens) in
* interactive TTYs. When specific missing scopes are detected in the API
* response, offers to re-authenticate with those scopes and retries the
* command — mirroring `gh auth refresh -s <scope>`.
*
* Env-var tokens are excluded: the user must regenerate those manually
* via the Sentry web UI (the 403 enrichment already directs them there).
* After a 401/403, compare a stored OAuth grant with the CLI's current scope
* set. Re-authorize stale grants and retry exactly once. Non-interactive and
* explicitly unattended commands never enter a device flow.
*/
const scopeRecoveryMiddleware: ErrorMiddleware = async (next, argv) => {
try {
await next(argv);
} catch (err) {
const scopes = await extractRecoverableScopes(err);
if (!scopes) {
throw err;
}

// Same host-trust gate as auto-login: re-authenticating to add scopes
// also runs the OAuth device flow, so refuse an unconfirmed self-hosted
// host before prompting (an injected env.SENTRY_URL must not steer the
// browser to an attacker's login page).
assertAutoLoginHostTrusted();

const scopeList = scopes.map((s) => `'${s}'`).join(", ");
const { logger: logModule } = await import("./lib/logger.js");
const confirmed = await logModule
.withTag("auth")
.prompt(
`Missing scope(s): ${scopeList}. Re-authenticate with default scopes?`,
{ type: "confirm", initial: true }
);

// Symbol(clack:cancel) is truthy — strict equality check
if (confirmed !== true) {
throw err;
}

process.stderr.write("\n");
// Merge missing scopes with the default set so the new token retains
// all previously-held scopes plus the ones the API requested.
const { OAUTH_SCOPES, resolveOAuthScopeString } = await import(
"./lib/oauth.js"
);
const merged = [...new Set([...OAUTH_SCOPES, ...scopes])];
const scope = resolveOAuthScopeString({ scopes: merged });
const loginSuccess = await runInteractiveLogin({ scope });

if (loginSuccess) {
process.stderr.write("\nRetrying command...\n\n");
await next(argv);
return;
}

// Login failed or was cancelled — re-throw so the user sees the
// original 403 message with the scope hint.
throw err;
}
const { runWithScopeRecovery } = await import("./lib/scope-recovery.js");
await runWithScopeRecovery(next, argv, runInteractiveLogin);
};

/**
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type SetupFlags = {
readonly "no-modify-path": boolean;
readonly "no-completions": boolean;
readonly "no-agent-skills": boolean;
readonly "ensure-auth-scopes": boolean;
readonly quiet: boolean;
};

Expand Down Expand Up @@ -499,6 +500,12 @@ export const setupCommand = buildCommand({
brief: "Skip agent skill installation for AI coding assistants",
default: false,
},
"ensure-auth-scopes": {
kind: "boolean",
brief: "Refresh an outdated stored OAuth authorization",
default: false,
hidden: true as const,
},
quiet: {
kind: "boolean",
brief: "Suppress output (for scripted usage)",
Expand Down Expand Up @@ -556,6 +563,21 @@ export const setupCommand = buildCommand({
warn,
});

if (flags["ensure-auth-scopes"]) {
await bestEffort(
"Authorization",
async () => {
const [{ runInteractiveLogin }, { ensureCurrentOAuthScopes }] =
await Promise.all([
import("../../lib/interactive-login.js"),
import("../../lib/scope-recovery.js"),
]);
await ensureCurrentOAuthScopes(runInteractiveLogin);
},
warn
);
}

// 5. Print welcome message only on fresh install — upgrades are silent
// since the upgrade command itself prints a success message.
if (!flags.quiet && freshInstall) {
Expand Down
47 changes: 40 additions & 7 deletions packages/cli/src/commands/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
VERSION_PREFIX_REGEX,
versionExists,
} from "../../lib/upgrade.js";
import { whichSync } from "../../lib/which.js";

const log = logger.withTag("cli.upgrade");

Expand Down Expand Up @@ -428,7 +429,7 @@ async function spawnWithRetry(
for (let attempt = 1; attempt <= SPAWN_MAX_ATTEMPTS; attempt++) {
try {
const proc = spawn(binaryPath, args, {
stdio: ["ignore", "inherit", "inherit"],
stdio: "inherit",
env,
});
return await new Promise<number>((resolve, reject) => {
Expand Down Expand Up @@ -493,6 +494,8 @@ type SetupOptions = {
install: boolean;
/** Pin the install directory (prevents relocation during upgrade) */
installDir?: string;
/** Ask the new binary to refresh a stored OAuth grant when scopes changed. */
ensureAuthScopes: boolean;
};

/**
Expand All @@ -508,7 +511,8 @@ type SetupOptions = {
* updates completions, agent skills, and records metadata.
*/
async function runSetupOnNewBinary(opts: SetupOptions): Promise<void> {
const { binaryPath, method, channel, install, installDir } = opts;
const { binaryPath, method, channel, install, installDir, ensureAuthScopes } =
opts;
const args = [
"cli",
"setup",
Expand All @@ -522,6 +526,9 @@ async function runSetupOnNewBinary(opts: SetupOptions): Promise<void> {
if (install) {
args.push("--install");
}
if (ensureAuthScopes) {
args.push("--ensure-auth-scopes");
}

const env = installDir
? { ...process.env, SENTRY_INSTALL_DIR: installDir }
Expand All @@ -536,6 +543,14 @@ async function runSetupOnNewBinary(opts: SetupOptions): Promise<void> {
}
}

function resolveUpdatedCliPath(
execPath: string,
entryPath: string | undefined,
pathEnv: string | undefined
): string {
return whichSync("sentry", { PATH: pathEnv }) ?? entryPath ?? execPath;
}

/**
* Execute the standard upgrade path: download via curl or package manager,
* then run setup on the new binary.
Expand All @@ -546,10 +561,22 @@ async function executeStandardUpgrade(opts: {
versionArg: string | undefined;
target: string;
execPath: string;
entryPath?: string;
pathEnv?: string;
offline?: OfflineMode;
json?: boolean;
}): Promise<void> {
const { method, channel, versionArg, target, execPath, offline, json } = opts;
const {
method,
channel,
versionArg,
target,
execPath,
entryPath,
pathEnv,
offline,
json,
} = opts;

// Use the rolling "nightly" tag only when upgrading to latest nightly
// (no specific version was requested). A specific version arg always
Expand Down Expand Up @@ -584,18 +611,21 @@ async function executeStandardUpgrade(opts: {
channel,
install: true,
installDir: currentInstallDir,
ensureAuthScopes: !json,
});
} finally {
releaseLock(downloadResult.lockPath);
}
} else if (method !== "brew") {
// Package manager: binary already in place, just run setup.
// Skip brew — Homebrew's post_install hook already runs setup.
} else {
// Package managers replace their PATH entry in place. Resolve it after the
// install so setup runs with the new CLI, not Node's process.execPath or a
// removed Homebrew keg path.
await runSetupOnNewBinary({
binaryPath: execPath,
binaryPath: resolveUpdatedCliPath(execPath, entryPath, pathEnv),
method,
channel,
install: false,
ensureAuthScopes: !json,
});
}
}
Expand Down Expand Up @@ -656,6 +686,7 @@ async function migrateToStandaloneForNightly(
channel: "nightly",
install: true,
installDir,
ensureAuthScopes: !json,
});
} finally {
releaseLock(downloadResult.lockPath);
Expand Down Expand Up @@ -923,6 +954,8 @@ export const upgradeCommand = buildCommand({
versionArg,
target,
execPath: this.process.execPath,
entryPath: this.process.argv?.[1],
pathEnv: this.process.env.PATH,
offline,
json: flags.json,
});
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/lib/api-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export function extractRequiredScopes(detail: unknown): string[] {
if (!detail) {
return [];
}
const serializedDetail =
typeof detail === "string" ? detail : JSON.stringify(detail);
if (isMemberProjectCreationPolicy(serializedDetail)) {
return [];
}
if (typeof detail === "object") {
const fromFields = extractFromRecord(detail as Record<string, unknown>);
if (fromFields.length > 0) {
Expand All @@ -75,6 +80,15 @@ export function extractRequiredScopes(detail: unknown): string[] {
return [];
}

/** A role/policy denial can mention scope names without a token lacking them. */
function isMemberProjectCreationPolicy(detail: string): boolean {
const normalized = detail.toLowerCase();
return (
normalized.includes("disabled this feature for members") ||
normalized.includes("org-level policy setting, not an auth issue")
);
}

function extractFromRecord(record: Record<string, unknown>): string[] {
for (const field of SCOPE_FIELD_NAMES) {
const value = record[field];
Expand Down
Loading
Loading