From ddddcf08c410a766e03c4ebda62c23e7cabafa82 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 12 Mar 2026 22:00:15 +0000 Subject: [PATCH 01/17] 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 1854672e2..5bdfc1fb3 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -18,7 +18,7 @@ import type { SentryContext } from "../context.js"; import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.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, @@ -133,7 +133,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); * ``` */ @@ -308,12 +308,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, @@ -323,8 +321,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 @@ -483,7 +480,7 @@ export function buildOrgListCommand( }, aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES }, }, - async func( + async *func( this: SentryContext, flags: { readonly limit: number; @@ -493,7 +490,7 @@ export function buildOrgListCommand( readonly fields?: string[]; }, target?: string - ): Promise>> { + ) { applyFreshFlag(flags); const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); @@ -507,7 +504,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 13047a56322557b51f2111e14e644e138a619d09 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 14:25:07 +0000 Subject: [PATCH 02/17] 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 5bdfc1fb3..9a378ddeb 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -18,7 +18,11 @@ import type { SentryContext } from "../context.js"; import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.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, @@ -321,7 +325,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 @@ -501,10 +506,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 5e76d00f5f05acf20b039139c8d66046425332df Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 17:15:33 +0000 Subject: [PATCH 03/17] 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 85e27c835..46410b0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -628,69 +628,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 9a378ddeb..a31f40f40 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -497,11 +497,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 6fd7c4f35528fbcf77ae06c1d3e0d3b0b3be6ed0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 18:29:00 +0000 Subject: [PATCH 04/17] 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 c874960fd29920c3e15d7ab81fd7636b5e33c2d3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 18:53:05 +0000 Subject: [PATCH 05/17] =?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 554b6ddfe2ae0b7ca183180d83b669ac2dc3ff03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 18:53:42 +0000 Subject: [PATCH 06/17] 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 4814dc9ae..cec1db199 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 8f18cb41c19d43372c3abcd4b940dd4e6ef8f1e0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:34:04 +0000 Subject: [PATCH 07/17] 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 a31f40f40..b992809dc 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -22,6 +22,7 @@ import { type CommandReturn, commandOutput, type OutputConfig, + stateless, } from "./formatters/output.js"; import { dispatchOrgScopedList, @@ -472,7 +473,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 c58f49a4bddff6bf023ed65a5eeda1c07f55acd0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:43:06 +0000 Subject: [PATCH 08/17] 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 6cc7a1f14a7e83db491b663490a8cbf770794eda Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 21:44:41 +0000 Subject: [PATCH 09/17] 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 ac92ad763e23f805cbe6b5ae493399b0b214793d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:15:16 +0000 Subject: [PATCH 10/17] 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 1b12b53c7a3481eb31e862ebc8632832c55fd7a3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:23:23 +0000 Subject: [PATCH 11/17] 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 b2d4599629a6dc0effb68b504b651a49c9b78f65 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:28:17 +0000 Subject: [PATCH 12/17] 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 b0cedb3aa7e81dadeac344f6bffcbe98ef7782f7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:34:16 +0000 Subject: [PATCH 13/17] 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 df5fd58bdb977febf328d8332a9f426112b96e61 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Mar 2026 23:41:06 +0000 Subject: [PATCH 14/17] 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 b91bae8a8c5a348c3391809ce1f225bf96df96a1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 20:50:06 +0000 Subject: [PATCH 15/17] 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 f7b2941b20af9940f41d970b98f8670c9a070ffa Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 21:03:34 +0000 Subject: [PATCH 16/17] 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 b08a507d87a17c8c7b5e4b460536b773bc1d4b73 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Mar 2026 22:08:18 +0000 Subject: [PATCH 17/17] 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 b992809dc..6c1c915e7 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -19,8 +19,8 @@ import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { warning } from "./formatters/colors.js"; import { + CommandOutput, type CommandReturn, - commandOutput, type OutputConfig, stateless, } from "./formatters/output.js"; @@ -84,7 +84,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. */ @@ -368,7 +368,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; @@ -472,7 +472,6 @@ export function buildOrgListCommand( return buildListCommand(routeName, { docs, output: { - json: true, human: stateless((result: ListResult) => formatListHuman(result, config) ), @@ -508,7 +507,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" }; }, });