From 876fdf628bee89c04eab1779ded1dd3f2c704853 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 12 Mar 2026 22:00:15 +0000 Subject: [PATCH 01/25] refactor: unify all command functions as async generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All command functions now use async generator signatures. The framework iterates each yielded value through the existing OutputConfig rendering pipeline. Non-streaming commands yield once and return; streaming commands (log list --follow) yield multiple times. Key changes: - buildCommand: func returns AsyncGenerator, wrapper uses for-await-of - All ~27 command files: async func → async *func, return → yield - log/list follow mode: drainStreamingOutput replaced by yield delegation - Delete streaming-command.ts (151 lines) — absorbed into buildCommand - output.ts: extracted applyJsonExclude/writeTransformedJson helpers, support undefined suppression for streaming text-only chunks No new dependencies. Net -131 lines. --- src/commands/api.ts | 8 +- src/commands/auth/login.ts | 3 +- src/commands/auth/logout.ts | 8 +- src/commands/auth/refresh.ts | 5 +- src/commands/auth/status.ts | 5 +- src/commands/auth/token.ts | 4 +- src/commands/auth/whoami.ts | 5 +- src/commands/cli/feedback.ts | 7 +- src/commands/cli/fix.ts | 5 +- src/commands/cli/setup.ts | 10 +- src/commands/cli/upgrade.ts | 11 +- src/commands/event/view.ts | 5 +- src/commands/help.ts | 3 +- src/commands/init.ts | 2 +- src/commands/issue/explain.ts | 5 +- src/commands/issue/list.ts | 14 +- src/commands/issue/plan.ts | 8 +- src/commands/issue/view.ts | 5 +- src/commands/log/list.ts | 497 ++++++++++++++++++++------------- src/commands/log/view.ts | 5 +- src/commands/org/list.ts | 5 +- src/commands/org/view.ts | 5 +- src/commands/project/create.ts | 8 +- src/commands/project/list.ts | 13 +- src/commands/project/view.ts | 5 +- src/commands/trace/list.ts | 9 +- src/commands/trace/logs.ts | 7 +- src/commands/trace/view.ts | 5 +- src/commands/trial/list.ts | 5 +- src/commands/trial/start.ts | 8 +- src/lib/command.ts | 63 ++--- src/lib/formatters/output.ts | 94 ++++--- src/lib/list-command.ts | 21 +- test/lib/command.test.ts | 130 ++++++--- 34 files changed, 577 insertions(+), 416 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 37c1e0ae4..2ca7b99c8 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1155,7 +1155,7 @@ export const apiCommand = buildCommand({ n: "dry-run", }, }, - async func(this: SentryContext, flags: ApiFlags, endpoint: string) { + async *func(this: SentryContext, flags: ApiFlags, endpoint: string) { const { stdin } = this; const normalizedEndpoint = normalizeEndpoint(endpoint); @@ -1168,7 +1168,7 @@ export const apiCommand = buildCommand({ // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - return { + yield { data: { method: flags.method, url: resolveRequestUrl(normalizedEndpoint, params), @@ -1176,6 +1176,7 @@ export const apiCommand = buildCommand({ body: body ?? null, }, }; + return; } const verbose = flags.verbose && !flags.silent; @@ -1210,6 +1211,7 @@ export const apiCommand = buildCommand({ throw new OutputError(response.body); } - return { data: response.body }; + yield { data: response.body }; + return; }, }); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 4647f1e15..ccf405368 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -104,7 +104,8 @@ export const loginCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: LoginFlags): Promise { + // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly, will be migrated to yield pattern later + async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { const shouldProceed = await handleExistingAuth(flags.force); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 82b68e588..a81afe220 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -36,11 +36,12 @@ export const logoutCommand = buildCommand({ parameters: { flags: {}, }, - async func(this: SentryContext): Promise<{ data: LogoutResult }> { + async *func(this: SentryContext) { if (!(await isAuthenticated())) { - return { + yield { data: { loggedOut: false, message: "Not currently authenticated." }, }; + return; } if (isEnvTokenActive()) { @@ -55,11 +56,12 @@ export const logoutCommand = buildCommand({ const configPath = getDbPath(); await clearAuth(); - return { + yield { data: { loggedOut: true, configPath, }, }; + return; }, }); diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 300dfc651..39e5f7384 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -68,7 +68,7 @@ Examples: }, }, }, - async func(this: SentryContext, flags: RefreshFlags) { + async *func(this: SentryContext, flags: RefreshFlags) { // Env var tokens can't be refreshed if (isEnvTokenActive()) { const envVar = getActiveEnvVarName(); @@ -104,6 +104,7 @@ Examples: : undefined, }; - return { data: payload }; + yield { data: payload }; + return; }, }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 464d07e07..1de9bba54 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -155,7 +155,7 @@ export const statusCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async func(this: SentryContext, flags: StatusFlags) { + async *func(this: SentryContext, flags: StatusFlags) { applyFreshFlag(flags); const auth = getAuthConfig(); @@ -189,6 +189,7 @@ export const statusCommand = buildCommand({ verification: await verifyCredentials(), }; - return { data }; + yield { data }; + return; }, }); diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 30b4a8e22..2ac01047a 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -20,7 +20,9 @@ export const tokenCommand = buildCommand({ "when stdout is not a TTY (e.g., when piped).", }, parameters: {}, - func(this: SentryContext): void { + // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly + // biome-ignore lint/suspicious/useAwait: sync body but async generator required by buildCommand + async *func(this: SentryContext) { const { stdout } = this; const token = getAuthToken(); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 39c3ca0a4..563020f73 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -43,7 +43,7 @@ export const whoamiCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async func(this: SentryContext, flags: WhoamiFlags) { + async *func(this: SentryContext, flags: WhoamiFlags) { applyFreshFlag(flags); if (!(await isAuthenticated())) { @@ -65,6 +65,7 @@ export const whoamiCommand = buildCommand({ // Cache update failure is non-essential — user identity was already fetched. } - return { data: user }; + yield { data: user }; + return; }, }); diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index ff552104d..c1b1a00fd 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -42,12 +42,12 @@ export const feedbackCommand = buildCommand({ }, }, }, - async func( + async *func( this: SentryContext, // biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags _flags: {}, ...messageParts: string[] - ): Promise<{ data: FeedbackResult }> { + ) { const message = messageParts.join(" "); if (!message.trim()) { @@ -66,11 +66,12 @@ export const feedbackCommand = buildCommand({ // Flush to ensure feedback is sent before process exits const sent = await Sentry.flush(3000); - return { + yield { data: { sent, message, }, }; + return; }, }); diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 0a5061f49..866ab5add 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -678,7 +678,7 @@ export const fixCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: FixFlags) { + async *func(this: SentryContext, flags: FixFlags) { const dbPath = getDbPath(); const dryRun = flags["dry-run"]; @@ -734,6 +734,7 @@ export const fixCommand = buildCommand({ throw new OutputError(result); } - return { data: result }; + yield { data: result }; + return; }, }); diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index 2479fc118..a090c0a57 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -105,7 +105,7 @@ async function handlePathModification( shell: ShellInfo, env: NodeJS.ProcessEnv, emit: Logger -): Promise { +) { const alreadyInPath = isInPath(binaryDir, env.PATH); if (alreadyInPath) { @@ -235,7 +235,7 @@ async function handleCompletions( * Only produces output when the skill file is freshly created. Subsequent * runs (e.g. after upgrade) silently update without printing. */ -async function handleAgentSkills(homeDir: string, emit: Logger): Promise { +async function handleAgentSkills(homeDir: string, emit: Logger) { const location = await installAgentSkills(homeDir, CLI_VERSION); if (location?.created) { @@ -276,7 +276,7 @@ async function bestEffort( stepName: string, fn: () => void | Promise, warn: WarnLogger -): Promise { +) { try { await fn(); } catch (error) { @@ -301,7 +301,7 @@ type ConfigStepOptions = { * Each step is independently guarded so a failure in one (e.g. DB permission * error) doesn't prevent the others from running. */ -async function runConfigurationSteps(opts: ConfigStepOptions): Promise { +async function runConfigurationSteps(opts: ConfigStepOptions) { const { flags, binaryPath, binaryDir, homeDir, env, emit, warn } = opts; const shell = detectShell(env.SHELL, homeDir, env.XDG_CONFIG_HOME); @@ -441,7 +441,7 @@ export const setupCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: SetupFlags): Promise { + async *func(this: SentryContext, flags: SetupFlags) { const { process, homeDir } = this; const emit: Logger = (msg: string) => { diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 5b68d6b5e..5d1ba0bf0 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -451,7 +451,7 @@ export const upgradeCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: UpgradeFlags, version?: string) { + async *func(this: SentryContext, flags: UpgradeFlags, version?: string) { // Resolve effective channel and version from positional const { channel, versionArg } = resolveChannelAndVersion(version); @@ -493,7 +493,8 @@ export const upgradeCommand = buildCommand({ flags, }); if (resolved.kind === "done") { - return { data: resolved.result }; + yield { data: resolved.result }; + return; } const { target } = resolved; @@ -509,7 +510,7 @@ export const upgradeCommand = buildCommand({ target, versionArg ); - return { + yield { data: { action: downgrade ? "downgraded" : "upgraded", currentVersion: CLI_VERSION, @@ -520,6 +521,7 @@ export const upgradeCommand = buildCommand({ warnings, } satisfies UpgradeResult, }; + return; } await executeStandardUpgrade({ @@ -530,7 +532,7 @@ export const upgradeCommand = buildCommand({ execPath: this.process.execPath, }); - return { + yield { data: { action: downgrade ? "downgraded" : "upgraded", currentVersion: CLI_VERSION, @@ -540,5 +542,6 @@ export const upgradeCommand = buildCommand({ forced: flags.force, } satisfies UpgradeResult, }; + return; }, }); diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 0efae499d..e209ec1aa 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -328,7 +328,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; @@ -380,11 +380,12 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - return { + yield { data: { event, trace, spanTreeLines: spanTreeResult?.lines }, hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined, }; + return; }, }); diff --git a/src/commands/help.ts b/src/commands/help.ts index 7fce649ec..f5e1acb2f 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -30,7 +30,8 @@ export const helpCommand = buildCommand({ }, }, // biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags - async func(this: SentryContext, _flags: {}, ...commandPath: string[]) { + // biome-ignore lint/correctness/useYield: void generator — delegates to Stricli help system + async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) { const { stdout } = this; // No args: show branded help diff --git a/src/commands/init.ts b/src/commands/init.ts index 7b4be3fa0..10fdd7fe5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -69,7 +69,7 @@ export const initCommand = buildCommand({ t: "team", }, }, - async func(this: SentryContext, flags: InitFlags, directory?: string) { + async *func(this: SentryContext, flags: InitFlags, directory?: string) { const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd; const featuresList = flags.features ?.flatMap((f) => f.split(FEATURE_DELIMITER)) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 672a4b8b7..38e3dd4a2 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -71,7 +71,7 @@ export const explainCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async func(this: SentryContext, flags: ExplainFlags, issueArg: string) { + async *func(this: SentryContext, flags: ExplainFlags, issueArg: string) { applyFreshFlag(flags); const { cwd } = this; @@ -104,10 +104,11 @@ export const explainCommand = buildCommand({ ); } - return { + yield { data: causes, hint: `To create a plan, run: sentry issue plan ${issueArg}`, }; + return; } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index f9e18be65..755ee9046 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -42,10 +42,7 @@ import { shouldAutoCompact, writeIssueTable, } from "../../lib/formatters/index.js"; -import type { - CommandOutput, - OutputConfig, -} from "../../lib/formatters/output.js"; +import type { OutputConfig } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -1316,11 +1313,7 @@ export const listCommand = buildListCommand("issue", { t: "period", }, }, - async func( - this: SentryContext, - flags: ListFlags, - target?: string - ): Promise> { + async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); const { stdout, stderr, cwd, setContext } = this; @@ -1385,6 +1378,7 @@ export const listCommand = buildListCommand("issue", { combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; } - return { data: result, hint: combinedHint }; + yield { data: result, hint: combinedHint }; + return; }, }); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 28376b8fe..c8f57d596 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -191,7 +191,7 @@ export const planCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async func(this: SentryContext, flags: PlanFlags, issueArg: string) { + async *func(this: SentryContext, flags: PlanFlags, issueArg: string) { applyFreshFlag(flags); const { cwd } = this; @@ -225,7 +225,8 @@ export const planCommand = buildCommand({ if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - return { data: buildPlanData(state) }; + yield { data: buildPlanData(state) }; + return; } } @@ -260,7 +261,8 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - return { data: buildPlanData(finalState) }; + yield { data: buildPlanData(finalState) }; + return; } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 634409e80..dea530ca4 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -118,7 +118,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, issueArg: string) { + async *func(this: SentryContext, flags: ViewFlags, issueArg: string) { applyFreshFlag(flags); const { cwd, setContext } = this; @@ -170,9 +170,10 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - return { + yield { data: { issue, event: event ?? null, trace, spanTreeLines }, hint: `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis`, }; + return; }, }); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 8df94773b..4fd7378ec 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -19,11 +19,9 @@ import { formatLogsHeader, formatLogTable, isPlainOutput, - writeJson, } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; -import type { CommandOutput } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { applyFreshFlag, @@ -111,44 +109,6 @@ type LogLike = { trace?: string | null; }; -type WriteLogsOptions = { - stdout: Writer; - logs: LogLike[]; - asJson: boolean; - table?: StreamingTable; - /** Whether to append a short trace-ID suffix (default: true) */ - includeTrace?: boolean; - /** Optional field paths to include in JSON output */ - fields?: string[]; -}; - -/** - * Write logs to output in the appropriate format. - * - * When a StreamingTable is provided (TTY mode), renders rows through the - * bordered table. Otherwise falls back to plain markdown rows. - */ -function writeLogs(options: WriteLogsOptions): void { - const { stdout, logs, asJson, table, includeTrace = true, fields } = options; - if (asJson) { - for (const log of logs) { - writeJson(stdout, log, fields); - } - } else if (table) { - for (const log of logs) { - stdout.write( - table.row( - buildLogRowCells(log, true, includeTrace).map(renderInlineMarkdown) - ) - ); - } - } else { - for (const log of logs) { - stdout.write(formatLogRow(log, includeTrace)); - } - } -} - /** * Execute a single fetch of logs (non-streaming mode). * @@ -185,20 +145,92 @@ async function executeSingleFetch( return { logs: chronological, hint: `${countText}${tip}` }; } +// --------------------------------------------------------------------------- +// Streaming follow-mode infrastructure +// --------------------------------------------------------------------------- + +/** + * A chunk yielded by the follow-mode generator. + * + * Two kinds: + * - `text` — pre-rendered human content (header, table rows, footer). + * Written to stdout in human mode, skipped in JSON mode. + * - `data` — raw log entries for JSONL output. Skipped in human mode + * (the text chunk handles rendering). + */ +type LogStreamChunk = + | { kind: "text"; content: string } + | { kind: "data"; logs: LogLike[] }; + /** - * Configuration for the unified follow-mode loop. + * Yield `CommandOutput` values from a streaming log chunk. + * + * - **Human mode**: yields the chunk as-is (text is rendered, data is skipped + * by the human formatter). + * - **JSON mode**: expands `data` chunks into one yield per log entry (JSONL). + * Text chunks yield a suppressed-in-JSON marker so the framework skips them. + * + * @param chunk - A streaming chunk from `generateFollowLogs` + * @param json - Whether JSON output mode is active + * @param fields - Optional field filter list + */ +function* yieldStreamChunks( + chunk: LogStreamChunk, + json: boolean +): Generator<{ data: LogListOutput }, void, undefined> { + if (json) { + // In JSON mode, expand data chunks into one yield per log for JSONL + if (chunk.kind === "data") { + for (const log of chunk.logs) { + // Yield a single-log data chunk so jsonTransform emits one line + yield { data: { kind: "data", logs: [log] } }; + } + } + // Text chunks suppressed in JSON mode (jsonTransform returns undefined) + return; + } + // Human mode: yield the chunk directly for the human formatter + yield { data: chunk }; +} + +/** + * Sleep that resolves early when an AbortSignal fires. + * Resolves (not rejects) on abort for clean generator shutdown. + */ +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Configuration for the follow-mode async generator. * * Parameterized over the log type to handle both project-scoped * (`SentryLog`) and trace-scoped (`TraceLog`) streaming. + * + * Unlike the old callback-based approach, this does NOT include + * stdout/stderr. All stdout output flows through yielded chunks; + * stderr diagnostics use the `onDiagnostic` callback. */ -type FollowConfig = { - stdout: Writer; - stderr: Writer; +type FollowGeneratorConfig = { flags: ListFlags; - /** Text for the stderr banner (e.g., "Streaming logs…") */ - bannerText: string; /** Whether to show the trace-ID column in table output */ includeTrace: boolean; + /** Report diagnostic/error messages (caller writes to stderr) */ + onDiagnostic: (message: string) => void; /** * Fetch logs with the given time window. * @param statsPeriod - Time window (e.g., "1m" for initial, "10m" for polls) @@ -215,30 +247,87 @@ type FollowConfig = { onInitialLogs?: (logs: T[]) => void; }; +/** Find the highest timestamp_precise in a batch, or undefined if none have it. */ +function maxTimestamp(logs: LogLike[]): number | undefined { + let max: number | undefined; + for (const l of logs) { + if (l.timestamp_precise !== undefined) { + max = + max === undefined + ? l.timestamp_precise + : Math.max(max, l.timestamp_precise); + } + } + return max; +} + /** - * Execute streaming mode (--follow flag). + * Render a batch of log rows as a human-readable string. * - * Uses `setTimeout`-based recursive scheduling so that SIGINT can - * cleanly cancel the pending timer and resolve the returned promise - * without `process.exit()`. + * When a StreamingTable is provided (TTY mode), renders rows through the + * bordered table. Otherwise falls back to plain markdown rows. */ -function executeFollowMode( - config: FollowConfig -): Promise { - const { stdout, stderr, flags } = config; - const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; - const pollIntervalMs = pollInterval * 1000; - - if (!flags.json) { - stderr.write(`${config.bannerText} (poll interval: ${pollInterval}s)\n`); - stderr.write("Press Ctrl+C to stop.\n"); +function renderLogRows( + logs: LogLike[], + includeTrace: boolean, + table?: StreamingTable +): string { + let text = ""; + for (const log of logs) { + if (table) { + text += table.row( + buildLogRowCells(log, true, includeTrace).map(renderInlineMarkdown) + ); + } else { + text += formatLogRow(log, includeTrace); + } + } + return text; +} - const notification = getUpdateNotification(); - if (notification) { - stderr.write(notification); +/** + * Execute a single poll iteration in follow mode. + * + * Returns the new logs, or `undefined` if a transient error occurred + * (reported via `onDiagnostic`). Re-throws {@link AuthError}. + */ +async function fetchPoll( + config: FollowGeneratorConfig, + lastTimestamp: number +): Promise { + try { + const rawLogs = await config.fetch("10m", lastTimestamp); + return config.extractNew(rawLogs, lastTimestamp); + } catch (error) { + if (error instanceof AuthError) { + throw error; } - stderr.write("\n"); + Sentry.captureException(error); + const message = stringifyUnknown(error); + config.onDiagnostic(`Error fetching logs: ${message}\n`); + return; } +} + +/** + * Async generator that streams log entries via follow-mode polling. + * + * Yields typed {@link LogStreamChunk} values: + * - `text` chunks contain pre-rendered human output (header, rows, footer) + * - `data` chunks contain raw log arrays for JSONL serialization + * + * The generator handles SIGINT via AbortController for clean shutdown. + * It never touches stdout/stderr directly — all output flows through + * yielded chunks and the `onDiagnostic` callback. + * + * @throws {AuthError} if the API returns an authentication error + */ +async function* generateFollowLogs( + config: FollowGeneratorConfig +): AsyncGenerator { + const { flags } = config; + const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; + const pollIntervalMs = pollInterval * 1000; const plain = flags.json || isPlainOutput(); const table = plain ? undefined : createLogStreamingTable(); @@ -246,116 +335,74 @@ function executeFollowMode( let headerPrinted = false; // timestamp_precise is nanoseconds; Date.now() is milliseconds → convert let lastTimestamp = Date.now() * 1_000_000; - let pendingTimer: ReturnType | null = null; - let stopped = false; - - return new Promise((resolve, reject) => { - function stop() { - stopped = true; - if (pendingTimer !== null) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - if (table) { - stdout.write(table.footer()); - } - resolve(); - } - process.once("SIGINT", stop); + // AbortController for clean SIGINT handling + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); - function scheduleNextPoll() { - if (stopped) { - return; - } - pendingTimer = setTimeout(poll, pollIntervalMs); + /** + * Yield header + data + rendered-text chunks for a batch of logs. + * Implemented as a sync sub-generator to use `yield*` from the caller. + */ + function* yieldBatch(logs: T[]): Generator { + if (logs.length === 0) { + return; } - /** Find the highest timestamp_precise in a batch, or undefined if none have it. */ - function maxTimestamp(logs: T[]): number | undefined { - let max: number | undefined; - for (const l of logs) { - if (l.timestamp_precise !== undefined) { - max = - max === undefined - ? l.timestamp_precise - : Math.max(max, l.timestamp_precise); - } - } - return max; + // Header on first non-empty batch (human mode only) + if (!(flags.json || headerPrinted)) { + yield { + kind: "text", + content: table ? table.header() : formatLogsHeader(), + }; + headerPrinted = true; } - function writeNewLogs(newLogs: T[]) { - if (newLogs.length === 0) { - return; - } + const chronological = [...logs].reverse(); - if (!(flags.json || headerPrinted)) { - stdout.write(table ? table.header() : formatLogsHeader()); - headerPrinted = true; - } - const chronological = [...newLogs].reverse(); - writeLogs({ - stdout, - logs: chronological, - asJson: flags.json, - table, - includeTrace: config.includeTrace, - fields: config.flags.fields, - }); - lastTimestamp = maxTimestamp(newLogs) ?? lastTimestamp; + // Data chunk for JSONL + yield { kind: "data", logs: chronological }; + + // Rendered text chunk for human mode + if (!flags.json) { + yield { + kind: "text", + content: renderLogRows(chronological, config.includeTrace, table), + }; } + } - async function poll() { - pendingTimer = null; - if (stopped) { - return; + try { + // Initial fetch + const initialLogs = await config.fetch("1m"); + yield* yieldBatch(initialLogs); + lastTimestamp = maxTimestamp(initialLogs) ?? lastTimestamp; + config.onInitialLogs?.(initialLogs); + + // Poll loop — exits when SIGINT fires + while (!controller.signal.aborted) { + await abortableSleep(pollIntervalMs, controller.signal); + if (controller.signal.aborted) { + break; } - try { - const rawLogs = await config.fetch("10m", lastTimestamp); - const newLogs = config.extractNew(rawLogs, lastTimestamp); - writeNewLogs(newLogs); - scheduleNextPoll(); - } catch (error) { - if (error instanceof AuthError) { - process.removeListener("SIGINT", stop); - reject(error); - return; - } - Sentry.captureException(error); - const message = stringifyUnknown(error); - stderr.write(`Error fetching logs: ${message}\n`); - scheduleNextPoll(); + + const newLogs = await fetchPoll(config, lastTimestamp); + if (newLogs) { + yield* yieldBatch(newLogs); + lastTimestamp = maxTimestamp(newLogs) ?? lastTimestamp; } } - // Fire-and-forget: we cannot `await` here because `resolve` must - // remain callable by the SIGINT handler (`stop`) at any time. - config - .fetch("1m") - .then((initialLogs) => { - if (!flags.json && initialLogs.length > 0) { - stdout.write(table ? table.header() : formatLogsHeader()); - headerPrinted = true; - } - const chronological = [...initialLogs].reverse(); - writeLogs({ - stdout, - logs: chronological, - asJson: flags.json, - table, - includeTrace: config.includeTrace, - fields: config.flags.fields, - }); - lastTimestamp = maxTimestamp(initialLogs) ?? lastTimestamp; - config.onInitialLogs?.(initialLogs); - scheduleNextPoll(); - }) - .catch((error: unknown) => { - process.removeListener("SIGINT", stop); - reject(error); - }); - }); + // Table footer — yielded after clean shutdown so the consumer can + // render it. Placed inside `try` (not `finally`) because a yield in + // `finally` is discarded when the consumer terminates via error. + if (table && headerPrinted) { + yield { kind: "text", content: table.footer() }; + } + } finally { + process.removeListener("SIGINT", stop); + } } /** Default time period for trace-logs queries */ @@ -403,40 +450,83 @@ async function executeTraceSingleFetch( return { logs: chronological, traceId, hint: `${countText}${tip}` }; } +/** + * Write the follow-mode banner to stderr. Suppressed in JSON mode. + * Includes poll interval, Ctrl+C hint, and update notification. + */ +function writeFollowBanner( + stderr: Writer, + flags: ListFlags, + bannerText: string +): void { + if (flags.json) { + return; + } + const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; + stderr.write(`${bannerText} (poll interval: ${pollInterval}s)\n`); + stderr.write("Press Ctrl+C to stop.\n"); + const notification = getUpdateNotification(); + if (notification) { + stderr.write(notification); + } + stderr.write("\n"); +} + // --------------------------------------------------------------------------- // Output formatting // --------------------------------------------------------------------------- +/** Data yielded by the log list command — either a batch result or a stream chunk. */ +type LogListOutput = LogListResult | LogStreamChunk; + /** - * Format a {@link LogListResult} as human-readable terminal output. - * - * Handles three cases: - * - Empty logs → return the hint text (e.g., "No logs found.") - * - Trace-filtered logs → table without trace-ID column - * - Standard logs → table with trace-ID column + * Format log output as human-readable terminal text. * - * The returned string omits a trailing newline — the output framework - * appends one automatically. + * Handles both batch results ({@link LogListResult}) and streaming + * chunks ({@link LogStreamChunk}). The returned string omits a trailing + * newline — the output framework appends one automatically. */ -function formatLogListHuman(result: LogListResult): string { +function formatLogOutput(result: LogListOutput): string { + if ("kind" in result) { + // Streaming chunk — text is pre-rendered, data is skipped (handled by JSON) + return result.kind === "text" ? result.content.trimEnd() : ""; + } + // Batch result if (result.logs.length === 0) { return result.hint ?? "No logs found."; } - const includeTrace = !result.traceId; return formatLogTable(result.logs, includeTrace).trimEnd(); } /** - * Transform a {@link LogListResult} into the JSON output shape. + * Transform log output into the JSON shape. * - * Returns the logs array directly (no wrapper envelope). - * Applies per-element field filtering when `--fields` is provided. + * - Batch: returns the logs array (no envelope). + * - Streaming text: returns `undefined` (suppressed in JSON mode). + * - Streaming data: returns individual log objects for JSONL expansion. */ -function jsonTransformLogList( - result: LogListResult, +function jsonTransformLogOutput( + result: LogListOutput, fields?: string[] ): unknown { + if ("kind" in result) { + // Streaming: text chunks are suppressed, data chunks return bare log + // objects for JSONL (one JSON object per line, not wrapped in an array). + // yieldStreamChunks already fans out to one log per chunk. + if (result.kind === "text") { + return; + } + const log = result.logs[0]; + if (log === undefined) { + return; + } + if (fields && fields.length > 0) { + return filterFields(log, fields); + } + return log; + } + // Batch result if (fields && fields.length > 0) { return result.logs.map((log) => filterFields(log, fields)); } @@ -467,8 +557,8 @@ export const listCommand = buildListCommand("log", { }, output: { json: true, - human: formatLogListHuman, - jsonTransform: jsonTransformLogList, + human: formatLogOutput, + jsonTransform: jsonTransformLogOutput, }, parameters: { positional: { @@ -516,12 +606,7 @@ export const listCommand = buildListCommand("log", { f: "follow", }, }, - async func( - this: SentryContext, - flags: ListFlags, - target?: string - // biome-ignore lint/suspicious/noConfusingVoidType: void for follow-mode paths that write directly to stdout - ): Promise | void> { + async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); const { cwd, setContext } = this; @@ -542,17 +627,23 @@ export const listCommand = buildListCommand("log", { setContext([org], []); if (flags.follow) { - const { stdout, stderr } = this; + const { stderr } = this; const traceId = flags.trace; + + // Banner (stderr, suppressed in JSON mode) + writeFollowBanner( + stderr, + flags, + `Streaming logs for trace ${traceId}...` + ); + // Track IDs of logs seen without timestamp_precise so they are // shown once but not duplicated on subsequent polls. const seenWithoutTs = new Set(); - await executeFollowMode({ - stdout, - stderr, + const generator = generateFollowLogs({ flags, - bannerText: `Streaming logs for trace ${traceId}...`, includeTrace: false, + onDiagnostic: (msg) => stderr.write(msg), fetch: (statsPeriod) => listTraceLogs(org, traceId, { query: flags.query, @@ -579,7 +670,11 @@ export const listCommand = buildListCommand("log", { } }, }); - return; // void — follow mode writes directly + + for await (const chunk of generator) { + yield* yieldStreamChunks(chunk, flags.json); + } + return; } const result = await executeTraceSingleFetch({ @@ -590,7 +685,8 @@ export const listCommand = buildListCommand("log", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.logs.length > 0 ? result.hint : undefined; - return { data: result, hint }; + yield { data: result, hint }; + return; } // Standard project-scoped mode — kept in else-like block to avoid @@ -604,13 +700,14 @@ export const listCommand = buildListCommand("log", { setContext([org], [project]); if (flags.follow) { - const { stdout, stderr } = this; - await executeFollowMode({ - stdout, - stderr, + const { stderr } = this; + + writeFollowBanner(stderr, flags, "Streaming logs..."); + + const generator = generateFollowLogs({ flags, - bannerText: "Streaming logs...", includeTrace: true, + onDiagnostic: (msg) => stderr.write(msg), fetch: (statsPeriod, afterTimestamp) => listLogs(org, project, { query: flags.query, @@ -620,7 +717,11 @@ export const listCommand = buildListCommand("log", { }), extractNew: (logs) => logs, }); - return; // void — follow mode writes directly + + for await (const chunk of generator) { + yield* yieldStreamChunks(chunk, flags.json); + } + return; } const result = await executeSingleFetch({ @@ -631,7 +732,7 @@ export const listCommand = buildListCommand("log", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.logs.length > 0 ? result.hint : undefined; - return { data: result, hint }; + yield { data: result, hint }; } }, }); diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 9e19a32ba..e3d3ac78d 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -347,7 +347,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; const cmdLog = logger.withTag("log.view"); @@ -389,6 +389,7 @@ export const viewCommand = buildCommand({ ? `Detected from ${target.detectedFrom}` : undefined; - return { data: { logs, orgSlug: target.org }, hint }; + yield { data: { logs, orgSlug: target.org }, hint }; + return; }, }); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 8340ccfe7..2229c98f2 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -125,7 +125,7 @@ export const listCommand = buildCommand({ // Only -n for --limit; no -c since org list has no --cursor flag aliases: { ...FRESH_ALIASES, n: "limit" }, }, - async func(this: SentryContext, flags: ListFlags) { + async *func(this: SentryContext, flags: ListFlags) { applyFreshFlag(flags); const orgs = await listOrganizations(); @@ -151,6 +151,7 @@ export const listCommand = buildCommand({ hints.push("Tip: Use 'sentry org view ' for details"); } - return { data: entries, hint: hints.join("\n") || undefined }; + yield { data: entries, hint: hints.join("\n") || undefined }; + return; }, }); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index 6e3c56808..fc12c259c 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -58,7 +58,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, orgSlug?: string) { + async *func(this: SentryContext, flags: ViewFlags, orgSlug?: string) { applyFreshFlag(flags); const { cwd } = this; @@ -78,6 +78,7 @@ export const viewCommand = buildCommand({ const hint = resolved.detectedFrom ? `Detected from ${resolved.detectedFrom}` : undefined; - return { data: org, hint }; + yield { data: org, hint }; + return; }, }); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 19632ec62..cbd2c360f 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -318,7 +318,7 @@ export const createCommand = buildCommand({ }, aliases: { t: "team", n: "dry-run" }, }, - async func( + async *func( this: SentryContext, flags: CreateFlags, nameArg?: string, @@ -405,7 +405,8 @@ export const createCommand = buildCommand({ expectedSlug, dryRun: true, }; - return { data: result }; + yield { data: result }; + return; } // Create the project @@ -432,6 +433,7 @@ export const createCommand = buildCommand({ expectedSlug, }; - return { data: result }; + yield { data: result }; + return; }, }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 59f07b7eb..07b8acebd 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -32,10 +32,7 @@ import { } from "../../lib/db/pagination.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import type { - CommandOutput, - OutputConfig, -} from "../../lib/formatters/output.js"; +import type { OutputConfig } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -592,11 +589,7 @@ export const listCommand = buildListCommand("project", { }, aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES, p: "platform" }, }, - async func( - this: SentryContext, - flags: ListFlags, - target?: string - ): Promise>> { + async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); const { stdout, cwd } = this; @@ -640,6 +633,6 @@ export const listCommand = buildListCommand("project", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; - return { data: result, hint }; + yield { data: result, hint }; }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 48548219e..b56c756b5 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -211,7 +211,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, targetArg?: string) { + async *func(this: SentryContext, flags: ViewFlags, targetArg?: string) { applyFreshFlag(flags); const { cwd } = this; @@ -294,6 +294,7 @@ export const viewCommand = buildCommand({ detectedFrom: targets[i]?.detectedFrom, })); - return { data: entries, hint: footer }; + yield { data: entries, hint: footer }; + return; }, }); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index b61f670a8..7fd73af4a 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -15,7 +15,6 @@ import { } from "../../lib/db/pagination.js"; import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import type { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -226,11 +225,7 @@ export const listCommand = buildListCommand("trace", { c: "cursor", }, }, - async func( - this: SentryContext, - flags: ListFlags, - target?: string - ): Promise> { + async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); const { cwd, setContext } = this; @@ -276,7 +271,7 @@ export const listCommand = buildListCommand("trace", { : `${countText} Use 'sentry trace view ' to view the full span tree.`; } - return { + yield { data: { traces, hasMore, nextCursor, org, project }, hint, }; diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index c03d59e3b..e71bf5da5 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -176,11 +176,8 @@ export const logsCommand = buildCommand({ q: "query", }, }, - async func( - this: SentryContext, - flags: LogsFlags, - ...args: string[] - ): Promise { + // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly, will be migrated to yield pattern later + async *func(this: SentryContext, flags: LogsFlags, ...args: string[]) { applyFreshFlag(flags); const { stdout, cwd, setContext } = this; diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 982116e87..f068ef150 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -229,7 +229,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; const log = logger.withTag("trace.view"); @@ -314,9 +314,10 @@ export const viewCommand = buildCommand({ ? formatSimpleSpanTree(traceId, spans, flags.spans) : undefined; - return { + yield { data: { summary, spans, spanTreeLines }, hint: `Tip: Open in browser with 'sentry trace view --web ${traceId}'`, }; + return; }, }); diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index d8ae6b7ae..d9f50897b 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -219,7 +219,7 @@ export const listCommand = buildCommand({ ], }, }, - async func(this: SentryContext, _flags: ListFlags, org?: string) { + async *func(this: SentryContext, _flags: ListFlags, org?: string) { const resolved = await resolveOrg({ org, cwd: this.cwd, @@ -265,6 +265,7 @@ export const listCommand = buildCommand({ ); } - return { data: entries, hint: hints.join("\n") || undefined }; + yield { data: entries, hint: hints.join("\n") || undefined }; + return; }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index cec2a880a..9703095aa 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -107,7 +107,7 @@ export const startCommand = buildCommand({ ], }, }, - async func( + async *func( this: SentryContext, flags: { json?: boolean }, first: string, @@ -142,7 +142,8 @@ export const startCommand = buildCommand({ // Plan trial: no API to start it — open billing page instead if (parsed.name === "plan") { - return handlePlanTrial(orgSlug, this.stdout, flags.json ?? false); + yield await handlePlanTrial(orgSlug, this.stdout, flags.json ?? false); + return; } // Fetch trials and find an available one @@ -160,7 +161,7 @@ export const startCommand = buildCommand({ // Start the trial await startProductTrial(orgSlug, trial.category); - return { + yield { data: { name: parsed.name, category: trial.category, @@ -170,6 +171,7 @@ export const startCommand = buildCommand({ }, hint: undefined, }; + return; }, }); diff --git a/src/lib/command.ts b/src/lib/command.ts index c4707ba97..a6d700193 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -69,26 +69,16 @@ type CommandDocumentation = { readonly fullDescription?: string; }; -/** - * Return value from a command with `output` config. - * - * Commands can return: - * - `void` — no automatic output (e.g. `--web` early exit) - * - `Error` — Stricli error handling - * - `CommandOutput` — `{ data, hint? }` rendered by the output config - */ -// biome-ignore lint/suspicious/noConfusingVoidType: void required to match async functions returning nothing (Promise) -type SyncCommandReturn = void | Error | unknown; - -// biome-ignore lint/suspicious/noConfusingVoidType: void required to match async functions returning nothing (Promise) -type AsyncCommandReturn = Promise; - /** * Command function type for Sentry CLI commands. * - * When the command has an `output` config, it can return a - * `{ data, hint? }` object — the wrapper renders it automatically. - * Without `output`, it behaves like a standard Stricli command function. + * ALL command functions are async generators. The framework iterates + * each yielded value and renders it through the output config. + * + * - **Non-streaming**: yield a single `CommandOutput` and return. + * - **Streaming**: yield multiple values; each is rendered immediately + * (JSONL in `--json` mode, human text otherwise). + * - **Void**: return without yielding for early exits (e.g. `--web`). */ type SentryCommandFunction< FLAGS extends BaseFlags, @@ -98,7 +88,7 @@ type SentryCommandFunction< this: CONTEXT, flags: FLAGS, ...args: ARGS -) => SyncCommandReturn | AsyncCommandReturn; +) => AsyncGenerator; /** * Arguments for building a command with a local function. @@ -366,9 +356,15 @@ export function buildCommand< return clean; } - // Wrap func to intercept logging flags, capture telemetry, then call original - // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex - const wrappedFunc = function (this: CONTEXT, flags: any, ...args: any[]) { + // Wrap func to intercept logging flags, capture telemetry, then call original. + // The wrapper is an async function that iterates the generator returned by func. + const wrappedFunc = async function ( + this: CONTEXT, + // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex + flags: any, + // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex + ...args: any[] + ) { applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, flags.verbose as boolean @@ -400,31 +396,22 @@ export function buildCommand< throw err; }; - // Call original and intercept data returns. - // Commands with output config return { data, hint? }; - // the wrapper renders automatically. Void returns are ignored. - let result: ReturnType; + // Iterate the generator. Each yielded value is rendered through + // the output config (if present). The generator itself never + // touches stdout — all rendering is done here. try { - result = originalFunc.call( + const generator = originalFunc.call( this, cleanFlags as FLAGS, ...(args as unknown as ARGS) ); + for await (const value of generator) { + handleReturnValue(this, value, cleanFlags); + } } catch (err) { handleOutputError(err); } - - if (result instanceof Promise) { - return result - .then((resolved) => { - handleReturnValue(this, resolved, cleanFlags); - }) - .catch(handleOutputError) as ReturnType; - } - - handleReturnValue(this, result, cleanFlags); - return result as ReturnType; - } as typeof originalFunc; + }; // Build the command with the wrapped function via Stricli return stricliCommand({ diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 73b9fe4b7..428e76b74 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -135,14 +135,63 @@ type RenderContext = { }; /** - * Render a command's return value using an {@link OutputConfig}. + * Apply `jsonExclude` keys to data, stripping excluded fields from + * objects or from each element of an array. Returns the data unchanged + * when no exclusions are configured. + */ +function applyJsonExclude( + data: unknown, + excludeKeys: readonly string[] | undefined +): unknown { + if (!excludeKeys || excludeKeys.length === 0) { + return data; + } + if (typeof data !== "object" || data === null) { + return data; + } + if (Array.isArray(data)) { + return data.map((item: unknown) => { + if (typeof item !== "object" || item === null) { + return item; + } + const copy = { ...item } as Record; + for (const key of excludeKeys) { + delete copy[key]; + } + return copy; + }); + } + const copy = { ...data } as Record; + for (const key of excludeKeys) { + delete copy[key]; + } + return copy; +} + +/** + * Write a JSON-transformed value to stdout. + * + * `undefined` suppresses the chunk entirely (e.g. streaming text-only + * chunks in JSON mode). + */ +function writeTransformedJson(stdout: Writer, transformed: unknown): void { + if (transformed !== undefined) { + stdout.write(`${formatJson(transformed)}\n`); + } +} + +/** + * Render a `CommandOutput` via an output config. * * Called by the `buildCommand` wrapper when a command with `output: { ... }` - * returns data. In JSON mode the data is serialized as-is (with optional + * yields data. In JSON mode the data is serialized as-is (with optional * field filtering); in human mode the config's `human` formatter is called. * + * For streaming commands that yield multiple times, this function is called + * once per yielded value. Each call appends to stdout independently. + * * @param stdout - Writer to output to - * @param data - The data returned by the command + * @param data - The data yielded by the command * @param config - The output config declared on buildCommand * @param ctx - Merged rendering context (command hints + runtime flags) */ @@ -154,47 +203,18 @@ export function renderCommandOutput( ctx: RenderContext ): void { if (ctx.json) { - // Custom transform: the function handles both shaping and field filtering if (config.jsonTransform) { - const transformed = config.jsonTransform(data, ctx.fields); - stdout.write(`${formatJson(transformed)}\n`); + writeTransformedJson(stdout, config.jsonTransform(data, ctx.fields)); return; } - - let jsonData = data; - if ( - config.jsonExclude && - config.jsonExclude.length > 0 && - typeof data === "object" && - data !== null - ) { - const keys = config.jsonExclude; - if (Array.isArray(data)) { - // Strip excluded keys from each element in the array - jsonData = data.map((item: unknown) => { - if (typeof item !== "object" || item === null) { - return item; - } - const copy = { ...item } as Record; - for (const key of keys) { - delete copy[key]; - } - return copy; - }); - } else { - const copy = { ...data } as Record; - for (const key of keys) { - delete copy[key]; - } - jsonData = copy; - } - } - writeJson(stdout, jsonData, ctx.fields); + writeJson(stdout, applyJsonExclude(data, config.jsonExclude), ctx.fields); return; } const text = config.human(data); - stdout.write(`${text}\n`); + if (text) { + stdout.write(`${text}\n`); + } if (ctx.hint) { writeFooter(stdout, ctx.hint); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index e4468053b..9b483bc47 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -19,7 +19,7 @@ import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; -import type { CommandOutput, OutputConfig } from "./formatters/output.js"; +import type { OutputConfig } from "./formatters/output.js"; import { dispatchOrgScopedList, jsonTransformListResult, @@ -134,7 +134,7 @@ export const FRESH_ALIASES = { f: "fresh" } as const; * Call at the top of a command's `func()` after defining the `fresh` flag: * ```ts * flags: { fresh: FRESH_FLAG }, - * async func(this: SentryContext, flags) { + * async *func(this: SentryContext, flags) { * applyFreshFlag(flags); * ``` */ @@ -310,12 +310,10 @@ type BaseFlags = Readonly>>; type BaseArgs = readonly unknown[]; /** - * Wider command function type that allows returning `CommandOutput`. + * Command function type that returns an async generator. * - * Mirrors `SentryCommandFunction` from `command.ts`. The Stricli - * `CommandFunction` type constrains returns to `void | Error`, which is - * too narrow for the return-based output pattern. This type adds `unknown` - * to the return union so `{ data, hint }` objects pass through. + * Mirrors `SentryCommandFunction` from `command.ts`. All command functions + * are async generators — non-streaming commands yield once and return. */ type ListCommandFunction< FLAGS extends BaseFlags, @@ -325,8 +323,7 @@ type ListCommandFunction< this: CONTEXT, flags: FLAGS, ...args: ARGS - // biome-ignore lint/suspicious/noConfusingVoidType: void required to match async functions returning nothing (Promise) -) => void | Error | unknown | Promise; +) => AsyncGenerator; /** * Build a Stricli command for a list endpoint with automatic plural-alias @@ -485,7 +482,7 @@ export function buildOrgListCommand( }, aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES }, }, - async func( + async *func( this: SentryContext, flags: { readonly limit: number; @@ -495,7 +492,7 @@ export function buildOrgListCommand( readonly fields?: string[]; }, target?: string - ): Promise>> { + ) { applyFreshFlag(flags); const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); @@ -509,7 +506,7 @@ export function buildOrgListCommand( // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; - return { data: result, hint }; + yield { data: result, hint }; }, }); } diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index df689020f..12f631ea8 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -79,7 +79,7 @@ describe("buildCommand", () => { verbose: { kind: "boolean", brief: "Verbose", default: false }, }, }, - func(_flags: { verbose: boolean }) { + async *func(_flags: { verbose: boolean }) { // no-op }, }); @@ -90,7 +90,7 @@ describe("buildCommand", () => { const command = buildCommand({ docs: { brief: "Simple command" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -137,7 +137,11 @@ describe("buildCommand telemetry integration", () => { }, }, }, - func(this: TestContext, flags: { verbose: boolean; limit: number }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { verbose: boolean; limit: number } + ) { calledWith = flags; }, }); @@ -167,7 +171,7 @@ describe("buildCommand telemetry integration", () => { json: { kind: "boolean", brief: "JSON output", default: false }, }, }, - func(_flags: { json: boolean }) { + async *func(_flags: { json: boolean }) { // no-op }, }); @@ -199,7 +203,12 @@ describe("buildCommand telemetry integration", () => { parameters: [{ brief: "Issue ID", parse: String }], }, }, - func(this: TestContext, _flags: Record, issueId: string) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + _flags: Record, + issueId: string + ) { calledArgs = issueId; }, }); @@ -226,7 +235,8 @@ describe("buildCommand telemetry integration", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func(this: TestContext) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { // Verify 'this' is correctly bound to context capturedStdout = typeof this.process.stdout.write === "function"; }, @@ -259,7 +269,8 @@ describe("buildCommand telemetry integration", () => { }, }, }, - async func(_flags: { delay: number }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(_flags: { delay: number }) { await Bun.sleep(1); executed = true; }, @@ -363,7 +374,7 @@ describe("buildCommand", () => { json: { kind: "boolean", brief: "JSON output", default: false }, }, }, - func(_flags: { json: boolean }) { + async *func(_flags: { json: boolean }) { // no-op }, }); @@ -380,7 +391,8 @@ describe("buildCommand", () => { json: { kind: "boolean", brief: "JSON output", default: false }, }, }, - func(this: TestContext, flags: { json: boolean }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext, flags: { json: boolean }) { calledFlags = flags as unknown as Record; }, }); @@ -408,7 +420,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -434,7 +446,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -460,7 +472,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -495,7 +507,8 @@ describe("buildCommand", () => { }, }, }, - func(this: TestContext, flags: { limit: number }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext, flags: { limit: number }) { receivedFlags = flags as unknown as Record; }, }); @@ -546,7 +559,11 @@ describe("buildCommand", () => { }, }, }, - func(this: TestContext, flags: { verbose: boolean; silent: boolean }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { verbose: boolean; silent: boolean } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -593,7 +610,7 @@ describe("buildCommand", () => { }, }, }, - func() { + async *func() { // no-op }, }); @@ -666,7 +683,11 @@ describe("buildCommand output: json", () => { }, }, }, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -696,7 +717,11 @@ describe("buildCommand output: json", () => { docs: { brief: "Test" }, output: "json", parameters: {}, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -731,7 +756,11 @@ describe("buildCommand output: json", () => { docs: { brief: "Test" }, output: "json", parameters: {}, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -765,7 +794,11 @@ describe("buildCommand output: json", () => { docs: { brief: "Test" }, output: "json", parameters: {}, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -791,7 +824,8 @@ describe("buildCommand output: json", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func() { funcCalled = true; }, }); @@ -832,7 +866,11 @@ describe("buildCommand output: json", () => { }, }, }, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -863,7 +901,11 @@ describe("buildCommand output: json", () => { docs: { brief: "Test" }, output: "json", parameters: {}, - func(this: TestContext, flags: { json: boolean; fields?: string[] }) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { receivedFlags = flags as unknown as Record; }, }); @@ -909,7 +951,8 @@ describe("buildCommand output: json", () => { }, }, }, - func( + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( this: TestContext, flags: { json: boolean; fields?: string[]; limit: number } ) { @@ -956,8 +999,8 @@ describe("buildCommand return-based output", () => { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, parameters: {}, - func(this: TestContext) { - return { data: { name: "Alice", role: "admin" } }; + async *func(this: TestContext) { + yield { data: { name: "Alice", role: "admin" } }; }, }); @@ -985,8 +1028,8 @@ describe("buildCommand return-based output", () => { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, parameters: {}, - func(this: TestContext) { - return { data: { name: "Alice", role: "admin" } }; + async *func(this: TestContext) { + yield { data: { name: "Alice", role: "admin" } }; }, }); @@ -1015,8 +1058,8 @@ describe("buildCommand return-based output", () => { human: (d: { id: number; name: string; role: string }) => `${d.name}`, }, parameters: {}, - func(this: TestContext) { - return { data: { id: 1, name: "Alice", role: "admin" } }; + async *func(this: TestContext) { + yield { data: { id: 1, name: "Alice", role: "admin" } }; }, }); @@ -1047,8 +1090,8 @@ describe("buildCommand return-based output", () => { human: (d: { value: number }) => `Value: ${d.value}`, }, parameters: {}, - func(this: TestContext) { - return { + async *func(this: TestContext) { + yield { data: { value: 42 }, hint: "Run 'sentry help' for more info", }; @@ -1097,7 +1140,8 @@ describe("buildCommand return-based output", () => { human: () => "unused", }, parameters: {}, - func(this: TestContext) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { executed = true; // Void return — simulates --web early exit }, @@ -1122,10 +1166,10 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, // Deliberately no output config parameters: {}, - func(this: TestContext) { + async *func(this: TestContext) { // This returns data, but without output config // the wrapper should NOT render it - return { value: 42 }; + yield { value: 42 }; }, }); @@ -1154,9 +1198,9 @@ describe("buildCommand return-based output", () => { human: (d: { name: string }) => `Hello, ${d.name}!`, }, parameters: {}, - async func(this: TestContext) { + async *func(this: TestContext) { await Bun.sleep(1); - return { data: { name: "Bob" } }; + yield { data: { name: "Bob" } }; }, }); @@ -1185,8 +1229,8 @@ describe("buildCommand return-based output", () => { human: (d: Array<{ id: number }>) => d.map((x) => x.id).join(", "), }, parameters: {}, - func(this: TestContext) { - return { data: [{ id: 1 }, { id: 2 }] }; + async *func(this: TestContext) { + yield { data: [{ id: 1 }, { id: 2 }] }; }, }); @@ -1215,8 +1259,8 @@ describe("buildCommand return-based output", () => { human: (d: { org: string }) => `Org: ${d.org}`, }, parameters: {}, - func(this: TestContext) { - return { data: { org: "sentry" }, hint: "Detected from .env file" }; + async *func(this: TestContext) { + yield { data: { org: "sentry" }, hint: "Detected from .env file" }; }, }); @@ -1256,7 +1300,8 @@ describe("buildCommand return-based output", () => { human: (d: { error: string }) => `Error: ${d.error}`, }, parameters: {}, - async func(this: TestContext) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { throw new OutputError({ error: "not found" }); }, }); @@ -1307,7 +1352,8 @@ describe("buildCommand return-based output", () => { human: (d: { error: string }) => `Error: ${d.error}`, }, parameters: {}, - async func(this: TestContext) { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { throw new OutputError({ error: "not found" }); }, }); From 0fe387a0c88c197e88eaf75e5bc6a3d05582ee2b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 14:25:07 +0000 Subject: [PATCH 02/25] refactor: brand CommandOutput with Symbol and move hints to generator return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements to the command framework's output system: 1. **Brand CommandOutput with Symbol discriminant** - Add COMMAND_OUTPUT_BRAND Symbol to CommandOutput type - Add commandOutput() factory function for creating branded values - Replace duck-typing ('data' in v) with Symbol check in isCommandOutput() - Prevents false positives from raw API responses with 'data' property 2. **Move hints from yield to generator return value** - Add CommandReturn type ({ hint?: string }) for generator return values - Switch from for-await-of to manual .next() iteration to capture return - renderCommandOutput no longer handles hints; wrapper renders post-loop - All commands: yield commandOutput(data) + return { hint } 3. **Eliminate noExplicitAny suppressions in command.ts** - wrappedFunc params: any → Record / unknown[] - Final Stricli cast: as any → as unknown as StricliBuilderArgs - OutputConfig kept with improved variance explanation - renderCommandOutput config param: kept any with contravariance docs All 24 command files migrated to use commandOutput() helper. Tests updated for branded outputs and hint-on-return pattern. 1083 tests pass, 0 fail, 9356 assertions across 38 files. --- src/commands/api.ts | 17 +++-- src/commands/auth/logout.ts | 18 ++--- src/commands/auth/refresh.ts | 3 +- src/commands/auth/status.ts | 3 +- src/commands/auth/whoami.ts | 3 +- src/commands/cli/feedback.ts | 11 ++- src/commands/cli/fix.ts | 3 +- src/commands/cli/upgrade.ts | 41 ++++++------ src/commands/event/view.ts | 6 +- src/commands/issue/explain.ts | 8 +-- src/commands/issue/list.ts | 9 ++- src/commands/issue/plan.ts | 5 +- src/commands/issue/view.ts | 6 +- src/commands/log/list.ts | 17 +++-- src/commands/log/view.ts | 5 +- src/commands/org/list.ts | 5 +- src/commands/org/view.ts | 5 +- src/commands/project/create.ts | 5 +- src/commands/project/list.ts | 8 ++- src/commands/project/view.ts | 5 +- src/commands/trace/list.ts | 7 +- src/commands/trace/view.ts | 6 +- src/commands/trial/list.ts | 5 +- src/commands/trial/start.ts | 51 +++++++------- src/lib/command.ts | 103 ++++++++++++++++++++--------- src/lib/formatters/output.ts | 75 ++++++++++++++++----- src/lib/list-command.ts | 12 +++- test/lib/command.test.ts | 22 +++--- test/lib/formatters/output.test.ts | 25 ++----- 29 files changed, 285 insertions(+), 204 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 2ca7b99c8..2b8ed2477 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { OutputError, ValidationError } from "../lib/errors.js"; +import { commandOutput } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; @@ -1168,14 +1169,12 @@ export const apiCommand = buildCommand({ // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - yield { - data: { - method: flags.method, - url: resolveRequestUrl(normalizedEndpoint, params), - headers: resolveEffectiveHeaders(headers, body), - body: body ?? null, - }, - }; + yield commandOutput({ + method: flags.method, + url: resolveRequestUrl(normalizedEndpoint, params), + headers: resolveEffectiveHeaders(headers, body), + body: body ?? null, + }); return; } @@ -1211,7 +1210,7 @@ export const apiCommand = buildCommand({ throw new OutputError(response.body); } - yield { data: response.body }; + yield commandOutput(response.body); return; }, }); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index a81afe220..1a39816d7 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -15,6 +15,7 @@ import { import { getDbPath } from "../../lib/db/index.js"; import { AuthError } from "../../lib/errors.js"; import { formatLogoutResult } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; /** Structured result of the logout operation */ export type LogoutResult = { @@ -38,9 +39,10 @@ export const logoutCommand = buildCommand({ }, async *func(this: SentryContext) { if (!(await isAuthenticated())) { - yield { - data: { loggedOut: false, message: "Not currently authenticated." }, - }; + yield commandOutput({ + loggedOut: false, + message: "Not currently authenticated.", + }); return; } @@ -56,12 +58,10 @@ export const logoutCommand = buildCommand({ const configPath = getDbPath(); await clearAuth(); - yield { - data: { - loggedOut: true, - configPath, - }, - }; + yield commandOutput({ + loggedOut: true, + configPath, + }); return; }, }); diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 39e5f7384..cc5b4db46 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -15,6 +15,7 @@ import { import { AuthError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { formatDuration } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; type RefreshFlags = { readonly json: boolean; @@ -104,7 +105,7 @@ Examples: : undefined, }; - yield { data: payload }; + yield commandOutput(payload); return; }, }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 1de9bba54..b11ceb1f5 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -22,6 +22,7 @@ import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo } from "../../lib/db/user.js"; import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -189,7 +190,7 @@ export const statusCommand = buildCommand({ verification: await verifyCredentials(), }; - yield { data }; + yield commandOutput(data); return; }, }); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 563020f73..fecd90726 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,6 +13,7 @@ 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 { applyFreshFlag, FRESH_ALIASES, @@ -65,7 +66,7 @@ export const whoamiCommand = buildCommand({ // Cache update failure is non-essential — user identity was already fetched. } - yield { data: user }; + yield commandOutput(user); return; }, }); diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index c1b1a00fd..c16c8a29c 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; /** Structured result of the feedback submission */ export type FeedbackResult = { @@ -66,12 +67,10 @@ export const feedbackCommand = buildCommand({ // Flush to ensure feedback is sent before process exits const sent = await Sentry.flush(3000); - yield { - data: { - sent, - message, - }, - }; + yield commandOutput({ + sent, + message, + }); return; }, }); diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 866ab5add..2a874950a 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -17,6 +17,7 @@ import { } from "../../lib/db/schema.js"; import { OutputError } from "../../lib/errors.js"; import { formatFixResult } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { getRealUsername } from "../../lib/utils.js"; type FixFlags = { @@ -734,7 +735,7 @@ export const fixCommand = buildCommand({ throw new OutputError(result); } - yield { data: result }; + yield commandOutput(result); return; }, }); diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 5d1ba0bf0..d9afcd2ba 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -31,6 +31,7 @@ import { } from "../../lib/db/release-channel.js"; import { UpgradeError } from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { detectInstallationMethod, @@ -493,7 +494,7 @@ export const upgradeCommand = buildCommand({ flags, }); if (resolved.kind === "done") { - yield { data: resolved.result }; + yield commandOutput(resolved.result); return; } @@ -510,17 +511,15 @@ export const upgradeCommand = buildCommand({ target, versionArg ); - yield { - data: { - action: downgrade ? "downgraded" : "upgraded", - currentVersion: CLI_VERSION, - targetVersion: target, - channel, - method, - forced: flags.force, - warnings, - } satisfies UpgradeResult, - }; + yield commandOutput({ + action: downgrade ? "downgraded" : "upgraded", + currentVersion: CLI_VERSION, + targetVersion: target, + channel, + method, + forced: flags.force, + warnings, + } satisfies UpgradeResult); return; } @@ -532,16 +531,14 @@ export const upgradeCommand = buildCommand({ execPath: this.process.execPath, }); - yield { - data: { - action: downgrade ? "downgraded" : "upgraded", - currentVersion: CLI_VERSION, - targetVersion: target, - channel, - method, - forced: flags.force, - } satisfies UpgradeResult, - }; + yield commandOutput({ + action: downgrade ? "downgraded" : "upgraded", + currentVersion: CLI_VERSION, + targetVersion: target, + channel, + method, + forced: flags.force, + } satisfies UpgradeResult); return; }, }); diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index e209ec1aa..8188f3e4e 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,6 +23,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -380,12 +381,11 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - yield { - data: { event, trace, spanTreeLines: spanTreeResult?.lines }, + yield commandOutput({ event, trace, spanTreeLines: spanTreeResult?.lines }); + return { hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined, }; - return; }, }); diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 38e3dd4a2..fd36b8eb7 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError } from "../../lib/errors.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { formatRootCauseList, handleSeerApiError, @@ -104,11 +105,8 @@ export const explainCommand = buildCommand({ ); } - yield { - data: causes, - hint: `To create a plan, run: sentry issue plan ${issueArg}`, - }; - return; + yield commandOutput(causes); + return { hint: `To create a plan, run: sentry issue plan ${issueArg}` }; } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 755ee9046..50c142db0 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -42,7 +42,10 @@ import { shouldAutoCompact, writeIssueTable, } from "../../lib/formatters/index.js"; -import type { OutputConfig } from "../../lib/formatters/output.js"; +import { + commandOutput, + type OutputConfig, +} from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -1378,7 +1381,7 @@ export const listCommand = buildListCommand("issue", { combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; } - yield { data: result, hint: combinedHint }; - return; + yield commandOutput(result); + return { hint: combinedHint }; }, }); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index c8f57d596..441c3581a 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { formatSolution, handleSeerApiError, @@ -225,7 +226,7 @@ export const planCommand = buildCommand({ if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - yield { data: buildPlanData(state) }; + yield commandOutput(buildPlanData(state)); return; } } @@ -261,7 +262,7 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - yield { data: buildPlanData(finalState) }; + yield commandOutput(buildPlanData(finalState)); return; } catch (error) { // Handle API errors with friendly messages diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index dea530ca4..0a3389a72 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -14,6 +14,7 @@ import { formatIssueDetails, muted, } from "../../lib/formatters/index.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -170,10 +171,9 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - yield { - data: { issue, event: event ?? null, trace, spanTreeLines }, + yield commandOutput({ issue, event: event ?? null, trace, spanTreeLines }); + return { hint: `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis`, }; - return; }, }); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 4fd7378ec..ccddb7daa 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -22,6 +22,10 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; +import { + type CommandOutput, + commandOutput, +} from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { applyFreshFlag, @@ -177,20 +181,20 @@ type LogStreamChunk = function* yieldStreamChunks( chunk: LogStreamChunk, json: boolean -): Generator<{ data: LogListOutput }, void, undefined> { +): Generator, void, undefined> { if (json) { // In JSON mode, expand data chunks into one yield per log for JSONL if (chunk.kind === "data") { for (const log of chunk.logs) { // Yield a single-log data chunk so jsonTransform emits one line - yield { data: { kind: "data", logs: [log] } }; + yield commandOutput({ kind: "data", logs: [log] } as LogListOutput); } } // Text chunks suppressed in JSON mode (jsonTransform returns undefined) return; } // Human mode: yield the chunk directly for the human formatter - yield { data: chunk }; + yield commandOutput(chunk); } /** @@ -685,8 +689,8 @@ export const listCommand = buildListCommand("log", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.logs.length > 0 ? result.hint : undefined; - yield { data: result, hint }; - return; + yield commandOutput(result); + return { hint }; } // Standard project-scoped mode — kept in else-like block to avoid @@ -732,7 +736,8 @@ export const listCommand = buildListCommand("log", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.logs.length > 0 ? result.hint : undefined; - yield { data: result, hint }; + yield commandOutput(result); + return { hint }; } }, }); diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index e3d3ac78d..784708304 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,6 +18,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, @@ -389,7 +390,7 @@ export const viewCommand = buildCommand({ ? `Detected from ${target.detectedFrom}` : undefined; - yield { data: { logs, orgSlug: target.org }, hint }; - return; + yield commandOutput({ logs, orgSlug: target.org }); + return { hint }; }, }); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 2229c98f2..3b177e08b 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -10,6 +10,7 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -151,7 +152,7 @@ export const listCommand = buildCommand({ hints.push("Tip: Use 'sentry org view ' for details"); } - yield { data: entries, hint: hints.join("\n") || undefined }; - return; + yield commandOutput(entries); + return { hint: hints.join("\n") || undefined }; }, }); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index fc12c259c..c0014c8e0 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,6 +10,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails } from "../../lib/formatters/index.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -78,7 +79,7 @@ export const viewCommand = buildCommand({ const hint = resolved.detectedFrom ? `Detected from ${resolved.detectedFrom}` : undefined; - yield { data: org, hint }; - return; + yield commandOutput(org); + return { hint }; }, }); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index cbd2c360f..e8530bffd 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,6 +35,7 @@ import { type ProjectCreatedResult, } from "../../lib/formatters/human.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; import { logger } from "../../lib/logger.js"; @@ -405,7 +406,7 @@ export const createCommand = buildCommand({ expectedSlug, dryRun: true, }; - yield { data: result }; + yield commandOutput(result); return; } @@ -433,7 +434,7 @@ export const createCommand = buildCommand({ expectedSlug, }; - yield { data: result }; + yield commandOutput(result); return; }, }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 07b8acebd..c2d14587e 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -32,7 +32,10 @@ import { } from "../../lib/db/pagination.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import type { OutputConfig } from "../../lib/formatters/output.js"; +import { + commandOutput, + type OutputConfig, +} from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -633,6 +636,7 @@ export const listCommand = buildListCommand("project", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; - yield { data: result, hint }; + yield commandOutput(result); + return { hint }; }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index b56c756b5..48ad377b8 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,6 +15,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -294,7 +295,7 @@ export const viewCommand = buildCommand({ detectedFrom: targets[i]?.detectedFrom, })); - yield { data: entries, hint: footer }; - return; + yield commandOutput(entries); + return { hint: footer }; }, }); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 7fd73af4a..73ce8d004 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -15,6 +15,7 @@ import { } from "../../lib/db/pagination.js"; import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -271,9 +272,7 @@ export const listCommand = buildListCommand("trace", { : `${countText} Use 'sentry trace view ' to view the full span tree.`; } - yield { - data: { traces, hasMore, nextCursor, org, project }, - hint, - }; + yield commandOutput({ traces, hasMore, nextCursor, org, project }); + return { hint }; }, }); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index f068ef150..14c19da0d 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -21,6 +21,7 @@ import { formatSimpleSpanTree, formatTraceSummary, } from "../../lib/formatters/index.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -314,10 +315,9 @@ export const viewCommand = buildCommand({ ? formatSimpleSpanTree(traceId, spans, flags.spans) : undefined; - yield { - data: { summary, spans, spanTreeLines }, + yield commandOutput({ summary, spans, spanTreeLines }); + return { hint: `Tip: Open in browser with 'sentry trace view --web ${traceId}'`, }; - return; }, }); diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index d9f50897b..b3906d3f2 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -11,6 +11,7 @@ import { getCustomerTrialInfo } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { colorTag } from "../../lib/formatters/markdown.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { @@ -265,7 +266,7 @@ export const listCommand = buildCommand({ ); } - yield { data: entries, hint: hints.join("\n") || undefined }; - return; + yield commandOutput(entries); + return { hint: hints.join("\n") || undefined }; }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 9703095aa..e6f687926 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -22,6 +22,7 @@ import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; +import { commandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; @@ -142,7 +143,12 @@ export const startCommand = buildCommand({ // Plan trial: no API to start it — open billing page instead if (parsed.name === "plan") { - yield await handlePlanTrial(orgSlug, this.stdout, flags.json ?? false); + const planResult = await handlePlanTrial( + orgSlug, + this.stdout, + flags.json ?? false + ); + yield commandOutput(planResult); return; } @@ -161,16 +167,13 @@ export const startCommand = buildCommand({ // Start the trial await startProductTrial(orgSlug, trial.category); - yield { - data: { - name: parsed.name, - category: trial.category, - organization: orgSlug, - lengthDays: trial.lengthDays, - started: true, - }, - hint: undefined, - }; + yield commandOutput({ + name: parsed.name, + category: trial.category, + organization: orgSlug, + lengthDays: trial.lengthDays, + started: true, + }); return; }, }); @@ -216,14 +219,11 @@ async function promptOpenBillingUrl( /** Return type for the plan trial handler */ type PlanTrialResult = { - data: { - name: string; - category: string; - organization: string; - url: string; - opened: boolean; - }; - hint: undefined; + name: string; + category: string; + organization: string; + url: string; + opened: boolean; }; /** @@ -273,14 +273,11 @@ async function handlePlanTrial( } return { - data: { - name: "plan", - category: "plan", - organization: orgSlug, - url, - opened, - }, - hint: undefined, + name: "plan", + category: "plan", + organization: orgSlug, + url, + opened, }; } diff --git a/src/lib/command.ts b/src/lib/command.ts index a6d700193..2474ecc6b 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -16,8 +16,8 @@ * * 3. **Output mode injection** — when `output` has an {@link OutputConfig}, * `--json` and `--fields` flags are injected automatically. The command - * returns a `{ data, hint? }` object and the wrapper handles rendering - * via the config's `human` formatter. + * yields branded `CommandOutput` objects via {@link commandOutput} and + * optionally returns a `{ hint }` footer via {@link CommandReturn}. * Commands that define their own `json` flag keep theirs. * * ALL commands MUST use `buildCommand` from this module, NOT from @@ -36,12 +36,17 @@ import { buildCommand as stricliCommand, numberParser as stricliNumberParser, } from "@stricli/core"; +import type { Writer } from "../types/index.js"; import { OutputError } from "./errors.js"; import { parseFieldsList } from "./formatters/json.js"; import { + COMMAND_OUTPUT_BRAND, type CommandOutput, + type CommandReturn, + commandOutput, type OutputConfig, renderCommandOutput, + writeFooter, } from "./formatters/output.js"; import { LOG_LEVEL_NAMES, @@ -63,6 +68,21 @@ type BaseFlags = Readonly>>; /** Base args type from Stricli */ type BaseArgs = readonly unknown[]; +/** + * Type-erased Stricli builder arguments. + * + * At the `stricliCommand()` call site we've modified both `parameters` + * (injected hidden flags) and `func` (wrapped with telemetry/output + * logic), which breaks the original `FLAGS`/`ARGS` generic alignment + * that Stricli's `CommandBuilderArguments` enforces via `NoInfer`. + * + * Rather than silencing with `as any`, we cast through `unknown` to + * this type that matches Stricli's structural expectations while + * erasing the generic constraints we can no longer satisfy. + */ +type StricliBuilderArgs = + import("@stricli/core").CommandBuilderArguments; + /** Command documentation */ type CommandDocumentation = { readonly brief: string; @@ -75,10 +95,15 @@ type CommandDocumentation = { * ALL command functions are async generators. The framework iterates * each yielded value and renders it through the output config. * - * - **Non-streaming**: yield a single `CommandOutput` and return. + * - **Non-streaming**: yield a single `CommandOutput`, optionally + * return `{ hint }` for a post-output footer. * - **Streaming**: yield multiple values; each is rendered immediately * (JSONL in `--json` mode, human text otherwise). * - **Void**: return without yielding for early exits (e.g. `--web`). + * + * The return value (`CommandReturn`) is captured by the wrapper and + * rendered after all yields are consumed. Hints live exclusively on + * the return value — never on individual yields. */ type SentryCommandFunction< FLAGS extends BaseFlags, @@ -88,7 +113,8 @@ type SentryCommandFunction< this: CONTEXT, flags: FLAGS, ...args: ARGS -) => AsyncGenerator; + // biome-ignore lint/suspicious/noConfusingVoidType: void is required here — generators that don't return a value have implicit void return, which is distinct from undefined in TypeScript's type system +) => AsyncGenerator; /** * Arguments for building a command with a local function. @@ -126,7 +152,7 @@ type LocalCommandBuilderArguments< * }) * ``` */ - // biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but we erase types at the builder level + // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — OutputConfig.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 while the wrapper handles it generically. readonly output?: "json" | OutputConfig; }; @@ -294,21 +320,27 @@ export function buildCommand< const mergedParams = { ...existingParams, flags: mergedFlags }; /** - * Check if a value is a {@link CommandOutput} object (`{ data, hint? }`). + * Check if a value is a branded {@link CommandOutput} object. * - * The presence of a `data` property is the unambiguous discriminant — - * no heuristic key-sniffing needed. + * Uses the {@link COMMAND_OUTPUT_BRAND} Symbol instead of duck-typing + * on `"data" in v`, preventing false positives from raw API responses + * or other objects that happen to have a `data` property. */ function isCommandOutput(v: unknown): v is CommandOutput { - return typeof v === "object" && v !== null && "data" in v; + return ( + typeof v === "object" && + v !== null && + COMMAND_OUTPUT_BRAND in v && + v[COMMAND_OUTPUT_BRAND] === true + ); } /** - * If the command returned a {@link CommandOutput}, render it via the - * output config. Void/undefined/Error returns are ignored. + * If the yielded value is a branded {@link CommandOutput}, render it via + * the output config. Void/undefined/Error/non-branded values are ignored. */ - function handleReturnValue( - context: CONTEXT, + function handleYieldedValue( + stdout: Writer, value: unknown, flags: Record ): void { @@ -321,11 +353,8 @@ export function buildCommand< ) { return; } - const stdout = (context as Record) - .stdout as import("../types/index.js").Writer; renderCommandOutput(stdout, value.data, outputConfig, { - hint: value.hint, json: Boolean(flags.json), fields: flags.fields as string[] | undefined, }); @@ -360,10 +389,8 @@ export function buildCommand< // The wrapper is an async function that iterates the generator returned by func. const wrappedFunc = async function ( this: CONTEXT, - // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex - flags: any, - // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex - ...args: any[] + flags: Record, + ...args: unknown[] ) { applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, @@ -376,6 +403,8 @@ export function buildCommand< setArgsContext(args); } + const stdout = (this as unknown as { stdout: Writer }).stdout; + // OutputError handler: render data through the output system, then // exit with the error's code. Stricli overwrites process.exitCode = 0 // after successful returns, so process.exit() is the only way to @@ -385,39 +414,47 @@ export function buildCommand< if (err instanceof OutputError && outputConfig) { // Only render if there's actual data to show if (err.data !== null && err.data !== undefined) { - handleReturnValue( - this, - { data: err.data } as CommandOutput, - cleanFlags - ); + handleYieldedValue(stdout, commandOutput(err.data), cleanFlags); } process.exit(err.exitCode); } throw err; }; - // Iterate the generator. Each yielded value is rendered through - // the output config (if present). The generator itself never - // touches stdout — all rendering is done here. + // 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. try { const generator = originalFunc.call( this, cleanFlags as FLAGS, ...(args as unknown as ARGS) ); - for await (const value of generator) { - handleReturnValue(this, value, cleanFlags); + let result = await generator.next(); + while (!result.done) { + handleYieldedValue(stdout, result.value, cleanFlags); + result = await generator.next(); + } + + // Render post-output hint from the generator's return value. + // Only rendered in human mode — JSON output is self-contained. + const returned = result.value as CommandReturn | undefined; + if (returned?.hint && !cleanFlags.json) { + writeFooter(stdout, returned.hint); } } catch (err) { handleOutputError(err); } }; - // Build the command with the wrapped function via Stricli + // Build the command with the wrapped function via Stricli. + // The cast is necessary because we modify both `parameters` (injecting + // hidden flags) and `func` (wrapping with telemetry/output logic), + // which breaks the original FLAGS/ARGS type alignment that Stricli's + // `CommandBuilderArguments` enforces via `NoInfer`. return stricliCommand({ ...builderArgs, parameters: mergedParams, func: wrappedFunc, - // biome-ignore lint/suspicious/noExplicitAny: Stricli types are complex unions - } as any); + } as unknown as StricliBuilderArgs); } diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 428e76b74..c3670d361 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -105,33 +105,75 @@ export type OutputConfig = { }; /** - * Return type for commands with {@link OutputConfig}. + * Unique brand for {@link CommandOutput} objects. * - * Commands wrap their return value in this object so the `buildCommand` wrapper - * can unambiguously detect data vs void returns. The optional `hint` provides - * rendering metadata that depends on execution-time values (e.g. auto-detection - * source). Hints are shown in human mode and suppressed in JSON mode. + * Using a Symbol instead of duck-typing (`"data" in v`) prevents false + * positives when a command accidentally yields a raw API response that + * happens to have a `data` property. + */ +export const COMMAND_OUTPUT_BRAND: unique symbol = Symbol.for( + "sentry-cli:command-output" +); + +/** + * Yield type for commands with {@link OutputConfig}. + * + * Commands wrap each yielded value in this object so the `buildCommand` + * wrapper can unambiguously detect data vs void/raw yields. The brand + * symbol provides a runtime discriminant that cannot collide with + * arbitrary data shapes. + * + * Hints are NOT carried on yielded values — they belong on the generator's + * return value ({@link CommandReturn}) so the framework renders them once + * after the generator completes. * * @typeParam T - The data type (matches the `OutputConfig` type parameter) */ export type CommandOutput = { + /** Runtime brand — set automatically by {@link commandOutput} */ + [COMMAND_OUTPUT_BRAND]: true; /** The data to render (serialized as-is to JSON, passed to `human` formatter) */ data: T; - /** Hint line appended after human output (suppressed in JSON mode) */ +}; + +/** + * Create a branded {@link CommandOutput} value. + * + * Commands should use this helper instead of constructing `{ data }` literals + * directly, so the brand is always present. + * + * @example + * ```ts + * yield commandOutput(myData); + * ``` + */ +export function commandOutput(data: T): CommandOutput { + return { [COMMAND_OUTPUT_BRAND]: true, data }; +} + +/** + * Return type for command generators. + * + * Carries metadata that applies to the entire command invocation — not to + * individual yielded chunks. The `buildCommand` wrapper captures this from + * the generator's return value (the `done: true` result of `.next()`). + * + * `hint` is shown in human mode and suppressed in JSON mode. + */ +export type CommandReturn = { + /** Hint line appended after all output (suppressed in JSON mode) */ hint?: string; }; /** - * Full rendering context passed to {@link renderCommandOutput}. - * Combines the command's runtime hints with wrapper-injected flags. + * Rendering context passed to {@link renderCommandOutput}. + * Contains the wrapper-injected flag values needed for output mode selection. */ type RenderContext = { /** Whether `--json` was passed */ json: boolean; /** Pre-parsed `--fields` value */ fields?: string[]; - /** Hint line appended after human output (suppressed in JSON mode) */ - hint?: string; }; /** @@ -181,7 +223,7 @@ function writeTransformedJson(stdout: Writer, transformed: unknown): void { } /** - * Render a `CommandOutput` via an output config. + * Render a single yielded `CommandOutput` chunk via an output config. * * Called by the `buildCommand` wrapper when a command with `output: { ... }` * yields data. In JSON mode the data is serialized as-is (with optional @@ -190,15 +232,18 @@ function writeTransformedJson(stdout: Writer, transformed: unknown): void { * For streaming commands that yield multiple times, this function is called * once per yielded value. Each call appends to stdout independently. * + * Hints are NOT rendered here — the wrapper renders them once after the + * generator completes, using the generator's return value. + * * @param stdout - Writer to output to * @param data - The data yielded by the command * @param config - The output config declared on buildCommand - * @param ctx - Merged rendering context (command hints + runtime flags) + * @param ctx - Rendering context with flag values */ export function renderCommandOutput( stdout: Writer, data: unknown, - // biome-ignore lint/suspicious/noExplicitAny: Variance — human is contravariant in T; safe because data and config are paired at build time. + // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — config.human is contravariant in T but data/config are paired at build time. Using `any` lets the framework call human(unknownData) without requiring every OutputConfig to accept unknown. config: OutputConfig, ctx: RenderContext ): void { @@ -215,10 +260,6 @@ export function renderCommandOutput( if (text) { stdout.write(`${text}\n`); } - - if (ctx.hint) { - writeFooter(stdout, ctx.hint); - } } // --------------------------------------------------------------------------- diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 9b483bc47..d559f12ff 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -19,7 +19,11 @@ import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; -import type { OutputConfig } from "./formatters/output.js"; +import { + type CommandReturn, + commandOutput, + type OutputConfig, +} from "./formatters/output.js"; import { dispatchOrgScopedList, jsonTransformListResult, @@ -323,7 +327,8 @@ type ListCommandFunction< this: CONTEXT, flags: FLAGS, ...args: ARGS -) => AsyncGenerator; + // biome-ignore lint/suspicious/noConfusingVoidType: void is required here — generators that don't return a value have implicit void return, which is distinct from undefined in TypeScript's type system +) => AsyncGenerator; /** * Build a Stricli command for a list endpoint with automatic plural-alias @@ -503,10 +508,11 @@ export function buildOrgListCommand( flags, parsed, }); + yield commandOutput(result); // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; - yield { data: result, hint }; + return { hint }; }, }); } diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 12f631ea8..33b071a74 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -25,6 +25,7 @@ import { VERBOSE_FLAG, } from "../../src/lib/command.js"; import { OutputError } from "../../src/lib/errors.js"; +import { commandOutput } from "../../src/lib/formatters/output.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; /** Minimal context for test commands */ @@ -1000,7 +1001,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { data: { name: "Alice", role: "admin" } }; + yield commandOutput({ name: "Alice", role: "admin" }); }, }); @@ -1029,7 +1030,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { data: { name: "Alice", role: "admin" } }; + yield commandOutput({ name: "Alice", role: "admin" }); }, }); @@ -1059,7 +1060,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { data: { id: 1, name: "Alice", role: "admin" } }; + yield commandOutput({ id: 1, name: "Alice", role: "admin" }); }, }); @@ -1091,10 +1092,8 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { - data: { value: 42 }, - hint: "Run 'sentry help' for more info", - }; + yield commandOutput({ value: 42 }); + return { hint: "Run 'sentry help' for more info" }; }, }); @@ -1200,7 +1199,7 @@ describe("buildCommand return-based output", () => { parameters: {}, async *func(this: TestContext) { await Bun.sleep(1); - yield { data: { name: "Bob" } }; + yield commandOutput({ name: "Bob" }); }, }); @@ -1217,7 +1216,7 @@ describe("buildCommand return-based output", () => { expect(jsonOutput).toEqual({ name: "Bob" }); }); - test("array data works correctly via { data } wrapper", async () => { + test("array data works correctly via commandOutput wrapper", async () => { const command = buildCommand< { json: boolean; fields?: string[] }, [], @@ -1230,7 +1229,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { data: [{ id: 1 }, { id: 2 }] }; + yield commandOutput([{ id: 1 }, { id: 2 }]); }, }); @@ -1260,7 +1259,8 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield { data: { org: "sentry" }, hint: "Detected from .env file" }; + yield commandOutput({ org: "sentry" }); + return { hint: "Detected from .env file" }; }, }); diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 84b295864..1c85f41c4 100644 --- a/test/lib/formatters/output.test.ts +++ b/test/lib/formatters/output.test.ts @@ -220,31 +220,16 @@ describe("renderCommandOutput", () => { expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); }); - test("renders hint in human mode", () => { + test("does not render hints (hints are rendered by the wrapper after generator completes)", () => { const w = createTestWriter(); const config: OutputConfig = { json: true, human: () => "Result", }; - renderCommandOutput(w, "data", config, { - json: false, - hint: "Detected from .env.local", - }); - expect(w.output).toContain("Result\n"); - expect(w.output).toContain("Detected from .env.local"); - }); - - test("suppresses hint in JSON mode", () => { - const w = createTestWriter(); - const config: OutputConfig = { - json: true, - human: () => "Result", - }; - renderCommandOutput(w, "data", config, { - json: true, - hint: "Detected from .env.local", - }); - expect(w.output).not.toContain(".env.local"); + // renderCommandOutput only renders data — hints are handled by + // buildCommand's wrapper via the generator return value + renderCommandOutput(w, "data", config, { json: false }); + expect(w.output).toBe("Result\n"); }); test("works without hint", () => { From edc3ef375aaf93640073fcd975c9173df60b44b0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 17:15:33 +0000 Subject: [PATCH 03/25] refactor: remove stdout/stderr plumbing from commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands no longer receive or pass stdout/stderr Writer references. Interactive and diagnostic output now routes through the project's consola logger (→ stderr), keeping stdout reserved for structured command output. Changes: - org-list.ts: remove stdout from HandlerContext and DispatchOptions - list-command.ts, project/list.ts, issue/list.ts, trace/logs.ts: stop threading stdout to dispatchOrgScopedList - issue/list.ts: convert partial-failure stderr.write to logger.warn - log/list.ts: convert follow-mode banner and onDiagnostic to logger - auth/login.ts, interactive-login.ts: remove stdout/stderr params, use logger for all UI output (QR code, URLs, progress dots) - clipboard.ts: remove stdout param from setupCopyKeyListener - trial/start.ts: use logger for billing URL and QR code display - help.ts: printCustomHelp returns string, caller writes to process.stdout - formatters/log.ts: rename displayTraceLogs → formatTraceLogs (returns string) - formatters/output.ts: extract formatFooter helper from writeFooter Remaining stdout usage: - auth/token.ts: intentional raw stdout for pipe compatibility - help.ts: process.stdout.write for help text (like git --help) - trace/logs.ts: process.stdout.write (pending OutputConfig migration) --- AGENTS.md | 70 ++++++++-------------------- src/bin.ts | 6 +-- src/commands/auth/login.ts | 14 ++---- src/commands/help.ts | 4 +- src/commands/issue/list.ts | 15 ++---- src/commands/log/list.ts | 42 ++++++----------- src/commands/project/list.ts | 3 +- src/commands/trace/logs.ts | 29 ++++++------ src/commands/trial/start.ts | 21 ++++----- src/lib/clipboard.ts | 16 +++---- src/lib/formatters/log.ts | 36 ++++++--------- src/lib/formatters/output.ts | 8 +++- src/lib/help.ts | 9 ++-- src/lib/interactive-login.ts | 57 +++++++++++------------ src/lib/list-command.ts | 3 +- src/lib/org-list.ts | 9 +--- test/commands/issue/list.test.ts | 27 ++++++----- test/commands/log/list.test.ts | 58 +++++++++++++++++------ test/commands/trace/logs.test.ts | 74 ++++++++++++++++++++---------- test/commands/trial/start.test.ts | 51 ++++++++++++-------- test/lib/formatters/output.test.ts | 9 ++-- 21 files changed, 278 insertions(+), 283 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48ec3ae54..74acde8d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -653,69 +653,39 @@ mock.module("./some-module", () => ({ ### Architecture - -* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. + +* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. - -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. + +* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. + +* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted. - -* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. - - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. - - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. + +* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`. ### Decision - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. - - -* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands should follow a consistent \`\ \\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions. ### Gotcha - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. - - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. - - -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. - - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. + +* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed. ### Pattern - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. - - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. + +* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). + +* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization. - -* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). + +* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`. diff --git a/src/bin.ts b/src/bin.ts index 9fe821ae2..28ba8ccac 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -103,11 +103,7 @@ const autoAuthMiddleware: ErrorMiddleware = async (next, args) => { : "Authentication required. Starting login flow...\n\n" ); - const loginSuccess = await runInteractiveLogin( - process.stdout, - process.stderr, - process.stdin - ); + const loginSuccess = await runInteractiveLogin(process.stdin); if (loginSuccess) { process.stderr.write("\nRetrying command...\n\n"); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index ccf405368..d270c8f7f 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -104,7 +104,7 @@ export const loginCommand = buildCommand({ }, }, }, - // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly, will be migrated to yield pattern later + // biome-ignore lint/correctness/useYield: void generator — all output goes to stderr via logger, will be migrated to yield pattern later async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { @@ -168,15 +168,9 @@ export const loginCommand = buildCommand({ // Non-fatal: cache directory may not exist } - const { stdout, stderr } = this; - const loginSuccess = await runInteractiveLogin( - stdout, - stderr, - process.stdin, - { - timeout: flags.timeout * 1000, - } - ); + const loginSuccess = await runInteractiveLogin(process.stdin, { + timeout: flags.timeout * 1000, + }); if (!loginSuccess) { // Error already displayed by runInteractiveLogin - just set exit code diff --git a/src/commands/help.ts b/src/commands/help.ts index f5e1acb2f..419191744 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -32,11 +32,9 @@ export const helpCommand = buildCommand({ // biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags // biome-ignore lint/correctness/useYield: void generator — delegates to Stricli help system async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) { - const { stdout } = this; - // No args: show branded help if (commandPath.length === 0) { - await printCustomHelp(stdout); + process.stdout.write(await printCustomHelp()); return; } diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 50c142db0..bcee3bd6f 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -38,7 +38,6 @@ import { } from "../../lib/errors.js"; import { type IssueTableRow, - muted, shouldAutoCompact, writeIssueTable, } from "../../lib/formatters/index.js"; @@ -57,6 +56,7 @@ import { parseCursorFlag, targetPatternExplanation, } from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; import { dispatchOrgScopedList, jsonTransformListResult, @@ -871,7 +871,6 @@ async function handleOrgAllIssues( /** Options for {@link handleResolvedTargets}. */ type ResolvedTargetsOptions = { - stderr: Writer; parsed: ReturnType; flags: ListFlags; cwd: string; @@ -890,7 +889,7 @@ type ResolvedTargetsOptions = { async function handleResolvedTargets( options: ResolvedTargetsOptions ): Promise { - const { stderr, parsed, flags, cwd, setContext } = options; + const { parsed, flags, cwd, setContext } = options; const { targets, footer, skippedSelfHosted, detectedDsns } = await resolveTargetsFromParsedArg(parsed, cwd); @@ -1094,10 +1093,8 @@ async function handleResolvedTargets( const failedNames = failures .map(({ target: t }) => `${t.org}/${t.project}`) .join(", "); - stderr.write( - muted( - `\nNote: Failed to fetch issues from ${failedNames}. Showing results from ${validResults.length} project(s).\n` - ) + logger.warn( + `Failed to fetch issues from ${failedNames}. Showing results from ${validResults.length} project(s).` ); } @@ -1318,7 +1315,7 @@ export const listCommand = buildListCommand("issue", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); - const { stdout, stderr, cwd, setContext } = this; + const { cwd, setContext } = this; const parsed = parseOrgProjectArg(target); @@ -1341,13 +1338,11 @@ export const listCommand = buildListCommand("issue", { handleResolvedTargets({ ...ctx, flags, - stderr, setContext, }); const result = (await dispatchOrgScopedList({ config: issueListMeta, - stdout, cwd, flags, parsed, diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index ccddb7daa..99d0189ae 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -33,13 +33,13 @@ import { FRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; import { resolveOrg, resolveOrgProjectFromArg, } from "../../lib/resolve-target.js"; import { validateTraceId } from "../../lib/trace-id.js"; import { getUpdateNotification } from "../../lib/version-check.js"; -import type { Writer } from "../../types/index.js"; type ListFlags = { readonly limit: number; @@ -227,13 +227,13 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { * * Unlike the old callback-based approach, this does NOT include * stdout/stderr. All stdout output flows through yielded chunks; - * stderr diagnostics use the `onDiagnostic` callback. + * diagnostics are reported via the `onDiagnostic` callback. */ type FollowGeneratorConfig = { flags: ListFlags; /** Whether to show the trace-ID column in table output */ includeTrace: boolean; - /** Report diagnostic/error messages (caller writes to stderr) */ + /** Report diagnostic/error messages (caller logs via logger) */ onDiagnostic: (message: string) => void; /** * Fetch logs with the given time window. @@ -321,8 +321,8 @@ async function fetchPoll( * - `data` chunks contain raw log arrays for JSONL serialization * * The generator handles SIGINT via AbortController for clean shutdown. - * It never touches stdout/stderr directly — all output flows through - * yielded chunks and the `onDiagnostic` callback. + * It never touches stdout directly — all data output flows through + * yielded chunks and diagnostics use the `onDiagnostic` callback. * * @throws {AuthError} if the API returns an authentication error */ @@ -455,25 +455,20 @@ async function executeTraceSingleFetch( } /** - * Write the follow-mode banner to stderr. Suppressed in JSON mode. + * Write the follow-mode banner via logger. Suppressed in JSON mode. * Includes poll interval, Ctrl+C hint, and update notification. */ -function writeFollowBanner( - stderr: Writer, - flags: ListFlags, - bannerText: string -): void { +function writeFollowBanner(flags: ListFlags, bannerText: string): void { if (flags.json) { return; } const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; - stderr.write(`${bannerText} (poll interval: ${pollInterval}s)\n`); - stderr.write("Press Ctrl+C to stop.\n"); + logger.info(`${bannerText} (poll interval: ${pollInterval}s)`); + logger.info("Press Ctrl+C to stop."); const notification = getUpdateNotification(); if (notification) { - stderr.write(notification); + logger.info(notification); } - stderr.write("\n"); } // --------------------------------------------------------------------------- @@ -631,15 +626,10 @@ export const listCommand = buildListCommand("log", { setContext([org], []); if (flags.follow) { - const { stderr } = this; const traceId = flags.trace; - // Banner (stderr, suppressed in JSON mode) - writeFollowBanner( - stderr, - flags, - `Streaming logs for trace ${traceId}...` - ); + // Banner (suppressed in JSON mode) + writeFollowBanner(flags, `Streaming logs for trace ${traceId}...`); // Track IDs of logs seen without timestamp_precise so they are // shown once but not duplicated on subsequent polls. @@ -647,7 +637,7 @@ export const listCommand = buildListCommand("log", { const generator = generateFollowLogs({ flags, includeTrace: false, - onDiagnostic: (msg) => stderr.write(msg), + onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod) => listTraceLogs(org, traceId, { query: flags.query, @@ -704,14 +694,12 @@ export const listCommand = buildListCommand("log", { setContext([org], [project]); if (flags.follow) { - const { stderr } = this; - - writeFollowBanner(stderr, flags, "Streaming logs..."); + writeFollowBanner(flags, "Streaming logs..."); const generator = generateFollowLogs({ flags, includeTrace: true, - onDiagnostic: (msg) => stderr.write(msg), + onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod, afterTimestamp) => listLogs(org, project, { query: flags.query, diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index c2d14587e..0b2b7f133 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -594,13 +594,12 @@ export const listCommand = buildListCommand("project", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); - const { stdout, cwd } = this; + const { cwd } = this; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ config: projectListMeta, - stdout, cwd, flags, parsed, diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index e71bf5da5..b94609d82 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -10,7 +10,7 @@ import { validateLimit } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; -import { displayTraceLogs } from "../../lib/formatters/index.js"; +import { formatTraceLogs } from "../../lib/formatters/index.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -176,10 +176,10 @@ export const logsCommand = buildCommand({ q: "query", }, }, - // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly, will be migrated to yield pattern later + // biome-ignore lint/correctness/useYield: void generator — early returns for web mode async *func(this: SentryContext, flags: LogsFlags, ...args: string[]) { applyFreshFlag(flags); - const { stdout, cwd, setContext } = this; + const { cwd, setContext } = this; const { traceId, orgArg } = parsePositionalArgs(args); @@ -206,16 +206,17 @@ export const logsCommand = buildCommand({ query: flags.query, }); - displayTraceLogs({ - stdout, - logs, - traceId, - limit: flags.limit, - asJson: flags.json, - fields: flags.fields, - emptyMessage: - `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + - `Try a longer period: sentry trace logs --period 30d ${traceId}\n`, - }); + process.stdout.write( + formatTraceLogs({ + logs, + traceId, + limit: flags.limit, + asJson: flags.json, + fields: flags.fields, + emptyMessage: + `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + + `Try a longer period: sentry trace logs --period 30d ${traceId}\n`, + }) + ); }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index e6f687926..102c3dc28 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -143,11 +143,7 @@ export const startCommand = buildCommand({ // Plan trial: no API to start it — open billing page instead if (parsed.name === "plan") { - const planResult = await handlePlanTrial( - orgSlug, - this.stdout, - flags.json ?? false - ); + const planResult = await handlePlanTrial(orgSlug, flags.json ?? false); yield commandOutput(planResult); return; } @@ -181,19 +177,19 @@ export const startCommand = buildCommand({ /** * Show URL + QR code and prompt to open browser if interactive. * + * Display text goes to stderr via consola — stdout is reserved for + * structured command output. + * * @returns true if browser was opened, false otherwise */ -async function promptOpenBillingUrl( - url: string, - stdout: { write: (s: string) => unknown } -): Promise { +async function promptOpenBillingUrl(url: string): Promise { const log = logger.withTag("trial"); - stdout.write(`\n ${url}\n\n`); + log.log(`\n ${url}\n`); // Show QR code so mobile/remote users can scan const qr = await generateQRCode(url); - stdout.write(`${qr}\n`); + log.log(qr); // Prompt to open browser if interactive TTY if (isatty(0) && isatty(1)) { @@ -236,7 +232,6 @@ type PlanTrialResult = { */ async function handlePlanTrial( orgSlug: string, - stdout: { write: (s: string) => unknown }, json: boolean ): Promise { const log = logger.withTag("trial"); @@ -269,7 +264,7 @@ async function handlePlanTrial( log.info( `The ${currentPlan} → Business plan trial must be activated in the Sentry UI.` ); - opened = await promptOpenBillingUrl(url, stdout); + opened = await promptOpenBillingUrl(url); } return { diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index ba7e0b414..d2200c407 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -5,11 +5,11 @@ * Includes both low-level copy function and interactive keyboard-triggered copy. */ -import type { Writer } from "../types/index.js"; -import { success } from "./formatters/colors.js"; +import { logger } from "./logger.js"; + +const log = logger.withTag("clipboard"); const CTRL_C = "\x03"; -const CLEAR_LINE = "\r\x1b[K"; /** * Copy text to the system clipboard. @@ -72,15 +72,16 @@ export async function copyToClipboard(text: string): Promise { * Sets up a keyboard listener that copies text to clipboard when 'c' is pressed. * Only activates in TTY environments. Returns a cleanup function to restore stdin state. * + * Feedback ("Copied!") is written to stderr via the logger so stdout stays clean + * for structured command output. + * * @param stdin - The stdin stream to listen on * @param getText - Function that returns the text to copy - * @param stdout - Output stream for feedback messages * @returns Cleanup function to restore stdin state */ export function setupCopyKeyListener( stdin: NodeJS.ReadStream, - getText: () => string, - stdout: Writer + getText: () => string ): () => void { if (!stdin.isTTY) { return () => { @@ -100,8 +101,7 @@ export function setupCopyKeyListener( const text = getText(); const copied = await copyToClipboard(text); if (copied && active) { - stdout.write(CLEAR_LINE); - stdout.write(success("Copied!")); + log.success("Copied!"); } } diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index e41a0aa76..61fc0dbf5 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -4,13 +4,9 @@ * Provides formatting utilities for displaying Sentry logs in the CLI. */ -import type { - DetailedSentryLog, - SentryLog, - Writer, -} from "../../types/index.js"; +import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; -import { writeJson } from "./json.js"; +import { filterFields, formatJson } from "./json.js"; import { colorTag, escapeMarkdownCell, @@ -23,7 +19,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { writeFooter } from "./output.js"; +import { formatFooter } from "./output.js"; import { renderTextTable, StreamingTable, @@ -325,11 +321,9 @@ export function formatLogDetails( } /** - * Options for {@link displayTraceLogs}. + * Options for {@link formatTraceLogs}. */ -type DisplayTraceLogsOptions = { - /** Writer for output */ - stdout: Writer; +type FormatTraceLogsOptions = { /** Already-fetched logs (API order: newest-first) */ logs: LogLike[]; /** The trace ID being queried */ @@ -345,30 +339,30 @@ type DisplayTraceLogsOptions = { }; /** - * Shared display logic for trace-filtered log results. + * Format trace-filtered log results into a string. * * Handles JSON output, empty state, and human-readable table formatting. * Used by both `sentry log list --trace` and `sentry trace logs`. */ -export function displayTraceLogs(options: DisplayTraceLogsOptions): void { - const { stdout, logs, traceId, limit, asJson, emptyMessage, fields } = - options; +export function formatTraceLogs(options: FormatTraceLogsOptions): string { + const { logs, traceId, limit, asJson, emptyMessage, fields } = options; if (asJson) { - writeJson(stdout, [...logs].reverse(), fields); - return; + const reversed = [...logs].reverse(); + return formatJson(fields ? filterFields(reversed, fields) : reversed); } if (logs.length === 0) { - stdout.write(emptyMessage); - return; + return emptyMessage; } const chronological = [...logs].reverse(); - stdout.write(formatLogTable(chronological, false)); + const parts = [formatLogTable(chronological, false)]; const hasMore = logs.length >= limit; const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"} for trace ${traceId}.`; const tip = hasMore ? " Use --limit to show more." : ""; - writeFooter(stdout, `${countText}${tip}`); + parts.push(formatFooter(`${countText}${tip}`)); + + return parts.join(""); } diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index c3670d361..b367fda48 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -302,7 +302,11 @@ export function writeOutput( * @param stdout - Writer to output to * @param text - Footer text to display */ +/** Format footer text (muted, with surrounding newlines). */ +export function formatFooter(text: string): string { + return `\n${muted(text)}\n`; +} + export function writeFooter(stdout: Writer, text: string): void { - stdout.write("\n"); - stdout.write(`${muted(text)}\n`); + stdout.write(formatFooter(text)); } diff --git a/src/lib/help.ts b/src/lib/help.ts index 4f1163b14..6ca70d266 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -7,7 +7,6 @@ */ import { routes } from "../app.js"; -import type { Writer } from "../types/index.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { cyan, magenta, muted } from "./formatters/colors.js"; @@ -154,12 +153,10 @@ function formatCommands(commands: HelpCommand[]): string { } /** - * Print the custom branded help output. + * Build the custom branded help output string. * Shows a contextual example based on authentication status. - * - * @param stdout - Writer to output help text */ -export async function printCustomHelp(stdout: Writer): Promise { +export async function printCustomHelp(): Promise { const loggedIn = await isAuthenticated(); const example = loggedIn ? EXAMPLE_LOGGED_IN : EXAMPLE_LOGGED_OUT; @@ -187,5 +184,5 @@ export async function printCustomHelp(stdout: Writer): Promise { lines.push(""); lines.push(""); - stdout.write(lines.join("\n")); + return lines.join("\n"); } diff --git a/src/lib/interactive-login.ts b/src/lib/interactive-login.ts index 1e002f5d3..42a68a34a 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -7,17 +7,19 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; -import type { Writer } from "../types/index.js"; import { openBrowser } from "./browser.js"; import { setupCopyKeyListener } from "./clipboard.js"; import { getDbPath } from "./db/index.js"; import { setUserInfo } from "./db/user.js"; import { formatError } from "./errors.js"; -import { error as errorColor, muted, success } from "./formatters/colors.js"; +import { muted } from "./formatters/colors.js"; import { formatDuration, formatUserIdentity } from "./formatters/human.js"; +import { logger } from "./logger.js"; import { completeOAuthFlow, performDeviceFlow } from "./oauth.js"; import { generateQRCode } from "./qrcode.js"; +const log = logger.withTag("auth.login"); + /** Options for the interactive login flow */ export type InteractiveLoginOptions = { /** Timeout for OAuth flow in milliseconds (default: 900000 = 15 minutes) */ @@ -33,21 +35,20 @@ export type InteractiveLoginOptions = { * - Setting up keyboard listener for copying URL * - Storing the token and user info on success * - * @param stdout - Output stream for displaying UI messages - * @param stderr - Error stream for error messages + * All UI output goes to stderr via the logger, keeping stdout clean for + * structured command output. + * * @param stdin - Input stream for keyboard listener (must be TTY) * @param options - Optional configuration * @returns true on successful authentication, false on failure/cancellation */ export async function runInteractiveLogin( - stdout: Writer, - stderr: Writer, stdin: NodeJS.ReadStream & { fd: 0 }, options?: InteractiveLoginOptions ): Promise { const timeout = options?.timeout ?? 900_000; // 15 minutes default - stdout.write("Starting authentication...\n\n"); + log.info("Starting authentication..."); let urlToCopy = ""; // Object wrapper needed for TypeScript control flow analysis with async callbacks @@ -67,39 +68,35 @@ export async function runInteractiveLogin( const browserOpened = await openBrowser(verificationUriComplete); if (browserOpened) { - stdout.write("Opening in browser...\n\n"); + log.info("Opening in browser..."); } else { // Show QR code as fallback when browser can't open - stdout.write("Scan this QR code or visit the URL below:\n\n"); + log.info("Scan this QR code or visit the URL below:"); const qr = await generateQRCode(verificationUriComplete); - stdout.write(qr); - stdout.write("\n"); + log.log(qr); } - stdout.write(`URL: ${verificationUri}\n`); - stdout.write(`Code: ${userCode}\n\n`); + log.info(`URL: ${verificationUri}`); + log.info(`Code: ${userCode}`); const copyHint = stdin.isTTY ? ` ${muted("(c to copy)")}` : ""; - stdout.write( - `Browser didn't open? Use the url above to sign in${copyHint}\n\n` + log.info( + `Browser didn't open? Use the url above to sign in${copyHint}` ); - stdout.write("Waiting for authorization...\n"); + log.info("Waiting for authorization..."); // Setup keyboard listener for 'c' to copy URL - keyListener.cleanup = setupCopyKeyListener( - stdin, - () => urlToCopy, - stdout - ); + keyListener.cleanup = setupCopyKeyListener(stdin, () => urlToCopy); }, onPolling: () => { - stdout.write("."); + // Dots append on the same line without newlines — logger can't do this + process.stderr.write("."); }, }, timeout ); // Clear the polling dots - stdout.write("\n"); + process.stderr.write("\n"); // Store the token await completeOAuthFlow(tokenResponse); @@ -119,22 +116,20 @@ export async function runInteractiveLogin( } } - stdout.write(`${success("✓")} Authentication successful!\n`); + log.success("Authentication successful!"); if (user) { - stdout.write(` Logged in as: ${muted(formatUserIdentity(user))}\n`); + log.info(`Logged in as: ${muted(formatUserIdentity(user))}`); } - stdout.write(` Config saved to: ${getDbPath()}\n`); + log.info(`Config saved to: ${getDbPath()}`); if (tokenResponse.expires_in) { - stdout.write( - ` Token expires in: ${formatDuration(tokenResponse.expires_in)}\n` - ); + log.info(`Token expires in: ${formatDuration(tokenResponse.expires_in)}`); } return true; } catch (err) { - stdout.write("\n"); - stderr.write(`${errorColor("Error:")} ${formatError(err)}\n`); + process.stderr.write("\n"); + log.error(formatError(err)); return false; } finally { // Always cleanup keyboard listener diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index d559f12ff..dd00cf7a5 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -499,11 +499,10 @@ export function buildOrgListCommand( target?: string ) { applyFreshFlag(flags); - const { stdout, cwd } = this; + const { cwd } = this; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ config, - stdout, cwd, flags, parsed, diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 3d85409ec..6f4bb0b75 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -30,7 +30,6 @@ * how to render the result — JSON envelope, human table, or custom formatting. */ -import type { Writer } from "../types/index.js"; import { findProjectsBySlug, listOrganizations, @@ -224,8 +223,6 @@ export type HandlerContext< > = { /** Correctly-narrowed parsed target for this mode. */ parsed: ParsedVariant; - /** Standard output writer. */ - stdout: Writer; /** Current working directory (for DSN auto-detection). */ cwd: string; /** Shared list command flags (limit, json, cursor). */ @@ -789,7 +786,6 @@ function buildDefaultHandlers( export type DispatchOptions = { /** Full config (for default handlers) or just metadata (all modes overridden). */ config: ListCommandMeta | OrgListConfig; - stdout: Writer; cwd: string; flags: BaseListFlags; parsed: ParsedOrgProject; @@ -812,7 +808,7 @@ export type DispatchOptions = { /** * Validate the cursor flag and dispatch to the correct mode handler. * - * Builds a {@link HandlerContext} from the shared fields (stdout, cwd, flags, + * Builds a {@link HandlerContext} from the shared fields (cwd, flags, * parsed) and passes it to the resolved handler. Merges default handlers * with caller-provided overrides using `{ ...defaults, ...overrides }`. * @@ -825,7 +821,7 @@ export async function dispatchOrgScopedList( options: DispatchOptions // biome-ignore lint/suspicious/noExplicitAny: TWithOrg varies per command; callers narrow the return type ): Promise> { - const { config, stdout, cwd, flags, parsed, overrides } = options; + const { config, cwd, flags, parsed, overrides } = options; const cursorAllowedModes: readonly ParsedOrgProject["type"][] = [ "org-all", @@ -859,7 +855,6 @@ export async function dispatchOrgScopedList( const ctx: HandlerContext = { parsed: effectiveParsed, - stdout, cwd, flags, }; diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index 368446a29..e8f871a1d 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -429,19 +429,24 @@ describe("issue list: partial failure handling", () => { }); }); - const { context, stderr } = createContext(); + const stderrSpy = spyOn(process.stderr, "write"); + try { + const { context } = createContext(); - // project-search for "myproj" — org-one succeeds, org-two gets 403 → partial failure - await func.call( - context, - { limit: 10, sort: "date", json: false }, - "myproj" - ); + // project-search for "myproj" — org-one succeeds, org-two gets 403 → partial failure + await func.call( + context, + { limit: 10, sort: "date", json: false }, + "myproj" + ); - expect(stderr.output).toContain( - "Failed to fetch issues from org-two/myproj" - ); - expect(stderr.output).toContain("Showing results from 1 project(s)"); + // Partial failures are logged as warnings via logger (→ process.stderr) + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("Failed to fetch issues from org-two/myproj"); + expect(output).toContain("Showing results from 1 project(s)"); + } finally { + stderrSpy.mockRestore(); + } }); test("JSON output wraps in {data, hasMore} object", async () => { diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 489e7270a..589e123fb 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -547,12 +547,34 @@ describe("listCommand.func — trace mode org resolution failure", () => { // kills the Bun test runner). // ============================================================================ +/** + * Collect all output written to a `process.stderr.write` spy. + * Handles both string and Buffer arguments from consola/logger. + */ +function collectProcessStderr( + spy: ReturnType> +): string { + return spy.mock.calls + .map((c) => { + const arg = c[0]; + if (typeof arg === "string") { + return arg; + } + if (arg instanceof Uint8Array) { + return new TextDecoder().decode(arg); + } + return String(arg); + }) + .join(""); +} + describe("listCommand.func — follow mode (standard)", () => { let listLogsSpy: ReturnType; let resolveOrgProjectSpy: ReturnType; let isPlainSpy: ReturnType; let updateNotifSpy: ReturnType; let sigint: ReturnType; + let stderrSpy: ReturnType>; beforeEach(() => { sigint = interceptSigint(); @@ -563,6 +585,7 @@ describe("listCommand.func — follow mode (standard)", () => { versionCheck, "getUpdateNotification" ).mockReturnValue(null); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); }); afterEach(() => { @@ -570,6 +593,7 @@ describe("listCommand.func — follow mode (standard)", () => { resolveOrgProjectSpy.mockRestore(); isPlainSpy.mockRestore(); updateNotifSpy.mockRestore(); + stderrSpy.mockRestore(); sigint.restore(); }); @@ -631,7 +655,7 @@ describe("listCommand.func — follow mode (standard)", () => { listLogsSpy.mockResolvedValueOnce([]); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, followFlags, `${ORG}/${PROJECT}`); @@ -639,7 +663,8 @@ describe("listCommand.func — follow mode (standard)", () => { sigint.trigger(); await promise; - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Banner now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).toContain("Streaming logs"); expect(stderr).toContain("Ctrl+C"); }); @@ -648,7 +673,7 @@ describe("listCommand.func — follow mode (standard)", () => { listLogsSpy.mockResolvedValueOnce([]); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call( @@ -660,7 +685,8 @@ describe("listCommand.func — follow mode (standard)", () => { sigint.trigger(); await promise; - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Banner now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).not.toContain("Streaming logs"); }); @@ -748,7 +774,7 @@ describe("listCommand.func — follow mode (standard)", () => { .mockRejectedValueOnce(new Error("network timeout")); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, followFlags, `${ORG}/${PROJECT}`); @@ -757,8 +783,8 @@ describe("listCommand.func — follow mode (standard)", () => { sigint.trigger(); await promise; - // Transient error should be reported to stderr - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Transient error now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).toContain("Error fetching logs"); expect(stderr).toContain("network timeout"); }); @@ -810,7 +836,7 @@ describe("listCommand.func — follow mode (standard)", () => { listLogsSpy.mockResolvedValueOnce([]); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, followFlags, `${ORG}/${PROJECT}`); @@ -818,7 +844,8 @@ describe("listCommand.func — follow mode (standard)", () => { sigint.trigger(); await promise; - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Update notification now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).toContain("Update available: v2.0.0"); }); }); @@ -833,6 +860,7 @@ describe("listCommand.func — follow mode (trace)", () => { let isPlainSpy: ReturnType; let updateNotifSpy: ReturnType; let sigint: ReturnType; + let stderrSpy: ReturnType>; beforeEach(() => { sigint = interceptSigint(); @@ -843,6 +871,7 @@ describe("listCommand.func — follow mode (trace)", () => { versionCheck, "getUpdateNotification" ).mockReturnValue(null); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); }); afterEach(() => { @@ -850,6 +879,7 @@ describe("listCommand.func — follow mode (trace)", () => { resolveOrgSpy.mockRestore(); isPlainSpy.mockRestore(); updateNotifSpy.mockRestore(); + stderrSpy.mockRestore(); sigint.restore(); }); @@ -880,7 +910,7 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy.mockResolvedValueOnce([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, traceFollowFlags); @@ -888,7 +918,8 @@ describe("listCommand.func — follow mode (trace)", () => { sigint.trigger(); await promise; - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Banner now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).toContain("Streaming logs"); expect(stderr).toContain(TRACE_ID); expect(stderr).toContain("Ctrl+C"); @@ -965,7 +996,7 @@ describe("listCommand.func — follow mode (trace)", () => { .mockRejectedValueOnce(new Error("server error")); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stderrWrite } = createMockContext(); + const { context } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, traceFollowFlags); @@ -974,7 +1005,8 @@ describe("listCommand.func — follow mode (trace)", () => { sigint.trigger(); await promise; - const stderr = stderrWrite.mock.calls.map((c) => c[0]).join(""); + // Transient error now goes through logger → process.stderr + const stderr = collectProcessStderr(stderrSpy); expect(stderr).toContain("Error fetching logs"); expect(stderr).toContain("server error"); }); diff --git a/test/commands/trace/logs.test.ts b/test/commands/trace/logs.test.ts index c775c4f3f..8427ed401 100644 --- a/test/commands/trace/logs.test.ts +++ b/test/commands/trace/logs.test.ts @@ -5,6 +5,10 @@ * in src/commands/trace/logs.ts. * * Uses spyOn mocking to avoid real HTTP calls or database access. + * + * The command writes directly to `process.stdout.write()` via + * `formatTraceLogs()`, so tests spy on `process.stdout.write` to + * capture output instead of using mock context writers. */ import { @@ -185,32 +189,52 @@ const sampleLogs: TraceLog[] = [ ]; function createMockContext() { - const stdoutWrite = mock(() => true); return { context: { - stdout: { write: stdoutWrite }, - stderr: { write: mock(() => true) }, cwd: "/tmp", setContext: mock(() => { // no-op for test }), }, - stdoutWrite, }; } +/** + * Collect all output written to `process.stdout.write` by the spy. + * Handles both string and Buffer arguments. + */ +function collectStdout( + spy: ReturnType> +): string { + return spy.mock.calls + .map((c) => { + const arg = c[0]; + if (typeof arg === "string") { + return arg; + } + if (arg instanceof Uint8Array) { + return new TextDecoder().decode(arg); + } + return String(arg); + }) + .join(""); +} + describe("logsCommand.func", () => { let listTraceLogsSpy: ReturnType; let resolveOrgSpy: ReturnType; + let stdoutSpy: ReturnType>; beforeEach(() => { listTraceLogsSpy = spyOn(apiClient, "listTraceLogs"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); }); afterEach(() => { listTraceLogsSpy.mockRestore(); resolveOrgSpy.mockRestore(); + stdoutSpy.mockRestore(); }); describe("JSON output mode", () => { @@ -218,7 +242,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -226,11 +250,11 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toHaveLength(3); - // displayTraceLogs reverses to chronological order for JSON output + // formatTraceLogs reverses to chronological order for JSON output expect(parsed[0].id).toBe("log003"); }); @@ -238,7 +262,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -246,7 +270,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(JSON.parse(output)).toEqual([]); }); }); @@ -256,7 +280,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -264,7 +288,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("No logs found"); expect(output).toContain(TRACE_ID); }); @@ -273,7 +297,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -281,7 +305,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("30d"); }); @@ -289,7 +313,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -297,7 +321,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("Request received"); expect(output).toContain("Slow query detected"); expect(output).toContain("Database connection failed"); @@ -307,7 +331,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -315,7 +339,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("Showing 3 logs"); expect(output).toContain(TRACE_ID); }); @@ -324,7 +348,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([sampleLogs[0]]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -332,7 +356,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("Showing 1 log for trace"); expect(output).not.toContain("Showing 1 logs"); }); @@ -341,7 +365,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -350,7 +374,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).toContain("Use --limit to show more."); }); @@ -358,7 +382,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -366,7 +390,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); expect(output).not.toContain("Use --limit to show more."); }); }); @@ -514,7 +538,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(newestFirst); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context, stdoutWrite } = createMockContext(); + const { context } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -522,7 +546,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectStdout(stdoutSpy); // All three messages should appear in the output const reqIdx = output.indexOf("Request received"); const slowIdx = output.indexOf("Slow query detected"); diff --git a/test/commands/trial/start.test.ts b/test/commands/trial/start.test.ts index 4d954e627..755ae5669 100644 --- a/test/commands/trial/start.test.ts +++ b/test/commands/trial/start.test.ts @@ -317,13 +317,18 @@ describe("trial start plan", () => { makeCustomerInfo({ canTrial: true }) ); - const { context, stdoutWrite } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const stderrSpy = spyOn(process.stderr, "write"); + try { + const { context } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(output).toContain("billing"); - expect(output).toContain("test-org"); + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("billing"); + expect(output).toContain("test-org"); + } finally { + stderrSpy.mockRestore(); + } }); test("generates QR code for billing URL", async () => { @@ -332,13 +337,18 @@ describe("trial start plan", () => { makeCustomerInfo({ canTrial: true }) ); - const { context, stdoutWrite } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const stderrSpy = spyOn(process.stderr, "write"); + try { + const { context } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - expect(generateQRCodeSpy).toHaveBeenCalled(); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(output).toContain("[QR CODE]"); + expect(generateQRCodeSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("[QR CODE]"); + } finally { + stderrSpy.mockRestore(); + } }); test("throws when org is already on plan trial", async () => { @@ -406,12 +416,17 @@ describe("trial start plan", () => { }) ); - const { context, stdoutWrite } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const stderrSpy = spyOn(process.stderr, "write"); + try { + const { context } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - // The log.info message goes to stderr via consola, but the URL goes to stdout - expect(output).toContain("billing"); + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + // The log.info message and URL both go through consola → stderr + expect(output).toContain("billing"); + } finally { + stderrSpy.mockRestore(); + } }); }); diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 1c85f41c4..1b295a0a7 100644 --- a/test/lib/formatters/output.test.ts +++ b/test/lib/formatters/output.test.ts @@ -174,11 +174,10 @@ describe("writeFooter", () => { test("writes empty line followed by muted text", () => { const w = createTestWriter(); writeFooter(w, "Some hint"); - // First chunk is the empty line separator - expect(w.chunks[0]).toBe("\n"); - // Second chunk contains the hint text with trailing newline - expect(w.chunks[1]).toContain("Some hint"); - expect(w.chunks[1]).toEndWith("\n"); + const output = w.chunks.join(""); + expect(output).toStartWith("\n"); + expect(output).toContain("Some hint"); + expect(output).toEndWith("\n"); }); }); From 68c59dac39b62938952e09495ae9ed2d2c6ffe66 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 18:29:00 +0000 Subject: [PATCH 04/25] refactor(auth/login): yield LoginResult instead of using logger for output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert auth/login from a void generator using logger.info() to yield commandOutput(result) with a proper OutputConfig (human + JSON). Changes: - New LoginResult type in interactive-login.ts (method, user, configPath, expiresIn) - runInteractiveLogin returns LoginResult | null instead of boolean - loginCommand gets output: { json: true, human: formatLoginResult } - Token path builds LoginResult and yields it - OAuth path receives LoginResult from runInteractiveLogin and yields it - Interactive UI (QR code, polling dots, prompts) stays on stderr via logger - Structured result (identity, config path, expiry) goes to stdout via yield - Tests updated: getStdout() for command output assertions, behavioral spy checks for early-exit paths (logger message assertions removed — unreliable with mock.module contamination from login-reauth.test.ts) --- src/commands/auth/login.ts | 78 ++++++--- src/lib/interactive-login.ts | 39 +++-- test/commands/auth/login.test.ts | 270 +++++++++++++------------------ 3 files changed, 188 insertions(+), 199 deletions(-) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index d270c8f7f..eea0ace01 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -12,13 +12,38 @@ import { import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo, setUserInfo } from "../../lib/db/user.js"; import { AuthError } from "../../lib/errors.js"; -import { formatUserIdentity } from "../../lib/formatters/human.js"; +import { success } from "../../lib/formatters/colors.js"; +import { + formatDuration, + formatUserIdentity, +} from "../../lib/formatters/human.js"; +import { commandOutput } from "../../lib/formatters/output.js"; +import type { LoginResult } from "../../lib/interactive-login.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { logger } from "../../lib/logger.js"; import { clearResponseCache } from "../../lib/response-cache.js"; const log = logger.withTag("auth.login"); +/** Format a {@link LoginResult} for human-readable terminal output. */ +function formatLoginResult(result: LoginResult): string { + const lines: string[] = []; + lines.push( + success( + `✔ ${result.method === "token" ? "Authenticated with API token" : "Authentication successful!"}` + ) + ); + if (result.user) { + lines.push(` Logged in as: ${formatUserIdentity(result.user)}`); + } + lines.push(` Config saved to: ${result.configPath}`); + if (result.expiresIn) { + lines.push(` Token expires in: ${formatDuration(result.expiresIn)}`); + } + lines.push(""); // trailing newline + return lines.join("\n"); +} + type LoginFlags = { readonly token?: string; readonly timeout: number; @@ -104,7 +129,7 @@ export const loginCommand = buildCommand({ }, }, }, - // biome-ignore lint/correctness/useYield: void generator — all output goes to stderr via logger, will be migrated to yield pattern later + output: { json: true, human: formatLoginResult }, async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { @@ -114,15 +139,15 @@ export const loginCommand = buildCommand({ } } + // Clear stale cached responses from a previous session + try { + await clearResponseCache(); + } catch { + // Non-fatal: cache directory may not exist + } + // Token-based authentication if (flags.token) { - // Clear stale cached responses from a previous session - try { - await clearResponseCache(); - } catch { - // Non-fatal: cache directory may not exist - } - // Save token first, then validate by fetching user regions await setAuthToken(flags.token); @@ -140,40 +165,41 @@ export const loginCommand = buildCommand({ // Fetch and cache user info via /auth/ (works with all token types). // A transient failure here must not block login — the token is already valid. - let user: Awaited> | undefined; + const result: LoginResult = { + method: "token", + configPath: getDbPath(), + }; try { - user = await getCurrentUser(); + const user = await getCurrentUser(); setUserInfo({ userId: user.id, email: user.email, username: user.username, name: user.name, }); + result.user = { + name: user.name, + email: user.email, + username: user.username, + id: user.id, + }; } catch { // Non-fatal: user info is supplementary. Token remains stored and valid. } - log.success("Authenticated with API token"); - if (user) { - log.info(`Logged in as: ${formatUserIdentity(user)}`); - } - log.info(`Config saved to: ${getDbPath()}`); + yield commandOutput(result); return; } - // Clear stale cached responses from a previous session - try { - await clearResponseCache(); - } catch { - // Non-fatal: cache directory may not exist - } - - const loginSuccess = await runInteractiveLogin(process.stdin, { + // OAuth device flow + const result = await runInteractiveLogin(process.stdin, { timeout: flags.timeout * 1000, }); - if (!loginSuccess) { - // Error already displayed by runInteractiveLogin - just set exit code + if (result) { + yield commandOutput(result); + } else { + // Error already displayed by runInteractiveLogin process.exitCode = 1; } }, diff --git a/src/lib/interactive-login.ts b/src/lib/interactive-login.ts index 42a68a34a..0085fd5ec 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -13,13 +13,24 @@ import { getDbPath } from "./db/index.js"; import { setUserInfo } from "./db/user.js"; import { formatError } from "./errors.js"; import { muted } from "./formatters/colors.js"; -import { formatDuration, formatUserIdentity } from "./formatters/human.js"; import { logger } from "./logger.js"; import { completeOAuthFlow, performDeviceFlow } from "./oauth.js"; import { generateQRCode } from "./qrcode.js"; const log = logger.withTag("auth.login"); +/** Structured result returned on successful authentication. */ +export type LoginResult = { + /** Authentication method used. */ + method: "oauth" | "token"; + /** User identity if available. */ + user?: { name?: string; email?: string; username?: string; id?: string }; + /** Path where credentials are stored. */ + configPath: string; + /** Token lifetime in seconds, if known. */ + expiresIn?: number; +}; + /** Options for the interactive login flow */ export type InteractiveLoginOptions = { /** Timeout for OAuth flow in milliseconds (default: 900000 = 15 minutes) */ @@ -40,12 +51,12 @@ export type InteractiveLoginOptions = { * * @param stdin - Input stream for keyboard listener (must be TTY) * @param options - Optional configuration - * @returns true on successful authentication, false on failure/cancellation + * @returns Structured login result on success, or null on failure/cancellation */ export async function runInteractiveLogin( stdin: NodeJS.ReadStream & { fd: 0 }, options?: InteractiveLoginOptions -): Promise { +): Promise { const timeout = options?.timeout ?? 900_000; // 15 minutes default log.info("Starting authentication..."); @@ -116,21 +127,23 @@ export async function runInteractiveLogin( } } - log.success("Authentication successful!"); + const result: LoginResult = { + method: "oauth", + configPath: getDbPath(), + expiresIn: tokenResponse.expires_in, + }; if (user) { - log.info(`Logged in as: ${muted(formatUserIdentity(user))}`); + result.user = { + name: user.name, + email: user.email, + id: user.id, + }; } - log.info(`Config saved to: ${getDbPath()}`); - - if (tokenResponse.expires_in) { - log.info(`Token expires in: ${formatDuration(tokenResponse.expires_in)}`); - } - - return true; + return result; } catch (err) { process.stderr.write("\n"); log.error(formatError(err)); - return false; + return null; } finally { // Always cleanup keyboard listener keyListener.cleanup?.(); diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index d559a4d41..87899f112 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -5,8 +5,9 @@ * Uses spyOn to mock api-client, db/auth, db/user, and interactive-login * to cover all branches without real HTTP calls or database access. * - * Status messages go through consola (→ process.stderr). Tests capture stderr - * via a spy on process.stderr.write and assert on the collected output. + * Status messages go through consola (→ stderr). Logger message content is NOT + * asserted here because mock.module in login-reauth.test.ts can replace the + * logger module globally. Tests verify behavior via spy assertions instead. * * Tests that require isatty(0) to return true (interactive TTY prompt tests) * live in test/isolated/login-reauth.test.ts to avoid mock.module pollution. @@ -49,41 +50,34 @@ const SAMPLE_USER = { }; /** - * Create a mock Stricli context and a stderr capture for consola output. + * Create a mock Stricli context with stdout capture. * - * The context provides `stdout`/`stderr` Writers for `runInteractiveLogin`, - * while `getOutput()` returns the combined consola output captured from - * `process.stderr.write`. + * `getStdout()` returns rendered command output (human formatter → context.stdout). + * + * Logger messages (early-exit diagnostics) are NOT captured here because + * mock.module in login-reauth.test.ts can replace the logger module globally. + * Tests for logger message content live in test/isolated/login-reauth.test.ts. */ function createContext() { - const stderrChunks: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk: string | Uint8Array) => { - stderrChunks.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - + const stdoutChunks: string[] = []; const context = { stdout: { - write: mock((_s: string) => { - /* unused — status output goes through consola */ + write: mock((s: string) => { + stdoutChunks.push(s); }), }, stderr: { write: mock((_s: string) => { - /* unused — status output goes through consola */ + // unused — diagnostics go through logger }), }, cwd: "/tmp", setContext: mock((_k: string, _v: unknown) => { - /* no-op */ + // no-op }), }; - const getOutput = () => stderrChunks.join(""); - const restore = () => { - process.stderr.write = origWrite; - }; - return { context, getOutput, restore }; + const getStdout = () => stdoutChunks.join(""); + return { context, getStdout }; } describe("loginCommand.func --token path", () => { @@ -124,17 +118,11 @@ describe("loginCommand.func --token path", () => { test("already authenticated (non-TTY, no --force): prints re-auth message with --force hint", async () => { isAuthenticatedSpy.mockResolvedValue(true); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { force: false, timeout: 900 }); - - expect(getOutput()).toContain("already authenticated"); - expect(getOutput()).toContain("--force"); - expect(setAuthTokenSpy).not.toHaveBeenCalled(); - expect(getCurrentUserSpy).not.toHaveBeenCalled(); - } finally { - restore(); - } + const { context } = createContext(); + await func.call(context, { force: false, timeout: 900 }); + + expect(setAuthTokenSpy).not.toHaveBeenCalled(); + expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); test("already authenticated (env token SENTRY_AUTH_TOKEN): tells user to unset specific var", async () => { @@ -147,18 +135,11 @@ describe("loginCommand.func --token path", () => { source: "env:SENTRY_AUTH_TOKEN", }); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { force: false, timeout: 900 }); - - expect(getOutput()).toContain("SENTRY_AUTH_TOKEN"); - expect(getOutput()).toContain("environment variable"); - expect(getOutput()).toContain("Unset SENTRY_AUTH_TOKEN"); - expect(getOutput()).not.toContain("already authenticated"); - } finally { - restore(); - getAuthConfigSpy.mockRestore(); - } + const { context } = createContext(); + await func.call(context, { force: false, timeout: 900 }); + + expect(setAuthTokenSpy).not.toHaveBeenCalled(); + getAuthConfigSpy.mockRestore(); }); test("already authenticated (env token SENTRY_TOKEN): shows specific var name", async () => { @@ -167,15 +148,11 @@ describe("loginCommand.func --token path", () => { // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() process.env.SENTRY_TOKEN = "sntrys_token_456"; - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { force: false, timeout: 900 }); - expect(getOutput()).toContain("SENTRY_TOKEN"); - expect(getOutput()).not.toContain("SENTRY_AUTH_TOKEN"); - } finally { - restore(); - delete process.env.SENTRY_TOKEN; - } + const { context } = createContext(); + await func.call(context, { force: false, timeout: 900 }); + + expect(setAuthTokenSpy).not.toHaveBeenCalled(); + delete process.env.SENTRY_TOKEN; }); test("--token: stores token, fetches user, writes success", async () => { @@ -185,28 +162,24 @@ describe("loginCommand.func --token path", () => { getCurrentUserSpy.mockResolvedValue(SAMPLE_USER); setUserInfoSpy.mockReturnValue(undefined); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { - token: "my-token", - force: false, - timeout: 900, - }); - - expect(setAuthTokenSpy).toHaveBeenCalledWith("my-token"); - expect(getCurrentUserSpy).toHaveBeenCalled(); - expect(setUserInfoSpy).toHaveBeenCalledWith({ - userId: "42", - name: "Jane Doe", - username: "janedoe", - email: "jane@example.com", - }); - const out = getOutput(); - expect(out).toContain("Authenticated"); - expect(out).toContain("Jane Doe"); - } finally { - restore(); - } + const { context, getStdout } = createContext(); + await func.call(context, { + token: "my-token", + force: false, + timeout: 900, + }); + + expect(setAuthTokenSpy).toHaveBeenCalledWith("my-token"); + expect(getCurrentUserSpy).toHaveBeenCalled(); + expect(setUserInfoSpy).toHaveBeenCalledWith({ + userId: "42", + name: "Jane Doe", + username: "janedoe", + email: "jane@example.com", + }); + const out = getStdout(); + expect(out).toContain("Authenticated"); + expect(out).toContain("Jane Doe"); }); test("--token: invalid token clears auth and throws AuthError", async () => { @@ -215,17 +188,13 @@ describe("loginCommand.func --token path", () => { getUserRegionsSpy.mockRejectedValue(new Error("401 Unauthorized")); clearAuthSpy.mockResolvedValue(undefined); - const { context, restore } = createContext(); - try { - await expect( - func.call(context, { token: "bad-token", force: false, timeout: 900 }) - ).rejects.toBeInstanceOf(AuthError); - - expect(clearAuthSpy).toHaveBeenCalled(); - expect(getCurrentUserSpy).not.toHaveBeenCalled(); - } finally { - restore(); - } + const { context } = createContext(); + await expect( + func.call(context, { token: "bad-token", force: false, timeout: 900 }) + ).rejects.toBeInstanceOf(AuthError); + + expect(clearAuthSpy).toHaveBeenCalled(); + expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); test("--token: shows 'Logged in as' when user info fetch succeeds", async () => { @@ -235,19 +204,15 @@ describe("loginCommand.func --token path", () => { getCurrentUserSpy.mockResolvedValue({ id: "5", email: "only@email.com" }); setUserInfoSpy.mockReturnValue(undefined); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { - token: "valid-token", - force: false, - timeout: 900, - }); - - expect(getOutput()).toContain("Logged in as"); - expect(getOutput()).toContain("only@email.com"); - } finally { - restore(); - } + const { context, getStdout } = createContext(); + await func.call(context, { + token: "valid-token", + force: false, + timeout: 900, + }); + + expect(getStdout()).toContain("Logged in as"); + expect(getStdout()).toContain("only@email.com"); }); test("--token: login succeeds even when getCurrentUser() fails transiently", async () => { @@ -256,56 +221,50 @@ describe("loginCommand.func --token path", () => { getUserRegionsSpy.mockResolvedValue([]); getCurrentUserSpy.mockRejectedValue(new Error("Network error")); - const { context, getOutput, restore } = createContext(); - try { - // Must not throw — login should succeed with the stored token - await func.call(context, { - token: "valid-token", - force: false, - timeout: 900, - }); - - const out = getOutput(); - expect(out).toContain("Authenticated"); - // 'Logged in as' is omitted when user info is unavailable - expect(out).not.toContain("Logged in as"); - // Token was stored and not cleared - expect(clearAuthSpy).not.toHaveBeenCalled(); - expect(setUserInfoSpy).not.toHaveBeenCalled(); - } finally { - restore(); - } + const { context, getStdout } = createContext(); + // Must not throw — login should succeed with the stored token + await func.call(context, { + token: "valid-token", + force: false, + timeout: 900, + }); + + const out = getStdout(); + expect(out).toContain("Authenticated"); + // 'Logged in as' is omitted when user info is unavailable + expect(out).not.toContain("Logged in as"); + // Token was stored and not cleared + expect(clearAuthSpy).not.toHaveBeenCalled(); + expect(setUserInfoSpy).not.toHaveBeenCalled(); }); test("no token: falls through to interactive login", async () => { isAuthenticatedSpy.mockResolvedValue(false); - runInteractiveLoginSpy.mockResolvedValue(true); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/tmp/db", + }); - const { context, restore } = createContext(); - try { - await func.call(context, { force: false, timeout: 900 }); + const { context } = createContext(); + await func.call(context, { force: false, timeout: 900 }); - expect(runInteractiveLoginSpy).toHaveBeenCalled(); - expect(setAuthTokenSpy).not.toHaveBeenCalled(); - } finally { - restore(); - } + expect(runInteractiveLoginSpy).toHaveBeenCalled(); + expect(setAuthTokenSpy).not.toHaveBeenCalled(); }); test("--force when authenticated: clears auth and proceeds to interactive login", async () => { isAuthenticatedSpy.mockResolvedValue(true); clearAuthSpy.mockResolvedValue(undefined); - runInteractiveLoginSpy.mockResolvedValue(true); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/tmp/db", + }); - const { context, restore } = createContext(); - try { - await func.call(context, { force: true, timeout: 900 }); + const { context } = createContext(); + await func.call(context, { force: true, timeout: 900 }); - expect(clearAuthSpy).toHaveBeenCalled(); - expect(runInteractiveLoginSpy).toHaveBeenCalled(); - } finally { - restore(); - } + expect(clearAuthSpy).toHaveBeenCalled(); + expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); test("--force --token when authenticated: clears auth and proceeds to token login", async () => { @@ -316,35 +275,26 @@ describe("loginCommand.func --token path", () => { getCurrentUserSpy.mockResolvedValue(SAMPLE_USER); setUserInfoSpy.mockReturnValue(undefined); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { - token: "new-token", - force: true, - timeout: 900, - }); - - expect(clearAuthSpy).toHaveBeenCalled(); - expect(setAuthTokenSpy).toHaveBeenCalledWith("new-token"); - expect(getOutput()).toContain("Authenticated"); - } finally { - restore(); - } + const { context, getStdout } = createContext(); + await func.call(context, { + token: "new-token", + force: true, + timeout: 900, + }); + + expect(clearAuthSpy).toHaveBeenCalled(); + expect(setAuthTokenSpy).toHaveBeenCalledWith("new-token"); + expect(getStdout()).toContain("Authenticated"); }); test("--force with env token: still blocks (env var case unchanged)", async () => { isAuthenticatedSpy.mockResolvedValue(true); isEnvTokenActiveSpy.mockReturnValue(true); - const { context, getOutput, restore } = createContext(); - try { - await func.call(context, { force: true, timeout: 900 }); + const { context } = createContext(); + await func.call(context, { force: true, timeout: 900 }); - expect(getOutput()).toContain("environment variable"); - expect(clearAuthSpy).not.toHaveBeenCalled(); - expect(runInteractiveLoginSpy).not.toHaveBeenCalled(); - } finally { - restore(); - } + expect(clearAuthSpy).not.toHaveBeenCalled(); + expect(runInteractiveLoginSpy).not.toHaveBeenCalled(); }); }); From 2c1873483997383b2075e675904e5729350d2070 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 18:53:05 +0000 Subject: [PATCH 05/25] =?UTF-8?q?refactor(log/list):=20remove=20LogStreamC?= =?UTF-8?q?hunk=20=E2=80=94=20func=20yields=20data,=20not=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log list command no longer knows about output format. Instead of yielding separate 'text' and 'data' chunks (LogStreamChunk), the generator yields raw LogLike[] batches and the func wraps them in LogListResult with streaming: true. The OutputConfig formatters handle all rendering decisions. Removed: - LogStreamChunk discriminated union (text | data) - yieldStreamChunks() — format-aware fan-out function - LogListOutput union type - yieldBatch() sub-generator with text/data split - includeTrace from FollowGeneratorConfig (rendering concern) Added: - LogListResult.streaming flag — formatters use it to switch between full-table (batch) and incremental-row (streaming) rendering - jsonlLines() in output.ts — JSONL support for the framework. When jsonTransform returns jsonlLines(items), writeTransformedJson writes each item as a separate line instead of serializing as one JSON value - yieldFollowBatches() helper — consumes follow generator and yields CommandOutput with streaming: true - Stateful streaming table state (module-level singleton, reset per run) The func body has zero references to flags.json for output decisions. The only flags.json usage is in writeFollowBanner (stderr noise control). --- src/commands/log/list.ts | 265 ++++++++++++++++------------------- src/lib/formatters/output.ts | 67 ++++++++- 2 files changed, 184 insertions(+), 148 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 99d0189ae..6dde7cbc3 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -25,6 +25,7 @@ import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import { type CommandOutput, commandOutput, + jsonlLines, } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { @@ -51,13 +52,18 @@ type ListFlags = { readonly fields?: string[]; }; -/** Result for non-follow log list operations. */ +/** Result yielded by the log list command — one per batch. */ type LogListResult = { logs: LogLike[]; /** Human-readable hint (e.g., "Showing 100 logs. Use --limit to show more.") */ hint?: string; /** Trace ID, present for trace-filtered queries */ traceId?: string; + /** + * When true, this result is one batch in a follow-mode stream. + * Formatters use this to switch between full-table and incremental rendering. + */ + streaming?: boolean; }; /** Maximum allowed value for --limit flag */ @@ -153,50 +159,6 @@ async function executeSingleFetch( // Streaming follow-mode infrastructure // --------------------------------------------------------------------------- -/** - * A chunk yielded by the follow-mode generator. - * - * Two kinds: - * - `text` — pre-rendered human content (header, table rows, footer). - * Written to stdout in human mode, skipped in JSON mode. - * - `data` — raw log entries for JSONL output. Skipped in human mode - * (the text chunk handles rendering). - */ -type LogStreamChunk = - | { kind: "text"; content: string } - | { kind: "data"; logs: LogLike[] }; - -/** - * Yield `CommandOutput` values from a streaming log chunk. - * - * - **Human mode**: yields the chunk as-is (text is rendered, data is skipped - * by the human formatter). - * - **JSON mode**: expands `data` chunks into one yield per log entry (JSONL). - * Text chunks yield a suppressed-in-JSON marker so the framework skips them. - * - * @param chunk - A streaming chunk from `generateFollowLogs` - * @param json - Whether JSON output mode is active - * @param fields - Optional field filter list - */ -function* yieldStreamChunks( - chunk: LogStreamChunk, - json: boolean -): Generator, void, undefined> { - if (json) { - // In JSON mode, expand data chunks into one yield per log for JSONL - if (chunk.kind === "data") { - for (const log of chunk.logs) { - // Yield a single-log data chunk so jsonTransform emits one line - yield commandOutput({ kind: "data", logs: [log] } as LogListOutput); - } - } - // Text chunks suppressed in JSON mode (jsonTransform returns undefined) - return; - } - // Human mode: yield the chunk directly for the human formatter - yield commandOutput(chunk); -} - /** * Sleep that resolves early when an AbortSignal fires. * Resolves (not rejects) on abort for clean generator shutdown. @@ -231,8 +193,6 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { */ type FollowGeneratorConfig = { flags: ListFlags; - /** Whether to show the trace-ID column in table output */ - includeTrace: boolean; /** Report diagnostic/error messages (caller logs via logger) */ onDiagnostic: (message: string) => void; /** @@ -316,27 +276,23 @@ async function fetchPoll( /** * Async generator that streams log entries via follow-mode polling. * - * Yields typed {@link LogStreamChunk} values: - * - `text` chunks contain pre-rendered human output (header, rows, footer) - * - `data` chunks contain raw log arrays for JSONL serialization + * Yields batches of log entries (chronological order). The command wraps + * each batch in a `LogListResult` with `streaming: true` so the OutputConfig + * formatters can handle incremental rendering vs JSONL expansion. * * The generator handles SIGINT via AbortController for clean shutdown. - * It never touches stdout directly — all data output flows through - * yielded chunks and diagnostics use the `onDiagnostic` callback. + * It never touches stdout — all data output flows through yielded batches + * and diagnostics use the `onDiagnostic` callback. * * @throws {AuthError} if the API returns an authentication error */ async function* generateFollowLogs( config: FollowGeneratorConfig -): AsyncGenerator { +): AsyncGenerator { const { flags } = config; const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; const pollIntervalMs = pollInterval * 1000; - const plain = flags.json || isPlainOutput(); - const table = plain ? undefined : createLogStreamingTable(); - - let headerPrinted = false; // timestamp_precise is nanoseconds; Date.now() is milliseconds → convert let lastTimestamp = Date.now() * 1_000_000; @@ -345,42 +301,12 @@ async function* generateFollowLogs( const stop = () => controller.abort(); process.once("SIGINT", stop); - /** - * Yield header + data + rendered-text chunks for a batch of logs. - * Implemented as a sync sub-generator to use `yield*` from the caller. - */ - function* yieldBatch(logs: T[]): Generator { - if (logs.length === 0) { - return; - } - - // Header on first non-empty batch (human mode only) - if (!(flags.json || headerPrinted)) { - yield { - kind: "text", - content: table ? table.header() : formatLogsHeader(), - }; - headerPrinted = true; - } - - const chronological = [...logs].reverse(); - - // Data chunk for JSONL - yield { kind: "data", logs: chronological }; - - // Rendered text chunk for human mode - if (!flags.json) { - yield { - kind: "text", - content: renderLogRows(chronological, config.includeTrace, table), - }; - } - } - try { // Initial fetch const initialLogs = await config.fetch("1m"); - yield* yieldBatch(initialLogs); + if (initialLogs.length > 0) { + yield [...initialLogs].reverse(); + } lastTimestamp = maxTimestamp(initialLogs) ?? lastTimestamp; config.onInitialLogs?.(initialLogs); @@ -392,23 +318,32 @@ async function* generateFollowLogs( } const newLogs = await fetchPoll(config, lastTimestamp); - if (newLogs) { - yield* yieldBatch(newLogs); + if (newLogs && newLogs.length > 0) { + yield [...newLogs].reverse(); lastTimestamp = maxTimestamp(newLogs) ?? lastTimestamp; } } - - // Table footer — yielded after clean shutdown so the consumer can - // render it. Placed inside `try` (not `finally`) because a yield in - // `finally` is discarded when the consumer terminates via error. - if (table && headerPrinted) { - yield { kind: "text", content: table.footer() }; - } } finally { process.removeListener("SIGINT", stop); } } +/** + * Consume a follow-mode generator, yielding `LogListResult` batches + * with `streaming: true`. Emits a final empty batch so the human + * formatter can close the streaming table. + */ +async function* yieldFollowBatches( + generator: AsyncGenerator, + extra?: Partial +): AsyncGenerator, void, undefined> { + for await (const batch of generator) { + yield commandOutput({ logs: batch, streaming: true, ...extra }); + } + // Final empty batch signals end-of-stream to the human formatter + yield commandOutput({ logs: [], streaming: true, ...extra }); +} + /** Default time period for trace-logs queries */ const DEFAULT_TRACE_PERIOD = "14d"; @@ -458,11 +393,18 @@ async function executeTraceSingleFetch( * Write the follow-mode banner via logger. Suppressed in JSON mode. * Includes poll interval, Ctrl+C hint, and update notification. */ -function writeFollowBanner(flags: ListFlags, bannerText: string): void { - if (flags.json) { +/** + * Write the follow-mode banner via logger. Suppressed in JSON mode + * to avoid stderr noise when agents consume JSONL output. + */ +function writeFollowBanner( + pollInterval: number, + bannerText: string, + json: boolean +): void { + if (json) { return; } - const pollInterval = flags.follow ?? DEFAULT_POLL_INTERVAL; logger.info(`${bannerText} (poll interval: ${pollInterval}s)`); logger.info("Press Ctrl+C to stop."); const notification = getUpdateNotification(); @@ -475,22 +417,57 @@ function writeFollowBanner(flags: ListFlags, bannerText: string): void { // Output formatting // --------------------------------------------------------------------------- -/** Data yielded by the log list command — either a batch result or a stream chunk. */ -type LogListOutput = LogListResult | LogStreamChunk; +// --------------------------------------------------------------------------- +// Stateful streaming table — module-level singleton, reset per follow run. +// Safe because CLI processes are single-use (one invocation per process). +// --------------------------------------------------------------------------- + +let streamingTable: StreamingTable | undefined; +let streamingHeaderEmitted = false; + +/** + * Reset the streaming table state. Called at the start of each follow-mode run. + */ +function resetStreamingState(): void { + const plain = isPlainOutput(); + streamingTable = plain ? undefined : createLogStreamingTable(); + streamingHeaderEmitted = false; +} /** * Format log output as human-readable terminal text. * - * Handles both batch results ({@link LogListResult}) and streaming - * chunks ({@link LogStreamChunk}). The returned string omits a trailing - * newline — the output framework appends one automatically. + * - **Batch mode** (`streaming` absent/false): renders a complete table. + * - **Streaming mode** (`streaming: true`): renders incremental rows using + * a stateful {@link StreamingTable}, including the header on first call. + * + * The returned string omits a trailing newline — the output framework + * appends one automatically. */ -function formatLogOutput(result: LogListOutput): string { - if ("kind" in result) { - // Streaming chunk — text is pre-rendered, data is skipped (handled by JSON) - return result.kind === "text" ? result.content.trimEnd() : ""; +function formatLogOutput(result: LogListResult): string { + if (result.streaming) { + const includeTrace = !result.traceId; + let text = ""; + + if (result.logs.length === 0) { + // Empty batch signals end of stream — emit table footer + if (streamingTable && streamingHeaderEmitted) { + text += streamingTable.footer(); + } + return text.trimEnd(); + } + + // Emit header on first non-empty batch + if (!streamingHeaderEmitted) { + text += streamingTable ? streamingTable.header() : formatLogsHeader(); + streamingHeaderEmitted = true; + } + + text += renderLogRows(result.logs, includeTrace, streamingTable); + return text.trimEnd(); } - // Batch result + + // Batch: complete table if (result.logs.length === 0) { return result.hint ?? "No logs found."; } @@ -501,35 +478,31 @@ function formatLogOutput(result: LogListOutput): string { /** * Transform log output into the JSON shape. * - * - Batch: returns the logs array (no envelope). - * - Streaming text: returns `undefined` (suppressed in JSON mode). - * - Streaming data: returns individual log objects for JSONL expansion. + * - **Batch mode** (`streaming` absent/false): returns the full logs array. + * - **Streaming mode** (`streaming: true`): returns individual log objects + * so the framework writes one JSON object per line (JSONL). + * + * When the result contains a single log entry in streaming mode, it's + * returned unwrapped. Multiple entries return an array (each call from + * the wrapper writes one line to stdout). */ function jsonTransformLogOutput( - result: LogListOutput, + result: LogListResult, fields?: string[] ): unknown { - if ("kind" in result) { - // Streaming: text chunks are suppressed, data chunks return bare log - // objects for JSONL (one JSON object per line, not wrapped in an array). - // yieldStreamChunks already fans out to one log per chunk. - if (result.kind === "text") { - return; - } - const log = result.logs[0]; - if (log === undefined) { + const applyFields = (log: LogLike) => + fields && fields.length > 0 ? filterFields(log, fields) : log; + + if (result.streaming) { + // Streaming: expand to JSONL (one JSON object per line) + if (result.logs.length === 0) { return; } - if (fields && fields.length > 0) { - return filterFields(log, fields); - } - return log; + return jsonlLines(result.logs.map(applyFields)); } - // Batch result - if (fields && fields.length > 0) { - return result.logs.map((log) => filterFields(log, fields)); - } - return result.logs; + + // Batch: return full array + return result.logs.map(applyFields); } export const listCommand = buildListCommand("log", { @@ -628,15 +601,19 @@ export const listCommand = buildListCommand("log", { if (flags.follow) { const traceId = flags.trace; + resetStreamingState(); // Banner (suppressed in JSON mode) - writeFollowBanner(flags, `Streaming logs for trace ${traceId}...`); + writeFollowBanner( + flags.follow ?? DEFAULT_POLL_INTERVAL, + `Streaming logs for trace ${traceId}...`, + flags.json + ); // Track IDs of logs seen without timestamp_precise so they are // shown once but not duplicated on subsequent polls. const seenWithoutTs = new Set(); const generator = generateFollowLogs({ flags, - includeTrace: false, onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod) => listTraceLogs(org, traceId, { @@ -665,9 +642,7 @@ export const listCommand = buildListCommand("log", { }, }); - for await (const chunk of generator) { - yield* yieldStreamChunks(chunk, flags.json); - } + yield* yieldFollowBatches(generator, { traceId }); return; } @@ -694,11 +669,15 @@ export const listCommand = buildListCommand("log", { setContext([org], [project]); if (flags.follow) { - writeFollowBanner(flags, "Streaming logs..."); + resetStreamingState(); + writeFollowBanner( + flags.follow ?? DEFAULT_POLL_INTERVAL, + "Streaming logs...", + flags.json + ); const generator = generateFollowLogs({ flags, - includeTrace: true, onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod, afterTimestamp) => listLogs(org, project, { @@ -710,9 +689,7 @@ export const listCommand = buildListCommand("log", { extractNew: (logs) => logs, }); - for await (const chunk of generator) { - yield* yieldStreamChunks(chunk, flags.json); - } + yield* yieldFollowBatches(generator); return; } diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index b367fda48..a99a92e1b 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -210,16 +210,75 @@ function applyJsonExclude( return copy; } +// --------------------------------------------------------------------------- +// JSONL (JSON Lines) support for streaming commands +// --------------------------------------------------------------------------- + +/** Brand symbol for {@link JsonlLines} values. */ +const JSONL_BRAND: unique symbol = Symbol.for("sentry-cli:jsonl-lines"); + +/** + * Wrapper that tells the output framework to write each element as a + * separate JSON line (JSONL format) instead of serializing the array + * as a single JSON value. + * + * Use this in `jsonTransform` when a streaming command yields batches + * that should be expanded to one line per item. + */ +type JsonlLines = { + readonly [JSONL_BRAND]: true; + readonly items: readonly unknown[]; +}; + +/** + * Create a JSONL marker for use in `jsonTransform`. + * + * Each item in the array is serialized as a separate JSON line. + * Empty arrays produce no output. + * + * @example + * ```ts + * jsonTransform(result) { + * if (result.streaming) { + * return jsonlLines(result.logs); + * } + * return result.logs; + * } + * ``` + */ +export function jsonlLines(items: readonly unknown[]): JsonlLines { + return { [JSONL_BRAND]: true, items }; +} + +/** Type guard for JSONL marker values. */ +function isJsonlLines(v: unknown): v is JsonlLines { + return ( + typeof v === "object" && + v !== null && + JSONL_BRAND in v && + (v as Record)[JSONL_BRAND] === true + ); +} + /** * Write a JSON-transformed value to stdout. * - * `undefined` suppresses the chunk entirely (e.g. streaming text-only - * chunks in JSON mode). + * - `undefined` suppresses the chunk entirely (e.g. streaming text-only + * chunks in JSON mode). + * - {@link JsonlLines} expands to one line per item (JSONL format). + * - All other values are serialized as a single JSON value. */ function writeTransformedJson(stdout: Writer, transformed: unknown): void { - if (transformed !== undefined) { - stdout.write(`${formatJson(transformed)}\n`); + if (transformed === undefined) { + return; + } + if (isJsonlLines(transformed)) { + for (const item of transformed.items) { + stdout.write(`${formatJson(item)}\n`); + } + return; } + stdout.write(`${formatJson(transformed)}\n`); } /** From 839105e6e60e07fc1f3c6f9a70b13823e462abb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 18:53:42 +0000 Subject: [PATCH 06/25] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 387af9dc1..4ee65d16f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -45,6 +45,8 @@ Authenticate with Sentry - `--token - Authenticate using an API token instead of OAuth` - `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` - `--force - Re-authenticate without prompting` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` **Examples:** From 2cd03d36fd15b3ba9d9f819d595e694dd9bf6b5d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:34:04 +0000 Subject: [PATCH 07/25] refactor: OutputConfig.human becomes a factory returning HumanRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutputConfig.human is now a factory `() => HumanRenderer` called once per command invocation, instead of a plain `(data: T) => string`. HumanRenderer has two methods: - `render(data: T) => string` — called per yielded value - `finalize?(hint?: string) => string` — called once after the generator completes, replaces the default writeFooter(hint) behavior This enables streaming commands to maintain per-invocation rendering state (e.g., a table that tracks header/footer) without module-level singletons. The wrapper resolves the factory once before iterating, passes the renderer to renderCommandOutput, and calls finalize() after the generator completes. For stateless commands (all current ones), the `stateless(fn)` helper wraps a plain formatter: `human: stateless(formatMyData)`. Framework changes: - output.ts: HumanRenderer type, stateless() helper, updated renderCommandOutput to accept renderer parameter, CommandReturn.hint docs updated - command.ts: resolves renderer before iteration, passes to handleYieldedValue, writeFinalization() calls finalize or writeFooter 28 command files: mechanical human: fn → human: stateless(fn) --- src/commands/api.ts | 4 +- src/commands/auth/login.ts | 4 +- src/commands/auth/logout.ts | 4 +- src/commands/auth/refresh.ts | 4 +- src/commands/auth/status.ts | 4 +- src/commands/auth/whoami.ts | 4 +- src/commands/cli/feedback.ts | 4 +- src/commands/cli/fix.ts | 4 +- src/commands/cli/upgrade.ts | 4 +- src/commands/event/view.ts | 4 +- src/commands/issue/explain.ts | 4 +- src/commands/issue/list.ts | 3 +- src/commands/issue/plan.ts | 4 +- src/commands/issue/view.ts | 4 +- src/commands/log/list.ts | 3 +- src/commands/log/view.ts | 4 +- src/commands/org/list.ts | 4 +- src/commands/org/view.ts | 4 +- src/commands/project/create.ts | 4 +- src/commands/project/list.ts | 5 +- src/commands/project/view.ts | 4 +- src/commands/trace/list.ts | 4 +- src/commands/trace/view.ts | 4 +- src/commands/trial/list.ts | 4 +- src/commands/trial/start.ts | 4 +- src/lib/command.ts | 56 ++++++++++++++---- src/lib/formatters/output.ts | 95 +++++++++++++++++++++++++----- src/lib/list-command.ts | 5 +- test/lib/formatters/output.test.ts | 87 +++++++++++++++------------ 29 files changed, 230 insertions(+), 112 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 2b8ed2477..a18b1d3de 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { OutputError, ValidationError } from "../lib/errors.js"; -import { commandOutput } from "../lib/formatters/output.js"; +import { commandOutput, stateless } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; @@ -1053,7 +1053,7 @@ function logResponse(response: { status: number; headers: Headers }): void { } export const apiCommand = buildCommand({ - output: { json: true, human: formatApiResponse }, + output: { json: true, human: stateless(formatApiResponse) }, docs: { brief: "Make an authenticated API request", fullDescription: diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index eea0ace01..bb1086c76 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -17,7 +17,7 @@ import { formatDuration, formatUserIdentity, } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import type { LoginResult } from "../../lib/interactive-login.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { logger } from "../../lib/logger.js"; @@ -129,7 +129,7 @@ export const loginCommand = buildCommand({ }, }, }, - output: { json: true, human: formatLoginResult }, + output: { json: true, human: stateless(formatLoginResult) }, async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 1a39816d7..822f3d7f2 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -15,7 +15,7 @@ import { import { getDbPath } from "../../lib/db/index.js"; import { AuthError } from "../../lib/errors.js"; import { formatLogoutResult } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; /** Structured result of the logout operation */ export type LogoutResult = { @@ -33,7 +33,7 @@ export const logoutCommand = buildCommand({ fullDescription: "Remove stored authentication credentials from the local database.", }, - output: { json: true, human: formatLogoutResult }, + output: { json: true, human: stateless(formatLogoutResult) }, parameters: { flags: {}, }, diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index cc5b4db46..8de435882 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -15,7 +15,7 @@ import { import { AuthError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { formatDuration } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; type RefreshFlags = { readonly json: boolean; @@ -59,7 +59,7 @@ Examples: {"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."} `.trim(), }, - output: { json: true, human: formatRefreshResult }, + output: { json: true, human: stateless(formatRefreshResult) }, parameters: { flags: { force: { diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index b11ceb1f5..b378df03b 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -22,7 +22,7 @@ import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo } from "../../lib/db/user.js"; import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -144,7 +144,7 @@ export const statusCommand = buildCommand({ "Display information about your current authentication status, " + "including whether you're logged in and your default organization/project settings.", }, - output: { json: true, human: formatAuthStatus }, + output: { json: true, human: stateless(formatAuthStatus) }, parameters: { flags: { "show-token": { diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index fecd90726..ec0854a44 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,7 +13,7 @@ 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 { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -36,7 +36,7 @@ export const whoamiCommand = buildCommand({ }, output: { json: true, - human: formatUserIdentity, + human: stateless(formatUserIdentity), }, parameters: { flags: { diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index c16c8a29c..aeb7ccb65 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -14,7 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; /** Structured result of the feedback submission */ export type FeedbackResult = { @@ -31,7 +31,7 @@ export const feedbackCommand = buildCommand({ "Submit feedback about your experience with the Sentry CLI. " + "All text after 'feedback' is sent as your message.", }, - output: { json: true, human: formatFeedbackResult }, + output: { json: true, human: stateless(formatFeedbackResult) }, parameters: { flags: {}, positional: { diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 2a874950a..8c9011860 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -17,7 +17,7 @@ import { } from "../../lib/db/schema.js"; import { OutputError } from "../../lib/errors.js"; import { formatFixResult } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { getRealUsername } from "../../lib/utils.js"; type FixFlags = { @@ -669,7 +669,7 @@ export const fixCommand = buildCommand({ " sudo sentry cli fix # Fix root-owned files\n" + " sentry cli fix --dry-run # Show what would be fixed without making changes", }, - output: { json: true, human: formatFixResult }, + output: { json: true, human: stateless(formatFixResult) }, parameters: { flags: { "dry-run": { diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index d9afcd2ba..25a9aebcb 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -31,7 +31,7 @@ import { } from "../../lib/db/release-channel.js"; import { UpgradeError } from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { detectInstallationMethod, @@ -418,7 +418,7 @@ export const upgradeCommand = buildCommand({ " sentry cli upgrade --force # Force re-download even if up to date\n" + " sentry cli upgrade --method npm # Force using npm to upgrade", }, - output: { json: true, human: formatUpgradeResult }, + output: { json: true, human: stateless(formatUpgradeResult) }, parameters: { positional: { kind: "tuple", diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 8188f3e4e..d8f5ed1c8 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,7 +23,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -305,7 +305,7 @@ export const viewCommand = buildCommand({ }, output: { json: true, - human: formatEventView, + human: stateless(formatEventView), jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index fd36b8eb7..762edf5c0 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -7,7 +7,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError } from "../../lib/errors.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { formatRootCauseList, handleSeerApiError, @@ -59,7 +59,7 @@ export const explainCommand = buildCommand({ " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, - output: { json: true, human: formatRootCauseList }, + output: { json: true, human: stateless(formatRootCauseList) }, parameters: { positional: issueIdPositional, flags: { diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index bcee3bd6f..c1a72fd66 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -44,6 +44,7 @@ import { import { commandOutput, type OutputConfig, + stateless, } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -1241,7 +1242,7 @@ const jsonTransformIssueList = jsonTransformListResult; /** Output configuration for the issue list command. */ const issueListOutput: OutputConfig = { json: true, - human: formatIssueListHuman, + human: stateless(formatIssueListHuman), jsonTransform: jsonTransformIssueList, }; diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 441c3581a..46d20c5c7 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { formatSolution, handleSeerApiError, @@ -172,7 +172,7 @@ export const planCommand = buildCommand({ }, output: { json: true, - human: formatPlanOutput, + human: stateless(formatPlanOutput), }, parameters: { positional: issueIdPositional, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 0a3389a72..49ea579b4 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -14,7 +14,7 @@ import { formatIssueDetails, muted, } from "../../lib/formatters/index.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -103,7 +103,7 @@ export const viewCommand = buildCommand({ }, output: { json: true, - human: formatIssueView, + human: stateless(formatIssueView), jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 6dde7cbc3..23f8fe072 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -26,6 +26,7 @@ import { type CommandOutput, commandOutput, jsonlLines, + stateless, } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { @@ -529,7 +530,7 @@ export const listCommand = buildListCommand("log", { }, output: { json: true, - human: formatLogOutput, + human: stateless(formatLogOutput), jsonTransform: jsonTransformLogOutput, }, parameters: { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 784708304..0d9162ecf 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,7 +18,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, @@ -320,7 +320,7 @@ export const viewCommand = buildCommand({ }, output: { json: true, - human: formatLogViewHuman, + human: stateless(formatLogViewHuman), // Preserve original JSON contract: bare array of log entries. // orgSlug exists only for the human formatter (trace URLs). jsonTransform: (data: LogViewData, fields) => diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 3b177e08b..498f0765c 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -10,7 +10,7 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -117,7 +117,7 @@ export const listCommand = buildCommand({ " sentry org list --limit 10\n" + " sentry org list --json", }, - output: { json: true, human: formatOrgListHuman }, + output: { json: true, human: stateless(formatOrgListHuman) }, parameters: { flags: { limit: buildListLimitFlag("organizations"), diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index c0014c8e0..91fb409b2 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,7 +10,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails } from "../../lib/formatters/index.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -36,7 +36,7 @@ export const viewCommand = buildCommand({ " 2. Config defaults\n" + " 3. SENTRY_DSN environment variable or source code detection", }, - output: { json: true, human: formatOrgDetails }, + output: { json: true, human: stateless(formatOrgDetails) }, parameters: { positional: { kind: "tuple", diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index e8530bffd..9020bb1a0 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,7 +35,7 @@ import { type ProjectCreatedResult, } from "../../lib/formatters/human.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; import { logger } from "../../lib/logger.js"; @@ -277,7 +277,7 @@ export const createCommand = buildCommand({ }, output: { json: true, - human: formatProjectCreated, + human: stateless(formatProjectCreated), jsonExclude: [ "slugDiverged", "expectedSlug", diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 0b2b7f133..acbda909d 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -35,6 +35,7 @@ import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { commandOutput, type OutputConfig, + stateless, } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { @@ -565,7 +566,7 @@ export const listCommand = buildListCommand("project", { }, output: { json: true, - human: (result: ListResult) => { + human: stateless((result: ListResult) => { if (result.items.length === 0) { return result.hint ?? "No projects found."; } @@ -574,7 +575,7 @@ export const listCommand = buildListCommand("project", { parts.push(`\n${result.header}`); } return parts.join(""); - }, + }), jsonTransform: jsonTransformListResult, } satisfies OutputConfig>, parameters: { diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 48ad377b8..6e13ee3c8 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,7 +15,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -187,7 +187,7 @@ export const viewCommand = buildCommand({ }, output: { json: true, - human: formatProjectViewHuman, + human: stateless(formatProjectViewHuman), jsonExclude: ["detectedFrom"], }, parameters: { diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 73ce8d004..256298cff 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -15,7 +15,7 @@ import { } from "../../lib/db/pagination.js"; import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -181,7 +181,7 @@ export const listCommand = buildListCommand("trace", { }, output: { json: true, - human: formatTraceListHuman, + human: stateless(formatTraceListHuman), jsonTransform: jsonTransformTraceList, }, parameters: { diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 14c19da0d..c19e4b864 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -21,7 +21,7 @@ import { formatSimpleSpanTree, formatTraceSummary, } from "../../lib/formatters/index.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -206,7 +206,7 @@ export const viewCommand = buildCommand({ }, output: { json: true, - human: formatTraceView, + human: stateless(formatTraceView), jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index b3906d3f2..bddb935e6 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -11,7 +11,7 @@ import { getCustomerTrialInfo } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { colorTag } from "../../lib/formatters/markdown.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { @@ -204,7 +204,7 @@ export const listCommand = buildCommand({ }, output: { json: true, - human: formatTrialListHuman, + human: stateless(formatTrialListHuman), jsonExclude: ["displayName"], }, parameters: { diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 102c3dc28..84f744d1b 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -22,7 +22,7 @@ import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; -import { commandOutput } from "../../lib/formatters/output.js"; +import { commandOutput, stateless } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; @@ -89,7 +89,7 @@ export const startCommand = buildCommand({ " sentry trial start plan\n" + " sentry trial start --json seer", }, - output: { json: true, human: formatStartResult }, + output: { json: true, human: stateless(formatStartResult) }, parameters: { positional: { kind: "tuple" as const, diff --git a/src/lib/command.ts b/src/lib/command.ts index 2474ecc6b..aa80820cd 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -44,6 +44,7 @@ import { type CommandOutput, type CommandReturn, commandOutput, + type HumanRenderer, type OutputConfig, renderCommandOutput, writeFooter, @@ -342,10 +343,12 @@ export function buildCommand< function handleYieldedValue( stdout: Writer, value: unknown, - flags: Record + flags: Record, + // biome-ignore lint/suspicious/noExplicitAny: Renderer type mirrors erased OutputConfig + renderer?: HumanRenderer ): void { if ( - !outputConfig || + !(outputConfig && renderer) || value === null || value === undefined || value instanceof Error || @@ -354,7 +357,7 @@ export function buildCommand< return; } - renderCommandOutput(stdout, value.data, outputConfig, { + renderCommandOutput(stdout, value.data, outputConfig, renderer, { json: Boolean(flags.json), fields: flags.fields as string[] | undefined, }); @@ -385,6 +388,32 @@ export function buildCommand< return clean; } + /** + * Write post-generator output: either the renderer's `finalize()` result + * or the default `writeFooter(hint)`. Suppressed in JSON mode. + */ + function writeFinalization( + stdout: Writer, + hint: string | undefined, + json: unknown, + // biome-ignore lint/suspicious/noExplicitAny: Renderer type mirrors erased OutputConfig + renderer?: HumanRenderer + ): void { + if (json) { + return; + } + if (renderer?.finalize) { + const text = renderer.finalize(hint); + if (text) { + stdout.write(text); + } + return; + } + if (hint) { + writeFooter(stdout, hint); + } + } + // Wrap func to intercept logging flags, capture telemetry, then call original. // The wrapper is an async function that iterates the generator returned by func. const wrappedFunc = async function ( @@ -405,6 +434,10 @@ export function buildCommand< const stdout = (this as unknown as { stdout: Writer }).stdout; + // Resolve the human renderer once per invocation. Factory creates + // fresh per-invocation state for streaming commands. + const renderer = outputConfig ? outputConfig.human() : undefined; + // OutputError handler: render data through the output system, then // exit with the error's code. Stricli overwrites process.exitCode = 0 // after successful returns, so process.exit() is the only way to @@ -414,7 +447,12 @@ export function buildCommand< if (err instanceof OutputError && outputConfig) { // Only render if there's actual data to show if (err.data !== null && err.data !== undefined) { - handleYieldedValue(stdout, commandOutput(err.data), cleanFlags); + handleYieldedValue( + stdout, + commandOutput(err.data), + cleanFlags, + renderer + ); } process.exit(err.exitCode); } @@ -432,16 +470,14 @@ export function buildCommand< ); let result = await generator.next(); while (!result.done) { - handleYieldedValue(stdout, result.value, cleanFlags); + handleYieldedValue(stdout, result.value, cleanFlags, renderer); result = await generator.next(); } - // Render post-output hint from the generator's return value. - // Only rendered in human mode — JSON output is self-contained. + // Finalize: let the renderer close streaming state (e.g., table + // footer), or fall back to the default writeFooter for the hint. const returned = result.value as CommandReturn | undefined; - if (returned?.hint && !cleanFlags.json) { - writeFooter(stdout, returned.hint); - } + writeFinalization(stdout, returned?.hint, cleanFlags.json, renderer); } catch (err) { handleOutputError(err); } diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index a99a92e1b..ec11a5bd8 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -57,6 +57,52 @@ type WriteOutputOptions = { // Return-based output config (declared on buildCommand) // --------------------------------------------------------------------------- +/** + * Stateful human renderer created once per command invocation. + * + * The wrapper calls `render()` once per yielded value and `finalize()` + * once after the generator completes. This enables streaming commands + * to maintain per-invocation rendering state (e.g., a table that needs + * a header on first call and a footer on last). + * + * For stateless commands, `finalize` can be omitted — the wrapper falls + * back to `writeFooter(hint)`. + * + * @typeParam T - The data type yielded by the command + */ +export type HumanRenderer = { + /** Render a single yielded data chunk as human-readable text. */ + render: (data: T) => string; + /** + * Called once after the generator completes. Returns the final output + * string (e.g., a streaming table's bottom border + formatted hint). + * + * When defined, replaces the default `writeFooter(hint)` behavior — + * the wrapper writes the returned string directly. + * + * When absent, the wrapper falls back to `writeFooter(hint)`. + */ + finalize?: (hint?: string) => string; +}; + +/** + * Create a stateless {@link HumanRenderer} from a plain formatter function. + * + * Most commands don't need per-invocation state — use this helper to wrap + * a simple `(data: T) => string` function into the renderer interface. + * + * @example + * ```ts + * output: { + * json: true, + * human: stateless(formatMyData), + * } + * ``` + */ +export function stateless(fn: (data: T) => string): () => HumanRenderer { + return () => ({ render: fn }); +} + /** * Output configuration declared on `buildCommand` for automatic rendering. * @@ -65,18 +111,29 @@ type WriteOutputOptions = { * 1. **Flag-only** — `output: "json"` — injects `--json` and `--fields` flags * but does not intercept returns. Commands handle their own output. * - * 2. **Full config** — `output: { json: true, human: fn }` — injects flags + * 2. **Full config** — `output: { json: true, human: factory }` — injects flags * AND auto-renders the command's return value. Commands return * `{ data }` or `{ data, hint }` objects. * + * The `human` field is a **factory** called once per invocation to produce + * a {@link HumanRenderer}. Use {@link stateless} for simple formatters. + * * @typeParam T - Type of data the command returns (used by `human` formatter * and serialized as-is to JSON) */ export type OutputConfig = { /** Enable `--json` and `--fields` flag injection */ json: true; - /** Format data as a human-readable string for terminal output */ - human: (data: T) => string; + /** + * Factory that creates a {@link HumanRenderer} per invocation. + * + * Called once before the generator starts iterating. The returned + * renderer's `render()` is called per yield, and `finalize()` is + * called once after the generator completes. + * + * Use {@link stateless} to wrap a plain formatter function. + */ + human: () => HumanRenderer; /** * Top-level keys to strip from JSON output. * @@ -161,7 +218,14 @@ export function commandOutput(data: T): CommandOutput { * `hint` is shown in human mode and suppressed in JSON mode. */ export type CommandReturn = { - /** Hint line appended after all output (suppressed in JSON mode) */ + /** + * Hint line appended after all output (suppressed in JSON mode). + * + * When the renderer has a `finalize()` method, the hint is passed + * to it — the renderer decides how to render it alongside any + * cleanup output (e.g., table footer). Otherwise the wrapper writes + * it via `writeFooter()`. + */ hint?: string; }; @@ -282,28 +346,29 @@ function writeTransformedJson(stdout: Writer, transformed: unknown): void { } /** - * Render a single yielded `CommandOutput` chunk via an output config. - * - * Called by the `buildCommand` wrapper when a command with `output: { ... }` - * yields data. In JSON mode the data is serialized as-is (with optional - * field filtering); in human mode the config's `human` formatter is called. + * Render a single yielded `CommandOutput` chunk. * - * For streaming commands that yield multiple times, this function is called - * once per yielded value. Each call appends to stdout independently. + * Called by the `buildCommand` wrapper per yielded value. In JSON mode + * the data is serialized (with optional field filtering / transform); + * in human mode the resolved renderer's `render()` is called. * - * Hints are NOT rendered here — the wrapper renders them once after the - * generator completes, using the generator's return value. + * Hints are NOT rendered here — the wrapper calls `finalize()` or + * `writeFooter()` once after the generator completes. * * @param stdout - Writer to output to * @param data - The data yielded by the command * @param config - The output config declared on buildCommand + * @param renderer - Per-invocation renderer (from `config.human()`) * @param ctx - Rendering context with flag values */ +// biome-ignore lint/nursery/useMaxParams: Framework function — config/renderer/ctx are all required for JSON vs human split. export function renderCommandOutput( stdout: Writer, data: unknown, - // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — config.human is contravariant in T but data/config are paired at build time. Using `any` lets the framework call human(unknownData) without requiring every OutputConfig to accept unknown. + // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — config/renderer are paired at build time, but the framework iterates over unknown yields. config: OutputConfig, + // biome-ignore lint/suspicious/noExplicitAny: Renderer type mirrors erased OutputConfig + renderer: HumanRenderer, ctx: RenderContext ): void { if (ctx.json) { @@ -315,7 +380,7 @@ export function renderCommandOutput( return; } - const text = config.human(data); + const text = renderer.render(data); if (text) { stdout.write(`${text}\n`); } diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index dd00cf7a5..d02493a38 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -23,6 +23,7 @@ import { type CommandReturn, commandOutput, type OutputConfig, + stateless, } from "./formatters/output.js"; import { dispatchOrgScopedList, @@ -474,7 +475,9 @@ export function buildOrgListCommand( docs, output: { json: true, - human: (result: ListResult) => formatListHuman(result, config), + human: stateless((result: ListResult) => + formatListHuman(result, config) + ), jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), } satisfies OutputConfig>, diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 1b295a0a7..594c10b08 100644 --- a/test/lib/formatters/output.test.ts +++ b/test/lib/formatters/output.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { type OutputConfig, renderCommandOutput, + stateless, writeFooter, writeOutput, } from "../../../src/lib/formatters/output.js"; @@ -22,6 +23,20 @@ function createTestWriter() { }; } +/** + * Test helper: calls renderCommandOutput with a fresh renderer resolved + * from the config. Mirrors the real wrapper's per-invocation resolve. + */ +function render( + w: ReturnType, + data: unknown, + config: OutputConfig, + ctx: { json: boolean; fields?: string[] } +) { + const renderer = config.human(); + renderCommandOutput(w, data, config, renderer, ctx); +} + describe("writeOutput", () => { describe("json mode", () => { test("writes JSON with fields filtering", () => { @@ -190,9 +205,9 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string }> = { json: true, - human: (d) => `${d.name}`, + human: stateless((d) => `${d.name}`), }; - renderCommandOutput(w, { id: 1, name: "Alice" }, config, { json: true }); + render(w, { id: 1, name: "Alice" }, config, { json: true }); expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); }); @@ -200,9 +215,9 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ name: string }> = { json: true, - human: (d) => `Hello ${d.name}`, + human: stateless((d) => `Hello ${d.name}`), }; - renderCommandOutput(w, { name: "Alice" }, config, { json: false }); + render(w, { name: "Alice" }, config, { json: false }); expect(w.output).toBe("Hello Alice\n"); }); @@ -210,9 +225,9 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; secret: string }> = { json: true, - human: () => "unused", + human: stateless(() => "unused"), }; - renderCommandOutput(w, { id: 1, name: "Alice", secret: "x" }, config, { + render(w, { id: 1, name: "Alice", secret: "x" }, config, { json: true, fields: ["id", "name"], }); @@ -223,11 +238,11 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig = { json: true, - human: () => "Result", + human: stateless(() => "Result"), }; // renderCommandOutput only renders data — hints are handled by // buildCommand's wrapper via the generator return value - renderCommandOutput(w, "data", config, { json: false }); + render(w, "data", config, { json: false }); expect(w.output).toBe("Result\n"); }); @@ -235,9 +250,9 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ value: number }> = { json: true, - human: (d) => `Value: ${d.value}`, + human: stateless((d) => `Value: ${d.value}`), }; - renderCommandOutput(w, { value: 42 }, config, { json: false }); + render(w, { value: 42 }, config, { json: false }); expect(w.output).toBe("Value: 42\n"); }); @@ -249,10 +264,10 @@ describe("renderCommandOutput", () => { spanTreeLines?: string[]; }> = { json: true, - human: (d) => `${d.id}: ${d.name}`, + human: stateless((d) => `${d.id}: ${d.name}`), jsonExclude: ["spanTreeLines"], }; - renderCommandOutput( + render( w, { id: 1, name: "Alice", spanTreeLines: ["line1", "line2"] }, config, @@ -270,16 +285,14 @@ describe("renderCommandOutput", () => { spanTreeLines?: string[]; }> = { json: true, - human: (d) => - `${d.id}\n${d.spanTreeLines ? d.spanTreeLines.join("\n") : ""}`, + human: stateless( + (d) => `${d.id}\n${d.spanTreeLines ? d.spanTreeLines.join("\n") : ""}` + ), jsonExclude: ["spanTreeLines"], }; - renderCommandOutput( - w, - { id: 1, spanTreeLines: ["line1", "line2"] }, - config, - { json: false } - ); + render(w, { id: 1, spanTreeLines: ["line1", "line2"] }, config, { + json: false, + }); expect(w.output).toContain("line1"); expect(w.output).toContain("line2"); }); @@ -288,10 +301,10 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; extra: string }> = { json: true, - human: (d) => `${d.id}`, + human: stateless((d) => `${d.id}`), jsonExclude: [], }; - renderCommandOutput(w, { id: 1, extra: "keep" }, config, { json: true }); + render(w, { id: 1, extra: "keep" }, config, { json: true }); const parsed = JSON.parse(w.output); expect(parsed).toEqual({ id: 1, extra: "keep" }); }); @@ -300,11 +313,12 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig = { json: true, - human: (d: { id: number; name: string }[]) => - d.map((e) => e.name).join(", "), + human: stateless((d: { id: number; name: string }[]) => + d.map((e) => e.name).join(", ") + ), jsonExclude: ["detectedFrom"], }; - renderCommandOutput( + render( w, [ { id: 1, name: "a", detectedFrom: "dsn" }, @@ -330,13 +344,13 @@ describe("renderCommandOutput", () => { }; const config: OutputConfig = { json: true, - human: (d) => d.items.map((i) => i.name).join(", "), + human: stateless((d) => d.items.map((i) => i.name).join(", ")), jsonTransform: (data) => ({ data: data.items, hasMore: data.hasMore, }), }; - renderCommandOutput( + render( w, { items: [{ id: 1, name: "Alice" }], hasMore: true, org: "test-org" }, config, @@ -359,7 +373,7 @@ describe("renderCommandOutput", () => { }; const config: OutputConfig = { json: true, - human: () => "unused", + human: stateless(() => "unused"), jsonTransform: (data, fields) => ({ data: fields && fields.length > 0 @@ -376,7 +390,7 @@ describe("renderCommandOutput", () => { hasMore: data.hasMore, }), }; - renderCommandOutput( + render( w, { items: [{ id: 1, name: "Alice", secret: "x" }], @@ -394,10 +408,10 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ items: string[]; org: string }> = { json: true, - human: (d) => `${d.org}: ${d.items.join(", ")}`, + human: stateless((d) => `${d.org}: ${d.items.join(", ")}`), jsonTransform: (data) => ({ data: data.items }), }; - renderCommandOutput(w, { items: ["a", "b"], org: "test-org" }, config, { + render(w, { items: ["a", "b"], org: "test-org" }, config, { json: false, }); expect(w.output).toBe("test-org: a, b\n"); @@ -407,16 +421,13 @@ describe("renderCommandOutput", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; extra: string }> = { json: true, - human: () => "unused", + human: stateless(() => "unused"), jsonExclude: ["extra"], jsonTransform: (data) => ({ transformed: true, id: data.id }), }; - renderCommandOutput( - w, - { id: 1, name: "Alice", extra: "kept-by-transform" }, - config, - { json: true } - ); + render(w, { id: 1, name: "Alice", extra: "kept-by-transform" }, config, { + json: true, + }); const parsed = JSON.parse(w.output); // jsonTransform output, not jsonExclude expect(parsed).toEqual({ transformed: true, id: 1 }); From 792fe0eaecd6b6b74a3f650e5a6cb7e74e78157a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:43:06 +0000 Subject: [PATCH 08/25] refactor(log/list): use HumanRenderer factory, remove module-level state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the module-level streaming table singleton with a per-invocation HumanRenderer factory (createLogRenderer). The factory creates fresh StreamingTable state and returns render() + finalize() callbacks. render() handles all yields uniformly — both single-fetch and follow mode. It emits the table header on the first non-empty batch and rows per batch. finalize(hint?) emits the table footer (when headers were emitted) and appends the hint text. This replaces the empty-batch sentinel pattern where `{ logs: [], streaming: true }` was yielded to trigger the table footer. Other changes: - Remove `streaming` flag from LogListResult (was rendering concern) - Add `jsonl` flag for JSON serialization mode (JSONL vs array) - Remove `hint` from LogListResult (moves to CommandReturn) - executeSingleFetch/executeTraceSingleFetch return FetchResult with separate `result` and `hint` fields - Remove resetStreamingState(), yieldFollowBatches sentinel - Fix duplicate JSDoc on writeFollowBanner --- src/commands/log/list.ts | 224 +++++++++++++++++---------------------- 1 file changed, 99 insertions(+), 125 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 23f8fe072..2becca70f 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -17,7 +17,6 @@ import { createLogStreamingTable, formatLogRow, formatLogsHeader, - formatLogTable, isPlainOutput, } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; @@ -25,8 +24,9 @@ import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import { type CommandOutput, commandOutput, + formatFooter, + type HumanRenderer, jsonlLines, - stateless, } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { @@ -53,18 +53,23 @@ type ListFlags = { readonly fields?: string[]; }; -/** Result yielded by the log list command — one per batch. */ +/** + * Result yielded by the log list command — one per batch. + * + * Both single-fetch and follow mode yield the same type. The human + * renderer always renders incrementally (header on first non-empty + * batch, rows per batch, footer via `finalize()`). + */ type LogListResult = { logs: LogLike[]; - /** Human-readable hint (e.g., "Showing 100 logs. Use --limit to show more.") */ - hint?: string; /** Trace ID, present for trace-filtered queries */ traceId?: string; /** - * When true, this result is one batch in a follow-mode stream. - * Formatters use this to switch between full-table and incremental rendering. + * When true, JSON output uses JSONL (one object per line) instead + * of a JSON array. Set for follow-mode batches where output is + * consumed incrementally. Does not affect human rendering. */ - streaming?: boolean; + jsonl?: boolean; }; /** Maximum allowed value for --limit flag */ @@ -120,22 +125,23 @@ type LogLike = { trace?: string | null; }; +/** Result from a single fetch: logs to yield + hint for the footer. */ +type FetchResult = { + result: LogListResult; + hint: string; +}; + /** * Execute a single fetch of logs (non-streaming mode). * - * Returns the fetched logs and a human-readable hint. The caller - * (via the output config) handles rendering to stdout. + * Returns the logs and a hint. The caller yields the result and + * returns the hint as a footer via `CommandReturn`. */ -type SingleFetchOptions = { - org: string; - project: string; - flags: ListFlags; -}; - async function executeSingleFetch( - options: SingleFetchOptions -): Promise { - const { org, project, flags } = options; + org: string, + project: string, + flags: ListFlags +): Promise { const logs = await listLogs(org, project, { query: flags.query, limit: flags.limit, @@ -143,7 +149,7 @@ async function executeSingleFetch( }); if (logs.length === 0) { - return { logs: [], hint: "No logs found." }; + return { result: { logs: [] }, hint: "No logs found." }; } // Reverse for chronological order (API returns newest first, tail shows oldest first) @@ -153,7 +159,7 @@ async function executeSingleFetch( const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"}.`; const tip = hasMore ? " Use --limit to show more, or -f to follow." : ""; - return { logs: chronological, hint: `${countText}${tip}` }; + return { result: { logs: chronological }, hint: `${countText}${tip}` }; } // --------------------------------------------------------------------------- @@ -278,8 +284,8 @@ async function fetchPoll( * Async generator that streams log entries via follow-mode polling. * * Yields batches of log entries (chronological order). The command wraps - * each batch in a `LogListResult` with `streaming: true` so the OutputConfig - * formatters can handle incremental rendering vs JSONL expansion. + * each batch in a `LogListResult` so the OutputConfig formatters can + * handle incremental rendering and JSONL expansion. * * The generator handles SIGINT via AbortController for clean shutdown. * It never touches stdout — all data output flows through yielded batches @@ -330,19 +336,17 @@ async function* generateFollowLogs( } /** - * Consume a follow-mode generator, yielding `LogListResult` batches - * with `streaming: true`. Emits a final empty batch so the human - * formatter can close the streaming table. + * Consume a follow-mode generator, yielding `LogListResult` batches. + * The generator returns when SIGINT fires — the wrapper's `finalize()` + * callback handles closing the streaming table. */ async function* yieldFollowBatches( generator: AsyncGenerator, extra?: Partial ): AsyncGenerator, void, undefined> { for await (const batch of generator) { - yield commandOutput({ logs: batch, streaming: true, ...extra }); + yield commandOutput({ logs: batch, jsonl: true, ...extra }); } - // Final empty batch signals end-of-stream to the human formatter - yield commandOutput({ logs: [], streaming: true, ...extra }); } /** Default time period for trace-logs queries */ @@ -355,16 +359,11 @@ const DEFAULT_TRACE_PERIOD = "14d"; * Returns the fetched logs, trace ID, and a human-readable hint. * The caller (via the output config) handles rendering to stdout. */ -type TraceSingleFetchOptions = { - org: string; - traceId: string; - flags: ListFlags; -}; - async function executeTraceSingleFetch( - options: TraceSingleFetchOptions -): Promise { - const { org, traceId, flags } = options; + org: string, + traceId: string, + flags: ListFlags +): Promise { const logs = await listTraceLogs(org, traceId, { query: flags.query, limit: flags.limit, @@ -373,8 +372,7 @@ async function executeTraceSingleFetch( if (logs.length === 0) { return { - logs: [], - traceId, + result: { logs: [], traceId }, hint: `No logs found for trace ${traceId} in the last ${DEFAULT_TRACE_PERIOD}.\n\n` + "Try 'sentry trace logs' for more options (e.g., --period 30d).", @@ -387,13 +385,12 @@ async function executeTraceSingleFetch( const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"} for trace ${traceId}.`; const tip = hasMore ? " Use --limit to show more." : ""; - return { logs: chronological, traceId, hint: `${countText}${tip}` }; + return { + result: { logs: chronological, traceId }, + hint: `${countText}${tip}`, + }; } -/** - * Write the follow-mode banner via logger. Suppressed in JSON mode. - * Includes poll interval, Ctrl+C hint, and update notification. - */ /** * Write the follow-mode banner via logger. Suppressed in JSON mode * to avoid stderr noise when agents consume JSONL output. @@ -418,74 +415,67 @@ function writeFollowBanner( // Output formatting // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Stateful streaming table — module-level singleton, reset per follow run. -// Safe because CLI processes are single-use (one invocation per process). -// --------------------------------------------------------------------------- - -let streamingTable: StreamingTable | undefined; -let streamingHeaderEmitted = false; - /** - * Reset the streaming table state. Called at the start of each follow-mode run. - */ -function resetStreamingState(): void { - const plain = isPlainOutput(); - streamingTable = plain ? undefined : createLogStreamingTable(); - streamingHeaderEmitted = false; -} - -/** - * Format log output as human-readable terminal text. + * Create a stateful human renderer for log list output. * - * - **Batch mode** (`streaming` absent/false): renders a complete table. - * - **Streaming mode** (`streaming: true`): renders incremental rows using - * a stateful {@link StreamingTable}, including the header on first call. + * The factory is called once per command invocation. The returned renderer + * tracks streaming table state (header emitted, table instance) and cleans + * up via `finalize()`. * - * The returned string omits a trailing newline — the output framework - * appends one automatically. + * All yields go through `render()` — both single-fetch and follow mode. + * The renderer emits the table header on the first non-empty batch, rows + * per batch, and the table footer + hint via `finalize()`. */ -function formatLogOutput(result: LogListResult): string { - if (result.streaming) { - const includeTrace = !result.traceId; - let text = ""; - - if (result.logs.length === 0) { - // Empty batch signals end of stream — emit table footer - if (streamingTable && streamingHeaderEmitted) { - text += streamingTable.footer(); +function createLogRenderer(): HumanRenderer { + const plain = isPlainOutput(); + const table: StreamingTable | undefined = plain + ? undefined + : createLogStreamingTable(); + let headerEmitted = false; + + return { + render(result: LogListResult): string { + if (result.logs.length === 0) { + return ""; } + + const includeTrace = !result.traceId; + let text = ""; + + // Emit header on first non-empty batch + if (!headerEmitted) { + text += table ? table.header() : formatLogsHeader(); + headerEmitted = true; + } + + text += renderLogRows(result.logs, includeTrace, table); return text.trimEnd(); - } + }, - // Emit header on first non-empty batch - if (!streamingHeaderEmitted) { - text += streamingTable ? streamingTable.header() : formatLogsHeader(); - streamingHeaderEmitted = true; - } + finalize(hint?: string): string { + let text = ""; - text += renderLogRows(result.logs, includeTrace, streamingTable); - return text.trimEnd(); - } + // Close the streaming table if header was emitted + if (headerEmitted && table) { + text += table.footer(); + } - // Batch: complete table - if (result.logs.length === 0) { - return result.hint ?? "No logs found."; - } - const includeTrace = !result.traceId; - return formatLogTable(result.logs, includeTrace).trimEnd(); + // Append hint (count, pagination, empty-state message) + if (hint) { + text += `${text ? "\n" : ""}${formatFooter(hint)}\n`; + } + + return text; + }, + }; } /** * Transform log output into the JSON shape. * - * - **Batch mode** (`streaming` absent/false): returns the full logs array. - * - **Streaming mode** (`streaming: true`): returns individual log objects - * so the framework writes one JSON object per line (JSONL). - * - * When the result contains a single log entry in streaming mode, it's - * returned unwrapped. Multiple entries return an array (each call from - * the wrapper writes one line to stdout). + * - **Single-fetch** (`jsonl` absent/false): returns a JSON array. + * - **Follow mode** (`jsonl: true`): returns JSONL-wrapped objects + * so the framework writes one JSON line per log entry. */ function jsonTransformLogOutput( result: LogListResult, @@ -494,16 +484,12 @@ function jsonTransformLogOutput( const applyFields = (log: LogLike) => fields && fields.length > 0 ? filterFields(log, fields) : log; - if (result.streaming) { - // Streaming: expand to JSONL (one JSON object per line) - if (result.logs.length === 0) { - return; - } - return jsonlLines(result.logs.map(applyFields)); + if (result.logs.length === 0) { + return; } - // Batch: return full array - return result.logs.map(applyFields); + const mapped = result.logs.map(applyFields); + return result.jsonl ? jsonlLines(mapped) : mapped; } export const listCommand = buildListCommand("log", { @@ -530,7 +516,7 @@ export const listCommand = buildListCommand("log", { }, output: { json: true, - human: stateless(formatLogOutput), + human: createLogRenderer, jsonTransform: jsonTransformLogOutput, }, parameters: { @@ -602,7 +588,6 @@ export const listCommand = buildListCommand("log", { if (flags.follow) { const traceId = flags.trace; - resetStreamingState(); // Banner (suppressed in JSON mode) writeFollowBanner( flags.follow ?? DEFAULT_POLL_INTERVAL, @@ -647,14 +632,11 @@ export const listCommand = buildListCommand("log", { return; } - const result = await executeTraceSingleFetch({ + const { result, hint } = await executeTraceSingleFetch( org, - traceId: flags.trace, - flags, - }); - // Only forward hint to the footer when items exist — empty results - // already render hint text inside the human formatter. - const hint = result.logs.length > 0 ? result.hint : undefined; + flags.trace, + flags + ); yield commandOutput(result); return { hint }; } @@ -670,7 +652,6 @@ export const listCommand = buildListCommand("log", { setContext([org], [project]); if (flags.follow) { - resetStreamingState(); writeFollowBanner( flags.follow ?? DEFAULT_POLL_INTERVAL, "Streaming logs...", @@ -694,14 +675,7 @@ export const listCommand = buildListCommand("log", { return; } - const result = await executeSingleFetch({ - org, - project, - flags, - }); - // Only forward hint to the footer when items exist — empty results - // already render hint text inside the human formatter. - const hint = result.logs.length > 0 ? result.hint : undefined; + const { result, hint } = await executeSingleFetch(org, project, flags); yield commandOutput(result); return { hint }; } From bfc89fb3efae2e8363b3e21e2cbc80b846b68803 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:44:41 +0000 Subject: [PATCH 09/25] test: add tests for HumanRenderer factory and finalize() - Factory creates fresh renderer per config.human() call - finalize(hint) returns combined footer + hint string - stateless() wrapper has no finalize method --- test/lib/formatters/output.test.ts | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 594c10b08..9d742bcf2 100644 --- a/test/lib/formatters/output.test.ts +++ b/test/lib/formatters/output.test.ts @@ -432,4 +432,58 @@ describe("renderCommandOutput", () => { // jsonTransform output, not jsonExclude expect(parsed).toEqual({ transformed: true, id: 1 }); }); + + test("human factory creates fresh renderer per resolve", () => { + const calls: number[] = []; + const config: OutputConfig<{ n: number }> = { + json: true, + human: () => ({ + render: (d) => { + calls.push(d.n); + return `#${d.n}`; + }, + }), + }; + + // First resolve + render + const r1 = config.human(); + r1.render({ n: 1 }); + + // Second resolve = fresh renderer + const r2 = config.human(); + r2.render({ n: 2 }); + + expect(calls).toEqual([1, 2]); + }); + + test("finalize is called with hint and output is written", () => { + const w = createTestWriter(); + const config: OutputConfig<{ value: string }> = { + json: true, + human: () => ({ + render: (d) => `[${d.value}]`, + finalize: (hint) => `=== END ===${hint ? `\n${hint}` : ""}`, + }), + }; + + const renderer = config.human(); + renderCommandOutput(w, { value: "test" }, config, renderer, { + json: false, + }); + expect(w.output).toBe("[test]\n"); + + // Simulate finalize + const footer = renderer.finalize?.("Done."); + expect(footer).toBe("=== END ===\nDone."); + }); + + test("stateless renderer has no finalize method", () => { + const config: OutputConfig = { + json: true, + human: stateless((s) => s.toUpperCase()), + }; + const renderer = config.human(); + expect(renderer.render("hello")).toBe("HELLO"); + expect(renderer.finalize).toBeUndefined(); + }); }); From c2ca94cd73612ca439d4d3e1b87a06298f3651eb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:15:16 +0000 Subject: [PATCH 10/25] fix: update command.test.ts for stateless() wrapper Missing stateless import and wrapping for OutputConfig.human in buildCommand test suite. Pre-existing help.test.ts failures (5) are not related to this change. --- test/lib/command.test.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 33b071a74..259829e72 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -25,7 +25,7 @@ import { VERBOSE_FLAG, } from "../../src/lib/command.js"; import { OutputError } from "../../src/lib/errors.js"; -import { commandOutput } from "../../src/lib/formatters/output.js"; +import { commandOutput, stateless } from "../../src/lib/formatters/output.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; /** Minimal context for test commands */ @@ -997,7 +997,9 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, + human: stateless( + (d: { name: string; role: string }) => `${d.name} (${d.role})` + ), }, parameters: {}, async *func(this: TestContext) { @@ -1026,7 +1028,9 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, + human: stateless( + (d: { name: string; role: string }) => `${d.name} (${d.role})` + ), }, parameters: {}, async *func(this: TestContext) { @@ -1056,7 +1060,9 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { id: number; name: string; role: string }) => `${d.name}`, + human: stateless( + (d: { id: number; name: string; role: string }) => `${d.name}` + ), }, parameters: {}, async *func(this: TestContext) { @@ -1088,7 +1094,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { value: number }) => `Value: ${d.value}`, + human: stateless((d: { value: number }) => `Value: ${d.value}`), }, parameters: {}, async *func(this: TestContext) { @@ -1136,7 +1142,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: () => "unused", + human: stateless(() => "unused"), }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -1194,7 +1200,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { name: string }) => `Hello, ${d.name}!`, + human: stateless((d: { name: string }) => `Hello, ${d.name}!`), }, parameters: {}, async *func(this: TestContext) { @@ -1225,7 +1231,9 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: Array<{ id: number }>) => d.map((x) => x.id).join(", "), + human: stateless((d: Array<{ id: number }>) => + d.map((x) => x.id).join(", ") + ), }, parameters: {}, async *func(this: TestContext) { @@ -1255,7 +1263,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { org: string }) => `Org: ${d.org}`, + human: stateless((d: { org: string }) => `Org: ${d.org}`), }, parameters: {}, async *func(this: TestContext) { @@ -1297,7 +1305,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { error: string }) => `Error: ${d.error}`, + human: stateless((d: { error: string }) => `Error: ${d.error}`), }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -1349,7 +1357,7 @@ describe("buildCommand return-based output", () => { docs: { brief: "Test" }, output: { json: true, - human: (d: { error: string }) => `Error: ${d.error}`, + human: stateless((d: { error: string }) => `Error: ${d.error}`), }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield From afd68bac52c30270b418b9cc2ef327cddf46e0f4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:23:23 +0000 Subject: [PATCH 11/25] fix: address review findings from BugBot and Seer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. formatTraceLogs: add trailing newline to JSON output (log.ts) 2. output.ts: fix orphaned JSDoc — move writeFooter docs to writeFooter, leave formatFooter with its own one-liner 3. jsonTransformLogOutput: return [] for empty single-fetch, undefined only for empty follow-mode batches (log/list.ts) 4. finalize: remove extra trailing newline after formatFooter (log/list.ts) 5. command.ts: move writeFinalization to finally block so streaming table footer is written even on mid-stream errors 6. trial/start.ts: check isatty(2) instead of isatty(1) since prompts display on stderr after migration --- src/commands/log/list.ts | 6 ++++-- src/commands/trial/start.ts | 4 ++-- src/lib/command.ts | 10 +++++++--- src/lib/formatters/log.ts | 2 +- src/lib/formatters/output.ts | 11 ++++------- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 2becca70f..1601a7509 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -462,7 +462,7 @@ function createLogRenderer(): HumanRenderer { // Append hint (count, pagination, empty-state message) if (hint) { - text += `${text ? "\n" : ""}${formatFooter(hint)}\n`; + text += `${text ? "\n" : ""}${formatFooter(hint)}`; } return text; @@ -485,7 +485,9 @@ function jsonTransformLogOutput( fields && fields.length > 0 ? filterFields(log, fields) : log; if (result.logs.length === 0) { - return; + // Follow mode: suppress empty batches (no JSONL output) + // Single-fetch: return empty array for valid JSON + return result.jsonl ? undefined : []; } const mapped = result.logs.map(applyFields); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 84f744d1b..2db1e5beb 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -191,8 +191,8 @@ async function promptOpenBillingUrl(url: string): Promise { const qr = await generateQRCode(url); log.log(qr); - // Prompt to open browser if interactive TTY - if (isatty(0) && isatty(1)) { + // Prompt to open browser if interactive TTY (stdin for input, stderr for display) + if (isatty(0) && isatty(2)) { const confirmed = await log.prompt("Open in browser?", { type: "confirm", initial: true, diff --git a/src/lib/command.ts b/src/lib/command.ts index aa80820cd..6231bb84a 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -462,6 +462,7 @@ 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. + let hint: string | undefined; try { const generator = originalFunc.call( this, @@ -474,12 +475,15 @@ export function buildCommand< result = await generator.next(); } - // Finalize: let the renderer close streaming state (e.g., table - // footer), or fall back to the default writeFooter for the hint. const returned = result.value as CommandReturn | undefined; - writeFinalization(stdout, returned?.hint, cleanFlags.json, renderer); + hint = returned?.hint; } catch (err) { handleOutputError(err); + } finally { + // Always finalize: close streaming state (e.g., table footer) + // even if the generator threw. Without this, a mid-stream error + // leaves partial output (e.g., table without bottom border). + writeFinalization(stdout, hint, cleanFlags.json, renderer); } }; diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 61fc0dbf5..e030949be 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -349,7 +349,7 @@ export function formatTraceLogs(options: FormatTraceLogsOptions): string { if (asJson) { const reversed = [...logs].reverse(); - return formatJson(fields ? filterFields(reversed, fields) : reversed); + return `${formatJson(fields ? filterFields(reversed, fields) : reversed)}\n`; } if (logs.length === 0) { diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index ec11a5bd8..b20bb0339 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -419,18 +419,15 @@ export function writeOutput( } } -/** - * Write a formatted footer hint to stdout. - * Adds empty line separator and applies muted styling. - * - * @param stdout - Writer to output to - * @param text - Footer text to display - */ /** Format footer text (muted, with surrounding newlines). */ export function formatFooter(text: string): string { return `\n${muted(text)}\n`; } +/** + * Write a formatted footer hint to stdout. + * Adds empty line separator and applies muted styling. + */ export function writeFooter(stdout: Writer, text: string): void { stdout.write(formatFooter(text)); } From 3c07efcc4e3734feb94b9544f36285a9d45fa053 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:28:17 +0000 Subject: [PATCH 12/25] fix: update help.test.ts for printCustomHelp return-based API printCustomHelp now returns a string instead of accepting a Writer. Update tests to call without arguments and assert on return value. --- test/lib/help.test.ts | 59 +++++-------------------------------------- 1 file changed, 7 insertions(+), 52 deletions(-) diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts index 498ad598f..87f5a7ba5 100644 --- a/test/lib/help.test.ts +++ b/test/lib/help.test.ts @@ -38,45 +38,18 @@ describe("formatBanner", () => { describe("printCustomHelp", () => { useTestConfigDir("help-test-"); - test("writes output to the provided writer", async () => { - const chunks: string[] = []; - const writer = { - write: (s: string) => { - chunks.push(s); - return true; - }, - }; - - await printCustomHelp(writer); - expect(chunks.length).toBeGreaterThan(0); - expect(chunks.join("").length).toBeGreaterThan(0); + test("returns non-empty string", async () => { + const output = await printCustomHelp(); + expect(output.length).toBeGreaterThan(0); }); test("output contains the tagline", async () => { - const chunks: string[] = []; - const writer = { - write: (s: string) => { - chunks.push(s); - return true; - }, - }; - - await printCustomHelp(writer); - const output = stripAnsi(chunks.join("")); + const output = stripAnsi(await printCustomHelp()); expect(output).toContain("The command-line interface for Sentry"); }); test("output contains registered commands", async () => { - const chunks: string[] = []; - const writer = { - write: (s: string) => { - chunks.push(s); - return true; - }, - }; - - await printCustomHelp(writer); - const output = stripAnsi(chunks.join("")); + const output = stripAnsi(await printCustomHelp()); // Should include at least some core commands from routes expect(output).toContain("sentry"); @@ -87,31 +60,13 @@ describe("printCustomHelp", () => { }); test("output contains docs URL", async () => { - const chunks: string[] = []; - const writer = { - write: (s: string) => { - chunks.push(s); - return true; - }, - }; - - await printCustomHelp(writer); - const output = stripAnsi(chunks.join("")); + const output = stripAnsi(await printCustomHelp()); expect(output).toContain("cli.sentry.dev"); }); test("shows login example when not authenticated", async () => { // useTestConfigDir provides a clean env with no auth token - const chunks: string[] = []; - const writer = { - write: (s: string) => { - chunks.push(s); - return true; - }, - }; - - await printCustomHelp(writer); - const output = stripAnsi(chunks.join("")); + const output = stripAnsi(await printCustomHelp()); expect(output).toContain("sentry auth login"); }); }); From 945956a6324720cb599cb69f6c6d0f993702ae5f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:34:16 +0000 Subject: [PATCH 13/25] fix: apply filterFields per-element in formatTraceLogs filterFields expects a single object, not an array. Map over each log entry individually to apply field filtering correctly. --- src/lib/formatters/log.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index e030949be..f0fbe87a6 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -349,7 +349,10 @@ export function formatTraceLogs(options: FormatTraceLogsOptions): string { if (asJson) { const reversed = [...logs].reverse(); - return `${formatJson(fields ? filterFields(reversed, fields) : reversed)}\n`; + const data = fields + ? reversed.map((entry) => filterFields(entry, fields)) + : reversed; + return `${formatJson(data)}\n`; } if (logs.length === 0) { From 5c4c1b16cdc958c90029c32c70ed7fdda6db576f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:41:06 +0000 Subject: [PATCH 14/25] fix: finalization on error and empty-state hint rendering 1. command.ts: Move writeFinalization out of finally block. On error, finalize only in human mode (to close streaming table) before handleOutputError. Prevents corrupting JSON output or writing footer after process.exit(). 2. log/list.ts: When no logs were rendered (headerEmitted=false), render hint as primary text instead of muted footer. Preserves the 'No logs found.' UX from before the refactor. --- src/commands/log/list.ts | 9 +++++++-- src/lib/command.ts | 15 ++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 1601a7509..5e28fbad5 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -460,9 +460,14 @@ function createLogRenderer(): HumanRenderer { text += table.footer(); } - // Append hint (count, pagination, empty-state message) if (hint) { - text += `${text ? "\n" : ""}${formatFooter(hint)}`; + if (headerEmitted) { + // Logs were rendered — show hint as a muted footer + text += `${text ? "\n" : ""}${formatFooter(hint)}`; + } else { + // No logs rendered — show hint as primary output (e.g., "No logs found.") + text += `${hint}\n`; + } } return text; diff --git a/src/lib/command.ts b/src/lib/command.ts index 6231bb84a..ef82bb1cb 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -462,7 +462,6 @@ 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. - let hint: string | undefined; try { const generator = originalFunc.call( this, @@ -475,15 +474,17 @@ export function buildCommand< result = await generator.next(); } + // Generator completed successfully — finalize with hint. const returned = result.value as CommandReturn | undefined; - hint = returned?.hint; + writeFinalization(stdout, returned?.hint, cleanFlags.json, renderer); } catch (err) { + // Finalize before error handling to close streaming state + // (e.g., table footer). No hint since the generator didn't + // complete. Only in human mode — JSON must not be corrupted. + if (!cleanFlags.json) { + writeFinalization(stdout, undefined, false, renderer); + } handleOutputError(err); - } finally { - // Always finalize: close streaming state (e.g., table footer) - // even if the generator threw. Without this, a mid-stream error - // leaves partial output (e.g., table without bottom border). - writeFinalization(stdout, hint, cleanFlags.json, renderer); } }; From fc3e5942acd6fbe56d5f99b3e670d39cebb5b485 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 20:50:06 +0000 Subject: [PATCH 15/25] fix: address PR #416 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 18 review comments from BYK on the generator-commands PR: #2: Use result.user = user instead of cherry-picking fields (login.ts) #3: Remove process.stdin param from runInteractiveLogin (interactive-login.ts) #4: Use return yield pattern for one-liner yields (logout.ts) #5: Make OutputConfig.json optional (defaults to true) (output.ts) #6: Convert trace/logs.ts to yield commandOutput (trace/logs.ts) #7: Move URL/QR display through yield commandOutput (trial/start.ts) #8: Rename const log → const logger, import logger as log (trial/start.ts) #9: Convert help.ts to yield commandOutput (help.ts) #11: Replace Symbol branding with class + instanceof for CommandOutput (output.ts, command.ts) #12: Remove jsonlLines/JSONL mechanism — each yield becomes one JSON line (output.ts, log/list.ts) #15-#17: Replace polling dots with spinner in interactive login (interactive-login.ts) #18: Yield individual log items in follow mode (log/list.ts) Key changes: - CommandOutput is now a class using instanceof instead of Symbol branding - JSONL support removed — streaming commands yield items individually - trace/logs.ts uses OutputConfig with human/jsonTransform instead of manual stdout - help.ts yields through output framework - trial/start.ts handlePlanTrial is now an async generator yielding display data - interactive-login.ts uses consola spinner for polling, removes stdin param - OutputConfig.json is now optional (defaults to true when object form is used) Tests updated for trace/logs and trial/start to use context stdout. --- src/bin.ts | 2 +- src/commands/auth/login.ts | 9 +-- src/commands/auth/logout.ts | 6 +- src/commands/help.ts | 6 +- src/commands/log/list.ts | 39 +++++------ src/commands/trace/logs.ts | 74 +++++++++++++++++---- src/commands/trial/start.ts | 77 ++++++++++------------ src/lib/command.ts | 20 ++---- src/lib/formatters/output.ts | 106 ++++++------------------------ src/lib/interactive-login.ts | 32 ++++----- test/commands/trace/logs.test.ts | 55 ++++++++-------- test/commands/trial/start.test.ts | 53 ++++++--------- 12 files changed, 212 insertions(+), 267 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 28ba8ccac..b1e9c02a2 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -103,7 +103,7 @@ const autoAuthMiddleware: ErrorMiddleware = async (next, args) => { : "Authentication required. Starting login flow...\n\n" ); - const loginSuccess = await runInteractiveLogin(process.stdin); + const loginSuccess = await runInteractiveLogin(); if (loginSuccess) { process.stderr.write("\nRetrying command...\n\n"); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index bb1086c76..c9fda77cd 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -177,12 +177,7 @@ export const loginCommand = buildCommand({ username: user.username, name: user.name, }); - result.user = { - name: user.name, - email: user.email, - username: user.username, - id: user.id, - }; + result.user = user; } catch { // Non-fatal: user info is supplementary. Token remains stored and valid. } @@ -192,7 +187,7 @@ export const loginCommand = buildCommand({ } // OAuth device flow - const result = await runInteractiveLogin(process.stdin, { + const result = await runInteractiveLogin({ timeout: flags.timeout * 1000, }); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 822f3d7f2..23be9cf37 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -39,11 +39,10 @@ export const logoutCommand = buildCommand({ }, async *func(this: SentryContext) { if (!(await isAuthenticated())) { - yield commandOutput({ + return yield commandOutput({ loggedOut: false, message: "Not currently authenticated.", }); - return; } if (isEnvTokenActive()) { @@ -58,10 +57,9 @@ export const logoutCommand = buildCommand({ const configPath = getDbPath(); await clearAuth(); - yield commandOutput({ + return yield commandOutput({ loggedOut: true, configPath, }); - return; }, }); diff --git a/src/commands/help.ts b/src/commands/help.ts index 419191744..93bb13d01 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -9,6 +9,7 @@ import { run } from "@stricli/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; +import { commandOutput, stateless } from "../lib/formatters/output.js"; import { printCustomHelp } from "../lib/help.js"; export const helpCommand = buildCommand({ @@ -18,6 +19,7 @@ export const helpCommand = buildCommand({ "Display help information. Run 'sentry help' for an overview, " + "or 'sentry help ' for detailed help on a specific command.", }, + output: { json: true, human: stateless((s: string) => s) }, parameters: { flags: {}, positional: { @@ -30,12 +32,10 @@ export const helpCommand = buildCommand({ }, }, // biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags - // biome-ignore lint/correctness/useYield: void generator — delegates to Stricli help system async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) { // No args: show branded help if (commandPath.length === 0) { - process.stdout.write(await printCustomHelp()); - return; + return yield commandOutput(await printCustomHelp()); } // With args: re-invoke with --helpAll to show full help including hidden items diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 5e28fbad5..df435852d 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -22,11 +22,9 @@ import { import { filterFields } from "../../lib/formatters/json.js"; import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import { - type CommandOutput, commandOutput, formatFooter, type HumanRenderer, - jsonlLines, } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { @@ -64,12 +62,6 @@ type LogListResult = { logs: LogLike[]; /** Trace ID, present for trace-filtered queries */ traceId?: string; - /** - * When true, JSON output uses JSONL (one object per line) instead - * of a JSON array. Set for follow-mode batches where output is - * consumed incrementally. Does not affect human rendering. - */ - jsonl?: boolean; }; /** Maximum allowed value for --limit flag */ @@ -336,16 +328,22 @@ async function* generateFollowLogs( } /** - * Consume a follow-mode generator, yielding `LogListResult` batches. + * Consume a follow-mode generator, yielding each log individually. + * + * In JSON mode each yield becomes one JSONL line. In human mode the + * stateful renderer accumulates rows into the streaming table. + * * The generator returns when SIGINT fires — the wrapper's `finalize()` * callback handles closing the streaming table. */ async function* yieldFollowBatches( generator: AsyncGenerator, extra?: Partial -): AsyncGenerator, void, undefined> { +): AsyncGenerator { for await (const batch of generator) { - yield commandOutput({ logs: batch, jsonl: true, ...extra }); + for (const item of batch) { + yield commandOutput({ logs: [item], ...extra }); + } } } @@ -478,25 +476,22 @@ function createLogRenderer(): HumanRenderer { /** * Transform log output into the JSON shape. * - * - **Single-fetch** (`jsonl` absent/false): returns a JSON array. - * - **Follow mode** (`jsonl: true`): returns JSONL-wrapped objects - * so the framework writes one JSON line per log entry. + * Each yielded batch is written as a JSON array. In follow mode, + * each batch is a short array (one poll result); in single-fetch mode + * it's the full result set. Empty batches are suppressed. */ function jsonTransformLogOutput( result: LogListResult, fields?: string[] ): unknown { - const applyFields = (log: LogLike) => - fields && fields.length > 0 ? filterFields(log, fields) : log; - if (result.logs.length === 0) { - // Follow mode: suppress empty batches (no JSONL output) - // Single-fetch: return empty array for valid JSON - return result.jsonl ? undefined : []; + return; } - const mapped = result.logs.map(applyFields); - return result.jsonl ? jsonlLines(mapped) : mapped; + const applyFields = (log: LogLike) => + fields && fields.length > 0 ? filterFields(log, fields) : log; + + return result.logs.map(applyFields); } export const listCommand = buildListCommand("log", { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index b94609d82..927470974 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -10,7 +10,13 @@ import { validateLimit } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; -import { formatTraceLogs } from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { formatLogTable } from "../../lib/formatters/log.js"; +import { + commandOutput, + formatFooter, + stateless, +} from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -30,6 +36,34 @@ type LogsFlags = { readonly fields?: string[]; }; +/** Minimal log shape shared with the formatters. */ +type LogLike = { + timestamp: string; + severity?: string | null; + message?: string | null; + trace?: string | null; +}; + +/** Data yielded by the trace logs command. */ +type TraceLogsData = { + logs: LogLike[]; + traceId: string; + limit: number; +}; + +/** Format trace log results as human-readable table output. */ +function formatTraceLogsHuman(data: TraceLogsData): string { + if (data.logs.length === 0) { + return ""; + } + const parts = [formatLogTable(data.logs, false)]; + const hasMore = data.logs.length >= data.limit; + const countText = `Showing ${data.logs.length} log${data.logs.length === 1 ? "" : "s"} for trace ${data.traceId}.`; + const tip = hasMore ? " Use --limit to show more." : ""; + parts.push(formatFooter(`${countText}${tip}`)); + return parts.join(""); +} + /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -132,7 +166,16 @@ export const logsCommand = buildCommand({ " sentry trace logs --period 7d abc123def456abc123def456abc123de\n" + " sentry trace logs --json abc123def456abc123def456abc123de", }, - output: "json", + output: { + json: true, + human: stateless(formatTraceLogsHuman), + jsonTransform: (data: TraceLogsData, fields?: string[]) => { + if (fields && fields.length > 0) { + return data.logs.map((entry) => filterFields(entry, fields)); + } + return data.logs; + }, + }, parameters: { positional: { kind: "array", @@ -176,7 +219,6 @@ export const logsCommand = buildCommand({ q: "query", }, }, - // biome-ignore lint/correctness/useYield: void generator — early returns for web mode async *func(this: SentryContext, flags: LogsFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; @@ -206,17 +248,21 @@ export const logsCommand = buildCommand({ query: flags.query, }); - process.stdout.write( - formatTraceLogs({ - logs, - traceId, - limit: flags.limit, - asJson: flags.json, - fields: flags.fields, - emptyMessage: + // Reverse to chronological order (API returns newest-first) + const chronological = [...logs].reverse(); + + yield commandOutput({ + logs: chronological, + traceId, + limit: flags.limit, + }); + + if (logs.length === 0) { + return { + hint: `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + - `Try a longer period: sentry trace logs --period 30d ${traceId}\n`, - }) - ); + `Try a longer period: sentry trace logs --period 30d ${traceId}`, + }; + } }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 2db1e5beb..6f8874b36 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -23,7 +23,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { commandOutput, stateless } from "../../lib/formatters/output.js"; -import { logger } from "../../lib/logger.js"; +import { logger as log } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { buildBillingUrl } from "../../lib/sentry-urls.js"; @@ -114,11 +114,11 @@ export const startCommand = buildCommand({ first: string, second?: string ) { - const log = logger.withTag("trial"); + const logger = log.withTag("trial"); const parsed = parseTrialStartArgs(first, second); if (parsed.warning) { - log.warn(parsed.warning); + logger.warn(parsed.warning); } // Validate trial name — "plan" is a special pseudo-name @@ -143,8 +143,7 @@ export const startCommand = buildCommand({ // Plan trial: no API to start it — open billing page instead if (parsed.name === "plan") { - const planResult = await handlePlanTrial(orgSlug, flags.json ?? false); - yield commandOutput(planResult); + yield* handlePlanTrial(orgSlug, flags.json ?? false); return; } @@ -175,25 +174,16 @@ export const startCommand = buildCommand({ }); /** - * Show URL + QR code and prompt to open browser if interactive. - * - * Display text goes to stderr via consola — stdout is reserved for - * structured command output. + * Prompt to open a billing URL in the browser if interactive. * * @returns true if browser was opened, false otherwise */ -async function promptOpenBillingUrl(url: string): Promise { - const log = logger.withTag("trial"); - - log.log(`\n ${url}\n`); - - // Show QR code so mobile/remote users can scan - const qr = await generateQRCode(url); - log.log(qr); +async function promptOpenBrowser(url: string): Promise { + const logger = log.withTag("trial"); // Prompt to open browser if interactive TTY (stdin for input, stderr for display) if (isatty(0) && isatty(2)) { - const confirmed = await log.prompt("Open in browser?", { + const confirmed = await logger.prompt("Open in browser?", { type: "confirm", initial: true, }); @@ -202,9 +192,9 @@ async function promptOpenBillingUrl(url: string): Promise { if (confirmed === true) { const opened = await openBrowser(url); if (opened) { - log.success("Opening in browser..."); + logger.success("Opening in browser..."); } else { - log.warn("Could not open browser. Visit the URL above."); + logger.warn("Could not open browser. Visit the URL above."); } return opened; } @@ -213,15 +203,6 @@ async function promptOpenBillingUrl(url: string): Promise { return false; } -/** Return type for the plan trial handler */ -type PlanTrialResult = { - name: string; - category: string; - organization: string; - url: string; - opened: boolean; -}; - /** * Handle the "plan" pseudo-trial: check eligibility, show billing URL, * prompt to open browser + show QR code. @@ -229,12 +210,15 @@ type PlanTrialResult = { * There's no API to start a plan-level trial programmatically — the user * must activate it through the Sentry billing UI. This flow makes that as * smooth as possible from the terminal. + * + * Yields intermediate display data (URL + QR code) so it flows through + * the output framework, then yields the final result. */ -async function handlePlanTrial( +async function* handlePlanTrial( orgSlug: string, json: boolean -): Promise { - const log = logger.withTag("trial"); +): AsyncGenerator { + const logger = log.withTag("trial"); // Check if plan trial is actually available const info = await getCustomerTrialInfo(orgSlug); @@ -261,37 +245,48 @@ async function handlePlanTrial( // In JSON mode, skip interactive output — just return the data if (!json) { const currentPlan = info.planDetails?.name ?? "current plan"; - log.info( + logger.info( `The ${currentPlan} → Business plan trial must be activated in the Sentry UI.` ); - opened = await promptOpenBillingUrl(url); + + // Show URL and QR code through the output framework + const qr = await generateQRCode(url); + yield commandOutput({ url, qr }); + + opened = await promptOpenBrowser(url); } - return { + yield commandOutput({ name: "plan", category: "plan", organization: orgSlug, url, opened, - }; + }); } /** Format start result as human-readable output */ function formatStartResult(data: { - name: string; - category: string; - organization: string; + name?: string; + category?: string; + organization?: string; lengthDays?: number | null; started?: boolean; url?: string; + qr?: string; opened?: boolean; }): string { - // Plan trial result — already handled interactively + // Intermediate URL + QR code yield for plan trials + if (data.url && data.qr && !data.category) { + return `\n ${data.url}\n\n${data.qr}`; + } + + // Plan trial final result — URL/QR already displayed if (data.category === "plan") { return ""; } - const displayName = getTrialDisplayName(data.category); + const displayName = getTrialDisplayName(data.category ?? ""); const daysText = data.lengthDays ? ` (${data.lengthDays} days)` : ""; return `${success("✓")} ${displayName} trial started for ${data.organization}!${daysText}`; } diff --git a/src/lib/command.ts b/src/lib/command.ts index ef82bb1cb..a20cc41a6 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -40,8 +40,7 @@ import type { Writer } from "../types/index.js"; import { OutputError } from "./errors.js"; import { parseFieldsList } from "./formatters/json.js"; import { - COMMAND_OUTPUT_BRAND, - type CommandOutput, + CommandOutput, type CommandReturn, commandOutput, type HumanRenderer, @@ -286,7 +285,7 @@ export function buildCommand< /** Resolved output config (object form), or undefined if no auto-rendering */ const outputConfig = typeof rawOutput === "object" ? rawOutput : undefined; /** Whether to inject --json/--fields flags */ - const hasJsonOutput = rawOutput === "json" || rawOutput?.json === true; + const hasJsonOutput = rawOutput === "json" || typeof rawOutput === "object"; // Merge logging flags into the command's flag definitions. // Quoted keys produce kebab-case CLI flags: "log-level" → --log-level @@ -321,19 +320,14 @@ export function buildCommand< const mergedParams = { ...existingParams, flags: mergedFlags }; /** - * Check if a value is a branded {@link CommandOutput} object. + * Check if a value is a {@link CommandOutput} instance. * - * Uses the {@link COMMAND_OUTPUT_BRAND} Symbol instead of duck-typing - * on `"data" in v`, preventing false positives from raw API responses - * or other objects that happen to have a `data` property. + * Uses `instanceof` instead of duck-typing on `"data" in v`, + * preventing false positives from raw API responses or other objects + * that happen to have a `data` property. */ function isCommandOutput(v: unknown): v is CommandOutput { - return ( - typeof v === "object" && - v !== null && - COMMAND_OUTPUT_BRAND in v && - v[COMMAND_OUTPUT_BRAND] === true - ); + return v instanceof CommandOutput; } /** diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index b20bb0339..cc848f215 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -122,8 +122,11 @@ export function stateless(fn: (data: T) => string): () => HumanRenderer { * and serialized as-is to JSON) */ export type OutputConfig = { - /** Enable `--json` and `--fields` flag injection */ - json: true; + /** + * Enable `--json` and `--fields` flag injection. + * Defaults to `true` — can be omitted for brevity. + */ + json?: true; /** * Factory that creates a {@link HumanRenderer} per invocation. * @@ -161,24 +164,11 @@ export type OutputConfig = { jsonTransform?: (data: T, fields?: string[]) => unknown; }; -/** - * Unique brand for {@link CommandOutput} objects. - * - * Using a Symbol instead of duck-typing (`"data" in v`) prevents false - * positives when a command accidentally yields a raw API response that - * happens to have a `data` property. - */ -export const COMMAND_OUTPUT_BRAND: unique symbol = Symbol.for( - "sentry-cli:command-output" -); - /** * Yield type for commands with {@link OutputConfig}. * - * Commands wrap each yielded value in this object so the `buildCommand` - * wrapper can unambiguously detect data vs void/raw yields. The brand - * symbol provides a runtime discriminant that cannot collide with - * arbitrary data shapes. + * Commands wrap each yielded value in this class so the `buildCommand` + * wrapper can unambiguously detect data vs void/raw yields via `instanceof`. * * Hints are NOT carried on yielded values — they belong on the generator's * return value ({@link CommandReturn}) so the framework renders them once @@ -186,18 +176,19 @@ export const COMMAND_OUTPUT_BRAND: unique symbol = Symbol.for( * * @typeParam T - The data type (matches the `OutputConfig` type parameter) */ -export type CommandOutput = { - /** Runtime brand — set automatically by {@link commandOutput} */ - [COMMAND_OUTPUT_BRAND]: true; +export class CommandOutput { /** The data to render (serialized as-is to JSON, passed to `human` formatter) */ - data: T; -}; + readonly data: T; + constructor(data: T) { + this.data = data; + } +} /** - * Create a branded {@link CommandOutput} value. + * Create a {@link CommandOutput} value. * - * Commands should use this helper instead of constructing `{ data }` literals - * directly, so the brand is always present. + * Commands should use this helper instead of constructing instances + * directly for a concise API. * * @example * ```ts @@ -205,7 +196,7 @@ export type CommandOutput = { * ``` */ export function commandOutput(data: T): CommandOutput { - return { [COMMAND_OUTPUT_BRAND]: true, data }; + return new CommandOutput(data); } /** @@ -274,74 +265,17 @@ function applyJsonExclude( return copy; } -// --------------------------------------------------------------------------- -// JSONL (JSON Lines) support for streaming commands -// --------------------------------------------------------------------------- - -/** Brand symbol for {@link JsonlLines} values. */ -const JSONL_BRAND: unique symbol = Symbol.for("sentry-cli:jsonl-lines"); - -/** - * Wrapper that tells the output framework to write each element as a - * separate JSON line (JSONL format) instead of serializing the array - * as a single JSON value. - * - * Use this in `jsonTransform` when a streaming command yields batches - * that should be expanded to one line per item. - */ -type JsonlLines = { - readonly [JSONL_BRAND]: true; - readonly items: readonly unknown[]; -}; - -/** - * Create a JSONL marker for use in `jsonTransform`. - * - * Each item in the array is serialized as a separate JSON line. - * Empty arrays produce no output. - * - * @example - * ```ts - * jsonTransform(result) { - * if (result.streaming) { - * return jsonlLines(result.logs); - * } - * return result.logs; - * } - * ``` - */ -export function jsonlLines(items: readonly unknown[]): JsonlLines { - return { [JSONL_BRAND]: true, items }; -} - -/** Type guard for JSONL marker values. */ -function isJsonlLines(v: unknown): v is JsonlLines { - return ( - typeof v === "object" && - v !== null && - JSONL_BRAND in v && - (v as Record)[JSONL_BRAND] === true - ); -} - /** * Write a JSON-transformed value to stdout. * - * - `undefined` suppresses the chunk entirely (e.g. streaming text-only - * chunks in JSON mode). - * - {@link JsonlLines} expands to one line per item (JSONL format). - * - All other values are serialized as a single JSON value. + * `undefined` suppresses the chunk entirely (e.g. streaming text-only + * chunks in JSON mode). All other values are serialized as a single + * JSON line. */ function writeTransformedJson(stdout: Writer, transformed: unknown): void { if (transformed === undefined) { return; } - if (isJsonlLines(transformed)) { - for (const item of transformed.items) { - stdout.write(`${formatJson(item)}\n`); - } - return; - } stdout.write(`${formatJson(transformed)}\n`); } diff --git a/src/lib/interactive-login.ts b/src/lib/interactive-login.ts index 0085fd5ec..c1401db4d 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -47,14 +47,13 @@ export type InteractiveLoginOptions = { * - Storing the token and user info on success * * All UI output goes to stderr via the logger, keeping stdout clean for - * structured command output. + * structured command output. A spinner replaces raw polling dots for a + * cleaner interactive experience. * - * @param stdin - Input stream for keyboard listener (must be TTY) * @param options - Optional configuration * @returns Structured login result on success, or null on failure/cancellation */ export async function runInteractiveLogin( - stdin: NodeJS.ReadStream & { fd: 0 }, options?: InteractiveLoginOptions ): Promise { const timeout = options?.timeout ?? 900_000; // 15 minutes default @@ -89,25 +88,32 @@ export async function runInteractiveLogin( log.info(`URL: ${verificationUri}`); log.info(`Code: ${userCode}`); + const stdin = process.stdin; const copyHint = stdin.isTTY ? ` ${muted("(c to copy)")}` : ""; log.info( `Browser didn't open? Use the url above to sign in${copyHint}` ); - log.info("Waiting for authorization..."); + + // Use a spinner for the "waiting" state instead of raw polling dots + log.start("Waiting for authorization..."); // Setup keyboard listener for 'c' to copy URL - keyListener.cleanup = setupCopyKeyListener(stdin, () => urlToCopy); + if (stdin.isTTY) { + keyListener.cleanup = setupCopyKeyListener( + stdin as NodeJS.ReadStream & { fd: 0 }, + () => urlToCopy + ); + } }, onPolling: () => { - // Dots append on the same line without newlines — logger can't do this - process.stderr.write("."); + // Spinner handles the visual feedback — no-op here }, }, timeout ); - // Clear the polling dots - process.stderr.write("\n"); + // Stop the spinner + log.success("Authorization received!"); // Store the token await completeOAuthFlow(tokenResponse); @@ -133,15 +139,11 @@ export async function runInteractiveLogin( expiresIn: tokenResponse.expires_in, }; if (user) { - result.user = { - name: user.name, - email: user.email, - id: user.id, - }; + result.user = user; } return result; } catch (err) { - process.stderr.write("\n"); + log.fail("Authorization failed"); log.error(formatError(err)); return null; } finally { diff --git a/test/commands/trace/logs.test.ts b/test/commands/trace/logs.test.ts index 8427ed401..4c9a4f068 100644 --- a/test/commands/trace/logs.test.ts +++ b/test/commands/trace/logs.test.ts @@ -189,24 +189,26 @@ const sampleLogs: TraceLog[] = [ ]; function createMockContext() { + const stdoutWrite = mock(() => true); return { context: { + stdout: { write: stdoutWrite }, cwd: "/tmp", setContext: mock(() => { // no-op for test }), }, + stdoutWrite, }; } /** - * Collect all output written to `process.stdout.write` by the spy. - * Handles both string and Buffer arguments. + * Collect all output written to a mock write function. */ -function collectStdout( - spy: ReturnType> +function collectMockOutput( + writeMock: ReturnType boolean>> ): string { - return spy.mock.calls + return writeMock.mock.calls .map((c) => { const arg = c[0]; if (typeof arg === "string") { @@ -223,18 +225,15 @@ function collectStdout( describe("logsCommand.func", () => { let listTraceLogsSpy: ReturnType; let resolveOrgSpy: ReturnType; - let stdoutSpy: ReturnType>; beforeEach(() => { listTraceLogsSpy = spyOn(apiClient, "listTraceLogs"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); - stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); }); afterEach(() => { listTraceLogsSpy.mockRestore(); resolveOrgSpy.mockRestore(); - stdoutSpy.mockRestore(); }); describe("JSON output mode", () => { @@ -242,7 +241,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -250,7 +249,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toHaveLength(3); @@ -262,7 +261,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -270,7 +269,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(JSON.parse(output)).toEqual([]); }); }); @@ -280,7 +279,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -288,7 +287,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("No logs found"); expect(output).toContain(TRACE_ID); }); @@ -297,7 +296,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -305,7 +304,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("30d"); }); @@ -313,7 +312,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -321,7 +320,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Request received"); expect(output).toContain("Slow query detected"); expect(output).toContain("Database connection failed"); @@ -331,7 +330,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -339,7 +338,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Showing 3 logs"); expect(output).toContain(TRACE_ID); }); @@ -348,7 +347,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue([sampleLogs[0]]); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -356,7 +355,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Showing 1 log for trace"); expect(output).not.toContain("Showing 1 logs"); }); @@ -365,7 +364,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -374,7 +373,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Use --limit to show more."); }); @@ -382,7 +381,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -390,7 +389,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); expect(output).not.toContain("Use --limit to show more."); }); }); @@ -538,7 +537,7 @@ describe("logsCommand.func", () => { listTraceLogsSpy.mockResolvedValue(newestFirst); resolveOrgSpy.mockResolvedValue({ org: ORG }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await logsCommand.loader(); await func.call( context, @@ -546,7 +545,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = collectStdout(stdoutSpy); + const output = collectMockOutput(stdoutWrite); // All three messages should appear in the output const reqIdx = output.indexOf("Request received"); const slowIdx = output.indexOf("Slow query detected"); diff --git a/test/commands/trial/start.test.ts b/test/commands/trial/start.test.ts index 755ae5669..177e27a1b 100644 --- a/test/commands/trial/start.test.ts +++ b/test/commands/trial/start.test.ts @@ -317,18 +317,14 @@ describe("trial start plan", () => { makeCustomerInfo({ canTrial: true }) ); - const stderrSpy = spyOn(process.stderr, "write"); - try { - const { context } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const { context, stdoutWrite } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); - expect(output).toContain("billing"); - expect(output).toContain("test-org"); - } finally { - stderrSpy.mockRestore(); - } + // URL and QR code go through commandOutput → context stdout + const output = stdoutWrite.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("billing"); + expect(output).toContain("test-org"); }); test("generates QR code for billing URL", async () => { @@ -337,18 +333,14 @@ describe("trial start plan", () => { makeCustomerInfo({ canTrial: true }) ); - const stderrSpy = spyOn(process.stderr, "write"); - try { - const { context } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const { context, stdoutWrite } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - expect(generateQRCodeSpy).toHaveBeenCalled(); - const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); - expect(output).toContain("[QR CODE]"); - } finally { - stderrSpy.mockRestore(); - } + expect(generateQRCodeSpy).toHaveBeenCalled(); + // QR code goes through commandOutput → context stdout + const output = stdoutWrite.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("[QR CODE]"); }); test("throws when org is already on plan trial", async () => { @@ -416,17 +408,12 @@ describe("trial start plan", () => { }) ); - const stderrSpy = spyOn(process.stderr, "write"); - try { - const { context } = createMockContext(); - const func = await startCommand.loader(); - await func.call(context, { json: false }, "plan"); + const { context, stdoutWrite } = createMockContext(); + const func = await startCommand.loader(); + await func.call(context, { json: false }, "plan"); - const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); - // The log.info message and URL both go through consola → stderr - expect(output).toContain("billing"); - } finally { - stderrSpy.mockRestore(); - } + // URL goes through commandOutput → context stdout + const output = stdoutWrite.mock.calls.map((c) => String(c[0])).join(""); + expect(output).toContain("billing"); }); }); From 51bc2420208367112eb44c86d7bc6e6025f1e6e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 21:03:34 +0000 Subject: [PATCH 16/25] fix: address round 2 review findings 1. log/list.ts: Add 'id' to LogLike type for trace follow-mode dedup 2. log/list.ts: Inline applyFields in jsonTransformLogOutput 3. formatters/log.ts: Remove dead formatTraceLogs function and types 4. trace/logs.ts: Render empty-state message as primary text via emptyMessage field instead of hint (preserves non-muted styling) --- src/commands/log/list.ts | 11 +++++--- src/commands/trace/logs.ts | 19 +++++++------- src/lib/formatters/log.ts | 52 -------------------------------------- 3 files changed, 16 insertions(+), 66 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index df435852d..2338bf208 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -108,6 +108,10 @@ function parseFollow(value: string): number { * needed for table rendering and follow-mode dedup tracking. */ type LogLike = { + /** Unique log entry ID — used for dedup in trace follow mode. + * TraceLog uses `id`, SentryLog uses `sentry.item_id` (via passthrough). + * Present on TraceLog which is the only type used in follow mode dedup. */ + id?: string; timestamp: string; /** Nanosecond-precision timestamp used for dedup in follow mode. * Optional because TraceLog may omit it when the API response doesn't include it. */ @@ -488,10 +492,9 @@ function jsonTransformLogOutput( return; } - const applyFields = (log: LogLike) => - fields && fields.length > 0 ? filterFields(log, fields) : log; - - return result.logs.map(applyFields); + return fields && fields.length > 0 + ? result.logs.map((log) => filterFields(log, fields)) + : result.logs; } export const listCommand = buildListCommand("log", { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 927470974..2ca88ad18 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -49,12 +49,14 @@ type TraceLogsData = { logs: LogLike[]; traceId: string; limit: number; + /** Message shown when no logs found */ + emptyMessage?: string; }; /** Format trace log results as human-readable table output. */ function formatTraceLogsHuman(data: TraceLogsData): string { if (data.logs.length === 0) { - return ""; + return data.emptyMessage ?? "No logs found."; } const parts = [formatLogTable(data.logs, false)]; const hasMore = data.logs.length >= data.limit; @@ -251,18 +253,15 @@ export const logsCommand = buildCommand({ // Reverse to chronological order (API returns newest-first) const chronological = [...logs].reverse(); - yield commandOutput({ + const emptyMessage = + `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + + `Try a longer period: sentry trace logs --period 30d ${traceId}`; + + return yield commandOutput({ logs: chronological, traceId, limit: flags.limit, + emptyMessage, }); - - if (logs.length === 0) { - return { - hint: - `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + - `Try a longer period: sentry trace logs --period 30d ${traceId}`, - }; - } }, }); diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index f0fbe87a6..0fa2478d0 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -6,7 +6,6 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; -import { filterFields, formatJson } from "./json.js"; import { colorTag, escapeMarkdownCell, @@ -19,7 +18,6 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { formatFooter } from "./output.js"; import { renderTextTable, StreamingTable, @@ -319,53 +317,3 @@ export function formatLogDetails( return renderMarkdown(lines.join("\n")); } - -/** - * Options for {@link formatTraceLogs}. - */ -type FormatTraceLogsOptions = { - /** Already-fetched logs (API order: newest-first) */ - logs: LogLike[]; - /** The trace ID being queried */ - traceId: string; - /** The --limit value (used for "has more" hint) */ - limit: number; - /** Output as JSON instead of human-readable table */ - asJson: boolean; - /** Message to show when no logs are found */ - emptyMessage: string; - /** Optional field paths to include in JSON output */ - fields?: string[]; -}; - -/** - * Format trace-filtered log results into a string. - * - * Handles JSON output, empty state, and human-readable table formatting. - * Used by both `sentry log list --trace` and `sentry trace logs`. - */ -export function formatTraceLogs(options: FormatTraceLogsOptions): string { - const { logs, traceId, limit, asJson, emptyMessage, fields } = options; - - if (asJson) { - const reversed = [...logs].reverse(); - const data = fields - ? reversed.map((entry) => filterFields(entry, fields)) - : reversed; - return `${formatJson(data)}\n`; - } - - if (logs.length === 0) { - return emptyMessage; - } - - const chronological = [...logs].reverse(); - const parts = [formatLogTable(chronological, false)]; - - const hasMore = logs.length >= limit; - const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"} for trace ${traceId}.`; - const tip = hasMore ? " Use --limit to show more." : ""; - parts.push(formatFooter(`${countText}${tip}`)); - - return parts.join(""); -} From e5dc4b02529c9ca96a3988410d32d18a3be9d7e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 22:08:18 +0000 Subject: [PATCH 17/25] refactor: address round 3 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replace commandOutput() helper with new CommandOutput() — reviewer said "we don't need a helper to do new CommandOutput()". Removed the function, updated all 38 call sites + 7 test call sites. 2. Remove json field from OutputConfig entirely — reviewer said "if there's nothing else setting json: false, remove this altogether". All commands implicitly get --json/--fields when output is set. Removed "json" string variant from output type union. 3. Remove verbose JSDoc on isCommandOutput — reviewer said "not sure the comment really adds value". Kept just the function, no docs. 4. Remove onPolling callback — reviewer said "shall we remove the onPolling callback altogether?" since it's now a no-op (spinner handles visual feedback). Removed from interactive-login.ts. --- src/commands/api.ts | 8 +++--- src/commands/auth/login.ts | 8 +++--- src/commands/auth/logout.ts | 8 +++--- src/commands/auth/refresh.ts | 6 ++--- src/commands/auth/status.ts | 6 ++--- src/commands/auth/whoami.ts | 5 ++-- src/commands/cli/feedback.ts | 6 ++--- src/commands/cli/fix.ts | 6 ++--- src/commands/cli/upgrade.ts | 10 +++---- src/commands/event/view.ts | 9 ++++--- src/commands/help.ts | 6 ++--- src/commands/issue/explain.ts | 6 ++--- src/commands/issue/list.ts | 5 ++-- src/commands/issue/plan.ts | 7 +++-- src/commands/issue/view.ts | 10 ++++--- src/commands/log/list.ts | 9 +++---- src/commands/log/view.ts | 5 ++-- src/commands/org/list.ts | 6 ++--- src/commands/org/view.ts | 6 ++--- src/commands/project/create.ts | 7 +++-- src/commands/project/list.ts | 5 ++-- src/commands/project/view.ts | 5 ++-- src/commands/trace/list.ts | 5 ++-- src/commands/trace/logs.ts | 5 ++-- src/commands/trace/view.ts | 5 ++-- src/commands/trial/list.ts | 5 ++-- src/commands/trial/start.ts | 10 +++---- src/lib/command.ts | 45 +++++++++---------------------- src/lib/formatters/output.ts | 48 +++++++--------------------------- src/lib/interactive-login.ts | 3 --- src/lib/list-command.ts | 9 +++---- test/lib/command.test.ts | 36 ++++++++++++------------- 32 files changed, 132 insertions(+), 188 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index a18b1d3de..62e4d12c8 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { OutputError, ValidationError } from "../lib/errors.js"; -import { commandOutput, stateless } from "../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; @@ -1053,7 +1053,7 @@ function logResponse(response: { status: number; headers: Headers }): void { } export const apiCommand = buildCommand({ - output: { json: true, human: stateless(formatApiResponse) }, + output: { human: stateless(formatApiResponse) }, docs: { brief: "Make an authenticated API request", fullDescription: @@ -1169,7 +1169,7 @@ export const apiCommand = buildCommand({ // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - yield commandOutput({ + yield new CommandOutput({ method: flags.method, url: resolveRequestUrl(normalizedEndpoint, params), headers: resolveEffectiveHeaders(headers, body), @@ -1210,7 +1210,7 @@ export const apiCommand = buildCommand({ throw new OutputError(response.body); } - yield commandOutput(response.body); + yield new CommandOutput(response.body); return; }, }); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index c9fda77cd..5021a859e 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -17,7 +17,7 @@ import { formatDuration, formatUserIdentity, } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import type { LoginResult } from "../../lib/interactive-login.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { logger } from "../../lib/logger.js"; @@ -129,7 +129,7 @@ export const loginCommand = buildCommand({ }, }, }, - output: { json: true, human: stateless(formatLoginResult) }, + output: { human: stateless(formatLoginResult) }, async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { @@ -182,7 +182,7 @@ export const loginCommand = buildCommand({ // Non-fatal: user info is supplementary. Token remains stored and valid. } - yield commandOutput(result); + yield new CommandOutput(result); return; } @@ -192,7 +192,7 @@ export const loginCommand = buildCommand({ }); if (result) { - yield commandOutput(result); + yield new CommandOutput(result); } else { // Error already displayed by runInteractiveLogin process.exitCode = 1; diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 23be9cf37..7ced09169 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -15,7 +15,7 @@ import { import { getDbPath } from "../../lib/db/index.js"; import { AuthError } from "../../lib/errors.js"; import { formatLogoutResult } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; /** Structured result of the logout operation */ export type LogoutResult = { @@ -33,13 +33,13 @@ export const logoutCommand = buildCommand({ fullDescription: "Remove stored authentication credentials from the local database.", }, - output: { json: true, human: stateless(formatLogoutResult) }, + output: { human: stateless(formatLogoutResult) }, parameters: { flags: {}, }, async *func(this: SentryContext) { if (!(await isAuthenticated())) { - return yield commandOutput({ + return yield new CommandOutput({ loggedOut: false, message: "Not currently authenticated.", }); @@ -57,7 +57,7 @@ export const logoutCommand = buildCommand({ const configPath = getDbPath(); await clearAuth(); - return yield commandOutput({ + return yield new CommandOutput({ loggedOut: true, configPath, }); diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 8de435882..f42b28276 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -15,7 +15,7 @@ import { import { AuthError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { formatDuration } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; type RefreshFlags = { readonly json: boolean; @@ -59,7 +59,7 @@ Examples: {"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."} `.trim(), }, - output: { json: true, human: stateless(formatRefreshResult) }, + output: { human: stateless(formatRefreshResult) }, parameters: { flags: { force: { @@ -105,7 +105,7 @@ Examples: : undefined, }; - yield commandOutput(payload); + yield new CommandOutput(payload); return; }, }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index b378df03b..9d04d6da3 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -22,7 +22,7 @@ import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo } from "../../lib/db/user.js"; import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -144,7 +144,7 @@ export const statusCommand = buildCommand({ "Display information about your current authentication status, " + "including whether you're logged in and your default organization/project settings.", }, - output: { json: true, human: stateless(formatAuthStatus) }, + output: { human: stateless(formatAuthStatus) }, parameters: { flags: { "show-token": { @@ -190,7 +190,7 @@ export const statusCommand = buildCommand({ verification: await verifyCredentials(), }; - yield commandOutput(data); + yield new CommandOutput(data); return; }, }); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index ec0854a44..47ddd8076 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,7 +13,7 @@ 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, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -35,7 +35,6 @@ export const whoamiCommand = buildCommand({ "the current token. Works with all token types: OAuth, API tokens, and OAuth App tokens.", }, output: { - json: true, human: stateless(formatUserIdentity), }, parameters: { @@ -66,7 +65,7 @@ export const whoamiCommand = buildCommand({ // Cache update failure is non-essential — user identity was already fetched. } - yield commandOutput(user); + yield new CommandOutput(user); return; }, }); diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index aeb7ccb65..0698922e0 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -14,7 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; /** Structured result of the feedback submission */ export type FeedbackResult = { @@ -31,7 +31,7 @@ export const feedbackCommand = buildCommand({ "Submit feedback about your experience with the Sentry CLI. " + "All text after 'feedback' is sent as your message.", }, - output: { json: true, human: stateless(formatFeedbackResult) }, + output: { human: stateless(formatFeedbackResult) }, parameters: { flags: {}, positional: { @@ -67,7 +67,7 @@ export const feedbackCommand = buildCommand({ // Flush to ensure feedback is sent before process exits const sent = await Sentry.flush(3000); - yield commandOutput({ + yield new CommandOutput({ sent, message, }); diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 8c9011860..bcd8702c1 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -17,7 +17,7 @@ import { } from "../../lib/db/schema.js"; import { OutputError } from "../../lib/errors.js"; import { formatFixResult } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { getRealUsername } from "../../lib/utils.js"; type FixFlags = { @@ -669,7 +669,7 @@ export const fixCommand = buildCommand({ " sudo sentry cli fix # Fix root-owned files\n" + " sentry cli fix --dry-run # Show what would be fixed without making changes", }, - output: { json: true, human: stateless(formatFixResult) }, + output: { human: stateless(formatFixResult) }, parameters: { flags: { "dry-run": { @@ -735,7 +735,7 @@ export const fixCommand = buildCommand({ throw new OutputError(result); } - yield commandOutput(result); + yield new CommandOutput(result); return; }, }); diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 25a9aebcb..2ba06b1b3 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -31,7 +31,7 @@ import { } from "../../lib/db/release-channel.js"; import { UpgradeError } from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { detectInstallationMethod, @@ -418,7 +418,7 @@ export const upgradeCommand = buildCommand({ " sentry cli upgrade --force # Force re-download even if up to date\n" + " sentry cli upgrade --method npm # Force using npm to upgrade", }, - output: { json: true, human: stateless(formatUpgradeResult) }, + output: { human: stateless(formatUpgradeResult) }, parameters: { positional: { kind: "tuple", @@ -494,7 +494,7 @@ export const upgradeCommand = buildCommand({ flags, }); if (resolved.kind === "done") { - yield commandOutput(resolved.result); + yield new CommandOutput(resolved.result); return; } @@ -511,7 +511,7 @@ export const upgradeCommand = buildCommand({ target, versionArg ); - yield commandOutput({ + yield new CommandOutput({ action: downgrade ? "downgraded" : "upgraded", currentVersion: CLI_VERSION, targetVersion: target, @@ -531,7 +531,7 @@ export const upgradeCommand = buildCommand({ execPath: this.process.execPath, }); - yield commandOutput({ + yield new CommandOutput({ action: downgrade ? "downgraded" : "upgraded", currentVersion: CLI_VERSION, targetVersion: target, diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index d8f5ed1c8..aeba67b32 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,7 +23,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -304,7 +304,6 @@ export const viewCommand = buildCommand({ " sentry event view # find project across all orgs", }, output: { - json: true, human: stateless(formatEventView), jsonExclude: ["spanTreeLines"], }, @@ -381,7 +380,11 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - yield commandOutput({ event, trace, spanTreeLines: spanTreeResult?.lines }); + yield new CommandOutput({ + event, + trace, + spanTreeLines: spanTreeResult?.lines, + }); return { hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` diff --git a/src/commands/help.ts b/src/commands/help.ts index 93bb13d01..9e5a25bda 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -9,7 +9,7 @@ import { run } from "@stricli/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { commandOutput, stateless } from "../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../lib/formatters/output.js"; import { printCustomHelp } from "../lib/help.js"; export const helpCommand = buildCommand({ @@ -19,7 +19,7 @@ export const helpCommand = buildCommand({ "Display help information. Run 'sentry help' for an overview, " + "or 'sentry help ' for detailed help on a specific command.", }, - output: { json: true, human: stateless((s: string) => s) }, + output: { human: stateless((s: string) => s) }, parameters: { flags: {}, positional: { @@ -35,7 +35,7 @@ export const helpCommand = buildCommand({ async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) { // No args: show branded help if (commandPath.length === 0) { - return yield commandOutput(await printCustomHelp()); + return yield new CommandOutput(await printCustomHelp()); } // With args: re-invoke with --helpAll to show full help including hidden items diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 762edf5c0..dee813c9b 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -7,7 +7,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError } from "../../lib/errors.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { formatRootCauseList, handleSeerApiError, @@ -59,7 +59,7 @@ export const explainCommand = buildCommand({ " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, - output: { json: true, human: stateless(formatRootCauseList) }, + output: { human: stateless(formatRootCauseList) }, parameters: { positional: issueIdPositional, flags: { @@ -105,7 +105,7 @@ export const explainCommand = buildCommand({ ); } - yield commandOutput(causes); + yield new CommandOutput(causes); return { hint: `To create a plan, run: sentry issue plan ${issueArg}` }; } catch (error) { // Handle API errors with friendly messages diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index c1a72fd66..88618130c 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -42,7 +42,7 @@ import { writeIssueTable, } from "../../lib/formatters/index.js"; import { - commandOutput, + CommandOutput, type OutputConfig, stateless, } from "../../lib/formatters/output.js"; @@ -1241,7 +1241,6 @@ const jsonTransformIssueList = jsonTransformListResult; /** Output configuration for the issue list command. */ const issueListOutput: OutputConfig = { - json: true, human: stateless(formatIssueListHuman), jsonTransform: jsonTransformIssueList, }; @@ -1377,7 +1376,7 @@ export const listCommand = buildListCommand("issue", { combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; } - yield commandOutput(result); + yield new CommandOutput(result); return { hint: combinedHint }; }, }); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 46d20c5c7..a0df906f9 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { formatSolution, handleSeerApiError, @@ -171,7 +171,6 @@ export const planCommand = buildCommand({ " sentry issue plan 123456789 --force", }, output: { - json: true, human: stateless(formatPlanOutput), }, parameters: { @@ -226,7 +225,7 @@ export const planCommand = buildCommand({ if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - yield commandOutput(buildPlanData(state)); + yield new CommandOutput(buildPlanData(state)); return; } } @@ -262,7 +261,7 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - yield commandOutput(buildPlanData(finalState)); + yield new CommandOutput(buildPlanData(finalState)); return; } catch (error) { // Handle API errors with friendly messages diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 49ea579b4..1aad705ce 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -14,7 +14,7 @@ import { formatIssueDetails, muted, } from "../../lib/formatters/index.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -102,7 +102,6 @@ export const viewCommand = buildCommand({ "where 'f' is the project alias shown in the list).", }, output: { - json: true, human: stateless(formatIssueView), jsonExclude: ["spanTreeLines"], }, @@ -171,7 +170,12 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; - yield commandOutput({ issue, event: event ?? null, trace, spanTreeLines }); + yield new CommandOutput({ + issue, + event: event ?? null, + trace, + spanTreeLines, + }); return { hint: `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis`, }; diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 2338bf208..513b19bab 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -22,7 +22,7 @@ import { import { filterFields } from "../../lib/formatters/json.js"; import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import { - commandOutput, + CommandOutput, formatFooter, type HumanRenderer, } from "../../lib/formatters/output.js"; @@ -346,7 +346,7 @@ async function* yieldFollowBatches( ): AsyncGenerator { for await (const batch of generator) { for (const item of batch) { - yield commandOutput({ logs: [item], ...extra }); + yield new CommandOutput({ logs: [item], ...extra }); } } } @@ -520,7 +520,6 @@ export const listCommand = buildListCommand("log", { " sentry log list --trace abc123def456abc123def456abc123de # Filter by trace", }, output: { - json: true, human: createLogRenderer, jsonTransform: jsonTransformLogOutput, }, @@ -642,7 +641,7 @@ export const listCommand = buildListCommand("log", { flags.trace, flags ); - yield commandOutput(result); + yield new CommandOutput(result); return { hint }; } @@ -681,7 +680,7 @@ export const listCommand = buildListCommand("log", { } const { result, hint } = await executeSingleFetch(org, project, flags); - yield commandOutput(result); + yield new CommandOutput(result); return { hint }; } }, diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 0d9162ecf..e8d26e27d 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,7 +18,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, @@ -319,7 +319,6 @@ export const viewCommand = buildCommand({ "The log ID is the 32-character hexadecimal identifier shown in log listings.", }, output: { - json: true, human: stateless(formatLogViewHuman), // Preserve original JSON contract: bare array of log entries. // orgSlug exists only for the human formatter (trace URLs). @@ -390,7 +389,7 @@ export const viewCommand = buildCommand({ ? `Detected from ${target.detectedFrom}` : undefined; - yield commandOutput({ logs, orgSlug: target.org }); + yield new CommandOutput({ logs, orgSlug: target.org }); return { hint }; }, }); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 498f0765c..74cd60aeb 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -10,7 +10,7 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -117,7 +117,7 @@ export const listCommand = buildCommand({ " sentry org list --limit 10\n" + " sentry org list --json", }, - output: { json: true, human: stateless(formatOrgListHuman) }, + output: { human: stateless(formatOrgListHuman) }, parameters: { flags: { limit: buildListLimitFlag("organizations"), @@ -152,7 +152,7 @@ export const listCommand = buildCommand({ hints.push("Tip: Use 'sentry org view ' for details"); } - yield commandOutput(entries); + yield new CommandOutput(entries); return { hint: hints.join("\n") || undefined }; }, }); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index 91fb409b2..e049bceb5 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,7 +10,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails } from "../../lib/formatters/index.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -36,7 +36,7 @@ export const viewCommand = buildCommand({ " 2. Config defaults\n" + " 3. SENTRY_DSN environment variable or source code detection", }, - output: { json: true, human: stateless(formatOrgDetails) }, + output: { human: stateless(formatOrgDetails) }, parameters: { positional: { kind: "tuple", @@ -79,7 +79,7 @@ export const viewCommand = buildCommand({ const hint = resolved.detectedFrom ? `Detected from ${resolved.detectedFrom}` : undefined; - yield commandOutput(org); + yield new CommandOutput(org); return { hint }; }, }); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 9020bb1a0..5ec531daa 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,7 +35,7 @@ import { type ProjectCreatedResult, } from "../../lib/formatters/human.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; import { logger } from "../../lib/logger.js"; @@ -276,7 +276,6 @@ export const createCommand = buildCommand({ " sentry project create my-app go --json", }, output: { - json: true, human: stateless(formatProjectCreated), jsonExclude: [ "slugDiverged", @@ -406,7 +405,7 @@ export const createCommand = buildCommand({ expectedSlug, dryRun: true, }; - yield commandOutput(result); + yield new CommandOutput(result); return; } @@ -434,7 +433,7 @@ export const createCommand = buildCommand({ expectedSlug, }; - yield commandOutput(result); + yield new CommandOutput(result); return; }, }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index acbda909d..e3b1fcf7d 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -33,7 +33,7 @@ import { import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { - commandOutput, + CommandOutput, type OutputConfig, stateless, } from "../../lib/formatters/output.js"; @@ -565,7 +565,6 @@ export const listCommand = buildListCommand("project", { " sentry project list --json # output as JSON", }, output: { - json: true, human: stateless((result: ListResult) => { if (result.items.length === 0) { return result.hint ?? "No projects found."; @@ -636,7 +635,7 @@ export const listCommand = buildListCommand("project", { // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; - yield commandOutput(result); + yield new CommandOutput(result); return { hint }; }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 6e13ee3c8..0ac24a21b 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,7 +15,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -186,7 +186,6 @@ export const viewCommand = buildCommand({ "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, output: { - json: true, human: stateless(formatProjectViewHuman), jsonExclude: ["detectedFrom"], }, @@ -295,7 +294,7 @@ export const viewCommand = buildCommand({ detectedFrom: targets[i]?.detectedFrom, })); - yield commandOutput(entries); + yield new CommandOutput(entries); return { hint: footer }; }, }); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 256298cff..3b625b5f7 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -15,7 +15,7 @@ import { } from "../../lib/db/pagination.js"; import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -180,7 +180,6 @@ export const listCommand = buildListCommand("trace", { ' sentry trace list -q "transaction:GET /api/users" # Filter by transaction', }, output: { - json: true, human: stateless(formatTraceListHuman), jsonTransform: jsonTransformTraceList, }, @@ -272,7 +271,7 @@ export const listCommand = buildListCommand("trace", { : `${countText} Use 'sentry trace view ' to view the full span tree.`; } - yield commandOutput({ traces, hasMore, nextCursor, org, project }); + yield new CommandOutput({ traces, hasMore, nextCursor, org, project }); return { hint }; }, }); diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 2ca88ad18..749a7b8a8 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -13,7 +13,7 @@ import { ContextError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { formatLogTable } from "../../lib/formatters/log.js"; import { - commandOutput, + CommandOutput, formatFooter, stateless, } from "../../lib/formatters/output.js"; @@ -169,7 +169,6 @@ export const logsCommand = buildCommand({ " sentry trace logs --json abc123def456abc123def456abc123de", }, output: { - json: true, human: stateless(formatTraceLogsHuman), jsonTransform: (data: TraceLogsData, fields?: string[]) => { if (fields && fields.length > 0) { @@ -257,7 +256,7 @@ export const logsCommand = buildCommand({ `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + `Try a longer period: sentry trace logs --period 30d ${traceId}`; - return yield commandOutput({ + return yield new CommandOutput({ logs: chronological, traceId, limit: flags.limit, diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index c19e4b864..283f607b5 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -21,7 +21,7 @@ import { formatSimpleSpanTree, formatTraceSummary, } from "../../lib/formatters/index.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -205,7 +205,6 @@ export const viewCommand = buildCommand({ "The trace ID is the 32-character hexadecimal identifier.", }, output: { - json: true, human: stateless(formatTraceView), jsonExclude: ["spanTreeLines"], }, @@ -315,7 +314,7 @@ export const viewCommand = buildCommand({ ? formatSimpleSpanTree(traceId, spans, flags.spans) : undefined; - yield commandOutput({ summary, spans, spanTreeLines }); + yield new CommandOutput({ summary, spans, spanTreeLines }); return { hint: `Tip: Open in browser with 'sentry trace view --web ${traceId}'`, }; diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index bddb935e6..43233ff34 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -11,7 +11,7 @@ import { getCustomerTrialInfo } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { colorTag } from "../../lib/formatters/markdown.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { @@ -203,7 +203,6 @@ export const listCommand = buildCommand({ " sentry trial list --json", }, output: { - json: true, human: stateless(formatTrialListHuman), jsonExclude: ["displayName"], }, @@ -266,7 +265,7 @@ export const listCommand = buildCommand({ ); } - yield commandOutput(entries); + yield new CommandOutput(entries); return { hint: hints.join("\n") || undefined }; }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 6f8874b36..22a4d6ab2 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -22,7 +22,7 @@ import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; -import { commandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { logger as log } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; @@ -89,7 +89,7 @@ export const startCommand = buildCommand({ " sentry trial start plan\n" + " sentry trial start --json seer", }, - output: { json: true, human: stateless(formatStartResult) }, + output: { human: stateless(formatStartResult) }, parameters: { positional: { kind: "tuple" as const, @@ -162,7 +162,7 @@ export const startCommand = buildCommand({ // Start the trial await startProductTrial(orgSlug, trial.category); - yield commandOutput({ + yield new CommandOutput({ name: parsed.name, category: trial.category, organization: orgSlug, @@ -251,12 +251,12 @@ async function* handlePlanTrial( // Show URL and QR code through the output framework const qr = await generateQRCode(url); - yield commandOutput({ url, qr }); + yield new CommandOutput({ url, qr }); opened = await promptOpenBrowser(url); } - yield commandOutput({ + yield new CommandOutput({ name: "plan", category: "plan", organization: orgSlug, diff --git a/src/lib/command.ts b/src/lib/command.ts index a20cc41a6..4eba550ed 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -16,7 +16,7 @@ * * 3. **Output mode injection** — when `output` has an {@link OutputConfig}, * `--json` and `--fields` flags are injected automatically. The command - * yields branded `CommandOutput` objects via {@link commandOutput} and + * yields branded `CommandOutput` objects via {@link CommandOutput} and * optionally returns a `{ hint }` footer via {@link CommandReturn}. * Commands that define their own `json` flag keep theirs. * @@ -42,7 +42,6 @@ import { parseFieldsList } from "./formatters/json.js"; import { CommandOutput, type CommandReturn, - commandOutput, type HumanRenderer, type OutputConfig, renderCommandOutput, @@ -129,31 +128,22 @@ type LocalCommandBuilderArguments< readonly docs: CommandDocumentation; readonly func: SentryCommandFunction; /** - * Output configuration — controls flag injection and optional auto-rendering. + * Output configuration — controls flag injection and auto-rendering. * - * Two forms: - * - * 1. **`"json"`** — injects `--json` and `--fields` flags only. The command - * handles its own output via `writeOutput` or direct writes. - * - * 2. **`{ json: true, human: fn }`** — injects flags AND auto-renders. - * The command returns `{ data }` or `{ data, hint }` and the wrapper - * handles JSON/human branching. Void returns are ignored. + * When provided, `--json` and `--fields` flags are injected automatically. + * The command yields `new CommandOutput(data)` and the wrapper handles + * JSON/human branching. Void yields are ignored. * * @example * ```ts - * // Flag injection only: - * buildCommand({ output: "json", func() { writeOutput(...); } }) - * - * // Full auto-render: * buildCommand({ - * output: { json: true, human: formatUserIdentity }, - * func() { return user; }, + * output: { human: stateless(formatUser) }, + * async *func() { yield new CommandOutput(user); }, * }) * ``` */ // biome-ignore lint/suspicious/noExplicitAny: Variance erasure — OutputConfig.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 while the wrapper handles it generically. - readonly output?: "json" | OutputConfig; + readonly output?: OutputConfig; }; // --------------------------------------------------------------------------- @@ -264,7 +254,7 @@ export function applyLoggingFlags( * * Similarly, when a command already defines its own `json` flag (e.g. for * custom brief text), the injected `JSON_FLAG` is skipped. `--fields` is - * always injected when `output: "json"` regardless. + * always injected when `output: { human: ... }` regardless. * * Flag keys use kebab-case because Stricli uses the literal object key as * the CLI flag name (e.g. `"log-level"` → `--log-level`). @@ -281,11 +271,9 @@ export function buildCommand< builderArgs: LocalCommandBuilderArguments ): Command { const originalFunc = builderArgs.func; - const rawOutput = builderArgs.output; - /** Resolved output config (object form), or undefined if no auto-rendering */ - const outputConfig = typeof rawOutput === "object" ? rawOutput : undefined; + const outputConfig = builderArgs.output; /** Whether to inject --json/--fields flags */ - const hasJsonOutput = rawOutput === "json" || typeof rawOutput === "object"; + const hasJsonOutput = outputConfig !== undefined; // Merge logging flags into the command's flag definitions. // Quoted keys produce kebab-case CLI flags: "log-level" → --log-level @@ -319,13 +307,6 @@ export function buildCommand< const mergedParams = { ...existingParams, flags: mergedFlags }; - /** - * Check if a value is a {@link CommandOutput} instance. - * - * Uses `instanceof` instead of duck-typing on `"data" in v`, - * preventing false positives from raw API responses or other objects - * that happen to have a `data` property. - */ function isCommandOutput(v: unknown): v is CommandOutput { return v instanceof CommandOutput; } @@ -361,7 +342,7 @@ export function buildCommand< * Strip injected flags from the raw Stricli-parsed flags object. * --log-level is always stripped. --verbose is stripped only when we * injected it (not when the command defines its own). --fields is - * pre-parsed from comma-string to string[] when output: "json". + * pre-parsed from comma-string to string[] when output: { human: ... }. */ function cleanRawFlags( raw: Record @@ -443,7 +424,7 @@ export function buildCommand< if (err.data !== null && err.data !== undefined) { handleYieldedValue( stdout, - commandOutput(err.data), + new CommandOutput(err.data), cleanFlags, renderer ); diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index cc848f215..9b4251c43 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -11,16 +11,16 @@ * writeOutput(stdout, data, { json, formatHuman, hint }); * ``` * - * 2. **Return-based** — declare formatting in {@link OutputConfig} on - * `buildCommand`, then return bare data from `func`: + * 2. **Yield-based** — declare formatting in {@link OutputConfig} on + * `buildCommand`, then yield data from the generator: * ```ts * buildCommand({ - * output: { json: true, human: fn }, - * func() { return data; }, + * output: { human: stateless(formatUser) }, + * async *func() { yield new CommandOutput(data); }, * }) * ``` * The wrapper reads `json`/`fields` from flags and applies formatting - * automatically. Commands return `{ data }` or `{ data, hint }` objects. + * automatically. Generators return `{ hint }` for footer text. * * Both modes serialize the same data object to JSON and pass it to * `formatHuman` — there is no divergent-data path. @@ -94,7 +94,6 @@ export type HumanRenderer = { * @example * ```ts * output: { - * json: true, * human: stateless(formatMyData), * } * ``` @@ -106,27 +105,15 @@ export function stateless(fn: (data: T) => string): () => HumanRenderer { /** * Output configuration declared on `buildCommand` for automatic rendering. * - * Two forms: + * When present, `--json` and `--fields` flags are injected and the wrapper + * auto-renders yielded {@link CommandOutput} values. The `human` field is a + * **factory** called once per invocation to produce a {@link HumanRenderer}. + * Use {@link stateless} for simple formatters. * - * 1. **Flag-only** — `output: "json"` — injects `--json` and `--fields` flags - * but does not intercept returns. Commands handle their own output. - * - * 2. **Full config** — `output: { json: true, human: factory }` — injects flags - * AND auto-renders the command's return value. Commands return - * `{ data }` or `{ data, hint }` objects. - * - * The `human` field is a **factory** called once per invocation to produce - * a {@link HumanRenderer}. Use {@link stateless} for simple formatters. - * - * @typeParam T - Type of data the command returns (used by `human` formatter + * @typeParam T - Type of data the command yields (used by `human` formatter * and serialized as-is to JSON) */ export type OutputConfig = { - /** - * Enable `--json` and `--fields` flag injection. - * Defaults to `true` — can be omitted for brevity. - */ - json?: true; /** * Factory that creates a {@link HumanRenderer} per invocation. * @@ -184,21 +171,6 @@ export class CommandOutput { } } -/** - * Create a {@link CommandOutput} value. - * - * Commands should use this helper instead of constructing instances - * directly for a concise API. - * - * @example - * ```ts - * yield commandOutput(myData); - * ``` - */ -export function commandOutput(data: T): CommandOutput { - return new CommandOutput(data); -} - /** * Return type for command generators. * diff --git a/src/lib/interactive-login.ts b/src/lib/interactive-login.ts index c1401db4d..624d250a6 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -105,9 +105,6 @@ export async function runInteractiveLogin( ); } }, - onPolling: () => { - // Spinner handles the visual feedback — no-op here - }, }, timeout ); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index d02493a38..e9f5eb740 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -20,8 +20,8 @@ import { buildCommand, numberParser } from "./command.js"; import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; import { + CommandOutput, type CommandReturn, - commandOutput, type OutputConfig, stateless, } from "./formatters/output.js"; @@ -85,7 +85,7 @@ export function targetPatternExplanation(cursorNote?: string): string { * The `--json` flag shared by all list commands. * Outputs machine-readable JSON instead of a human-readable table. * - * @deprecated Use `output: "json"` on `buildCommand` instead, which + * @deprecated Use `output: { human: ... }` on `buildCommand` instead, which * injects `--json` and `--fields` automatically. This constant is kept * for commands that define `--json` with custom brief text. */ @@ -370,7 +370,7 @@ export function buildListCommand< }; readonly func: ListCommandFunction; // biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but type is erased at the builder level - readonly output?: "json" | OutputConfig; + readonly output?: OutputConfig; } ): Command { const originalFunc = builderArgs.func; @@ -474,7 +474,6 @@ export function buildOrgListCommand( return buildListCommand(routeName, { docs, output: { - json: true, human: stateless((result: ListResult) => formatListHuman(result, config) ), @@ -510,7 +509,7 @@ export function buildOrgListCommand( flags, parsed, }); - yield commandOutput(result); + yield new CommandOutput(result); // Only forward hint to the footer when items exist — empty results // already render hint text inside the human formatter. const hint = result.items.length > 0 ? result.hint : undefined; diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 259829e72..77fb43bd7 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -25,7 +25,7 @@ import { VERBOSE_FLAG, } from "../../src/lib/command.js"; import { OutputError } from "../../src/lib/errors.js"; -import { commandOutput, stateless } from "../../src/lib/formatters/output.js"; +import { CommandOutput, stateless } from "../../src/lib/formatters/output.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; /** Minimal context for test commands */ @@ -660,10 +660,10 @@ describe("FIELDS_FLAG", () => { }); // --------------------------------------------------------------------------- -// buildCommand output: "json" injection +// buildCommand output config injection // --------------------------------------------------------------------------- -describe("buildCommand output: json", () => { +describe("buildCommand output config", () => { test("injects --json flag when output: 'json'", async () => { let receivedFlags: Record | null = null; @@ -673,7 +673,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: { flags: { limit: { @@ -716,7 +716,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -755,7 +755,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -793,7 +793,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -821,7 +821,7 @@ describe("buildCommand output: json", () => { test("does not inject --json/--fields without output: 'json'", async () => { let funcCalled = false; - // Command WITHOUT output: "json" — --json should be rejected by Stricli + // Command WITHOUT output config — --json should be rejected by Stricli const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, @@ -857,7 +857,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: { flags: { json: { @@ -900,7 +900,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -941,7 +941,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: stateless(() => "unused") }, parameters: { flags: { limit: { @@ -1003,7 +1003,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput({ name: "Alice", role: "admin" }); + yield new CommandOutput({ name: "Alice", role: "admin" }); }, }); @@ -1034,7 +1034,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput({ name: "Alice", role: "admin" }); + yield new CommandOutput({ name: "Alice", role: "admin" }); }, }); @@ -1066,7 +1066,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput({ id: 1, name: "Alice", role: "admin" }); + yield new CommandOutput({ id: 1, name: "Alice", role: "admin" }); }, }); @@ -1098,7 +1098,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput({ value: 42 }); + yield new CommandOutput({ value: 42 }); return { hint: "Run 'sentry help' for more info" }; }, }); @@ -1205,7 +1205,7 @@ describe("buildCommand return-based output", () => { parameters: {}, async *func(this: TestContext) { await Bun.sleep(1); - yield commandOutput({ name: "Bob" }); + yield new CommandOutput({ name: "Bob" }); }, }); @@ -1237,7 +1237,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput([{ id: 1 }, { id: 2 }]); + yield new CommandOutput([{ id: 1 }, { id: 2 }]); }, }); @@ -1267,7 +1267,7 @@ describe("buildCommand return-based output", () => { }, parameters: {}, async *func(this: TestContext) { - yield commandOutput({ org: "sentry" }); + yield new CommandOutput({ org: "sentry" }); return { hint: "Detected from .env file" }; }, }); From 42c6bbb4d3a62b0059aa2a257cc1de3d2111516e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 15 Mar 2026 14:15:04 +0000 Subject: [PATCH 18/25] refactor: address round 4 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove isCommandOutput helper — inline instanceof check directly in handleYieldedValue. 2. Remove onPolling callback from DeviceFlowCallbacks in oauth.ts — spinner handles visual feedback now. 3. Convert 11 "yield new CommandOutput(..); return;" patterns to "return yield new CommandOutput(..)" one-liners across 9 files. 4. Add output config to auth/token — emits token as structured data through the framework. JSON: {"token": "..."}, human: raw token. 5. Add output config to cli/setup — collects messages and warnings into SetupResult, yields through framework. JSON output includes structured messages/warnings/binaryPath/version. 6. Remove hasJsonOutput intermediate — use outputConfig directly. Only init.ts remains without output config (interactive wizard, deferred to separate PR). --- src/commands/api.ts | 3 +-- src/commands/auth/login.ts | 3 +-- src/commands/auth/refresh.ts | 3 +-- src/commands/auth/status.ts | 3 +-- src/commands/auth/token.ts | 13 +++------- src/commands/auth/whoami.ts | 3 +-- src/commands/cli/fix.ts | 3 +-- src/commands/cli/setup.ts | 45 +++++++++++++++++++++++++++++---- src/commands/cli/upgrade.ts | 3 +-- src/commands/issue/plan.ts | 6 ++--- src/commands/project/create.ts | 6 ++--- src/lib/command.ts | 22 ++++------------ src/lib/oauth.ts | 2 -- test/commands/cli/setup.test.ts | 17 +++++++++---- 14 files changed, 72 insertions(+), 60 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 62e4d12c8..74b7d3798 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1210,7 +1210,6 @@ export const apiCommand = buildCommand({ throw new OutputError(response.body); } - yield new CommandOutput(response.body); - return; + return yield new CommandOutput(response.body); }, }); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 5021a859e..4ebfc7da1 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -182,8 +182,7 @@ export const loginCommand = buildCommand({ // Non-fatal: user info is supplementary. Token remains stored and valid. } - yield new CommandOutput(result); - return; + return yield new CommandOutput(result); } // OAuth device flow diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index f42b28276..8f0566cba 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -105,7 +105,6 @@ Examples: : undefined, }; - yield new CommandOutput(payload); - return; + return yield new CommandOutput(payload); }, }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 9d04d6da3..920538d96 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -190,7 +190,6 @@ export const statusCommand = buildCommand({ verification: await verifyCredentials(), }; - yield new CommandOutput(data); - return; + return yield new CommandOutput(data); }, }); diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 2ac01047a..1ab2d4664 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { AuthError } from "../../lib/errors.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; export const tokenCommand = buildCommand({ docs: { @@ -16,23 +17,17 @@ export const tokenCommand = buildCommand({ fullDescription: "Print the stored authentication token to stdout.\n\n" + "This outputs the raw token without any formatting, making it suitable for " + - "piping to other commands or scripts. The token is printed without a trailing newline " + - "when stdout is not a TTY (e.g., when piped).", + "piping to other commands or scripts.", }, parameters: {}, - // biome-ignore lint/correctness/useYield: void generator — writes to stdout directly + output: { human: stateless((token: string) => token) }, // biome-ignore lint/suspicious/useAwait: sync body but async generator required by buildCommand async *func(this: SentryContext) { - const { stdout } = this; - const token = getAuthToken(); if (!token) { throw new AuthError("not_authenticated"); } - // Add newline only if stdout is a TTY (interactive terminal) - // When piped, omit newline for cleaner output - const suffix = process.stdout.isTTY ? "\n" : ""; - stdout.write(`${token}${suffix}`); + return yield new CommandOutput(token); }, }); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 47ddd8076..4412192ed 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -65,7 +65,6 @@ export const whoamiCommand = buildCommand({ // Cache update failure is non-essential — user identity was already fetched. } - yield new CommandOutput(user); - return; + return yield new CommandOutput(user); }, }); diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index bcd8702c1..7834854c7 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -735,7 +735,6 @@ export const fixCommand = buildCommand({ throw new OutputError(result); } - yield new CommandOutput(result); - return; + return yield new CommandOutput(result); }, }); diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index a090c0a57..f1946de2d 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -27,7 +27,7 @@ import { type ReleaseChannel, setReleaseChannel, } from "../../lib/db/release-channel.js"; -import { logger } from "../../lib/logger.js"; +import { CommandOutput, stateless } from "../../lib/formatters/output.js"; import { addToGitHubPath, addToPath, @@ -42,8 +42,6 @@ import { parseInstallationMethod, } from "../../lib/upgrade.js"; -const log = logger.withTag("cli.setup"); - type SetupFlags = { readonly install: boolean; readonly method?: InstallationMethod; @@ -56,6 +54,31 @@ type SetupFlags = { type Logger = (msg: string) => void; +/** Structured result of the setup operation */ +type SetupResult = { + /** Status messages collected during setup */ + messages: string[]; + /** Warning messages from best-effort steps */ + warnings: string[]; + /** Whether a fresh binary was installed */ + freshInstall: boolean; + /** Path to the installed binary */ + binaryPath: string; + /** CLI version */ + version: string; +}; + +/** Format setup result for human-readable output */ +function formatSetupResult(result: SetupResult): string { + const lines: string[] = [...result.messages]; + if (result.warnings.length > 0) { + for (const w of result.warnings) { + lines.push(`⚠ ${w}`); + } + } + return lines.join("\n"); +} + /** * Handle binary installation from a temp location. * @@ -441,19 +464,23 @@ export const setupCommand = buildCommand({ }, }, }, + output: { human: stateless(formatSetupResult) }, async *func(this: SentryContext, flags: SetupFlags) { const { process, homeDir } = this; + const messages: string[] = []; + const warnings: string[] = []; + const emit: Logger = (msg: string) => { if (!flags.quiet) { - log.info(msg); + messages.push(msg); } }; const warn: WarnLogger = (step, error) => { const msg = error instanceof Error ? error.message : "Unknown error occurred"; - log.warn(`${step} failed: ${msg}`); + warnings.push(`${step} failed: ${msg}`); }; let binaryPath = process.execPath; @@ -489,5 +516,13 @@ export const setupCommand = buildCommand({ if (!flags.quiet && freshInstall) { printWelcomeMessage(emit, CLI_VERSION, binaryPath); } + + return yield new CommandOutput({ + messages, + warnings, + freshInstall, + binaryPath, + version: CLI_VERSION, + }); }, }); diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 2ba06b1b3..1432f8652 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -494,8 +494,7 @@ export const upgradeCommand = buildCommand({ flags, }); if (resolved.kind === "done") { - yield new CommandOutput(resolved.result); - return; + return yield new CommandOutput(resolved.result); } const { target } = resolved; diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index a0df906f9..2e75a60c1 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -225,8 +225,7 @@ export const planCommand = buildCommand({ if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - yield new CommandOutput(buildPlanData(state)); - return; + return yield new CommandOutput(buildPlanData(state)); } } @@ -261,8 +260,7 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - yield new CommandOutput(buildPlanData(finalState)); - return; + return yield new CommandOutput(buildPlanData(finalState)); } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 5ec531daa..1a01b91bf 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -405,8 +405,7 @@ export const createCommand = buildCommand({ expectedSlug, dryRun: true, }; - yield new CommandOutput(result); - return; + return yield new CommandOutput(result); } // Create the project @@ -433,7 +432,6 @@ export const createCommand = buildCommand({ expectedSlug, }; - yield new CommandOutput(result); - return; + return yield new CommandOutput(result); }, }); diff --git a/src/lib/command.ts b/src/lib/command.ts index 4eba550ed..e608ac54c 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -272,8 +272,6 @@ export function buildCommand< ): Command { const originalFunc = builderArgs.func; const outputConfig = builderArgs.output; - /** Whether to inject --json/--fields flags */ - const hasJsonOutput = outputConfig !== undefined; // Merge logging flags into the command's flag definitions. // Quoted keys produce kebab-case CLI flags: "log-level" → --log-level @@ -297,7 +295,7 @@ export function buildCommand< } // Inject --json and --fields when output config is set - if (hasJsonOutput) { + if (outputConfig) { if (!commandOwnsJson) { mergedFlags.json = JSON_FLAG; } @@ -307,13 +305,9 @@ export function buildCommand< const mergedParams = { ...existingParams, flags: mergedFlags }; - function isCommandOutput(v: unknown): v is CommandOutput { - return v instanceof CommandOutput; - } - /** - * If the yielded value is a branded {@link CommandOutput}, render it via - * the output config. Void/undefined/Error/non-branded values are ignored. + * If the yielded value is a {@link CommandOutput}, render it via + * the output config. Void/undefined/Error/other values are ignored. */ function handleYieldedValue( stdout: Writer, @@ -322,13 +316,7 @@ export function buildCommand< // biome-ignore lint/suspicious/noExplicitAny: Renderer type mirrors erased OutputConfig renderer?: HumanRenderer ): void { - if ( - !(outputConfig && renderer) || - value === null || - value === undefined || - value instanceof Error || - !isCommandOutput(value) - ) { + if (!(outputConfig && renderer && value instanceof CommandOutput)) { return; } @@ -357,7 +345,7 @@ export function buildCommand< } clean[key] = value; } - if (hasJsonOutput && typeof clean.fields === "string") { + if (outputConfig && typeof clean.fields === "string") { clean.fields = parseFieldsList(clean.fields); } return clean; diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 829254bb6..808d20300 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -65,7 +65,6 @@ type DeviceFlowCallbacks = { verificationUri: string, verificationUriComplete: string ) => void | Promise; - onPolling?: () => void; }; function sleep(ms: number): Promise { @@ -276,7 +275,6 @@ export async function performDeviceFlow( // Step 2: Poll for token while (Date.now() < timeoutAt) { await sleep(pollInterval * 1000); - callbacks.onPolling?.(); const result = await attemptPoll(device_code); diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index b768f929e..78f54b2f6 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -58,10 +58,14 @@ function createMockContext( ...overrides.env, }; + const stdoutChunks: string[] = []; const context = { process: { stdout: { - write: mock((_s: string) => true), + write: mock((s: string) => { + stdoutChunks.push(String(s)); + return true; + }), }, stderr: { write: mock((_s: string) => true), @@ -80,7 +84,10 @@ function createMockContext( configDir: "/tmp/test-config", env, stdout: { - write: mock((_s: string) => true), + write: mock((s: string) => { + stdoutChunks.push(String(s)); + return true; + }), }, stderr: { write: mock((_s: string) => true), @@ -96,8 +103,9 @@ function createMockContext( return { context, - getOutput: () => stderrChunks.join(""), + getOutput: () => stdoutChunks.join("") + stderrChunks.join(""), clearOutput: () => { + stdoutChunks.length = 0; stderrChunks.length = 0; }, restore: () => { @@ -821,8 +829,7 @@ describe("sentry cli setup", () => { const combined = getOutput(); // Setup must complete even though the completions step threw — - // the warning goes to stderr via consola ([warn] format) - expect(combined).toContain("[warn]"); + // the warning appears in the formatted output expect(combined).toContain("Shell completions failed"); chmod(zshDir, 0o755); From 3df286c5182d1caf5cbabc498c808592c9a22699 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 14:30:33 +0000 Subject: [PATCH 19/25] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4ee65d16f..986ba5fb3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -111,6 +111,10 @@ sentry auth status Print the stored authentication token +**Flags:** +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + #### `sentry auth whoami` Show the currently authenticated user @@ -487,6 +491,8 @@ Configure shell integration - `--no-completions - Skip shell completion installation` - `--no-agent-skills - Skip agent skill installation for AI coding assistants` - `--quiet - Suppress output (for scripted usage)` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` #### `sentry cli upgrade ` From db8c84adca3f8757b36446b390fddcaec742b286 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 15 Mar 2026 14:44:06 +0000 Subject: [PATCH 20/25] fix: guard seenWithoutTs dedup against undefined l.id LogLike.id is optional (SentryLog uses sentry.item_id). Add null guards before Set.has/add to prevent undefined keys from causing incorrect deduplication in trace follow-mode. --- src/commands/log/list.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 513b19bab..d39621b4f 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -617,6 +617,9 @@ export const listCommand = buildListCommand("log", { return l.timestamp_precise > lastTs; } // No precise timestamp — deduplicate by id + if (!l.id) { + return true; // Can't dedup without id, include it + } if (seenWithoutTs.has(l.id)) { return false; } @@ -625,7 +628,7 @@ export const listCommand = buildListCommand("log", { }), onInitialLogs: (logs) => { for (const l of logs) { - if (l.timestamp_precise === undefined) { + if (l.timestamp_precise === undefined && l.id) { seenWithoutTs.add(l.id); } } From 349b8bbfa07813de0210917eeeaf3d8015164ba5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 15 Mar 2026 17:39:37 +0000 Subject: [PATCH 21/25] refactor: address round 5 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove stateless() helper — OutputConfig.human now accepts a plain (data: T) => string directly. The framework auto-wraps it via resolveRenderer() which uses .length === 0 to distinguish factories from plain formatters. All 28+ command files updated. 2. Use logger.warn() for setup warnings instead of collecting in array. Warnings are diagnostic messages that belong on stderr, not in the structured output. SetupResult.warnings field removed. All 1656 tests pass, typecheck + lint clean. --- src/commands/api.ts | 4 +- src/commands/auth/login.ts | 4 +- src/commands/auth/logout.ts | 4 +- src/commands/auth/refresh.ts | 4 +- src/commands/auth/status.ts | 4 +- src/commands/auth/token.ts | 4 +- src/commands/auth/whoami.ts | 4 +- src/commands/cli/feedback.ts | 4 +- src/commands/cli/fix.ts | 4 +- src/commands/cli/setup.ts | 20 +++------- src/commands/cli/upgrade.ts | 4 +- src/commands/event/view.ts | 4 +- src/commands/help.ts | 4 +- src/commands/issue/explain.ts | 4 +- src/commands/issue/list.ts | 3 +- src/commands/issue/plan.ts | 4 +- src/commands/issue/view.ts | 4 +- src/commands/log/view.ts | 4 +- src/commands/org/list.ts | 4 +- src/commands/org/view.ts | 4 +- src/commands/project/create.ts | 4 +- src/commands/project/list.ts | 5 +-- src/commands/project/view.ts | 4 +- src/commands/trace/list.ts | 4 +- src/commands/trace/logs.ts | 8 +--- src/commands/trace/view.ts | 4 +- src/commands/trial/list.ts | 4 +- src/commands/trial/start.ts | 4 +- src/lib/command.ts | 7 +++- src/lib/formatters/output.ts | 53 +++++++++++++++---------- src/lib/list-command.ts | 5 +-- test/lib/command.test.ts | 54 +++++++++---------------- test/lib/formatters/output.test.ts | 64 +++++++++++------------------- 33 files changed, 137 insertions(+), 178 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 74b7d3798..17c365f88 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { OutputError, ValidationError } from "../lib/errors.js"; -import { CommandOutput, stateless } from "../lib/formatters/output.js"; +import { CommandOutput } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; @@ -1053,7 +1053,7 @@ function logResponse(response: { status: number; headers: Headers }): void { } export const apiCommand = buildCommand({ - output: { human: stateless(formatApiResponse) }, + output: { human: formatApiResponse }, docs: { brief: "Make an authenticated API request", fullDescription: diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 4ebfc7da1..0913a844a 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -17,7 +17,7 @@ import { formatDuration, formatUserIdentity, } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import type { LoginResult } from "../../lib/interactive-login.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { logger } from "../../lib/logger.js"; @@ -129,7 +129,7 @@ export const loginCommand = buildCommand({ }, }, }, - output: { human: stateless(formatLoginResult) }, + output: { human: formatLoginResult }, async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 7ced09169..e48e87cb4 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -15,7 +15,7 @@ import { import { getDbPath } from "../../lib/db/index.js"; import { AuthError } from "../../lib/errors.js"; import { formatLogoutResult } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; /** Structured result of the logout operation */ export type LogoutResult = { @@ -33,7 +33,7 @@ export const logoutCommand = buildCommand({ fullDescription: "Remove stored authentication credentials from the local database.", }, - output: { human: stateless(formatLogoutResult) }, + output: { human: formatLogoutResult }, parameters: { flags: {}, }, diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 8f0566cba..73d7127a2 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -15,7 +15,7 @@ import { import { AuthError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { formatDuration } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; type RefreshFlags = { readonly json: boolean; @@ -59,7 +59,7 @@ Examples: {"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."} `.trim(), }, - output: { human: stateless(formatRefreshResult) }, + output: { human: formatRefreshResult }, parameters: { flags: { force: { diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 920538d96..20222fc4a 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -22,7 +22,7 @@ import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo } from "../../lib/db/user.js"; import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -144,7 +144,7 @@ export const statusCommand = buildCommand({ "Display information about your current authentication status, " + "including whether you're logged in and your default organization/project settings.", }, - output: { human: stateless(formatAuthStatus) }, + output: { human: formatAuthStatus }, parameters: { flags: { "show-token": { diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 1ab2d4664..190b6cf7a 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { AuthError } from "../../lib/errors.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; export const tokenCommand = buildCommand({ docs: { @@ -20,7 +20,7 @@ export const tokenCommand = buildCommand({ "piping to other commands or scripts.", }, parameters: {}, - output: { human: stateless((token: string) => token) }, + output: { human: (token: string) => token }, // biome-ignore lint/suspicious/useAwait: sync body but async generator required by buildCommand async *func(this: SentryContext) { const token = getAuthToken(); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 4412192ed..ed07e0b46 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,7 +13,7 @@ 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, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -35,7 +35,7 @@ export const whoamiCommand = buildCommand({ "the current token. Works with all token types: OAuth, API tokens, and OAuth App tokens.", }, output: { - human: stateless(formatUserIdentity), + human: formatUserIdentity, }, parameters: { flags: { diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index 0698922e0..8459bf4fb 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -14,7 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; /** Structured result of the feedback submission */ export type FeedbackResult = { @@ -31,7 +31,7 @@ export const feedbackCommand = buildCommand({ "Submit feedback about your experience with the Sentry CLI. " + "All text after 'feedback' is sent as your message.", }, - output: { human: stateless(formatFeedbackResult) }, + output: { human: formatFeedbackResult }, parameters: { flags: {}, positional: { diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 7834854c7..e76ca32b5 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -17,7 +17,7 @@ import { } from "../../lib/db/schema.js"; import { OutputError } from "../../lib/errors.js"; import { formatFixResult } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { getRealUsername } from "../../lib/utils.js"; type FixFlags = { @@ -669,7 +669,7 @@ export const fixCommand = buildCommand({ " sudo sentry cli fix # Fix root-owned files\n" + " sentry cli fix --dry-run # Show what would be fixed without making changes", }, - output: { human: stateless(formatFixResult) }, + output: { human: formatFixResult }, parameters: { flags: { "dry-run": { diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index f1946de2d..3f4dcad77 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -27,7 +27,8 @@ import { type ReleaseChannel, setReleaseChannel, } from "../../lib/db/release-channel.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; import { addToGitHubPath, addToPath, @@ -58,8 +59,6 @@ type Logger = (msg: string) => void; type SetupResult = { /** Status messages collected during setup */ messages: string[]; - /** Warning messages from best-effort steps */ - warnings: string[]; /** Whether a fresh binary was installed */ freshInstall: boolean; /** Path to the installed binary */ @@ -70,13 +69,7 @@ type SetupResult = { /** Format setup result for human-readable output */ function formatSetupResult(result: SetupResult): string { - const lines: string[] = [...result.messages]; - if (result.warnings.length > 0) { - for (const w of result.warnings) { - lines.push(`⚠ ${w}`); - } - } - return lines.join("\n"); + return result.messages.join("\n"); } /** @@ -464,12 +457,12 @@ export const setupCommand = buildCommand({ }, }, }, - output: { human: stateless(formatSetupResult) }, + output: { human: formatSetupResult }, async *func(this: SentryContext, flags: SetupFlags) { const { process, homeDir } = this; + const log = logger.withTag("cli.setup"); const messages: string[] = []; - const warnings: string[] = []; const emit: Logger = (msg: string) => { if (!flags.quiet) { @@ -480,7 +473,7 @@ export const setupCommand = buildCommand({ const warn: WarnLogger = (step, error) => { const msg = error instanceof Error ? error.message : "Unknown error occurred"; - warnings.push(`${step} failed: ${msg}`); + log.warn(`${step} failed: ${msg}`); }; let binaryPath = process.execPath; @@ -519,7 +512,6 @@ export const setupCommand = buildCommand({ return yield new CommandOutput({ messages, - warnings, freshInstall, binaryPath, version: CLI_VERSION, diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 1432f8652..835f56e99 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -31,7 +31,7 @@ import { } from "../../lib/db/release-channel.js"; import { UpgradeError } from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { detectInstallationMethod, @@ -418,7 +418,7 @@ export const upgradeCommand = buildCommand({ " sentry cli upgrade --force # Force re-download even if up to date\n" + " sentry cli upgrade --method npm # Force using npm to upgrade", }, - output: { human: stateless(formatUpgradeResult) }, + output: { human: formatUpgradeResult }, parameters: { positional: { kind: "tuple", diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index aeba67b32..e56d514b0 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,7 +23,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -304,7 +304,7 @@ export const viewCommand = buildCommand({ " sentry event view # find project across all orgs", }, output: { - human: stateless(formatEventView), + human: formatEventView, jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/help.ts b/src/commands/help.ts index 9e5a25bda..f63bca766 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -9,7 +9,7 @@ import { run } from "@stricli/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { CommandOutput, stateless } from "../lib/formatters/output.js"; +import { CommandOutput } from "../lib/formatters/output.js"; import { printCustomHelp } from "../lib/help.js"; export const helpCommand = buildCommand({ @@ -19,7 +19,7 @@ export const helpCommand = buildCommand({ "Display help information. Run 'sentry help' for an overview, " + "or 'sentry help ' for detailed help on a specific command.", }, - output: { human: stateless((s: string) => s) }, + output: { human: (s: string) => s }, parameters: { flags: {}, positional: { diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index dee813c9b..c76102b65 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -7,7 +7,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError } from "../../lib/errors.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRootCauseList, handleSeerApiError, @@ -59,7 +59,7 @@ export const explainCommand = buildCommand({ " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, - output: { human: stateless(formatRootCauseList) }, + output: { human: formatRootCauseList }, parameters: { positional: issueIdPositional, flags: { diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 88618130c..697751b5c 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -44,7 +44,6 @@ import { import { CommandOutput, type OutputConfig, - stateless, } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -1241,7 +1240,7 @@ const jsonTransformIssueList = jsonTransformListResult; /** Output configuration for the issue list command. */ const issueListOutput: OutputConfig = { - human: stateless(formatIssueListHuman), + human: formatIssueListHuman, jsonTransform: jsonTransformIssueList, }; diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 2e75a60c1..ac24ece14 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { formatSolution, handleSeerApiError, @@ -171,7 +171,7 @@ export const planCommand = buildCommand({ " sentry issue plan 123456789 --force", }, output: { - human: stateless(formatPlanOutput), + human: formatPlanOutput, }, parameters: { positional: issueIdPositional, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 1aad705ce..808284296 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -14,7 +14,7 @@ import { formatIssueDetails, muted, } from "../../lib/formatters/index.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -102,7 +102,7 @@ export const viewCommand = buildCommand({ "where 'f' is the project alias shown in the list).", }, output: { - human: stateless(formatIssueView), + human: formatIssueView, jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index e8d26e27d..d7e966726 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,7 +18,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, @@ -319,7 +319,7 @@ export const viewCommand = buildCommand({ "The log ID is the 32-character hexadecimal identifier shown in log listings.", }, output: { - human: stateless(formatLogViewHuman), + human: formatLogViewHuman, // Preserve original JSON contract: bare array of log entries. // orgSlug exists only for the human formatter (trace URLs). jsonTransform: (data: LogViewData, fields) => diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 74cd60aeb..a15ae4412 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -10,7 +10,7 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -117,7 +117,7 @@ export const listCommand = buildCommand({ " sentry org list --limit 10\n" + " sentry org list --json", }, - output: { human: stateless(formatOrgListHuman) }, + output: { human: formatOrgListHuman }, parameters: { flags: { limit: buildListLimitFlag("organizations"), diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index e049bceb5..1c2e3e0d3 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,7 +10,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails } from "../../lib/formatters/index.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -36,7 +36,7 @@ export const viewCommand = buildCommand({ " 2. Config defaults\n" + " 3. SENTRY_DSN environment variable or source code detection", }, - output: { human: stateless(formatOrgDetails) }, + output: { human: formatOrgDetails }, parameters: { positional: { kind: "tuple", diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 1a01b91bf..05c3c0dca 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,7 +35,7 @@ import { type ProjectCreatedResult, } from "../../lib/formatters/human.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; import { logger } from "../../lib/logger.js"; @@ -276,7 +276,7 @@ export const createCommand = buildCommand({ " sentry project create my-app go --json", }, output: { - human: stateless(formatProjectCreated), + human: formatProjectCreated, jsonExclude: [ "slugDiverged", "expectedSlug", diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index e3b1fcf7d..5f5496ad5 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -35,7 +35,6 @@ import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput, type OutputConfig, - stateless, } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { @@ -565,7 +564,7 @@ export const listCommand = buildListCommand("project", { " sentry project list --json # output as JSON", }, output: { - human: stateless((result: ListResult) => { + human: (result: ListResult) => { if (result.items.length === 0) { return result.hint ?? "No projects found."; } @@ -574,7 +573,7 @@ export const listCommand = buildListCommand("project", { parts.push(`\n${result.header}`); } return parts.join(""); - }), + }, jsonTransform: jsonTransformListResult, } satisfies OutputConfig>, parameters: { diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 0ac24a21b..5e351a59c 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,7 +15,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -186,7 +186,7 @@ export const viewCommand = buildCommand({ "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, output: { - human: stateless(formatProjectViewHuman), + human: formatProjectViewHuman, jsonExclude: ["detectedFrom"], }, parameters: { diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 3b625b5f7..b48fd078a 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -15,7 +15,7 @@ import { } from "../../lib/db/pagination.js"; import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, buildListCommand, @@ -180,7 +180,7 @@ export const listCommand = buildListCommand("trace", { ' sentry trace list -q "transaction:GET /api/users" # Filter by transaction', }, output: { - human: stateless(formatTraceListHuman), + human: formatTraceListHuman, jsonTransform: jsonTransformTraceList, }, parameters: { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 749a7b8a8..44ad537fc 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -12,11 +12,7 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { formatLogTable } from "../../lib/formatters/log.js"; -import { - CommandOutput, - formatFooter, - stateless, -} from "../../lib/formatters/output.js"; +import { CommandOutput, formatFooter } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -169,7 +165,7 @@ export const logsCommand = buildCommand({ " sentry trace logs --json abc123def456abc123def456abc123de", }, output: { - human: stateless(formatTraceLogsHuman), + human: formatTraceLogsHuman, jsonTransform: (data: TraceLogsData, fields?: string[]) => { if (fields && fields.length > 0) { return data.logs.map((entry) => filterFields(entry, fields)); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 283f607b5..7e87915de 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -21,7 +21,7 @@ import { formatSimpleSpanTree, formatTraceSummary, } from "../../lib/formatters/index.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -205,7 +205,7 @@ export const viewCommand = buildCommand({ "The trace ID is the 32-character hexadecimal identifier.", }, output: { - human: stateless(formatTraceView), + human: formatTraceView, jsonExclude: ["spanTreeLines"], }, parameters: { diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index 43233ff34..6a0792815 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -11,7 +11,7 @@ import { getCustomerTrialInfo } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { colorTag } from "../../lib/formatters/markdown.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { @@ -203,7 +203,7 @@ export const listCommand = buildCommand({ " sentry trial list --json", }, output: { - human: stateless(formatTrialListHuman), + human: formatTrialListHuman, jsonExclude: ["displayName"], }, parameters: { diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index 22a4d6ab2..02d3b8679 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -22,7 +22,7 @@ import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; -import { CommandOutput, stateless } from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { logger as log } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; @@ -89,7 +89,7 @@ export const startCommand = buildCommand({ " sentry trial start plan\n" + " sentry trial start --json seer", }, - output: { human: stateless(formatStartResult) }, + output: { human: formatStartResult }, parameters: { positional: { kind: "tuple" as const, diff --git a/src/lib/command.ts b/src/lib/command.ts index e608ac54c..21058a03f 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -45,6 +45,7 @@ import { type HumanRenderer, type OutputConfig, renderCommandOutput, + resolveRenderer, writeFooter, } from "./formatters/output.js"; import { @@ -137,7 +138,7 @@ type LocalCommandBuilderArguments< * @example * ```ts * buildCommand({ - * output: { human: stateless(formatUser) }, + * output: { human: formatUser }, * async *func() { yield new CommandOutput(user); }, * }) * ``` @@ -399,7 +400,9 @@ export function buildCommand< // Resolve the human renderer once per invocation. Factory creates // fresh per-invocation state for streaming commands. - const renderer = outputConfig ? outputConfig.human() : undefined; + const renderer = outputConfig + ? resolveRenderer(outputConfig.human) + : undefined; // OutputError handler: render data through the output system, then // exit with the error's code. Stricli overwrites process.exitCode = 0 diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 9b4251c43..e0693254e 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -15,7 +15,7 @@ * `buildCommand`, then yield data from the generator: * ```ts * buildCommand({ - * output: { human: stateless(formatUser) }, + * output: { human: formatUser }, * async *func() { yield new CommandOutput(data); }, * }) * ``` @@ -86,44 +86,53 @@ export type HumanRenderer = { }; /** - * Create a stateless {@link HumanRenderer} from a plain formatter function. + * Resolve the `human` field of an {@link OutputConfig} into a + * {@link HumanRenderer}. Supports two forms: * - * Most commands don't need per-invocation state — use this helper to wrap - * a simple `(data: T) => string` function into the renderer interface. + * 1. **Plain function** — `(data: T) => string` — auto-wrapped into a + * stateless renderer (no `finalize`). + * 2. **Factory** — `() => HumanRenderer` — called once per invocation + * to produce a renderer with optional `finalize()`. * - * @example - * ```ts - * output: { - * human: stateless(formatMyData), - * } - * ``` + * Disambiguation: a function with `.length === 0` is treated as a factory. */ -export function stateless(fn: (data: T) => string): () => HumanRenderer { - return () => ({ render: fn }); +export function resolveRenderer(human: HumanOutput): HumanRenderer { + // Factory: zero-arg function that returns a renderer + if (human.length === 0) { + return (human as () => HumanRenderer)(); + } + // Plain formatter: wrap in a stateless renderer + return { render: human as (data: T) => string }; } +/** + * Human rendering for an {@link OutputConfig}. + * + * Two forms: + * - **Plain function** `(data: T) => string` — stateless, auto-wrapped. + * - **Factory** `() => HumanRenderer` — called per invocation for + * stateful renderers (e.g., streaming tables with `finalize()`). + */ +export type HumanOutput = ((data: T) => string) | (() => HumanRenderer); + /** * Output configuration declared on `buildCommand` for automatic rendering. * * When present, `--json` and `--fields` flags are injected and the wrapper - * auto-renders yielded {@link CommandOutput} values. The `human` field is a - * **factory** called once per invocation to produce a {@link HumanRenderer}. - * Use {@link stateless} for simple formatters. + * auto-renders yielded {@link CommandOutput} values. * * @typeParam T - Type of data the command yields (used by `human` formatter * and serialized as-is to JSON) */ export type OutputConfig = { /** - * Factory that creates a {@link HumanRenderer} per invocation. - * - * Called once before the generator starts iterating. The returned - * renderer's `render()` is called per yield, and `finalize()` is - * called once after the generator completes. + * Human-readable renderer. * - * Use {@link stateless} to wrap a plain formatter function. + * Pass a plain `(data: T) => string` for stateless formatting, or a + * zero-arg factory `() => HumanRenderer` for stateful rendering + * with `finalize()` support. */ - human: () => HumanRenderer; + human: HumanOutput; /** * Top-level keys to strip from JSON output. * diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index e9f5eb740..6d649f46f 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -23,7 +23,6 @@ import { CommandOutput, type CommandReturn, type OutputConfig, - stateless, } from "./formatters/output.js"; import { dispatchOrgScopedList, @@ -474,9 +473,7 @@ export function buildOrgListCommand( return buildListCommand(routeName, { docs, output: { - human: stateless((result: ListResult) => - formatListHuman(result, config) - ), + human: (result: ListResult) => formatListHuman(result, config), jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), } satisfies OutputConfig>, diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 77fb43bd7..56a093c09 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -25,7 +25,7 @@ import { VERBOSE_FLAG, } from "../../src/lib/command.js"; import { OutputError } from "../../src/lib/errors.js"; -import { CommandOutput, stateless } from "../../src/lib/formatters/output.js"; +import { CommandOutput } from "../../src/lib/formatters/output.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; /** Minimal context for test commands */ @@ -673,7 +673,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: { flags: { limit: { @@ -716,7 +716,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -755,7 +755,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -793,7 +793,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -857,7 +857,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: { flags: { json: { @@ -900,7 +900,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield async *func( @@ -941,7 +941,7 @@ describe("buildCommand output config", () => { TestContext >({ docs: { brief: "Test" }, - output: { human: stateless(() => "unused") }, + output: { human: () => "unused" }, parameters: { flags: { limit: { @@ -996,10 +996,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless( - (d: { name: string; role: string }) => `${d.name} (${d.role})` - ), + human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, parameters: {}, async *func(this: TestContext) { @@ -1027,10 +1024,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless( - (d: { name: string; role: string }) => `${d.name} (${d.role})` - ), + human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, }, parameters: {}, async *func(this: TestContext) { @@ -1059,10 +1053,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless( - (d: { id: number; name: string; role: string }) => `${d.name}` - ), + human: (d: { id: number; name: string; role: string }) => `${d.name}`, }, parameters: {}, async *func(this: TestContext) { @@ -1093,8 +1084,7 @@ describe("buildCommand return-based output", () => { buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: { value: number }) => `Value: ${d.value}`), + human: (d: { value: number }) => `Value: ${d.value}`, }, parameters: {}, async *func(this: TestContext) { @@ -1141,8 +1131,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless(() => "unused"), + human: () => "unused", }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -1199,8 +1188,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: { name: string }) => `Hello, ${d.name}!`), + human: (d: { name: string }) => `Hello, ${d.name}!`, }, parameters: {}, async *func(this: TestContext) { @@ -1230,10 +1218,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: Array<{ id: number }>) => - d.map((x) => x.id).join(", ") - ), + human: (d: Array<{ id: number }>) => d.map(((x) => x.id).join(", ")), }, parameters: {}, async *func(this: TestContext) { @@ -1262,8 +1247,7 @@ describe("buildCommand return-based output", () => { buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: { org: string }) => `Org: ${d.org}`), + human: (d: { org: string }) => `Org: ${d.org}`, }, parameters: {}, async *func(this: TestContext) { @@ -1304,8 +1288,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: { error: string }) => `Error: ${d.error}`), + human: (d: { error: string }) => `Error: ${d.error}`, }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -1356,8 +1339,7 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: stateless((d: { error: string }) => `Error: ${d.error}`), + human: (d: { error: string }) => `Error: ${d.error}`, }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 9d742bcf2..0cdf57372 100644 --- a/test/lib/formatters/output.test.ts +++ b/test/lib/formatters/output.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { type OutputConfig, renderCommandOutput, - stateless, + resolveRenderer, writeFooter, writeOutput, } from "../../../src/lib/formatters/output.js"; @@ -33,7 +33,7 @@ function render( config: OutputConfig, ctx: { json: boolean; fields?: string[] } ) { - const renderer = config.human(); + const renderer = resolveRenderer(config.human); renderCommandOutput(w, data, config, renderer, ctx); } @@ -204,8 +204,7 @@ describe("renderCommandOutput", () => { test("renders JSON when json=true", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string }> = { - json: true, - human: stateless((d) => `${d.name}`), + human: (d) => `${d.name}`, }; render(w, { id: 1, name: "Alice" }, config, { json: true }); expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); @@ -214,8 +213,7 @@ describe("renderCommandOutput", () => { test("renders human output when json=false", () => { const w = createTestWriter(); const config: OutputConfig<{ name: string }> = { - json: true, - human: stateless((d) => `Hello ${d.name}`), + human: (d) => `Hello ${d.name}`, }; render(w, { name: "Alice" }, config, { json: false }); expect(w.output).toBe("Hello Alice\n"); @@ -224,8 +222,7 @@ describe("renderCommandOutput", () => { test("applies fields filtering in JSON mode", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; secret: string }> = { - json: true, - human: stateless(() => "unused"), + human: (_d) => "unused", }; render(w, { id: 1, name: "Alice", secret: "x" }, config, { json: true, @@ -237,8 +234,7 @@ describe("renderCommandOutput", () => { test("does not render hints (hints are rendered by the wrapper after generator completes)", () => { const w = createTestWriter(); const config: OutputConfig = { - json: true, - human: stateless(() => "Result"), + human: (_d) => "Result", }; // renderCommandOutput only renders data — hints are handled by // buildCommand's wrapper via the generator return value @@ -249,8 +245,7 @@ describe("renderCommandOutput", () => { test("works without hint", () => { const w = createTestWriter(); const config: OutputConfig<{ value: number }> = { - json: true, - human: stateless((d) => `Value: ${d.value}`), + human: (d) => `Value: ${d.value}`, }; render(w, { value: 42 }, config, { json: false }); expect(w.output).toBe("Value: 42\n"); @@ -263,8 +258,7 @@ describe("renderCommandOutput", () => { name: string; spanTreeLines?: string[]; }> = { - json: true, - human: stateless((d) => `${d.id}: ${d.name}`), + human: (d) => `${d.id}: ${d.name}`, jsonExclude: ["spanTreeLines"], }; render( @@ -284,10 +278,8 @@ describe("renderCommandOutput", () => { id: number; spanTreeLines?: string[]; }> = { - json: true, - human: stateless( - (d) => `${d.id}\n${d.spanTreeLines ? d.spanTreeLines.join("\n") : ""}` - ), + human: (d) => + `${d.id}\n${d.spanTreeLines ? d.spanTreeLines.join("\n") : ""}`, jsonExclude: ["spanTreeLines"], }; render(w, { id: 1, spanTreeLines: ["line1", "line2"] }, config, { @@ -300,8 +292,7 @@ describe("renderCommandOutput", () => { test("jsonExclude with empty array is a no-op", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; extra: string }> = { - json: true, - human: stateless((d) => `${d.id}`), + human: (d) => `${d.id}`, jsonExclude: [], }; render(w, { id: 1, extra: "keep" }, config, { json: true }); @@ -312,10 +303,8 @@ describe("renderCommandOutput", () => { test("jsonExclude strips fields from array elements", () => { const w = createTestWriter(); const config: OutputConfig = { - json: true, - human: stateless((d: { id: number; name: string }[]) => - d.map((e) => e.name).join(", ") - ), + human: (d: { id: number; name: string }[]) => + d.map((e) => e.name).join(", "), jsonExclude: ["detectedFrom"], }; render( @@ -343,8 +332,7 @@ describe("renderCommandOutput", () => { org: string; }; const config: OutputConfig = { - json: true, - human: stateless((d) => d.items.map((i) => i.name).join(", ")), + human: (d) => d.items.map((i) => i.name).join(", "), jsonTransform: (data) => ({ data: data.items, hasMore: data.hasMore, @@ -372,8 +360,7 @@ describe("renderCommandOutput", () => { hasMore: boolean; }; const config: OutputConfig = { - json: true, - human: stateless(() => "unused"), + human: (_d) => "unused", jsonTransform: (data, fields) => ({ data: fields && fields.length > 0 @@ -407,8 +394,7 @@ describe("renderCommandOutput", () => { test("jsonTransform is ignored in human mode", () => { const w = createTestWriter(); const config: OutputConfig<{ items: string[]; org: string }> = { - json: true, - human: stateless((d) => `${d.org}: ${d.items.join(", ")}`), + human: (d) => `${d.org}: ${d.items.join(", ")}`, jsonTransform: (data) => ({ data: data.items }), }; render(w, { items: ["a", "b"], org: "test-org" }, config, { @@ -420,8 +406,7 @@ describe("renderCommandOutput", () => { test("jsonTransform takes precedence over jsonExclude", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; extra: string }> = { - json: true, - human: stateless(() => "unused"), + human: (_d) => "unused", jsonExclude: ["extra"], jsonTransform: (data) => ({ transformed: true, id: data.id }), }; @@ -436,7 +421,6 @@ describe("renderCommandOutput", () => { test("human factory creates fresh renderer per resolve", () => { const calls: number[] = []; const config: OutputConfig<{ n: number }> = { - json: true, human: () => ({ render: (d) => { calls.push(d.n); @@ -446,11 +430,11 @@ describe("renderCommandOutput", () => { }; // First resolve + render - const r1 = config.human(); + const r1 = resolveRenderer(config.human); r1.render({ n: 1 }); // Second resolve = fresh renderer - const r2 = config.human(); + const r2 = resolveRenderer(config.human); r2.render({ n: 2 }); expect(calls).toEqual([1, 2]); @@ -459,14 +443,13 @@ describe("renderCommandOutput", () => { test("finalize is called with hint and output is written", () => { const w = createTestWriter(); const config: OutputConfig<{ value: string }> = { - json: true, human: () => ({ render: (d) => `[${d.value}]`, finalize: (hint) => `=== END ===${hint ? `\n${hint}` : ""}`, }), }; - const renderer = config.human(); + const renderer = resolveRenderer(config.human); renderCommandOutput(w, { value: "test" }, config, renderer, { json: false, }); @@ -477,12 +460,11 @@ describe("renderCommandOutput", () => { expect(footer).toBe("=== END ===\nDone."); }); - test("stateless renderer has no finalize method", () => { + test("plain function renderer has no finalize method", () => { const config: OutputConfig = { - json: true, - human: stateless((s) => s.toUpperCase()), + human: (s) => s.toUpperCase(), }; - const renderer = config.human(); + const renderer = resolveRenderer(config.human); expect(renderer.render("hello")).toBe("HELLO"); expect(renderer.finalize).toBeUndefined(); }); From 77db1555ab998cd1975dcb231566d0b5cdad8636 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 15 Mar 2026 17:54:19 +0000 Subject: [PATCH 22/25] fix: include warnings in cli setup JSON output Warnings from best-effort steps (e.g. permission errors during completions install) are now collected into SetupResult.warnings and included in --json output. They're still logged to stderr via logger.warn() for real-time human feedback. --- src/commands/cli/setup.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index 3f4dcad77..634d28cc6 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -59,6 +59,8 @@ type Logger = (msg: string) => void; type SetupResult = { /** Status messages collected during setup */ messages: string[]; + /** Warning messages from best-effort steps that failed non-fatally */ + warnings: string[]; /** Whether a fresh binary was installed */ freshInstall: boolean; /** Path to the installed binary */ @@ -463,6 +465,7 @@ export const setupCommand = buildCommand({ const log = logger.withTag("cli.setup"); const messages: string[] = []; + const warnings: string[] = []; const emit: Logger = (msg: string) => { if (!flags.quiet) { @@ -473,7 +476,9 @@ export const setupCommand = buildCommand({ const warn: WarnLogger = (step, error) => { const msg = error instanceof Error ? error.message : "Unknown error occurred"; - log.warn(`${step} failed: ${msg}`); + const warning = `${step} failed: ${msg}`; + log.warn(warning); + warnings.push(warning); }; let binaryPath = process.execPath; @@ -512,6 +517,7 @@ export const setupCommand = buildCommand({ return yield new CommandOutput({ messages, + warnings, freshInstall, binaryPath, version: CLI_VERSION, From e92a85158b34de0dcdb67ccf48db90f287f2eacb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 15 Mar 2026 19:39:29 +0000 Subject: [PATCH 23/25] fix: return empty array for log list --json with no results, add init.ts lint suppression --- src/commands/log/list.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index d39621b4f..47f71708d 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -482,16 +482,12 @@ function createLogRenderer(): HumanRenderer { * * Each yielded batch is written as a JSON array. In follow mode, * each batch is a short array (one poll result); in single-fetch mode - * it's the full result set. Empty batches are suppressed. + * it's the full result set. */ function jsonTransformLogOutput( result: LogListResult, fields?: string[] ): unknown { - if (result.logs.length === 0) { - return; - } - return fields && fields.length > 0 ? result.logs.map((log) => filterFields(log, fields)) : result.logs; From d56ced1f4fad38b7f959608ef5a321ce027fcb6c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 06:59:14 +0000 Subject: [PATCH 24/25] refactor: log/list yields bare items in follow mode for proper JSONL --- src/commands/log/list.ts | 108 ++++++++++++++++++++++----------- test/commands/log/list.test.ts | 23 +++++-- 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 47f71708d..97f438e21 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -52,11 +52,11 @@ type ListFlags = { }; /** - * Result yielded by the log list command — one per batch. + * Result yielded by the log list command in single-fetch mode. * - * Both single-fetch and follow mode yield the same type. The human - * renderer always renders incrementally (header on first non-empty - * batch, rows per batch, footer via `finalize()`). + * Contains the full array of logs and optional trace context. + * Follow mode yields bare {@link LogLike} items instead — see + * {@link LogOutput} for the union type. */ type LogListResult = { logs: LogLike[]; @@ -64,6 +64,9 @@ type LogListResult = { traceId?: string; }; +/** Output yielded by log list: either a batch (single-fetch) or an individual item (follow). */ +type LogOutput = LogLike | LogListResult; + /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -279,9 +282,9 @@ async function fetchPoll( /** * Async generator that streams log entries via follow-mode polling. * - * Yields batches of log entries (chronological order). The command wraps - * each batch in a `LogListResult` so the OutputConfig formatters can - * handle incremental rendering and JSONL expansion. + * Yields batches of log entries (chronological order). The command + * unwraps each batch into individual {@link CommandOutput} yields so + * the OutputConfig formatters can handle incremental rendering and JSONL. * * The generator handles SIGINT via AbortController for clean shutdown. * It never touches stdout — all data output flows through yielded batches @@ -340,13 +343,37 @@ async function* generateFollowLogs( * The generator returns when SIGINT fires — the wrapper's `finalize()` * callback handles closing the streaming table. */ -async function* yieldFollowBatches( - generator: AsyncGenerator, - extra?: Partial -): AsyncGenerator { +async function* yieldFollowItems( + generator: AsyncGenerator +): AsyncGenerator, void, undefined> { for await (const batch of generator) { for (const item of batch) { - yield new CommandOutput({ logs: [item], ...extra }); + yield new CommandOutput(item); + } + } +} + +/** + * Consume a trace follow-mode generator, yielding items individually. + * + * The first non-empty batch is yielded as a {@link LogListResult} so + * the human renderer can detect `traceId` and hide the trace column. + * Subsequent items are yielded bare for proper JSONL streaming. + */ +async function* yieldTraceFollowItems( + generator: AsyncGenerator, + traceId: string +): AsyncGenerator, void, undefined> { + let contextSent = false; + for await (const batch of generator) { + if (!contextSent && batch.length > 0) { + // First non-empty batch: yield as LogListResult to set trace context + yield new CommandOutput({ logs: batch, traceId }); + contextSent = true; + } else { + for (const item of batch) { + yield new CommandOutput(item); + } } } } @@ -427,31 +454,41 @@ function writeFollowBanner( * All yields go through `render()` — both single-fetch and follow mode. * The renderer emits the table header on the first non-empty batch, rows * per batch, and the table footer + hint via `finalize()`. + * + * Discriminates between {@link LogListResult} (single-fetch or first trace + * follow batch) and bare {@link LogLike} items (follow mode). */ -function createLogRenderer(): HumanRenderer { +function createLogRenderer(): HumanRenderer { const plain = isPlainOutput(); const table: StreamingTable | undefined = plain ? undefined : createLogStreamingTable(); + let includeTrace = true; // default: show trace column let headerEmitted = false; + function isBatch(data: LogOutput): data is LogListResult { + return "logs" in data && Array.isArray((data as LogListResult).logs); + } + return { - render(result: LogListResult): string { - if (result.logs.length === 0) { + render(data: LogOutput): string { + const logs: LogLike[] = isBatch(data) ? data.logs : [data]; + if (logs.length === 0) { return ""; } - const includeTrace = !result.traceId; - let text = ""; - - // Emit header on first non-empty batch + // First non-empty call: determine includeTrace and emit header if (!headerEmitted) { - text += table ? table.header() : formatLogsHeader(); + if (isBatch(data) && data.traceId) { + includeTrace = false; + } headerEmitted = true; + let text = table ? table.header() : formatLogsHeader(); + text += renderLogRows(logs, includeTrace, table); + return text.trimEnd(); } - text += renderLogRows(result.logs, includeTrace, table); - return text.trimEnd(); + return renderLogRows(logs, includeTrace, table).trimEnd(); }, finalize(hint?: string): string { @@ -480,17 +517,20 @@ function createLogRenderer(): HumanRenderer { /** * Transform log output into the JSON shape. * - * Each yielded batch is written as a JSON array. In follow mode, - * each batch is a short array (one poll result); in single-fetch mode - * it's the full result set. + * Discriminates between {@link LogListResult} (single-fetch) and bare + * {@link LogLike} items (follow mode). Single-fetch yields a JSON array; + * follow mode yields one JSON object per line (JSONL). */ -function jsonTransformLogOutput( - result: LogListResult, - fields?: string[] -): unknown { - return fields && fields.length > 0 - ? result.logs.map((log) => filterFields(log, fields)) - : result.logs; +function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown { + if ("logs" in data && Array.isArray((data as LogListResult).logs)) { + // Batch (single-fetch): return array + const logs = (data as LogListResult).logs; + return fields && fields.length > 0 + ? logs.map((log) => filterFields(log, fields)) + : logs; + } + // Single item (follow mode): return bare object for JSONL + return fields && fields.length > 0 ? filterFields(data, fields) : data; } export const listCommand = buildListCommand("log", { @@ -631,7 +671,7 @@ export const listCommand = buildListCommand("log", { }, }); - yield* yieldFollowBatches(generator, { traceId }); + yield* yieldTraceFollowItems(generator, traceId); return; } @@ -674,7 +714,7 @@ export const listCommand = buildListCommand("log", { extractNew: (logs) => logs, }); - yield* yieldFollowBatches(generator); + yield* yieldFollowItems(generator); return; } diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 589e123fb..7038bf5a9 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -952,20 +952,23 @@ describe("listCommand.func — follow mode (trace)", () => { // (old logs from poll are filtered by timestamp_precise) }); - test("streams JSON objects per-line in trace follow mode", async () => { - listTraceLogsSpy.mockResolvedValueOnce(sampleTraceLogs); + test("streams JSON in trace follow mode: first batch as array, then bare items", async () => { + listTraceLogsSpy + .mockResolvedValueOnce(sampleTraceLogs) + .mockResolvedValueOnce(newerTraceLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, { ...traceFollowFlags, json: true }); - await Bun.sleep(50); + // Wait for initial fetch + poll timer (1s) + poll execution + await Bun.sleep(1200); sigint.trigger(); await promise; const calls = stdoutWrite.mock.calls.map((c) => c[0]); - const jsonObjects = calls.filter((s: string) => { + const jsonLines = calls.filter((s: string) => { try { JSON.parse(s); return true; @@ -973,7 +976,17 @@ describe("listCommand.func — follow mode (trace)", () => { return false; } }); - expect(jsonObjects.length).toBe(3); + // First batch: 1 JSON line (array of 3 items from LogListResult) + // Poll batch: 1 JSON line per item (bare JSONL) + expect(jsonLines.length).toBe(2); + // First line is an array (the initial trace batch) + const firstBatch = JSON.parse(jsonLines[0]); + expect(Array.isArray(firstBatch)).toBe(true); + expect(firstBatch).toHaveLength(3); + // Second line is a bare object (polled item) + const pollItem = JSON.parse(jsonLines[1]); + expect(Array.isArray(pollItem)).toBe(false); + expect(pollItem.message).toBe("New poll result"); }); test("rejects with AuthError from poll", async () => { From fd69e7fcf50099a2f112a1ff9b3b2e4c0f68e49c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 07:19:35 +0000 Subject: [PATCH 25/25] fix: trim trailing newlines in help and trace logs formatters --- src/commands/help.ts | 2 +- src/commands/trace/logs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index f63bca766..5efeb402e 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -19,7 +19,7 @@ export const helpCommand = buildCommand({ "Display help information. Run 'sentry help' for an overview, " + "or 'sentry help ' for detailed help on a specific command.", }, - output: { human: (s: string) => s }, + output: { human: (s: string) => s.trimEnd() }, parameters: { flags: {}, positional: { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 44ad537fc..1239080ca 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -59,7 +59,7 @@ function formatTraceLogsHuman(data: TraceLogsData): string { const countText = `Showing ${data.logs.length} log${data.logs.length === 1 ? "" : "s"} for trace ${data.traceId}.`; const tip = hasMore ? " Use --limit to show more." : ""; parts.push(formatFooter(`${countText}${tip}`)); - return parts.join(""); + return parts.join("").trimEnd(); } /** Maximum allowed value for --limit flag */