Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async function handleExistingAuth(force: boolean): Promise<boolean> {
}

export const loginCommand = buildCommand({
auth: false,
docs: {
brief: "Authenticate with Sentry",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type LogoutResult = {
};

export const logoutCommand = buildCommand({
auth: false,
docs: {
brief: "Log out of Sentry",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function formatRefreshResult(data: RefreshOutput): string {
}

export const refreshCommand = buildCommand({
auth: false,
docs: {
brief: "Refresh your authentication token",
fullDescription: `
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async function verifyCredentials(): Promise<AuthStatusData["verification"]> {
}

export const statusCommand = buildCommand({
auth: false,
docs: {
brief: "View authentication status",
fullDescription:
Expand Down
1 change: 0 additions & 1 deletion src/commands/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const tokenCommand = buildCommand({
if (!token) {
throw new AuthError("not_authenticated");
}

return yield new CommandOutput(token);
},
});
Comment thread
sentry[bot] marked this conversation as resolved.
6 changes: 0 additions & 6 deletions src/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
import type { SentryContext } from "../../context.js";
import { getCurrentUser } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { isAuthenticated } from "../../lib/db/auth.js";
import { setUserInfo } from "../../lib/db/user.js";
import { AuthError } from "../../lib/errors.js";
import { formatUserIdentity } from "../../lib/formatters/index.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
Expand Down Expand Up @@ -46,10 +44,6 @@ export const whoamiCommand = buildCommand({
async *func(this: SentryContext, flags: WhoamiFlags) {
applyFreshFlag(flags);

if (!isAuthenticated()) {
throw new AuthError("not_authenticated");
}

const user = await getCurrentUser();

// Keep cached user info up to date. Non-fatal: display must succeed even
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type FeedbackResult = {
};

export const feedbackCommand = buildCommand({
auth: false,
docs: {
brief: "Send feedback about the CLI",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ function safeHandleSchemaIssues(
}

export const fixCommand = buildCommand({
auth: false,
docs: {
brief: "Diagnose and repair CLI database issues",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ async function runConfigurationSteps(opts: ConfigStepOptions) {
}

export const setupCommand = buildCommand({
auth: false,
docs: {
brief: "Configure shell integration",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ async function buildCheckResultWithChangelog(opts: {
}

export const upgradeCommand = buildCommand({
auth: false,
docs: {
brief: "Update the Sentry CLI to the latest version",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../lib/help.js";

export const helpCommand = buildCommand({
auth: false,
docs: {
brief: "Display help for a command",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ type SchemaFlags = {
};

export const schemaCommand = buildCommand({
auth: false,
docs: {
brief: "Browse the Sentry API schema",
fullDescription:
Expand Down
30 changes: 29 additions & 1 deletion src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import {
numberParser as stricliNumberParser,
} from "@stricli/core";
import type { Writer } from "../types/index.js";
import { CliError, OutputError } from "./errors.js";
import { getAuthConfig } from "./db/auth.js";
import { AuthError, CliError, OutputError } from "./errors.js";
import { warning } from "./formatters/colors.js";
import { parseFieldsList } from "./formatters/json.js";
import {
Expand Down Expand Up @@ -150,6 +151,19 @@ type LocalCommandBuilderArguments<
*/
// biome-ignore lint/suspicious/noExplicitAny: Variance erasure — OutputConfig<T>.human is contravariant in T, but the builder erases T because it doesn't know the output type. Using `any` allows commands to declare OutputConfig<SpecificType> while the wrapper handles it generically.
readonly output?: OutputConfig<any>;
/**
* Whether the command requires authentication. Defaults to `true`.
*
* When `true` (the default), the command throws `AuthError("not_authenticated")`
* before executing if no credentials exist at all (no token or refresh token
* in the DB or env vars). Expired tokens with a valid refresh token pass the
* guard — the API client handles silent refresh. The auto-auth middleware in
* `cli.ts` catches the error and triggers the login flow.
*
* Set to `false` for commands that intentionally work without a token
* (e.g. `auth login`, `auth logout`, `auth status`, `help`, `cli upgrade`).
*/
readonly auth?: boolean;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -252,6 +266,10 @@ export function applyLoggingFlags(
* 4. Captures flag values and positional arguments as Sentry telemetry context
* 5. When `output` has an {@link OutputConfig}, injects `--json` and `--fields`
* flags, pre-parses `--fields`, and auto-renders the command's `{ data }` return
* 6. Enforces authentication by default — throws `AuthError("not_authenticated")`
* before the command runs if no credentials exist at all (expired tokens with
* a refresh token pass through so the API client can silently refresh). Opt out with `auth: false`
* for commands that intentionally work without a token (e.g. `auth login`, `help`)
*
* When a command already defines its own `verbose` flag (e.g. the `api` command
* uses `--verbose` for HTTP request/response output), the injected `VERBOSE_FLAG`
Expand Down Expand Up @@ -324,6 +342,7 @@ export function buildCommand<
): Command<CONTEXT> {
const originalFunc = builderArgs.func;
const outputConfig = builderArgs.output;
const requiresAuth = builderArgs.auth !== false;

// Merge logging flags into the command's flag definitions.
// Quoted keys produce kebab-case CLI flags: "log-level" → --log-level
Expand Down Expand Up @@ -557,7 +576,16 @@ export function buildCommand<
// Iterate the generator using manual .next() instead of for-await-of
// so we can capture the return value (done: true result). The return
// value carries the final `hint` — for-await-of discards it.
//
// Auth guard is inside the try block so that maybeRecoverWithHelp can
// intercept the AuthError when "help" appears as a positional arg (e.g.
// `sentry issue list help`). Without this, the auth prompt would fire
// before the help-recovery path could show the command's help text.
try {
if (requiresAuth && !getAuthConfig()) {
throw new AuthError("not_authenticated");
}

const generator = originalFunc.call(
this,
cleanFlags as FLAGS,
Expand Down
1 change: 1 addition & 0 deletions src/lib/list-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export function buildListCommand<
readonly func: ListCommandFunction<FLAGS, ARGS, CONTEXT>;
// biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but type is erased at the builder level
readonly output?: OutputConfig<any>;
readonly auth?: boolean;
},
options?: ListCommandOptions
): Command<CONTEXT> {
Expand Down
31 changes: 20 additions & 11 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ const LIBRARY_EXCLUDED_INTEGRATIONS = new Set([
...EXCLUDED_INTEGRATIONS,
"OnUncaughtException", // process.on('uncaughtException')
"OnUnhandledRejection", // process.on('unhandledRejection')
"ProcessSession", // process.on('beforeExit')
"ProcessSession", // process.on('beforeExit') — anonymous handler, no cleanup
"Http", // diagnostics_channel + trace headers
"NodeFetch", // diagnostics_channel + trace headers
"FunctionToString", // wraps Function.prototype.toString
Expand Down Expand Up @@ -355,6 +355,13 @@ export function initSentry(
const libraryMode = options?.libraryMode ?? false;
const environment = getEnv().NODE_ENV ?? "development";

// Close the previous client to clean up its internal timers and beforeExit
// handlers (client report flusher interval, log flush listener). Without
// this, re-initializing the SDK (e.g., in tests) leaks setInterval handles
// that keep the event loop alive and prevent the process from exiting.
// close(0) removes listeners synchronously; we don't need to await the flush.
Sentry.getClient()?.close(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Telemetry init closes host Sentry client

High Severity

initSentry() now calls Sentry.getClient()?.close(0) before every init. In library usage, this can close an already-initialized host application's Sentry client, not just this module's previous client, unexpectedly disabling host telemetry and mutating global process instrumentation.

Fix in Cursor Fix in Web


const client = Sentry.init({
dsn: SENTRY_CLI_DSN,
enabled,
Expand All @@ -374,10 +381,12 @@ export function initSentry(
},
environment,
// Enable Sentry structured logs for non-exception telemetry (e.g., unexpected input warnings).
// Disabled in library mode — logs use timers/beforeExit that pollute the host process.
enableLogs: !libraryMode,
// Disable client reports in library mode — they use timers/beforeExit.
...(libraryMode && { sendClientReports: false }),
// Disabled when telemetry is off or in library mode — the SDK registers
// beforeExit handlers for log flushing that keep the event loop alive.
enableLogs: enabled && !libraryMode,
// Disable client reports when telemetry is off or in library mode — the SDK
// registers a setInterval + beforeExit handler that keep the event loop alive.
...((libraryMode || !enabled) && { sendClientReports: false }),
// Sample all events for CLI telemetry (low volume)
tracesSampleRate: 1,
sampleRate: 1,
Expand Down Expand Up @@ -405,6 +414,12 @@ export function initSentry(
},
});

// Always remove our own previous handler on re-init.
if (currentBeforeExitHandler) {
process.removeListener("beforeExit", currentBeforeExitHandler);
currentBeforeExitHandler = null;
}

if (client?.getOptions().enabled) {
const isBun = typeof process.versions.bun !== "undefined";
const runtime = isBun ? "bun" : "node";
Expand Down Expand Up @@ -442,13 +457,7 @@ export function initSentry(
// Skipped in library mode — the host owns the process lifecycle.
// The library entry point calls client.flush() manually after completion.
//
// Replace previous handler on re-init (e.g., auto-login retry calls
// withTelemetry → initSentry twice) to avoid duplicate handlers with
// independent re-entry guards and stale client references.
if (!libraryMode) {
if (currentBeforeExitHandler) {
process.removeListener("beforeExit", currentBeforeExitHandler);
}
currentBeforeExitHandler = createBeforeExitHandler(client);
process.on("beforeExit", currentBeforeExitHandler);
}
Expand Down
2 changes: 2 additions & 0 deletions test/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ describe("logoutCommand.func", () => {
isAuthenticatedSpy.mockReturnValue(true);
isEnvTokenActiveSpy.mockReturnValue(true);
// Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken()
// Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority
delete process.env.SENTRY_AUTH_TOKEN;
process.env.SENTRY_TOKEN = "sntrys_token_456";
const { context } = createContext();

Expand Down
2 changes: 2 additions & 0 deletions test/commands/auth/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ describe("refreshCommand.func", () => {
test("env token (SENTRY_TOKEN): throws AuthError with SENTRY_TOKEN in message", async () => {
isEnvTokenActiveSpy.mockReturnValue(true);
// Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken()
// Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority
delete process.env.SENTRY_AUTH_TOKEN;
process.env.SENTRY_TOKEN = "sntrys_token_456";

const { context } = createContext();
Expand Down
12 changes: 12 additions & 0 deletions test/commands/auth/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,36 @@ function createContext() {

describe("whoamiCommand.func", () => {
let isAuthenticatedSpy: ReturnType<typeof spyOn>;
let getAuthConfigSpy: ReturnType<typeof spyOn>;
let getCurrentUserSpy: ReturnType<typeof spyOn>;
let setUserInfoSpy: ReturnType<typeof spyOn>;
let func: WhoamiFunc;

beforeEach(async () => {
isAuthenticatedSpy = spyOn(dbAuth, "isAuthenticated");
getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue({
token: "sntrys_test",
source: "config",
});
getCurrentUserSpy = spyOn(apiClient, "getCurrentUser");
setUserInfoSpy = spyOn(dbUser, "setUserInfo");
func = (await whoamiCommand.loader()) as unknown as WhoamiFunc;
});

afterEach(() => {
isAuthenticatedSpy.mockRestore();
getAuthConfigSpy.mockRestore();
getCurrentUserSpy.mockRestore();
setUserInfoSpy.mockRestore();
});

describe("unauthenticated", () => {
beforeEach(() => {
// Override the auth mock so buildCommand's auth guard
// sees no credentials — this tests the unauthenticated path end-to-end.
getAuthConfigSpy.mockReturnValue(undefined);
});

test("throws AuthError(not_authenticated) when no token stored", async () => {
isAuthenticatedSpy.mockReturnValue(false);

Expand Down
3 changes: 3 additions & 0 deletions test/commands/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { feedbackCommand } from "../../src/commands/cli/feedback.js";
import type { UpgradeResult } from "../../src/commands/cli/upgrade.js";
import { upgradeCommand } from "../../src/commands/cli/upgrade.js";
import { useAuthMock } from "../helpers.js";

/**
* Create a mock context with a process.stderr.write spy for capturing
Expand Down Expand Up @@ -62,6 +63,8 @@ function createMockContext(overrides: Partial<{ execPath: string }> = {}): {
};
}

useAuthMock();

describe("feedbackCommand.func", () => {
test("throws ValidationError for empty message", async () => {
// Access func through loader
Expand Down
3 changes: 3 additions & 0 deletions test/commands/dashboard/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ContextError, ValidationError } from "../../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as resolveTarget from "../../../src/lib/resolve-target.js";
import type { DashboardDetail } from "../../../src/types/dashboard.js";
import { useAuthMock } from "../../helpers.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -54,6 +55,8 @@ const sampleDashboard: DashboardDetail = {
// Tests
// ---------------------------------------------------------------------------

useAuthMock();

describe("dashboard create", () => {
let createDashboardSpy: ReturnType<typeof spyOn>;
let resolveOrgSpy: ReturnType<typeof spyOn>;
Expand Down
3 changes: 3 additions & 0 deletions test/commands/dashboard/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as polling from "../../../src/lib/polling.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as resolveTarget from "../../../src/lib/resolve-target.js";
import type { DashboardListItem } from "../../../src/types/dashboard.js";
import { useAuthMock } from "../../helpers.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -104,6 +105,8 @@ const DASHBOARD_C: DashboardListItem = {
// Tests
// ---------------------------------------------------------------------------

useAuthMock();

describe("dashboard list command", () => {
let listDashboardsPaginatedSpy: ReturnType<typeof spyOn>;
let resolveOrgSpy: ReturnType<typeof spyOn>;
Expand Down
3 changes: 3 additions & 0 deletions test/commands/dashboard/widget/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ValidationError } from "../../../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as resolveTarget from "../../../../src/lib/resolve-target.js";
import type { DashboardDetail } from "../../../../src/types/dashboard.js";
import { useAuthMock } from "../../../helpers.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -85,6 +86,8 @@ const sampleDashboard: DashboardDetail = {
// Tests
// ---------------------------------------------------------------------------

useAuthMock();

describe("dashboard widget add", () => {
let getDashboardSpy: ReturnType<typeof spyOn>;
let updateDashboardSpy: ReturnType<typeof spyOn>;
Expand Down
3 changes: 3 additions & 0 deletions test/commands/dashboard/widget/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ValidationError } from "../../../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as resolveTarget from "../../../../src/lib/resolve-target.js";
import type { DashboardDetail } from "../../../../src/types/dashboard.js";
import { useAuthMock } from "../../../helpers.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -85,6 +86,8 @@ const sampleDashboard: DashboardDetail = {
// Tests
// ---------------------------------------------------------------------------

useAuthMock();

describe("dashboard widget delete", () => {
let getDashboardSpy: ReturnType<typeof spyOn>;
let updateDashboardSpy: ReturnType<typeof spyOn>;
Expand Down
Loading
Loading