diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 13345d34f..6b6666c99 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -389,6 +389,7 @@ async function handleTraceMode( cursor, ...timeRangeToApiParams(timeRange), extraFields: extraApiFields, + allProjects: true, }) ); diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index e800cccff..2d3c6e376 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -386,6 +386,8 @@ type ListSpansOptions = { start?: string; /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ end?: string; + /** When true, query spans across all projects (sends project=-1). Used for cross-project traces. */ + allProjects?: boolean; }; /** @@ -402,8 +404,10 @@ export async function listSpans( projectSlug: string, options: ListSpansOptions = {} ): Promise> { - const isNumericProject = isAllDigits(projectSlug); - const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const useAllProjects = options.allProjects === true; + const isNumericProject = !useAllProjects && isAllDigits(projectSlug); + const projectFilter = + useAllProjects || isNumericProject ? "" : `project:${projectSlug}`; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); const fields = options.extraFields?.length @@ -412,6 +416,13 @@ export async function listSpans( const regionUrl = await resolveOrgRegion(orgSlug); + let projectParam: string | number | undefined; + if (useAllProjects) { + projectParam = -1; + } else if (isNumericProject) { + projectParam = projectSlug; + } + const { data: response, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/events/`, @@ -419,7 +430,7 @@ export async function listSpans( params: { dataset: "spans", field: fields, - project: isNumericProject ? projectSlug : undefined, + project: projectParam, query: fullQuery || undefined, per_page: options.limit || 10, statsPeriod: diff --git a/src/lib/dsn/env-file.ts b/src/lib/dsn/env-file.ts index 3193b1a0d..4cc3fc9fb 100644 --- a/src/lib/dsn/env-file.ts +++ b/src/lib/dsn/env-file.ts @@ -44,11 +44,17 @@ export const ENV_FILES = [ ] as const; /** - * Pattern to match SENTRY_DSN in .env files. - * Handles: SENTRY_DSN=value, SENTRY_DSN="value", SENTRY_DSN='value' - * Also handles trailing comments: SENTRY_DSN=value # comment + * Pattern to match Sentry DSN variables in .env files. + * Handles the canonical `SENTRY_DSN` as well as framework-prefixed variants + * commonly used by Next.js, Vite, CRA, Expo, Nuxt, and others: + * NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, VITE_SENTRY_DSN, + * EXPO_PUBLIC_SENTRY_DSN, NUXT_PUBLIC_SENTRY_DSN, etc. + * + * Handles: VAR=value, VAR="value", VAR='value' + * Also handles trailing comments: VAR=value # comment */ -const ENV_DSN_PATTERN = /^SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/; +const ENV_DSN_PATTERN = + /^(?:\w+_)?SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/; /** * Extract SENTRY_DSN value from .env file content. diff --git a/src/lib/dsn/env.ts b/src/lib/dsn/env.ts index c8dfb0dbe..dc2c01d64 100644 --- a/src/lib/dsn/env.ts +++ b/src/lib/dsn/env.ts @@ -13,7 +13,22 @@ import type { DetectedDsn } from "./types.js"; export const SENTRY_DSN_ENV = "SENTRY_DSN"; /** - * Detect DSN from environment variable. + * Framework-prefixed env var names that commonly hold a Sentry DSN. + * Checked in order after `SENTRY_DSN` (canonical name has priority). + */ +const FRAMEWORK_DSN_VARS = [ + "NEXT_PUBLIC_SENTRY_DSN", + "REACT_APP_SENTRY_DSN", + "VITE_SENTRY_DSN", + "EXPO_PUBLIC_SENTRY_DSN", + "NUXT_PUBLIC_SENTRY_DSN", +] as const; + +/** + * Detect DSN from environment variables. + * + * Checks `SENTRY_DSN` first (canonical), then common framework-prefixed + * variants (NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, etc.). * * @returns Detected DSN or null if not set/invalid * @@ -23,10 +38,19 @@ export const SENTRY_DSN_ENV = "SENTRY_DSN"; * // { raw: "...", source: "env", projectId: "456", ... } */ export function detectFromEnv(): DetectedDsn | null { - const dsn = getEnv()[SENTRY_DSN_ENV]; - if (!dsn) { - return null; + const env = getEnv(); + + const canonical = env[SENTRY_DSN_ENV]; + if (canonical) { + return createDetectedDsn(canonical, "env"); + } + + for (const varName of FRAMEWORK_DSN_VARS) { + const value = env[varName]; + if (value) { + return createDetectedDsn(value, "env"); + } } - return createDetectedDsn(dsn, "env"); + return null; } diff --git a/src/lib/init/clack-plain.ts b/src/lib/init/clack-plain.ts new file mode 100644 index 000000000..aad1d40ef --- /dev/null +++ b/src/lib/init/clack-plain.ts @@ -0,0 +1,122 @@ +/** + * Plain-mode adapter for @clack/prompts. + * + * When stdout is not a TTY (piped, redirected, CI), clack writes raw ANSI + * escape sequences (cursor hide/show, erase codes, spinner animation) that + * make captured output unreadable. This module re-exports the clack functions + * used by the init wizard, replacing them with plain-text equivalents when + * `isPlainOutput()` is true. + * + * Interactive prompts (confirm, select, multiselect) pass through to real + * clack unconditionally — they're only reached in TTY mode (the wizard + * guards with `process.stdin.isTTY` before prompts). + */ + +import * as clack from "@clack/prompts"; +import { isPlainOutput } from "../formatters/plain-detect.js"; +import { stripAnsi } from "../formatters/plain-detect.js"; + +function plainWrite(message: string): void { + process.stdout.write(`${stripAnsi(message)}\n`); +} + +function plainWriteErr(message: string): void { + process.stderr.write(`${stripAnsi(message)}\n`); +} + +/** intro() — in plain mode, just print the title without box-drawing chars */ +export function intro(title?: string): void { + if (isPlainOutput()) { + if (title) { + plainWrite(`── ${stripAnsi(String(title))} ──`); + } + return; + } + clack.intro(title); +} + +/** outro() — in plain mode, print a simple closing line */ +export function outro(message?: string): void { + if (isPlainOutput()) { + if (message) { + plainWrite(stripAnsi(String(message))); + } + return; + } + clack.outro(message); +} + +/** cancel() — in plain mode, print the cancellation message */ +export function cancel(message?: string): void { + if (isPlainOutput()) { + if (message) { + plainWriteErr(stripAnsi(String(message))); + } + return; + } + clack.cancel(message); +} + +/** + * log — delegates to the real clack.log, but in plain mode strips ANSI + * and writes to stdout/stderr without box-drawing characters. + */ +export const log: typeof clack.log = { + message(message?: string, opts?: { symbol?: string }) { + if (isPlainOutput()) { + if (message) { + const prefix = opts?.symbol + ? `${stripAnsi(opts.symbol)} ` + : ""; + plainWrite(`${prefix}${stripAnsi(message)}`); + } + return; + } + clack.log.message(message, opts); + }, + info(message: string) { + if (isPlainOutput()) { + plainWrite(`INFO: ${stripAnsi(message)}`); + return; + } + clack.log.info(message); + }, + success(message: string) { + if (isPlainOutput()) { + plainWrite(`OK: ${stripAnsi(message)}`); + return; + } + clack.log.success(message); + }, + step(message: string) { + if (isPlainOutput()) { + plainWrite(`> ${stripAnsi(message)}`); + return; + } + clack.log.step(message); + }, + warn(message: string) { + if (isPlainOutput()) { + plainWriteErr(`WARN: ${stripAnsi(message)}`); + return; + } + clack.log.warn(message); + }, + warning(message: string) { + if (isPlainOutput()) { + plainWriteErr(`WARN: ${stripAnsi(message)}`); + return; + } + clack.log.warning(message); + }, + error(message: string) { + if (isPlainOutput()) { + plainWriteErr(`ERROR: ${stripAnsi(message)}`); + return; + } + clack.log.error(message); + }, +}; + +export { isCancel } from "@clack/prompts"; +export { confirm, select, multiselect } from "@clack/prompts"; diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index d796fd003..a4a8236af 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -4,7 +4,7 @@ * Shared helpers for the clack-based init wizard UI. */ -import { cancel, isCancel } from "@clack/prompts"; +import { cancel, isCancel } from "./clack-plain.js"; import { terminalLink } from "../formatters/colors.js"; import { SENTRY_DOCS_URL } from "./constants.js"; diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index a7dda1a4a..5e7f5d1d6 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -4,7 +4,7 @@ * Format wizard results and errors for terminal display using clack. */ -import { cancel, log, outro } from "@clack/prompts"; +import { cancel, log, outro } from "./clack-plain.js"; import { terminalLink } from "../formatters/colors.js"; import { colorTag, mdKvTable, renderMarkdown } from "../formatters/markdown.js"; import { featureLabel } from "./clack-utils.js"; diff --git a/src/lib/init/git.ts b/src/lib/init/git.ts index d3f7a4760..3012d8f66 100644 --- a/src/lib/init/git.ts +++ b/src/lib/init/git.ts @@ -9,7 +9,7 @@ * `checkGitStatus` orchestrator (coupled to `@clack/prompts` UI). */ -import { confirm, isCancel, log } from "@clack/prompts"; +import { confirm, isCancel, log } from "./clack-plain.js"; import { getUncommittedFiles, isInsideGitWorkTree as isInsideWorkTree, diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 52f39cddf..c18508d51 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -6,7 +6,7 @@ * Respects --yes flag for non-interactive mode. */ -import { confirm, log, multiselect, select } from "@clack/prompts"; +import { confirm, log, multiselect, select } from "./clack-plain.js"; import chalk from "chalk"; import { abortIfCancelled, diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index ba8a9b16e..fec0a967a 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, log, select } from "@clack/prompts"; +import { cancel, isCancel, log, select } from "./clack-plain.js"; import type { SentryTeam } from "../../types/index.js"; import { listOrganizations } from "../api-client.js"; import { getAuthToken } from "../db/auth.js"; diff --git a/src/lib/init/spinner.ts b/src/lib/init/spinner.ts index 2491705a7..63bf57737 100644 --- a/src/lib/init/spinner.ts +++ b/src/lib/init/spinner.ts @@ -5,6 +5,7 @@ import { renderInlineMarkdown, stripColorTags, } from "../formatters/markdown.js"; +import { isPlainOutput } from "../formatters/plain-detect.js"; const HIDE_CURSOR = "\u001B[?25l"; const SHOW_CURSOR = "\u001B[?25h"; @@ -108,11 +109,12 @@ export function createWizardSpinner( running = true; frameIndex = 0; message = nextMessage; - if (output.isTTY) { + const plain = isPlainOutput(); + if (output.isTTY && !plain) { output.write(HIDE_CURSOR); } renderCurrentFrame(); - if (output.isTTY) { + if (output.isTTY && !plain) { timer = setInterval(() => { frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; renderCurrentFrame(); @@ -138,7 +140,7 @@ export function createWizardSpinner( `${renderInlineMarkdown(formatStoppedBlock(message, code))}\n` ); } - if (output.isTTY) { + if (output.isTTY && !isPlainOutput()) { output.write(SHOW_CURSOR); } } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index caa8d7f85..9ff8e0789 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -8,7 +8,7 @@ import { randomBytes } from "node:crypto"; import { basename } from "node:path"; -import { cancel, confirm, intro, log } from "@clack/prompts"; +import { cancel, confirm, intro, log } from "./clack-plain.js"; import { MastraClient } from "@mastra/client-js"; import { captureException, getTraceData } from "@sentry/node-core/light"; import { formatBanner } from "../banner.js"; diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index a306eeaba..80b6cb3bb 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -23,7 +23,11 @@ const noop = () => { /* suppress clack output */ }; +let savedPlainOutput: string | undefined; + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; logMessageSpy = spyOn(clack.log, "message").mockImplementation(noop); outroSpy = spyOn(clack, "outro").mockImplementation(noop); cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); @@ -39,6 +43,11 @@ afterEach(() => { logInfoSpy.mockRestore(); logWarnSpy.mockRestore(); logErrorSpy.mockRestore(); + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } }); describe("formatResult", () => { diff --git a/test/lib/init/git.test.ts b/test/lib/init/git.test.ts index e7adfdfe8..ef6c65b0b 100644 --- a/test/lib/init/git.test.ts +++ b/test/lib/init/git.test.ts @@ -19,7 +19,11 @@ let confirmSpy: ReturnType; let isCancelSpy: ReturnType; let logWarnSpy: ReturnType; +let savedPlainOutput: string | undefined; + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; isInsideWorkTreeSpy = spyOn(gitLib, "isInsideGitWorkTree"); getUncommittedFilesSpy = spyOn(gitLib, "getUncommittedFiles"); confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true); @@ -35,6 +39,11 @@ afterEach(() => { confirmSpy.mockRestore(); isCancelSpy.mockRestore(); logWarnSpy.mockRestore(); + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } }); describe("isInsideGitWorkTree", () => { diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index f0ff04851..d6c23db0d 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -34,7 +34,11 @@ function makeOptions( }; } +let savedPlainOutput: string | undefined; + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; selectSpy = spyOn(clack, "select").mockImplementation( () => Promise.resolve("default") as any ); @@ -62,6 +66,11 @@ afterEach(() => { logWarnSpy.mockRestore(); cancelSpy.mockRestore(); isCancelSpy.mockRestore(); + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } }); describe("handleInteractive dispatcher", () => { diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index 4828ba6f9..9484d4fa4 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -43,7 +43,11 @@ let resolveOrCreateTeamSpy: ReturnType; let detectDsnSpy: ReturnType; let resolveDsnByPublicKeySpy: ReturnType; +let savedPlainOutput: string | undefined; + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; selectSpy = spyOn(clack, "select").mockResolvedValue("existing"); isCancelSpy = spyOn(clack, "isCancel").mockImplementation( (value: unknown) => value === Symbol.for("cancel") @@ -98,6 +102,11 @@ afterEach(() => { detectDsnSpy.mockRestore(); resolveDsnByPublicKeySpy.mockRestore(); process.exitCode = 0; + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } }); describe("resolveInitContext", () => { diff --git a/test/lib/init/spinner.test.ts b/test/lib/init/spinner.test.ts index 4cade8547..2c9352386 100644 --- a/test/lib/init/spinner.test.ts +++ b/test/lib/init/spinner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Writable } from "node:stream"; import { createWizardSpinner } from "../../../src/lib/init/spinner.js"; @@ -37,7 +37,21 @@ function stripAnsi(value: string): string { return value.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, ""); } +let savedPlainOutput: string | undefined; + describe("createWizardSpinner", () => { + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + }); + afterEach(() => { + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } + }); + test("clears upward when repainting a multiline status block", () => { const output = new CaptureStream(); const spin = createWizardSpinner(output as unknown as NodeJS.WriteStream); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index d3f46a09c..5b3f12438 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -102,7 +102,11 @@ let stderrSpy: ReturnType; */ let capturedClientOptions: { abortSignal?: AbortSignal }[] = []; +let savedPlainOutput: string | undefined; + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; mockStartResult = { status: "success", result: { platform: "React" } }; mockResumeResults = []; resumeCallCount = 0; @@ -211,6 +215,11 @@ afterEach(() => { stderrSpy.mockRestore(); process.exitCode = 0; + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } }); describe("runWizard", () => {