Skip to content

Commit a3c6a91

Browse files
refactor: unify commands as generators with HumanRenderer factory, remove stdout plumbing (#416)
## Summary Unifies all command functions as async generators, introduces a `HumanRenderer` factory for stateful rendering, and removes direct stdout/stderr plumbing from commands. This is Phase 6b of the output convergence plan. ### 1. Unify all command functions as async generators Every command `func` is now an `async *func` generator. Commands yield `CommandOutput<T>` values instead of writing to stdout directly. - Commands that produce output yield via `commandOutput(data)` - Void generators (e.g. `auth/token`) are valid no-op generators - The `buildCommand` wrapper iterates the generator and routes output through the `OutputConfig` pipeline ### 2. Brand `CommandOutput` with Symbol discriminant Replace duck-typing (`"data" in value`) with a `COMMAND_OUTPUT_BRAND` Symbol. Prevents false positives from raw API responses that have a `data` property. - `commandOutput<T>(data)` factory creates branded values - `isCommandOutput()` checks the Symbol instead of structural shape - All command files migrated to use `commandOutput()` ### 3. Move hints from yield to generator return value Hints (footer text like "Detected from .env.local") are no longer part of the yielded `CommandOutput`. Generators `return { hint }` after their final yield. - New `CommandReturn` type: `{ hint?: string }` - Wrapper uses manual `.next()` iteration to capture the return value - Hints are suppressed in JSON mode ### 4. `HumanRenderer` factory pattern for `OutputConfig.human` `OutputConfig.human` is now a factory function `() => HumanRenderer<T>` instead of a plain `(data: T) => string`. This enables stateful rendering (e.g., streaming tables) without module-level singletons. - `HumanRenderer<T>` has `render(data: T): string` and optional `finalize(hint?: string): string` - `stateless(fn)` helper wraps plain formatters for commands that don't need state - `createLogRenderer()` replaces the old module-level `streamingTable`/`streamingHeaderEmitted` singletons in `log/list.ts` - The `buildCommand` wrapper resolves the factory once per invocation, passes the renderer through iteration, and calls `finalize()` after the generator completes ### 5. Remove stdout/stderr plumbing from commands Commands no longer receive or pass `stdout`/`stderr` Writer references. All diagnostic/interactive output routes through consola logger (→ stderr), keeping stdout reserved for structured command output. - `HandlerContext` and `DispatchOptions` no longer carry `stdout` - `runInteractiveLogin` uses logger instead of Writer params - Follow-mode banners, diagnostics, and QR code display all use logger - `displayTraceLogs` → `formatTraceLogs` (returns string) - `formatFooter()` helper extracted from `writeFooter()` ### 6. `log/list.ts` streaming refactor The most complex command — 685 lines with follow-mode streaming, trace filtering, and JSONL output — is fully converted: - `createLogRenderer()` factory manages streaming table state per-invocation - `render()` emits table header on first non-empty batch, rows per batch - `finalize(hint?)` closes the table footer and appends hint text - Empty-state hints render as primary text (not muted footer) - `jsonl` flag on `LogListResult` controls JSONL vs array serialization - `executeSingleFetch`/`executeTraceSingleFetch` return `FetchResult` with separate `result` and `hint` fields ### 7. `auth/login` yield pattern Interactive OAuth login now yields `LoginResult` values through the generator instead of writing to stdout/stderr directly: - Token-based login: yields success result, returns hint - OAuth flow: yields QR code + URL, progress dots, and final result - `runInteractiveLogin` returns `LoginResult` values consumed by the generator **Remaining stdout usage (intentional):** - `auth/token`: raw stdout for pipe compatibility (`sentry auth token | pbcopy`) - `help.ts`: help text to stdout (like `git --help`) - `trace/logs.ts`: `process.stdout.write` (pending OutputConfig migration) ## Test plan - 1656 tests pass, 0 fail, 15753 assertions across 55 files - Typecheck clean, lint clean (375 files) - E2E tests pass (103 pass, 3 skip) - All CI checks green including Seer Code Review and Cursor BugBot --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 224e6dc commit a3c6a91

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1573
-1314
lines changed

AGENTS.md

Lines changed: 20 additions & 50 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Authenticate with Sentry
4545
- `--token <value> - Authenticate using an API token instead of OAuth`
4646
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
4747
- `--force - Re-authenticate without prompting`
48+
- `--json - Output as JSON`
49+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
4850

4951
**Examples:**
5052

@@ -109,6 +111,10 @@ sentry auth status
109111

110112
Print the stored authentication token
111113

114+
**Flags:**
115+
- `--json - Output as JSON`
116+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
117+
112118
#### `sentry auth whoami`
113119

114120
Show the currently authenticated user
@@ -485,6 +491,8 @@ Configure shell integration
485491
- `--no-completions - Skip shell completion installation`
486492
- `--no-agent-skills - Skip agent skill installation for AI coding assistants`
487493
- `--quiet - Suppress output (for scripted usage)`
494+
- `--json - Output as JSON`
495+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
488496

489497
#### `sentry cli upgrade <version>`
490498

src/bin.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,7 @@ const autoAuthMiddleware: ErrorMiddleware = async (next, args) => {
103103
: "Authentication required. Starting login flow...\n\n"
104104
);
105105

106-
const loginSuccess = await runInteractiveLogin(
107-
process.stdout,
108-
process.stderr,
109-
process.stdin
110-
);
106+
const loginSuccess = await runInteractiveLogin();
111107

112108
if (loginSuccess) {
113109
process.stderr.write("\nRetrying command...\n\n");

src/commands/api.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { SentryContext } from "../context.js";
99
import { buildSearchParams, rawApiRequest } from "../lib/api-client.js";
1010
import { buildCommand } from "../lib/command.js";
1111
import { OutputError, ValidationError } from "../lib/errors.js";
12+
import { CommandOutput } from "../lib/formatters/output.js";
1213
import { validateEndpoint } from "../lib/input-validation.js";
1314
import { logger } from "../lib/logger.js";
1415
import { getDefaultSdkConfig } from "../lib/sentry-client.js";
@@ -1052,7 +1053,7 @@ function logResponse(response: { status: number; headers: Headers }): void {
10521053
}
10531054

10541055
export const apiCommand = buildCommand({
1055-
output: { json: true, human: formatApiResponse },
1056+
output: { human: formatApiResponse },
10561057
docs: {
10571058
brief: "Make an authenticated API request",
10581059
fullDescription:
@@ -1155,7 +1156,7 @@ export const apiCommand = buildCommand({
11551156
n: "dry-run",
11561157
},
11571158
},
1158-
async func(this: SentryContext, flags: ApiFlags, endpoint: string) {
1159+
async *func(this: SentryContext, flags: ApiFlags, endpoint: string) {
11591160
const { stdin } = this;
11601161

11611162
const normalizedEndpoint = normalizeEndpoint(endpoint);
@@ -1168,14 +1169,13 @@ export const apiCommand = buildCommand({
11681169

11691170
// Dry-run mode: preview the request that would be sent
11701171
if (flags["dry-run"]) {
1171-
return {
1172-
data: {
1173-
method: flags.method,
1174-
url: resolveRequestUrl(normalizedEndpoint, params),
1175-
headers: resolveEffectiveHeaders(headers, body),
1176-
body: body ?? null,
1177-
},
1178-
};
1172+
yield new CommandOutput({
1173+
method: flags.method,
1174+
url: resolveRequestUrl(normalizedEndpoint, params),
1175+
headers: resolveEffectiveHeaders(headers, body),
1176+
body: body ?? null,
1177+
});
1178+
return;
11791179
}
11801180

11811181
const verbose = flags.verbose && !flags.silent;
@@ -1210,6 +1210,6 @@ export const apiCommand = buildCommand({
12101210
throw new OutputError(response.body);
12111211
}
12121212

1213-
return { data: response.body };
1213+
return yield new CommandOutput(response.body);
12141214
},
12151215
});

src/commands/auth/login.ts

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,38 @@ import {
1212
import { getDbPath } from "../../lib/db/index.js";
1313
import { getUserInfo, setUserInfo } from "../../lib/db/user.js";
1414
import { AuthError } from "../../lib/errors.js";
15-
import { formatUserIdentity } from "../../lib/formatters/human.js";
15+
import { success } from "../../lib/formatters/colors.js";
16+
import {
17+
formatDuration,
18+
formatUserIdentity,
19+
} from "../../lib/formatters/human.js";
20+
import { CommandOutput } from "../../lib/formatters/output.js";
21+
import type { LoginResult } from "../../lib/interactive-login.js";
1622
import { runInteractiveLogin } from "../../lib/interactive-login.js";
1723
import { logger } from "../../lib/logger.js";
1824
import { clearResponseCache } from "../../lib/response-cache.js";
1925

2026
const log = logger.withTag("auth.login");
2127

28+
/** Format a {@link LoginResult} for human-readable terminal output. */
29+
function formatLoginResult(result: LoginResult): string {
30+
const lines: string[] = [];
31+
lines.push(
32+
success(
33+
`✔ ${result.method === "token" ? "Authenticated with API token" : "Authentication successful!"}`
34+
)
35+
);
36+
if (result.user) {
37+
lines.push(` Logged in as: ${formatUserIdentity(result.user)}`);
38+
}
39+
lines.push(` Config saved to: ${result.configPath}`);
40+
if (result.expiresIn) {
41+
lines.push(` Token expires in: ${formatDuration(result.expiresIn)}`);
42+
}
43+
lines.push(""); // trailing newline
44+
return lines.join("\n");
45+
}
46+
2247
type LoginFlags = {
2348
readonly token?: string;
2449
readonly timeout: number;
@@ -104,7 +129,8 @@ export const loginCommand = buildCommand({
104129
},
105130
},
106131
},
107-
async func(this: SentryContext, flags: LoginFlags): Promise<void> {
132+
output: { human: formatLoginResult },
133+
async *func(this: SentryContext, flags: LoginFlags) {
108134
// Check if already authenticated and handle re-authentication
109135
if (await isAuthenticated()) {
110136
const shouldProceed = await handleExistingAuth(flags.force);
@@ -113,15 +139,15 @@ export const loginCommand = buildCommand({
113139
}
114140
}
115141

142+
// Clear stale cached responses from a previous session
143+
try {
144+
await clearResponseCache();
145+
} catch {
146+
// Non-fatal: cache directory may not exist
147+
}
148+
116149
// Token-based authentication
117150
if (flags.token) {
118-
// Clear stale cached responses from a previous session
119-
try {
120-
await clearResponseCache();
121-
} catch {
122-
// Non-fatal: cache directory may not exist
123-
}
124-
125151
// Save token first, then validate by fetching user regions
126152
await setAuthToken(flags.token);
127153

@@ -139,46 +165,35 @@ export const loginCommand = buildCommand({
139165

140166
// Fetch and cache user info via /auth/ (works with all token types).
141167
// A transient failure here must not block login — the token is already valid.
142-
let user: Awaited<ReturnType<typeof getCurrentUser>> | undefined;
168+
const result: LoginResult = {
169+
method: "token",
170+
configPath: getDbPath(),
171+
};
143172
try {
144-
user = await getCurrentUser();
173+
const user = await getCurrentUser();
145174
setUserInfo({
146175
userId: user.id,
147176
email: user.email,
148177
username: user.username,
149178
name: user.name,
150179
});
180+
result.user = user;
151181
} catch {
152182
// Non-fatal: user info is supplementary. Token remains stored and valid.
153183
}
154184

155-
log.success("Authenticated with API token");
156-
if (user) {
157-
log.info(`Logged in as: ${formatUserIdentity(user)}`);
158-
}
159-
log.info(`Config saved to: ${getDbPath()}`);
160-
return;
185+
return yield new CommandOutput(result);
161186
}
162187

163-
// Clear stale cached responses from a previous session
164-
try {
165-
await clearResponseCache();
166-
} catch {
167-
// Non-fatal: cache directory may not exist
168-
}
169-
170-
const { stdout, stderr } = this;
171-
const loginSuccess = await runInteractiveLogin(
172-
stdout,
173-
stderr,
174-
process.stdin,
175-
{
176-
timeout: flags.timeout * 1000,
177-
}
178-
);
188+
// OAuth device flow
189+
const result = await runInteractiveLogin({
190+
timeout: flags.timeout * 1000,
191+
});
179192

180-
if (!loginSuccess) {
181-
// Error already displayed by runInteractiveLogin - just set exit code
193+
if (result) {
194+
yield new CommandOutput(result);
195+
} else {
196+
// Error already displayed by runInteractiveLogin
182197
process.exitCode = 1;
183198
}
184199
},

src/commands/auth/logout.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { getDbPath } from "../../lib/db/index.js";
1616
import { AuthError } from "../../lib/errors.js";
1717
import { formatLogoutResult } from "../../lib/formatters/human.js";
18+
import { CommandOutput } from "../../lib/formatters/output.js";
1819

1920
/** Structured result of the logout operation */
2021
export type LogoutResult = {
@@ -32,15 +33,16 @@ export const logoutCommand = buildCommand({
3233
fullDescription:
3334
"Remove stored authentication credentials from the local database.",
3435
},
35-
output: { json: true, human: formatLogoutResult },
36+
output: { human: formatLogoutResult },
3637
parameters: {
3738
flags: {},
3839
},
39-
async func(this: SentryContext): Promise<{ data: LogoutResult }> {
40+
async *func(this: SentryContext) {
4041
if (!(await isAuthenticated())) {
41-
return {
42-
data: { loggedOut: false, message: "Not currently authenticated." },
43-
};
42+
return yield new CommandOutput({
43+
loggedOut: false,
44+
message: "Not currently authenticated.",
45+
});
4446
}
4547

4648
if (isEnvTokenActive()) {
@@ -55,11 +57,9 @@ export const logoutCommand = buildCommand({
5557
const configPath = getDbPath();
5658
await clearAuth();
5759

58-
return {
59-
data: {
60-
loggedOut: true,
61-
configPath,
62-
},
63-
};
60+
return yield new CommandOutput({
61+
loggedOut: true,
62+
configPath,
63+
});
6464
},
6565
});

src/commands/auth/refresh.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { AuthError } from "../../lib/errors.js";
1616
import { success } from "../../lib/formatters/colors.js";
1717
import { formatDuration } from "../../lib/formatters/human.js";
18+
import { CommandOutput } from "../../lib/formatters/output.js";
1819

1920
type RefreshFlags = {
2021
readonly json: boolean;
@@ -58,7 +59,7 @@ Examples:
5859
{"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."}
5960
`.trim(),
6061
},
61-
output: { json: true, human: formatRefreshResult },
62+
output: { human: formatRefreshResult },
6263
parameters: {
6364
flags: {
6465
force: {
@@ -68,7 +69,7 @@ Examples:
6869
},
6970
},
7071
},
71-
async func(this: SentryContext, flags: RefreshFlags) {
72+
async *func(this: SentryContext, flags: RefreshFlags) {
7273
// Env var tokens can't be refreshed
7374
if (isEnvTokenActive()) {
7475
const envVar = getActiveEnvVarName();
@@ -104,6 +105,6 @@ Examples:
104105
: undefined,
105106
};
106107

107-
return { data: payload };
108+
return yield new CommandOutput(payload);
108109
},
109110
});

src/commands/auth/status.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getDbPath } from "../../lib/db/index.js";
2222
import { getUserInfo } from "../../lib/db/user.js";
2323
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
2424
import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js";
25+
import { CommandOutput } from "../../lib/formatters/output.js";
2526
import {
2627
applyFreshFlag,
2728
FRESH_ALIASES,
@@ -143,7 +144,7 @@ export const statusCommand = buildCommand({
143144
"Display information about your current authentication status, " +
144145
"including whether you're logged in and your default organization/project settings.",
145146
},
146-
output: { json: true, human: formatAuthStatus },
147+
output: { human: formatAuthStatus },
147148
parameters: {
148149
flags: {
149150
"show-token": {
@@ -155,7 +156,7 @@ export const statusCommand = buildCommand({
155156
},
156157
aliases: FRESH_ALIASES,
157158
},
158-
async func(this: SentryContext, flags: StatusFlags) {
159+
async *func(this: SentryContext, flags: StatusFlags) {
159160
applyFreshFlag(flags);
160161

161162
const auth = getAuthConfig();
@@ -189,6 +190,6 @@ export const statusCommand = buildCommand({
189190
verification: await verifyCredentials(),
190191
};
191192

192-
return { data };
193+
return yield new CommandOutput(data);
193194
},
194195
});

src/commands/auth/token.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,25 @@ import type { SentryContext } from "../../context.js";
99
import { buildCommand } from "../../lib/command.js";
1010
import { getAuthToken } from "../../lib/db/auth.js";
1111
import { AuthError } from "../../lib/errors.js";
12+
import { CommandOutput } from "../../lib/formatters/output.js";
1213

1314
export const tokenCommand = buildCommand({
1415
docs: {
1516
brief: "Print the stored authentication token",
1617
fullDescription:
1718
"Print the stored authentication token to stdout.\n\n" +
1819
"This outputs the raw token without any formatting, making it suitable for " +
19-
"piping to other commands or scripts. The token is printed without a trailing newline " +
20-
"when stdout is not a TTY (e.g., when piped).",
20+
"piping to other commands or scripts.",
2121
},
2222
parameters: {},
23-
func(this: SentryContext): void {
24-
const { stdout } = this;
25-
23+
output: { human: (token: string) => token },
24+
// biome-ignore lint/suspicious/useAwait: sync body but async generator required by buildCommand
25+
async *func(this: SentryContext) {
2626
const token = getAuthToken();
2727
if (!token) {
2828
throw new AuthError("not_authenticated");
2929
}
3030

31-
// Add newline only if stdout is a TTY (interactive terminal)
32-
// When piped, omit newline for cleaner output
33-
const suffix = process.stdout.isTTY ? "\n" : "";
34-
stdout.write(`${token}${suffix}`);
31+
return yield new CommandOutput(token);
3532
},
3633
});

0 commit comments

Comments
 (0)