diff --git a/AGENTS.md b/AGENTS.md index 48ec3ae5..74acde8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -653,69 +653,39 @@ mock.module("./some-module", () => ({ ### Architecture - -* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. + +* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. - -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. + +* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. + +* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted. - -* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. - - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. - - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. + +* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`. ### Decision - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. - - -* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands should follow a consistent \`\ \\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions. ### Gotcha - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. - - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. - - -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. - - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. + +* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed. ### Pattern - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. - - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. + +* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). + +* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization. - -* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). + +* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 387af9dc..986ba5fb 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:** @@ -109,6 +111,10 @@ sentry auth status Print the stored authentication token +**Flags:** +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + #### `sentry auth whoami` Show the currently authenticated user @@ -485,6 +491,8 @@ Configure shell integration - `--no-completions - Skip shell completion installation` - `--no-agent-skills - Skip agent skill installation for AI coding assistants` - `--quiet - Suppress output (for scripted usage)` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` #### `sentry cli upgrade ` diff --git a/src/bin.ts b/src/bin.ts index 9fe821ae..b1e9c02a 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(); if (loginSuccess) { process.stderr.write("\nRetrying command...\n\n"); diff --git a/src/commands/api.ts b/src/commands/api.ts index 37c1e0ae..17c365f8 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"; @@ -1052,7 +1053,7 @@ function logResponse(response: { status: number; headers: Headers }): void { } export const apiCommand = buildCommand({ - output: { json: true, human: formatApiResponse }, + output: { human: formatApiResponse }, docs: { brief: "Make an authenticated API request", fullDescription: @@ -1155,7 +1156,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,14 +1169,13 @@ export const apiCommand = buildCommand({ // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - return { - data: { - method: flags.method, - url: resolveRequestUrl(normalizedEndpoint, params), - headers: resolveEffectiveHeaders(headers, body), - body: body ?? null, - }, - }; + yield new CommandOutput({ + method: flags.method, + url: resolveRequestUrl(normalizedEndpoint, params), + headers: resolveEffectiveHeaders(headers, body), + body: body ?? null, + }); + return; } const verbose = flags.verbose && !flags.silent; @@ -1210,6 +1210,6 @@ export const apiCommand = buildCommand({ throw new OutputError(response.body); } - return { data: response.body }; + return yield new CommandOutput(response.body); }, }); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 4647f1e1..0913a844 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,8 @@ export const loginCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: LoginFlags): Promise { + output: { human: formatLoginResult }, + async *func(this: SentryContext, flags: LoginFlags) { // Check if already authenticated and handle re-authentication if (await isAuthenticated()) { const shouldProceed = await handleExistingAuth(flags.force); @@ -113,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); @@ -139,46 +165,35 @@ 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 = user; } 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()}`); - return; + return yield new CommandOutput(result); } - // Clear stale cached responses from a previous session - try { - await clearResponseCache(); - } catch { - // Non-fatal: cache directory may not exist - } - - const { stdout, stderr } = this; - const loginSuccess = await runInteractiveLogin( - stdout, - stderr, - process.stdin, - { - timeout: flags.timeout * 1000, - } - ); + // OAuth device flow + const result = await runInteractiveLogin({ + timeout: flags.timeout * 1000, + }); - if (!loginSuccess) { - // Error already displayed by runInteractiveLogin - just set exit code + if (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 82b68e58..e48e87cb 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 = { @@ -32,15 +33,16 @@ export const logoutCommand = buildCommand({ fullDescription: "Remove stored authentication credentials from the local database.", }, - output: { json: true, human: formatLogoutResult }, + output: { human: formatLogoutResult }, parameters: { flags: {}, }, - async func(this: SentryContext): Promise<{ data: LogoutResult }> { + async *func(this: SentryContext) { if (!(await isAuthenticated())) { - return { - data: { loggedOut: false, message: "Not currently authenticated." }, - }; + return yield new CommandOutput({ + loggedOut: false, + message: "Not currently authenticated.", + }); } if (isEnvTokenActive()) { @@ -55,11 +57,9 @@ export const logoutCommand = buildCommand({ const configPath = getDbPath(); await clearAuth(); - return { - data: { - loggedOut: true, - configPath, - }, - }; + return yield new CommandOutput({ + loggedOut: true, + configPath, + }); }, }); diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index 300dfc65..73d7127a 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; @@ -58,7 +59,7 @@ Examples: {"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."} `.trim(), }, - output: { json: true, human: formatRefreshResult }, + output: { human: formatRefreshResult }, parameters: { flags: { force: { @@ -68,7 +69,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 +105,6 @@ Examples: : undefined, }; - return { data: payload }; + return yield new CommandOutput(payload); }, }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 464d07e0..20222fc4 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, @@ -143,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: { human: formatAuthStatus }, parameters: { flags: { "show-token": { @@ -155,7 +156,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 +190,6 @@ export const statusCommand = buildCommand({ verification: await verifyCredentials(), }; - return { data }; + return yield new CommandOutput(data); }, }); diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 30b4a8e2..190b6cf7 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { AuthError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; export const tokenCommand = buildCommand({ docs: { @@ -16,21 +17,17 @@ export const tokenCommand = buildCommand({ fullDescription: "Print the stored authentication token to stdout.\n\n" + "This outputs the raw token without any formatting, making it suitable for " + - "piping to other commands or scripts. The token is printed without a trailing newline " + - "when stdout is not a TTY (e.g., when piped).", + "piping to other commands or scripts.", }, parameters: {}, - func(this: SentryContext): void { - const { stdout } = this; - + output: { human: (token: string) => token }, + // biome-ignore lint/suspicious/useAwait: sync body but async generator required by buildCommand + async *func(this: SentryContext) { const token = getAuthToken(); if (!token) { throw new AuthError("not_authenticated"); } - // Add newline only if stdout is a TTY (interactive terminal) - // When piped, omit newline for cleaner output - const suffix = process.stdout.isTTY ? "\n" : ""; - stdout.write(`${token}${suffix}`); + return yield new CommandOutput(token); }, }); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 39c3ca0a..ed07e0b4 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, @@ -34,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: formatUserIdentity, }, parameters: { @@ -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,6 @@ export const whoamiCommand = buildCommand({ // Cache update failure is non-essential — user identity was already fetched. } - return { data: user }; + return yield new CommandOutput(user); }, }); diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index ff552104..8459bf4f 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 = { @@ -30,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: { human: formatFeedbackResult }, parameters: { flags: {}, positional: { @@ -42,12 +43,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 +67,10 @@ export const feedbackCommand = buildCommand({ // Flush to ensure feedback is sent before process exits const sent = await Sentry.flush(3000); - return { - data: { - sent, - message, - }, - }; + yield new CommandOutput({ + sent, + message, + }); + return; }, }); diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index 0a5061f4..e76ca32b 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 = { @@ -668,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: { human: formatFixResult }, parameters: { flags: { "dry-run": { @@ -678,7 +679,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 +735,6 @@ export const fixCommand = buildCommand({ throw new OutputError(result); } - return { data: result }; + return yield new CommandOutput(result); }, }); diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index 2479fc11..634d28cc 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -27,6 +27,7 @@ import { type ReleaseChannel, setReleaseChannel, } from "../../lib/db/release-channel.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { addToGitHubPath, @@ -42,8 +43,6 @@ import { parseInstallationMethod, } from "../../lib/upgrade.js"; -const log = logger.withTag("cli.setup"); - type SetupFlags = { readonly install: boolean; readonly method?: InstallationMethod; @@ -56,6 +55,25 @@ type SetupFlags = { type Logger = (msg: string) => void; +/** Structured result of the setup operation */ +type SetupResult = { + /** Status messages collected during setup */ + messages: string[]; + /** Warning messages from best-effort steps that failed non-fatally */ + warnings: string[]; + /** Whether a fresh binary was installed */ + freshInstall: boolean; + /** Path to the installed binary */ + binaryPath: string; + /** CLI version */ + version: string; +}; + +/** Format setup result for human-readable output */ +function formatSetupResult(result: SetupResult): string { + return result.messages.join("\n"); +} + /** * Handle binary installation from a temp location. * @@ -105,7 +123,7 @@ async function handlePathModification( shell: ShellInfo, env: NodeJS.ProcessEnv, emit: Logger -): Promise { +) { const alreadyInPath = isInPath(binaryDir, env.PATH); if (alreadyInPath) { @@ -235,7 +253,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 +294,7 @@ async function bestEffort( stepName: string, fn: () => void | Promise, warn: WarnLogger -): Promise { +) { try { await fn(); } catch (error) { @@ -301,7 +319,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,19 +459,26 @@ export const setupCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: SetupFlags): Promise { + output: { human: formatSetupResult }, + async *func(this: SentryContext, flags: SetupFlags) { const { process, homeDir } = this; + const log = logger.withTag("cli.setup"); + const messages: string[] = []; + const warnings: string[] = []; + const emit: Logger = (msg: string) => { if (!flags.quiet) { - log.info(msg); + messages.push(msg); } }; const warn: WarnLogger = (step, error) => { const msg = error instanceof Error ? error.message : "Unknown error occurred"; - log.warn(`${step} failed: ${msg}`); + const warning = `${step} failed: ${msg}`; + log.warn(warning); + warnings.push(warning); }; let binaryPath = process.execPath; @@ -489,5 +514,13 @@ export const setupCommand = buildCommand({ if (!flags.quiet && freshInstall) { printWelcomeMessage(emit, CLI_VERSION, binaryPath); } + + return yield new CommandOutput({ + messages, + warnings, + freshInstall, + binaryPath, + version: CLI_VERSION, + }); }, }); diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 5b68d6b5..835f56e9 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, @@ -417,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: { human: formatUpgradeResult }, parameters: { positional: { kind: "tuple", @@ -451,7 +452,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 +494,7 @@ export const upgradeCommand = buildCommand({ flags, }); if (resolved.kind === "done") { - return { data: resolved.result }; + return yield new CommandOutput(resolved.result); } const { target } = resolved; @@ -509,17 +510,16 @@ export const upgradeCommand = buildCommand({ target, versionArg ); - return { - data: { - action: downgrade ? "downgraded" : "upgraded", - currentVersion: CLI_VERSION, - targetVersion: target, - channel, - method, - forced: flags.force, - warnings, - } satisfies UpgradeResult, - }; + yield new CommandOutput({ + action: downgrade ? "downgraded" : "upgraded", + currentVersion: CLI_VERSION, + targetVersion: target, + channel, + method, + forced: flags.force, + warnings, + } satisfies UpgradeResult); + return; } await executeStandardUpgrade({ @@ -530,15 +530,14 @@ export const upgradeCommand = buildCommand({ execPath: this.process.execPath, }); - return { - data: { - action: downgrade ? "downgraded" : "upgraded", - currentVersion: CLI_VERSION, - targetVersion: target, - channel, - method, - forced: flags.force, - } satisfies UpgradeResult, - }; + yield new 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 0efae499..e56d514b 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, @@ -303,7 +304,6 @@ export const viewCommand = buildCommand({ " sentry event view # find project across all orgs", }, output: { - json: true, human: formatEventView, jsonExclude: ["spanTreeLines"], }, @@ -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,8 +380,12 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; + yield new CommandOutput({ + event, + trace, + spanTreeLines: spanTreeResult?.lines, + }); return { - data: { event, trace, spanTreeLines: spanTreeResult?.lines }, hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined, diff --git a/src/commands/help.ts b/src/commands/help.ts index 7fce649e..5efeb402 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 } 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: { human: (s: string) => s.trimEnd() }, parameters: { flags: {}, positional: { @@ -30,13 +32,10 @@ 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[]) { - const { stdout } = this; - + async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) { // No args: show branded help if (commandPath.length === 0) { - await printCustomHelp(stdout); - return; + return yield new CommandOutput(await printCustomHelp()); } // With args: re-invoke with --helpAll to show full help including hidden items diff --git a/src/commands/init.ts b/src/commands/init.ts index 7b4be3fa..10fdd7fe 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 672a4b8b..c76102b6 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, @@ -58,7 +59,7 @@ export const explainCommand = buildCommand({ " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, - output: { json: true, human: formatRootCauseList }, + output: { human: formatRootCauseList }, parameters: { positional: issueIdPositional, flags: { @@ -71,7 +72,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 +105,8 @@ export const explainCommand = buildCommand({ ); } - return { - data: causes, - hint: `To create a plan, run: sentry issue plan ${issueArg}`, - }; + yield new 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 f9e18be6..697751b5 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -38,13 +38,12 @@ import { } from "../../lib/errors.js"; import { type IssueTableRow, - muted, shouldAutoCompact, writeIssueTable, } from "../../lib/formatters/index.js"; -import type { +import { CommandOutput, - OutputConfig, + type OutputConfig, } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -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).` ); } @@ -1243,7 +1240,6 @@ const jsonTransformIssueList = jsonTransformListResult; /** Output configuration for the issue list command. */ const issueListOutput: OutputConfig = { - json: true, human: formatIssueListHuman, jsonTransform: jsonTransformIssueList, }; @@ -1316,13 +1312,9 @@ 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; + const { cwd, setContext } = this; const parsed = parseOrgProjectArg(target); @@ -1345,13 +1337,11 @@ export const listCommand = buildListCommand("issue", { handleResolvedTargets({ ...ctx, flags, - stderr, setContext, }); const result = (await dispatchOrgScopedList({ config: issueListMeta, - stdout, cwd, flags, parsed, @@ -1385,6 +1375,7 @@ export const listCommand = buildListCommand("issue", { combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; } - return { data: result, hint: combinedHint }; + yield new CommandOutput(result); + return { hint: combinedHint }; }, }); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 28376b8f..ac24ece1 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, @@ -170,7 +171,6 @@ export const planCommand = buildCommand({ " sentry issue plan 123456789 --force", }, output: { - json: true, human: formatPlanOutput, }, parameters: { @@ -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,7 @@ export const planCommand = buildCommand({ if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - return { data: buildPlanData(state) }; + return yield new CommandOutput(buildPlanData(state)); } } @@ -260,7 +260,7 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - return { data: buildPlanData(finalState) }; + return yield new CommandOutput(buildPlanData(finalState)); } 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 634409e8..80828429 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, @@ -101,7 +102,6 @@ export const viewCommand = buildCommand({ "where 'f' is the project alias shown in the list).", }, output: { - json: true, human: formatIssueView, jsonExclude: ["spanTreeLines"], }, @@ -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,8 +170,13 @@ export const viewCommand = buildCommand({ ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } : null; + yield new CommandOutput({ + issue, + event: event ?? null, + trace, + spanTreeLines, + }); return { - data: { issue, event: event ?? null, trace, spanTreeLines }, 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 8df94773..97f438e2 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -17,13 +17,15 @@ import { createLogStreamingTable, formatLogRow, 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 { + CommandOutput, + formatFooter, + type HumanRenderer, +} from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { applyFreshFlag, @@ -31,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; @@ -49,15 +51,22 @@ type ListFlags = { readonly fields?: string[]; }; -/** Result for non-follow log list operations. */ +/** + * Result yielded by the log list command in single-fetch mode. + * + * Contains the full array of logs and optional trace context. + * Follow mode yields bare {@link LogLike} items instead — see + * {@link LogOutput} for the union type. + */ type LogListResult = { logs: LogLike[]; - /** Human-readable hint (e.g., "Showing 100 logs. Use --limit to show more.") */ - hint?: string; /** Trace ID, present for trace-filtered queries */ traceId?: string; }; +/** Output yielded by log list: either a batch (single-fetch) or an individual item (follow). */ +type LogOutput = LogLike | LogListResult; + /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -102,6 +111,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. */ @@ -111,60 +124,23 @@ 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[]; +/** Result from a single fetch: logs to yield + hint for the footer. */ +type FetchResult = { + result: LogListResult; + hint: 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). * - * 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, @@ -172,7 +148,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) @@ -182,23 +158,49 @@ 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}` }; +} + +// --------------------------------------------------------------------------- +// Streaming follow-mode infrastructure +// --------------------------------------------------------------------------- + +/** + * 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 unified follow-mode loop. + * 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; + * diagnostics are reported via 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 logs via logger) */ + onDiagnostic: (message: string) => void; /** * Fetch logs with the given time window. * @param statsPeriod - Time window (e.g., "1m" for initial, "10m" for polls) @@ -215,147 +217,165 @@ 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; } +} - const plain = flags.json || isPlainOutput(); - const table = plain ? undefined : createLogStreamingTable(); +/** + * Async generator that streams log entries via follow-mode polling. + * + * Yields batches of log entries (chronological order). The command + * unwraps each batch into individual {@link CommandOutput} yields so + * the OutputConfig formatters can handle incremental rendering and JSONL. + * + * The generator handles SIGINT via AbortController for clean shutdown. + * It never touches stdout — all data output flows through yielded batches + * and diagnostics use 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; - 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); + try { + // Initial fetch + const initialLogs = await config.fetch("1m"); + if (initialLogs.length > 0) { + yield [...initialLogs].reverse(); } - - /** 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); - } + 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; } - return max; - } - function writeNewLogs(newLogs: T[]) { - if (newLogs.length === 0) { - return; + const newLogs = await fetchPoll(config, lastTimestamp); + if (newLogs && newLogs.length > 0) { + yield [...newLogs].reverse(); + lastTimestamp = maxTimestamp(newLogs) ?? lastTimestamp; } + } + } finally { + process.removeListener("SIGINT", stop); + } +} - 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; +/** + * 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* yieldFollowItems( + generator: AsyncGenerator +): AsyncGenerator, void, undefined> { + for await (const batch of generator) { + for (const item of batch) { + yield new CommandOutput(item); } + } +} - async function poll() { - pendingTimer = null; - if (stopped) { - return; - } - 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(); +/** + * Consume a trace follow-mode generator, yielding items individually. + * + * The first non-empty batch is yielded as a {@link LogListResult} so + * the human renderer can detect `traceId` and hide the trace column. + * Subsequent items are yielded bare for proper JSONL streaming. + */ +async function* yieldTraceFollowItems( + generator: AsyncGenerator, + traceId: string +): AsyncGenerator, void, undefined> { + let contextSent = false; + for await (const batch of generator) { + if (!contextSent && batch.length > 0) { + // First non-empty batch: yield as LogListResult to set trace context + yield new CommandOutput({ logs: batch, traceId }); + contextSent = true; + } else { + for (const item of batch) { + yield new CommandOutput(item); } } - - // 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); - }); - }); + } } /** Default time period for trace-logs queries */ @@ -368,16 +388,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, @@ -386,8 +401,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).", @@ -400,7 +414,30 @@ 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 + * to avoid stderr noise when agents consume JSONL output. + */ +function writeFollowBanner( + pollInterval: number, + bannerText: string, + json: boolean +): void { + if (json) { + return; + } + logger.info(`${bannerText} (poll interval: ${pollInterval}s)`); + logger.info("Press Ctrl+C to stop."); + const notification = getUpdateNotification(); + if (notification) { + logger.info(notification); + } } // --------------------------------------------------------------------------- @@ -408,39 +445,92 @@ async function executeTraceSingleFetch( // --------------------------------------------------------------------------- /** - * Format a {@link LogListResult} as human-readable terminal output. + * Create a stateful human renderer for log list output. + * + * The factory is called once per command invocation. The returned renderer + * tracks streaming table state (header emitted, table instance) and cleans + * up via `finalize()`. * - * 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 + * 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()`. * - * The returned string omits a trailing newline — the output framework - * appends one automatically. + * Discriminates between {@link LogListResult} (single-fetch or first trace + * follow batch) and bare {@link LogLike} items (follow mode). */ -function formatLogListHuman(result: LogListResult): string { - if (result.logs.length === 0) { - return result.hint ?? "No logs found."; +function createLogRenderer(): HumanRenderer { + const plain = isPlainOutput(); + const table: StreamingTable | undefined = plain + ? undefined + : createLogStreamingTable(); + let includeTrace = true; // default: show trace column + let headerEmitted = false; + + function isBatch(data: LogOutput): data is LogListResult { + return "logs" in data && Array.isArray((data as LogListResult).logs); } - const includeTrace = !result.traceId; - return formatLogTable(result.logs, includeTrace).trimEnd(); + return { + render(data: LogOutput): string { + const logs: LogLike[] = isBatch(data) ? data.logs : [data]; + if (logs.length === 0) { + return ""; + } + + // First non-empty call: determine includeTrace and emit header + if (!headerEmitted) { + if (isBatch(data) && data.traceId) { + includeTrace = false; + } + headerEmitted = true; + let text = table ? table.header() : formatLogsHeader(); + text += renderLogRows(logs, includeTrace, table); + return text.trimEnd(); + } + + return renderLogRows(logs, includeTrace, table).trimEnd(); + }, + + finalize(hint?: string): string { + let text = ""; + + // Close the streaming table if header was emitted + if (headerEmitted && table) { + text += table.footer(); + } + + if (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; + }, + }; } /** - * 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. + * Discriminates between {@link LogListResult} (single-fetch) and bare + * {@link LogLike} items (follow mode). Single-fetch yields a JSON array; + * follow mode yields one JSON object per line (JSONL). */ -function jsonTransformLogList( - result: LogListResult, - fields?: string[] -): unknown { - if (fields && fields.length > 0) { - return result.logs.map((log) => filterFields(log, fields)); +function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown { + if ("logs" in data && Array.isArray((data as LogListResult).logs)) { + // Batch (single-fetch): return array + const logs = (data as LogListResult).logs; + return fields && fields.length > 0 + ? logs.map((log) => filterFields(log, fields)) + : logs; } - return result.logs; + // Single item (follow mode): return bare object for JSONL + return fields && fields.length > 0 ? filterFields(data, fields) : data; } export const listCommand = buildListCommand("log", { @@ -466,9 +556,8 @@ export const listCommand = buildListCommand("log", { " sentry log list --trace abc123def456abc123def456abc123de # Filter by trace", }, output: { - json: true, - human: formatLogListHuman, - jsonTransform: jsonTransformLogList, + human: createLogRenderer, + jsonTransform: jsonTransformLogOutput, }, parameters: { positional: { @@ -516,12 +605,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 +626,21 @@ export const listCommand = buildListCommand("log", { setContext([org], []); if (flags.follow) { - const { stdout, stderr } = this; const traceId = flags.trace; + + // Banner (suppressed in JSON mode) + 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(); - await executeFollowMode({ - stdout, - stderr, + const generator = generateFollowLogs({ flags, - bannerText: `Streaming logs for trace ${traceId}...`, - includeTrace: false, + onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod) => listTraceLogs(org, traceId, { query: flags.query, @@ -565,6 +653,9 @@ export const listCommand = buildListCommand("log", { return l.timestamp_precise > lastTs; } // No precise timestamp — deduplicate by id + if (!l.id) { + return true; // Can't dedup without id, include it + } if (seenWithoutTs.has(l.id)) { return false; } @@ -573,24 +664,24 @@ export const listCommand = buildListCommand("log", { }), onInitialLogs: (logs) => { for (const l of logs) { - if (l.timestamp_precise === undefined) { + if (l.timestamp_precise === undefined && l.id) { seenWithoutTs.add(l.id); } } }, }); - return; // void — follow mode writes directly + + yield* yieldTraceFollowItems(generator, traceId); + 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; - return { data: result, hint }; + flags.trace, + flags + ); + yield new CommandOutput(result); + return { hint }; } // Standard project-scoped mode — kept in else-like block to avoid @@ -604,13 +695,15 @@ export const listCommand = buildListCommand("log", { setContext([org], [project]); if (flags.follow) { - const { stdout, stderr } = this; - await executeFollowMode({ - stdout, - stderr, + writeFollowBanner( + flags.follow ?? DEFAULT_POLL_INTERVAL, + "Streaming logs...", + flags.json + ); + + const generator = generateFollowLogs({ flags, - bannerText: "Streaming logs...", - includeTrace: true, + onDiagnostic: (msg) => logger.warn(msg), fetch: (statsPeriod, afterTimestamp) => listLogs(org, project, { query: flags.query, @@ -620,18 +713,14 @@ export const listCommand = buildListCommand("log", { }), extractNew: (logs) => logs, }); - return; // void — follow mode writes directly + + yield* yieldFollowItems(generator); + 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; - return { data: result, hint }; + const { result, hint } = await executeSingleFetch(org, project, flags); + yield new CommandOutput(result); + return { hint }; } }, }); diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 9e19a32b..d7e96672 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, @@ -318,7 +319,6 @@ export const viewCommand = buildCommand({ "The log ID is the 32-character hexadecimal identifier shown in log listings.", }, output: { - json: true, human: formatLogViewHuman, // Preserve original JSON contract: bare array of log entries. // orgSlug exists only for the human formatter (trace URLs). @@ -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 new CommandOutput({ logs, orgSlug: target.org }); + return { hint }; }, }); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 8340ccfe..a15ae441 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, @@ -116,7 +117,7 @@ export const listCommand = buildCommand({ " sentry org list --limit 10\n" + " sentry org list --json", }, - output: { json: true, human: formatOrgListHuman }, + output: { human: formatOrgListHuman }, parameters: { flags: { limit: buildListLimitFlag("organizations"), @@ -125,7 +126,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 +152,7 @@ export const listCommand = buildCommand({ hints.push("Tip: Use 'sentry org view ' for details"); } - return { data: entries, hint: hints.join("\n") || undefined }; + 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 6e3c5680..1c2e3e0d 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, @@ -35,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: { human: formatOrgDetails }, parameters: { positional: { kind: "tuple", @@ -58,7 +59,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 +79,7 @@ export const viewCommand = buildCommand({ const hint = resolved.detectedFrom ? `Detected from ${resolved.detectedFrom}` : undefined; - return { data: org, hint }; + yield new CommandOutput(org); + return { hint }; }, }); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 19632ec6..05c3c0dc 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"; @@ -275,7 +276,6 @@ export const createCommand = buildCommand({ " sentry project create my-app go --json", }, output: { - json: true, human: formatProjectCreated, jsonExclude: [ "slugDiverged", @@ -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,7 @@ export const createCommand = buildCommand({ expectedSlug, dryRun: true, }; - return { data: result }; + return yield new CommandOutput(result); } // Create the project @@ -432,6 +432,6 @@ export const createCommand = buildCommand({ expectedSlug, }; - return { data: result }; + return yield new CommandOutput(result); }, }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 59f07b7e..5f5496ad 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -32,9 +32,9 @@ import { } from "../../lib/db/pagination.js"; import { ContextError, withAuthGuard } from "../../lib/errors.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; -import type { +import { CommandOutput, - OutputConfig, + type OutputConfig, } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { @@ -564,7 +564,6 @@ export const listCommand = buildListCommand("project", { " sentry project list --json # output as JSON", }, output: { - json: true, human: (result: ListResult) => { if (result.items.length === 0) { return result.hint ?? "No projects found."; @@ -592,19 +591,14 @@ 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; + const { cwd } = this; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ config: projectListMeta, - stdout, cwd, flags, parsed, @@ -640,6 +634,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; - return { data: result, hint }; + yield new CommandOutput(result); + return { hint }; }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 48548219..5e351a59 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, @@ -185,7 +186,6 @@ export const viewCommand = buildCommand({ "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, output: { - json: true, human: formatProjectViewHuman, jsonExclude: ["detectedFrom"], }, @@ -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 new CommandOutput(entries); + return { hint: footer }; }, }); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index b61f670a..b48fd078 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 type { CommandOutput } from "../../lib/formatters/output.js"; +import { CommandOutput } 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: formatTraceListHuman, jsonTransform: jsonTransformTraceList, }, @@ -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,9 +271,7 @@ export const listCommand = buildListCommand("trace", { : `${countText} Use 'sentry trace view ' to view the full span tree.`; } - return { - data: { traces, hasMore, nextCursor, org, project }, - hint, - }; + 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 c03d59e3..1239080c 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -10,7 +10,9 @@ 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 { filterFields } from "../../lib/formatters/json.js"; +import { formatLogTable } from "../../lib/formatters/log.js"; +import { CommandOutput, formatFooter } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -30,6 +32,36 @@ 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; + /** 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 data.emptyMessage ?? "No logs found."; + } + 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("").trimEnd(); +} + /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -132,7 +164,15 @@ export const logsCommand = buildCommand({ " sentry trace logs --period 7d abc123def456abc123def456abc123de\n" + " sentry trace logs --json abc123def456abc123def456abc123de", }, - output: "json", + output: { + human: 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,13 +216,9 @@ export const logsCommand = buildCommand({ q: "query", }, }, - async func( - this: SentryContext, - flags: LogsFlags, - ...args: string[] - ): Promise { + async *func(this: SentryContext, flags: LogsFlags, ...args: string[]) { applyFreshFlag(flags); - const { stdout, cwd, setContext } = this; + const { cwd, setContext } = this; const { traceId, orgArg } = parsePositionalArgs(args); @@ -209,16 +245,18 @@ export const logsCommand = buildCommand({ query: flags.query, }); - displayTraceLogs({ - stdout, - logs, + // Reverse to chronological order (API returns newest-first) + const chronological = [...logs].reverse(); + + 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 new CommandOutput({ + logs: chronological, 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`, + emptyMessage, }); }, }); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 982116e8..7e87915d 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, @@ -204,7 +205,6 @@ export const viewCommand = buildCommand({ "The trace ID is the 32-character hexadecimal identifier.", }, output: { - json: true, human: formatTraceView, jsonExclude: ["spanTreeLines"], }, @@ -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,8 +314,8 @@ export const viewCommand = buildCommand({ ? formatSimpleSpanTree(traceId, spans, flags.spans) : undefined; + yield new CommandOutput({ summary, spans, spanTreeLines }); return { - data: { summary, spans, spanTreeLines }, 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 d8ae6b7a..6a079281 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 { @@ -202,7 +203,6 @@ export const listCommand = buildCommand({ " sentry trial list --json", }, output: { - json: true, human: formatTrialListHuman, jsonExclude: ["displayName"], }, @@ -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 new CommandOutput(entries); + return { hint: hints.join("\n") || undefined }; }, }); diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts index cec2a880..02d3b867 100644 --- a/src/commands/trial/start.ts +++ b/src/commands/trial/start.ts @@ -22,7 +22,8 @@ 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 { logger } from "../../lib/logger.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger as log } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { buildBillingUrl } from "../../lib/sentry-urls.js"; @@ -88,7 +89,7 @@ export const startCommand = buildCommand({ " sentry trial start plan\n" + " sentry trial start --json seer", }, - output: { json: true, human: formatStartResult }, + output: { human: formatStartResult }, parameters: { positional: { kind: "tuple" as const, @@ -107,17 +108,17 @@ export const startCommand = buildCommand({ ], }, }, - async func( + async *func( this: SentryContext, flags: { json?: boolean }, 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 @@ -142,7 +143,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* handlePlanTrial(orgSlug, flags.json ?? false); + return; } // Fetch trials and find an available one @@ -160,39 +162,28 @@ export const startCommand = buildCommand({ // Start the trial await startProductTrial(orgSlug, trial.category); - return { - data: { - name: parsed.name, - category: trial.category, - organization: orgSlug, - lengthDays: trial.lengthDays, - started: true, - }, - hint: undefined, - }; + yield new CommandOutput({ + name: parsed.name, + category: trial.category, + organization: orgSlug, + lengthDays: trial.lengthDays, + started: true, + }); + return; }, }); /** - * Show URL + QR code and prompt to open browser if interactive. + * Prompt to open a billing URL in the browser if interactive. * * @returns true if browser was opened, false otherwise */ -async function promptOpenBillingUrl( - url: string, - stdout: { write: (s: string) => unknown } -): Promise { - const log = logger.withTag("trial"); - - stdout.write(`\n ${url}\n\n`); +async function promptOpenBrowser(url: string): Promise { + const logger = log.withTag("trial"); - // Show QR code so mobile/remote users can scan - const qr = await generateQRCode(url); - stdout.write(`${qr}\n`); - - // Prompt to open browser if interactive TTY - if (isatty(0) && isatty(1)) { - const confirmed = await log.prompt("Open in browser?", { + // Prompt to open browser if interactive TTY (stdin for input, stderr for display) + if (isatty(0) && isatty(2)) { + const confirmed = await logger.prompt("Open in browser?", { type: "confirm", initial: true, }); @@ -201,9 +192,9 @@ async function promptOpenBillingUrl( 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; } @@ -212,18 +203,6 @@ async function promptOpenBillingUrl( return false; } -/** Return type for the plan trial handler */ -type PlanTrialResult = { - data: { - name: string; - category: string; - organization: string; - url: string; - opened: boolean; - }; - hint: undefined; -}; - /** * Handle the "plan" pseudo-trial: check eligibility, show billing URL, * prompt to open browser + show QR code. @@ -231,13 +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, - stdout: { write: (s: string) => unknown }, 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); @@ -264,40 +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, stdout); + + // Show URL and QR code through the output framework + const qr = await generateQRCode(url); + yield new CommandOutput({ url, qr }); + + opened = await promptOpenBrowser(url); } - return { - data: { - name: "plan", - category: "plan", - organization: orgSlug, - url, - opened, - }, - hint: undefined, - }; + yield new 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/clipboard.ts b/src/lib/clipboard.ts index ba7e0b41..d2200c40 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/command.ts b/src/lib/command.ts index c4707ba9..21058a03 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 { - type CommandOutput, + CommandOutput, + type CommandReturn, + type HumanRenderer, type OutputConfig, renderCommandOutput, + resolveRenderer, + writeFooter, } from "./formatters/output.js"; import { LOG_LEVEL_NAMES, @@ -63,32 +68,42 @@ 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; 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`, 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, @@ -98,7 +113,8 @@ type SentryCommandFunction< this: CONTEXT, flags: FLAGS, ...args: ARGS -) => SyncCommandReturn | AsyncCommandReturn; + // 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. @@ -113,31 +129,22 @@ type LocalCommandBuilderArguments< readonly docs: CommandDocumentation; readonly func: SentryCommandFunction; /** - * Output configuration — controls flag injection and optional auto-rendering. - * - * Two forms: + * Output configuration — controls flag injection and auto-rendering. * - * 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: formatUser }, + * async *func() { yield new CommandOutput(user); }, * }) * ``` */ - // biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but we erase types at the builder level - readonly output?: "json" | OutputConfig; + // 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?: OutputConfig; }; // --------------------------------------------------------------------------- @@ -248,7 +255,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`). @@ -265,11 +272,7 @@ 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; - /** Whether to inject --json/--fields flags */ - const hasJsonOutput = rawOutput === "json" || rawOutput?.json === true; + const outputConfig = builderArgs.output; // Merge logging flags into the command's flag definitions. // Quoted keys produce kebab-case CLI flags: "log-level" → --log-level @@ -293,7 +296,7 @@ export function buildCommand< } // Inject --json and --fields when output config is set - if (hasJsonOutput) { + if (outputConfig) { if (!commandOwnsJson) { mergedFlags.json = JSON_FLAG; } @@ -304,38 +307,21 @@ export function buildCommand< const mergedParams = { ...existingParams, flags: mergedFlags }; /** - * Check if a value is a {@link CommandOutput} object (`{ data, hint? }`). - * - * The presence of a `data` property is the unambiguous discriminant — - * no heuristic key-sniffing needed. - */ - function isCommandOutput(v: unknown): v is CommandOutput { - return typeof v === "object" && v !== null && "data" in v; - } - - /** - * 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 {@link CommandOutput}, render it via + * the output config. Void/undefined/Error/other values are ignored. */ - function handleReturnValue( - context: CONTEXT, + 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 || - value === null || - value === undefined || - value instanceof Error || - !isCommandOutput(value) - ) { + if (!(outputConfig && renderer && value instanceof CommandOutput)) { return; } - const stdout = (context as Record) - .stdout as import("../types/index.js").Writer; - renderCommandOutput(stdout, value.data, outputConfig, { - hint: value.hint, + renderCommandOutput(stdout, value.data, outputConfig, renderer, { json: Boolean(flags.json), fields: flags.fields as string[] | undefined, }); @@ -345,7 +331,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 @@ -360,15 +346,45 @@ export function buildCommand< } clean[key] = value; } - if (hasJsonOutput && typeof clean.fields === "string") { + if (outputConfig && typeof clean.fields === "string") { clean.fields = parseFieldsList(clean.fields); } return clean; } - // 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[]) { + /** + * 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 ( + this: CONTEXT, + flags: Record, + ...args: unknown[] + ) { applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, flags.verbose as boolean @@ -380,6 +396,14 @@ export function buildCommand< setArgsContext(args); } + 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 + ? resolveRenderer(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 @@ -389,10 +413,11 @@ 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, + new CommandOutput(err.data), + cleanFlags, + renderer ); } process.exit(err.exitCode); @@ -400,37 +425,43 @@ 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 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 { - result = originalFunc.call( + const generator = originalFunc.call( this, cleanFlags as FLAGS, ...(args as unknown as ARGS) ); + let result = await generator.next(); + while (!result.done) { + handleYieldedValue(stdout, result.value, cleanFlags, renderer); + result = await generator.next(); + } + + // Generator completed successfully — finalize with hint. + const returned = result.value as CommandReturn | undefined; + 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); } + }; - 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 + // 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/log.ts b/src/lib/formatters/log.ts index e41a0aa7..0fa2478d 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -4,13 +4,8 @@ * 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 { colorTag, escapeMarkdownCell, @@ -23,7 +18,6 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { writeFooter } from "./output.js"; import { renderTextTable, StreamingTable, @@ -323,52 +317,3 @@ export function formatLogDetails( return renderMarkdown(lines.join("\n")); } - -/** - * Options for {@link displayTraceLogs}. - */ -type DisplayTraceLogsOptions = { - /** Writer for output */ - stdout: Writer; - /** 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[]; -}; - -/** - * Shared display logic for trace-filtered log results. - * - * 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; - - if (asJson) { - writeJson(stdout, [...logs].reverse(), fields); - return; - } - - if (logs.length === 0) { - stdout.write(emptyMessage); - return; - } - - const chronological = [...logs].reverse(); - stdout.write(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}`); -} diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 73b9fe4b..e0693254 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: 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. @@ -58,25 +58,81 @@ type WriteOutputOptions = { // --------------------------------------------------------------------------- /** - * Output configuration declared on `buildCommand` for automatic rendering. + * Stateful human renderer created once per command invocation. * - * Two forms: + * 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; +}; + +/** + * Resolve the `human` field of an {@link OutputConfig} into a + * {@link HumanRenderer}. Supports two forms: + * + * 1. **Plain function** — `(data: T) => string` — auto-wrapped into a + * stateless renderer (no `finalize`). + * 2. **Factory** — `() => HumanRenderer` — called once per invocation + * to produce a renderer with optional `finalize()`. + * + * Disambiguation: a function with `.length === 0` is treated as a factory. + */ +export function resolveRenderer(human: HumanOutput): HumanRenderer { + // Factory: zero-arg function that returns a renderer + if (human.length === 0) { + return (human as () => HumanRenderer)(); + } + // Plain formatter: wrap in a stateless renderer + return { render: human as (data: T) => string }; +} + +/** + * Human rendering for an {@link OutputConfig}. * - * 1. **Flag-only** — `output: "json"` — injects `--json` and `--fields` flags - * but does not intercept returns. Commands handle their own output. + * Two forms: + * - **Plain function** `(data: T) => string` — stateless, auto-wrapped. + * - **Factory** `() => HumanRenderer` — called per invocation for + * stateful renderers (e.g., streaming tables with `finalize()`). + */ +export type HumanOutput = ((data: T) => string) | (() => HumanRenderer); + +/** + * Output configuration declared on `buildCommand` for automatic rendering. * - * 2. **Full config** — `output: { json: true, human: fn }` — injects flags - * AND auto-renders the command's return value. Commands return - * `{ data }` or `{ data, hint }` objects. + * When present, `--json` and `--fields` flags are injected and the wrapper + * auto-renders yielded {@link CommandOutput} values. * - * @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 */ - json: true; - /** Format data as a human-readable string for terminal output */ - human: (data: T) => string; + /** + * Human-readable renderer. + * + * Pass a plain `(data: T) => string` for stateless formatting, or a + * zero-arg factory `() => HumanRenderer` for stateful rendering + * with `finalize()` support. + */ + human: HumanOutput; /** * Top-level keys to strip from JSON output. * @@ -105,99 +161,143 @@ export type OutputConfig = { }; /** - * Return type for commands with {@link OutputConfig}. + * Yield type for commands with {@link OutputConfig}. + * + * Commands wrap each yielded value in this class so the `buildCommand` + * wrapper can unambiguously detect data vs void/raw yields via `instanceof`. * - * 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. + * 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 = { +export class CommandOutput { /** 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) */ + readonly data: T; + constructor(data: T) { + this.data = 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). + * + * 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; }; /** - * 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; }; /** - * 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. * - * Called by the `buildCommand` wrapper when a command with `output: { ... }` - * returns data. In JSON mode the data is serialized as-is (with optional - * field filtering); in human mode the config's `human` formatter is called. + * `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; + } + stdout.write(`${formatJson(transformed)}\n`); +} + +/** + * Render a single yielded `CommandOutput` chunk. + * + * 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 calls `finalize()` or + * `writeFooter()` once after the generator completes. * * @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) + * @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 — human is contravariant in T; safe because data and config are paired at build time. + // 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) { - // 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 (ctx.hint) { - writeFooter(stdout, ctx.hint); + const text = renderer.render(data); + if (text) { + stdout.write(`${text}\n`); } } @@ -234,14 +334,15 @@ export function writeOutput( } } +/** 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. - * - * @param stdout - Writer to output to - * @param text - Footer text to display */ 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 4f1163b1..6ca70d26 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 1e002f5d..624d250a 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -7,17 +7,30 @@ // 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 { formatDuration, formatUserIdentity } from "./formatters/human.js"; +import { muted } from "./formatters/colors.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) */ @@ -33,21 +46,19 @@ 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 - * @param stdin - Input stream for keyboard listener (must be TTY) + * All UI output goes to stderr via the logger, keeping stdout clean for + * structured command output. A spinner replaces raw polling dots for a + * cleaner interactive experience. + * * @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( - stdout: Writer, - stderr: Writer, - stdin: NodeJS.ReadStream & { fd: 0 }, options?: InteractiveLoginOptions -): Promise { +): 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 +78,39 @@ 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 stdin = process.stdin; 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"); + + // 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, - stdout - ); - }, - onPolling: () => { - stdout.write("."); + if (stdin.isTTY) { + keyListener.cleanup = setupCopyKeyListener( + stdin as NodeJS.ReadStream & { fd: 0 }, + () => urlToCopy + ); + } }, }, timeout ); - // Clear the polling dots - stdout.write("\n"); + // Stop the spinner + log.success("Authorization received!"); // Store the token await completeOAuthFlow(tokenResponse); @@ -119,23 +130,19 @@ export async function runInteractiveLogin( } } - stdout.write(`${success("✓")} Authentication successful!\n`); + const result: LoginResult = { + method: "oauth", + configPath: getDbPath(), + expiresIn: tokenResponse.expires_in, + }; if (user) { - stdout.write(` Logged in as: ${muted(formatUserIdentity(user))}\n`); + result.user = user; } - stdout.write(` Config saved to: ${getDbPath()}\n`); - - if (tokenResponse.expires_in) { - stdout.write( - ` Token expires in: ${formatDuration(tokenResponse.expires_in)}\n` - ); - } - - return true; + return result; } catch (err) { - stdout.write("\n"); - stderr.write(`${errorColor("Error:")} ${formatError(err)}\n`); - return false; + log.fail("Authorization failed"); + log.error(formatError(err)); + return null; } finally { // Always cleanup keyboard listener keyListener.cleanup?.(); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index e4468053..6d649f46 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -19,7 +19,11 @@ import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; -import type { CommandOutput, OutputConfig } from "./formatters/output.js"; +import { + CommandOutput, + type CommandReturn, + type OutputConfig, +} from "./formatters/output.js"; import { dispatchOrgScopedList, jsonTransformListResult, @@ -80,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. */ @@ -134,7 +138,7 @@ export const FRESH_ALIASES = { f: "fresh" } as const; * Call at the top of a command's `func()` after defining the `fresh` flag: * ```ts * flags: { fresh: FRESH_FLAG }, - * async func(this: SentryContext, flags) { + * async *func(this: SentryContext, flags) { * applyFreshFlag(flags); * ``` */ @@ -310,12 +314,10 @@ type BaseFlags = Readonly>>; type BaseArgs = readonly unknown[]; /** - * Wider command function type that allows returning `CommandOutput`. + * Command function type that returns an async generator. * - * Mirrors `SentryCommandFunction` from `command.ts`. The Stricli - * `CommandFunction` type constrains returns to `void | Error`, which is - * too narrow for the return-based output pattern. This type adds `unknown` - * to the return union so `{ data, hint }` objects pass through. + * Mirrors `SentryCommandFunction` from `command.ts`. All command functions + * are async generators — non-streaming commands yield once and return. */ type ListCommandFunction< FLAGS extends BaseFlags, @@ -325,8 +327,8 @@ 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; + // 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 @@ -367,7 +369,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; @@ -471,7 +473,6 @@ export function buildOrgListCommand( return buildListCommand(routeName, { docs, output: { - json: true, human: (result: ListResult) => formatListHuman(result, config), jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), @@ -485,7 +486,7 @@ export function buildOrgListCommand( }, aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES }, }, - async func( + async *func( this: SentryContext, flags: { readonly limit: number; @@ -495,21 +496,21 @@ export function buildOrgListCommand( readonly fields?: string[]; }, target?: string - ): Promise>> { + ) { applyFreshFlag(flags); - const { stdout, cwd } = this; + const { cwd } = this; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ config, - stdout, cwd, flags, parsed, }); + 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; - return { data: result, hint }; + return { hint }; }, }); } diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 829254bb..808d2030 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -65,7 +65,6 @@ type DeviceFlowCallbacks = { verificationUri: string, verificationUriComplete: string ) => void | Promise; - onPolling?: () => void; }; function sleep(ms: number): Promise { @@ -276,7 +275,6 @@ export async function performDeviceFlow( // Step 2: Poll for token while (Date.now() < timeoutAt) { await sleep(pollInterval * 1000); - callbacks.onPolling?.(); const result = await attemptPoll(device_code); diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 3d85409e..6f4bb0b7 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/auth/login.test.ts b/test/commands/auth/login.test.ts index d559a4d4..87899f11 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(); }); }); diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index b768f929..78f54b2f 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -58,10 +58,14 @@ function createMockContext( ...overrides.env, }; + const stdoutChunks: string[] = []; const context = { process: { stdout: { - write: mock((_s: string) => true), + write: mock((s: string) => { + stdoutChunks.push(String(s)); + return true; + }), }, stderr: { write: mock((_s: string) => true), @@ -80,7 +84,10 @@ function createMockContext( configDir: "/tmp/test-config", env, stdout: { - write: mock((_s: string) => true), + write: mock((s: string) => { + stdoutChunks.push(String(s)); + return true; + }), }, stderr: { write: mock((_s: string) => true), @@ -96,8 +103,9 @@ function createMockContext( return { context, - getOutput: () => stderrChunks.join(""), + getOutput: () => stdoutChunks.join("") + stderrChunks.join(""), clearOutput: () => { + stdoutChunks.length = 0; stderrChunks.length = 0; }, restore: () => { @@ -821,8 +829,7 @@ describe("sentry cli setup", () => { const combined = getOutput(); // Setup must complete even though the completions step threw — - // the warning goes to stderr via consola ([warn] format) - expect(combined).toContain("[warn]"); + // the warning appears in the formatted output expect(combined).toContain("Shell completions failed"); chmod(zshDir, 0o755); diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index 368446a2..e8f871a1 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 489e7270..7038bf5a 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"); @@ -921,20 +952,23 @@ describe("listCommand.func — follow mode (trace)", () => { // (old logs from poll are filtered by timestamp_precise) }); - test("streams JSON objects per-line in trace follow mode", async () => { - listTraceLogsSpy.mockResolvedValueOnce(sampleTraceLogs); + test("streams JSON in trace follow mode: first batch as array, then bare items", async () => { + listTraceLogsSpy + .mockResolvedValueOnce(sampleTraceLogs) + .mockResolvedValueOnce(newerTraceLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); const promise = func.call(context, { ...traceFollowFlags, json: true }); - await Bun.sleep(50); + // Wait for initial fetch + poll timer (1s) + poll execution + await Bun.sleep(1200); sigint.trigger(); await promise; const calls = stdoutWrite.mock.calls.map((c) => c[0]); - const jsonObjects = calls.filter((s: string) => { + const jsonLines = calls.filter((s: string) => { try { JSON.parse(s); return true; @@ -942,7 +976,17 @@ describe("listCommand.func — follow mode (trace)", () => { return false; } }); - expect(jsonObjects.length).toBe(3); + // First batch: 1 JSON line (array of 3 items from LogListResult) + // Poll batch: 1 JSON line per item (bare JSONL) + expect(jsonLines.length).toBe(2); + // First line is an array (the initial trace batch) + const firstBatch = JSON.parse(jsonLines[0]); + expect(Array.isArray(firstBatch)).toBe(true); + expect(firstBatch).toHaveLength(3); + // Second line is a bare object (polled item) + const pollItem = JSON.parse(jsonLines[1]); + expect(Array.isArray(pollItem)).toBe(false); + expect(pollItem.message).toBe("New poll result"); }); test("rejects with AuthError from poll", async () => { @@ -965,7 +1009,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 +1018,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 c775c4f3..4c9a4f06 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 { @@ -189,7 +193,6 @@ function createMockContext() { return { context: { stdout: { write: stdoutWrite }, - stderr: { write: mock(() => true) }, cwd: "/tmp", setContext: mock(() => { // no-op for test @@ -199,6 +202,26 @@ function createMockContext() { }; } +/** + * Collect all output written to a mock write function. + */ +function collectMockOutput( + writeMock: ReturnType boolean>> +): string { + return writeMock.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; @@ -226,11 +249,11 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); 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"); }); @@ -246,7 +269,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(JSON.parse(output)).toEqual([]); }); }); @@ -264,7 +287,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("No logs found"); expect(output).toContain(TRACE_ID); }); @@ -281,7 +304,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("30d"); }); @@ -297,7 +320,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Request received"); expect(output).toContain("Slow query detected"); expect(output).toContain("Database connection failed"); @@ -315,7 +338,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Showing 3 logs"); expect(output).toContain(TRACE_ID); }); @@ -332,7 +355,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Showing 1 log for trace"); expect(output).not.toContain("Showing 1 logs"); }); @@ -350,7 +373,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).toContain("Use --limit to show more."); }); @@ -366,7 +389,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const output = collectMockOutput(stdoutWrite); expect(output).not.toContain("Use --limit to show more."); }); }); @@ -522,7 +545,7 @@ describe("logsCommand.func", () => { TRACE_ID ); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + 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 4d954e62..177e27a1 100644 --- a/test/commands/trial/start.test.ts +++ b/test/commands/trial/start.test.ts @@ -321,7 +321,8 @@ describe("trial start plan", () => { const func = await startCommand.loader(); await func.call(context, { json: false }, "plan"); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // 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"); }); @@ -337,7 +338,8 @@ describe("trial start plan", () => { await func.call(context, { json: false }, "plan"); expect(generateQRCodeSpy).toHaveBeenCalled(); - const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // QR code goes through commandOutput → context stdout + const output = stdoutWrite.mock.calls.map((c) => String(c[0])).join(""); expect(output).toContain("[QR CODE]"); }); @@ -410,8 +412,8 @@ describe("trial start plan", () => { 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 + // URL goes through commandOutput → context stdout + const output = stdoutWrite.mock.calls.map((c) => String(c[0])).join(""); expect(output).toContain("billing"); }); }); diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index df689020..56a093c0 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 */ @@ -79,7 +80,7 @@ describe("buildCommand", () => { verbose: { kind: "boolean", brief: "Verbose", default: false }, }, }, - func(_flags: { verbose: boolean }) { + async *func(_flags: { verbose: boolean }) { // no-op }, }); @@ -90,7 +91,7 @@ describe("buildCommand", () => { const command = buildCommand({ docs: { brief: "Simple command" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -137,7 +138,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 +172,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 +204,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 +236,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 +270,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 +375,7 @@ describe("buildCommand", () => { json: { kind: "boolean", brief: "JSON output", default: false }, }, }, - func(_flags: { json: boolean }) { + async *func(_flags: { json: boolean }) { // no-op }, }); @@ -380,7 +392,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 +421,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -434,7 +447,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -460,7 +473,7 @@ describe("buildCommand", () => { const command = buildCommand, [], TestContext>({ docs: { brief: "Test" }, parameters: {}, - func() { + async *func() { // no-op }, }); @@ -495,7 +508,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 +560,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 +611,7 @@ describe("buildCommand", () => { }, }, }, - func() { + async *func() { // no-op }, }); @@ -642,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; @@ -655,7 +673,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, parameters: { flags: { limit: { @@ -666,7 +684,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; }, }); @@ -694,9 +716,13 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, 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; }, }); @@ -729,9 +755,13 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, 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; }, }); @@ -763,9 +793,13 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, 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; }, }); @@ -787,11 +821,12 @@ 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: {}, - func() { + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func() { funcCalled = true; }, }); @@ -822,7 +857,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, parameters: { flags: { json: { @@ -832,7 +867,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; }, }); @@ -861,9 +900,13 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, 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; }, }); @@ -898,7 +941,7 @@ describe("buildCommand output: json", () => { TestContext >({ docs: { brief: "Test" }, - output: "json", + output: { human: () => "unused" }, parameters: { flags: { limit: { @@ -909,7 +952,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 } ) { @@ -952,12 +996,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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 new CommandOutput({ name: "Alice", role: "admin" }); }, }); @@ -981,12 +1024,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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 new CommandOutput({ name: "Alice", role: "admin" }); }, }); @@ -1011,12 +1053,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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 new CommandOutput({ id: 1, name: "Alice", role: "admin" }); }, }); @@ -1043,15 +1084,12 @@ describe("buildCommand return-based output", () => { buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, output: { - json: true, human: (d: { value: number }) => `Value: ${d.value}`, }, parameters: {}, - func(this: TestContext) { - return { - data: { value: 42 }, - hint: "Run 'sentry help' for more info", - }; + async *func(this: TestContext) { + yield new CommandOutput({ value: 42 }); + return { hint: "Run 'sentry help' for more info" }; }, }); @@ -1093,11 +1131,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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 +1160,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 }; }, }); @@ -1150,13 +1188,12 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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 new CommandOutput({ name: "Bob" }); }, }); @@ -1173,7 +1210,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[] }, [], @@ -1181,12 +1218,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, - human: (d: Array<{ id: number }>) => d.map((x) => x.id).join(", "), + 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 new CommandOutput([{ id: 1 }, { id: 2 }]); }, }); @@ -1211,12 +1247,12 @@ describe("buildCommand return-based output", () => { buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ docs: { brief: "Test" }, output: { - json: true, 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 new CommandOutput({ org: "sentry" }); + return { hint: "Detected from .env file" }; }, }); @@ -1252,11 +1288,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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" }); }, }); @@ -1303,11 +1339,11 @@ describe("buildCommand return-based output", () => { >({ docs: { brief: "Test" }, output: { - json: true, 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" }); }, }); diff --git a/test/lib/formatters/output.test.ts b/test/lib/formatters/output.test.ts index 84b29586..0cdf5737 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, + resolveRenderer, 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 = resolveRenderer(config.human); + renderCommandOutput(w, data, config, renderer, ctx); +} + describe("writeOutput", () => { describe("json mode", () => { test("writes JSON with fields filtering", () => { @@ -174,11 +189,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"); }); }); @@ -190,70 +204,50 @@ describe("renderCommandOutput", () => { test("renders JSON when json=true", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string }> = { - json: true, human: (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" }); }); test("renders human output when json=false", () => { const w = createTestWriter(); const config: OutputConfig<{ name: string }> = { - json: true, human: (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"); }); test("applies fields filtering in JSON mode", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; secret: string }> = { - json: true, - human: () => "unused", + human: (_d) => "unused", }; - renderCommandOutput(w, { id: 1, name: "Alice", secret: "x" }, config, { + render(w, { id: 1, name: "Alice", secret: "x" }, config, { json: true, fields: ["id", "name"], }); expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); }); - test("renders hint in human mode", () => { - 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", () => { + test("does not render hints (hints are rendered by the wrapper after generator completes)", () => { const w = createTestWriter(); const config: OutputConfig = { - json: true, - human: () => "Result", + human: (_d) => "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 + render(w, "data", config, { json: false }); + expect(w.output).toBe("Result\n"); }); test("works without hint", () => { const w = createTestWriter(); const config: OutputConfig<{ value: number }> = { - json: true, human: (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"); }); @@ -264,11 +258,10 @@ describe("renderCommandOutput", () => { name: string; spanTreeLines?: string[]; }> = { - json: true, human: (d) => `${d.id}: ${d.name}`, jsonExclude: ["spanTreeLines"], }; - renderCommandOutput( + render( w, { id: 1, name: "Alice", spanTreeLines: ["line1", "line2"] }, config, @@ -285,17 +278,13 @@ describe("renderCommandOutput", () => { id: number; spanTreeLines?: string[]; }> = { - json: true, human: (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"); }); @@ -303,11 +292,10 @@ describe("renderCommandOutput", () => { test("jsonExclude with empty array is a no-op", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; extra: string }> = { - json: true, human: (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" }); }); @@ -315,12 +303,11 @@ describe("renderCommandOutput", () => { test("jsonExclude strips fields from array elements", () => { const w = createTestWriter(); const config: OutputConfig = { - json: true, human: (d: { id: number; name: string }[]) => d.map((e) => e.name).join(", "), jsonExclude: ["detectedFrom"], }; - renderCommandOutput( + render( w, [ { id: 1, name: "a", detectedFrom: "dsn" }, @@ -345,14 +332,13 @@ describe("renderCommandOutput", () => { org: string; }; const config: OutputConfig = { - json: true, human: (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, @@ -374,8 +360,7 @@ describe("renderCommandOutput", () => { hasMore: boolean; }; const config: OutputConfig = { - json: true, - human: () => "unused", + human: (_d) => "unused", jsonTransform: (data, fields) => ({ data: fields && fields.length > 0 @@ -392,7 +377,7 @@ describe("renderCommandOutput", () => { hasMore: data.hasMore, }), }; - renderCommandOutput( + render( w, { items: [{ id: 1, name: "Alice", secret: "x" }], @@ -409,11 +394,10 @@ describe("renderCommandOutput", () => { test("jsonTransform is ignored in human mode", () => { const w = createTestWriter(); const config: OutputConfig<{ items: string[]; org: string }> = { - json: true, human: (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"); @@ -422,19 +406,66 @@ describe("renderCommandOutput", () => { test("jsonTransform takes precedence over jsonExclude", () => { const w = createTestWriter(); const config: OutputConfig<{ id: number; name: string; extra: string }> = { - json: true, - human: () => "unused", + human: (_d) => "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 }); }); + + test("human factory creates fresh renderer per resolve", () => { + const calls: number[] = []; + const config: OutputConfig<{ n: number }> = { + human: () => ({ + render: (d) => { + calls.push(d.n); + return `#${d.n}`; + }, + }), + }; + + // First resolve + render + const r1 = resolveRenderer(config.human); + r1.render({ n: 1 }); + + // Second resolve = fresh renderer + const r2 = resolveRenderer(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 }> = { + human: () => ({ + render: (d) => `[${d.value}]`, + finalize: (hint) => `=== END ===${hint ? `\n${hint}` : ""}`, + }), + }; + + const renderer = resolveRenderer(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("plain function renderer has no finalize method", () => { + const config: OutputConfig = { + human: (s) => s.toUpperCase(), + }; + const renderer = resolveRenderer(config.human); + expect(renderer.render("hello")).toBe("HELLO"); + expect(renderer.finalize).toBeUndefined(); + }); }); diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts index 498ad598..87f5a7ba 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"); }); });