Skip to content

Commit 4304ab8

Browse files
refactor: migrate non-streaming commands to CommandOutput with markdown rendering (#398)
## Summary Migrate 5 commands from imperative `log.info`/`log.success`/`log.warn` output to the structured `CommandOutput<T>` return pattern with dedicated markdown formatters. This is a continuation of the output convergence work from PR #394. ### Commands migrated | Command | Data Type | Key Change | |---------|-----------|------------| | `auth status` | `AuthStatusData` | Multi-section display (source, user, token, defaults, verification) → `mdKvTable` + `colorTag` | | `auth logout` | `LogoutResult` | Not-authenticated / env-token cases now throw typed errors instead of `log.warn` | | `cli feedback` | `FeedbackResult` | Telemetry-disabled case throws `ConfigError` with suggestion | | `cli fix` | `FixResult` / `FixIssue` | 20+ diagnostic log calls → structured issues array. Uses `OutputError` for failures (renders data then exits 1) | | `cli upgrade` | `UpgradeResult` | Discriminated `ResolveResult` union. Progress messages kept on stderr; final result returned as data | ### Pattern Every migrated command follows the same structure: ```ts // 1. Define data type export type FooResult = { ... }; // 2. Add output config to buildCommand output: { json: true, human: formatFooResult }, // 3. Return data from func() return { data: result }; ``` Human formatters in `src/lib/formatters/human.ts` use the shared markdown helpers (`mdKvTable`, `colorTag`, `safeCodeSpan`, `renderMarkdown`) for consistent terminal rendering. All commands now automatically support `--json` and `--fields`. ### What's NOT in this PR - **`auth login`** — Has an interactive OAuth path that needs raw `stdout`/`stderr` Writers. Deferred to `buildStreamingCommand()`. - **List commands** (`issue list`, `project list`, etc.) — Need streaming infrastructure. - **`api` command** — Intentionally raw (direct API proxy). - **Progress `log.info` calls** in `cli/upgrade` — These are transient status messages on stderr, which is the correct place for them. ### Commands now on return-based `CommandOutput` (19 total) `api`, `auth/refresh`, `auth/whoami`, `auth/status`, `auth/logout`, `auth/token`, `cli/feedback`, `cli/fix`, `cli/upgrade`, `issue/explain`, `issue/plan`, `issue/view`, `event/view`, `log/view`, `trace/view`, `org/view`, `project/view`, `project/create`, `team/create` ### Test results 917 pass, 0 fail, 30 test files. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 4956615 commit 4304ab8

File tree

12 files changed

+1372
-708
lines changed

12 files changed

+1372
-708
lines changed

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ SENTRY_URL=https://sentry.example.com sentry auth login --token YOUR_TOKEN
6363

6464
Log out of Sentry
6565

66+
**Flags:**
67+
- `--json - Output as JSON`
68+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
69+
6670
**Examples:**
6771

6872
```bash
@@ -91,6 +95,8 @@ View authentication status
9195
**Flags:**
9296
- `--show-token - Show the stored token (masked by default)`
9397
- `-f, --fresh - Bypass cache and fetch fresh data`
98+
- `--json - Output as JSON`
99+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
94100

95101
**Examples:**
96102

@@ -453,12 +459,18 @@ CLI-related commands
453459

454460
Send feedback about the CLI
455461

462+
**Flags:**
463+
- `--json - Output as JSON`
464+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
465+
456466
#### `sentry cli fix`
457467

458468
Diagnose and repair CLI database issues
459469

460470
**Flags:**
461471
- `--dry-run - Show what would be fixed without making changes`
472+
- `--json - Output as JSON`
473+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
462474

463475
#### `sentry cli setup`
464476

@@ -481,6 +493,8 @@ Update the Sentry CLI to the latest version
481493
- `--check - Check for updates without installing`
482494
- `--force - Force upgrade even if already on the latest version`
483495
- `--method <value> - Installation method to use (curl, brew, npm, pnpm, bun, yarn)`
496+
- `--json - Output as JSON`
497+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
484498

485499
### Repo
486500

src/commands/auth/logout.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,53 @@ import {
1313
isEnvTokenActive,
1414
} from "../../lib/db/auth.js";
1515
import { getDbPath } from "../../lib/db/index.js";
16-
import { logger } from "../../lib/logger.js";
16+
import { AuthError } from "../../lib/errors.js";
17+
import { formatLogoutResult } from "../../lib/formatters/human.js";
1718

18-
const log = logger.withTag("auth.logout");
19+
/** Structured result of the logout operation */
20+
export type LogoutResult = {
21+
/** Whether logout actually cleared credentials */
22+
loggedOut: boolean;
23+
/** Informational message when no action was taken */
24+
message?: string;
25+
/** Path where credentials were stored (when loggedOut is true) */
26+
configPath?: string;
27+
};
1928

2029
export const logoutCommand = buildCommand({
2130
docs: {
2231
brief: "Log out of Sentry",
2332
fullDescription:
2433
"Remove stored authentication credentials from the configuration file.",
2534
},
35+
output: { json: true, human: formatLogoutResult },
2636
parameters: {
2737
flags: {},
2838
},
29-
async func(this: SentryContext): Promise<void> {
39+
async func(this: SentryContext): Promise<{ data: LogoutResult }> {
3040
if (!(await isAuthenticated())) {
31-
log.warn("Not currently authenticated.");
32-
return;
41+
return {
42+
data: { loggedOut: false, message: "Not currently authenticated." },
43+
};
3344
}
3445

3546
if (isEnvTokenActive()) {
3647
const envVar = getActiveEnvVarName();
37-
log.warn(
38-
`Authentication is provided via ${envVar} environment variable.\n` +
48+
throw new AuthError(
49+
"invalid",
50+
`Authentication is provided via ${envVar} environment variable. ` +
3951
`Unset ${envVar} to log out.`
4052
);
41-
return;
4253
}
4354

55+
const configPath = getDbPath();
4456
await clearAuth();
45-
log.success("Logged out successfully.");
46-
log.info(`Credentials removed from: ${getDbPath()}`);
57+
58+
return {
59+
data: {
60+
loggedOut: true,
61+
configPath,
62+
},
63+
};
4764
},
4865
});

src/commands/auth/status.ts

Lines changed: 97 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -21,116 +21,118 @@ import {
2121
import { getDbPath } from "../../lib/db/index.js";
2222
import { getUserInfo } from "../../lib/db/user.js";
2323
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
24-
import {
25-
formatExpiration,
26-
formatUserIdentity,
27-
maskToken,
28-
} from "../../lib/formatters/human.js";
24+
import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js";
2925
import {
3026
applyFreshFlag,
3127
FRESH_ALIASES,
3228
FRESH_FLAG,
3329
} from "../../lib/list-command.js";
34-
import { logger } from "../../lib/logger.js";
35-
36-
const log = logger.withTag("auth.status");
3730

3831
type StatusFlags = {
3932
readonly "show-token": boolean;
33+
readonly json: boolean;
4034
readonly fresh: boolean;
35+
readonly fields?: string[];
4136
};
4237

43-
/**
44-
* Log user identity information if available.
45-
*/
46-
function logUserInfo(): void {
47-
const user = getUserInfo();
48-
if (!user) {
49-
return;
50-
}
51-
log.info(`User: ${formatUserIdentity(user)}`);
52-
}
53-
5438
/** Check if the auth source is an environment variable */
5539
function isEnvSource(source: AuthSource): boolean {
5640
return source.startsWith(ENV_SOURCE_PREFIX);
5741
}
5842

59-
/** Extract the env var name from an env-based AuthSource (e.g. "env:SENTRY_AUTH_TOKEN" → "SENTRY_AUTH_TOKEN") */
60-
function envVarName(source: AuthSource): string {
61-
return source.slice(ENV_SOURCE_PREFIX.length);
62-
}
43+
/**
44+
* Structured data representing the full auth status.
45+
* Serves as both the JSON output shape and input to the human formatter.
46+
*/
47+
export type AuthStatusData = {
48+
/** Whether the user is currently authenticated */
49+
authenticated: boolean;
50+
/** Auth source: "oauth" or "env:SENTRY_AUTH_TOKEN" etc. */
51+
source: string;
52+
/** Path to the SQLite config database (only for non-env tokens) */
53+
configPath?: string;
54+
/** User identity from cached user info */
55+
user?: { name?: string; email?: string; username?: string };
56+
/** Token display and metadata */
57+
token?: {
58+
/** Masked or full token string depending on --show-token */
59+
display: string;
60+
/** Expiration timestamp (ms since epoch), if available */
61+
expiresAt?: number;
62+
/** Whether auto-refresh via refresh token is enabled */
63+
refreshEnabled: boolean;
64+
};
65+
/** Default org/project settings */
66+
defaults?: {
67+
organization?: string;
68+
project?: string;
69+
};
70+
/** Credential verification results */
71+
verification?: {
72+
/** Whether the API call succeeded */
73+
success: boolean;
74+
/** Organizations accessible with the current token */
75+
organizations?: Array<{ name: string; slug: string }>;
76+
/** Error message if verification failed */
77+
error?: string;
78+
};
79+
};
6380

6481
/**
65-
* Log token information.
82+
* Collect token information into the data structure.
6683
*/
67-
function logTokenInfo(auth: AuthConfig | undefined, showToken: boolean): void {
84+
function collectTokenInfo(
85+
auth: AuthConfig | undefined,
86+
showToken: boolean
87+
): AuthStatusData["token"] | undefined {
6888
if (!auth?.token) {
6989
return;
7090
}
7191

72-
const tokenDisplay = showToken ? auth.token : maskToken(auth.token);
73-
log.info(`Token: ${tokenDisplay}`);
74-
75-
// Env var tokens have no expiry or refresh — skip those sections
76-
if (isEnvSource(auth.source)) {
77-
return;
78-
}
79-
80-
if (auth.expiresAt) {
81-
log.info(`Expires: ${formatExpiration(auth.expiresAt)}`);
82-
}
92+
const display = showToken ? auth.token : maskToken(auth.token);
93+
const fromEnv = isEnvSource(auth.source);
8394

84-
// Show refresh token status
85-
if (auth.refreshToken) {
86-
log.info("Auto-refresh: enabled");
87-
} else {
88-
log.info("Auto-refresh: disabled (no refresh token)");
89-
}
95+
return {
96+
display,
97+
// Env var tokens have no expiry or refresh
98+
expiresAt: fromEnv ? undefined : auth.expiresAt,
99+
refreshEnabled: fromEnv ? false : Boolean(auth.refreshToken),
100+
};
90101
}
91102

92103
/**
93-
* Log default org/project settings if configured.
104+
* Collect default org/project into the data structure.
94105
*/
95-
async function logDefaults(): Promise<void> {
96-
const defaultOrg = await getDefaultOrganization();
97-
const defaultProject = await getDefaultProject();
106+
async function collectDefaults(): Promise<AuthStatusData["defaults"]> {
107+
const org = await getDefaultOrganization();
108+
const project = await getDefaultProject();
98109

99-
if (!(defaultOrg || defaultProject)) {
110+
if (!(org || project)) {
100111
return;
101112
}
102113

103-
log.info("Defaults:");
104-
if (defaultOrg) {
105-
log.info(` Organization: ${defaultOrg}`);
106-
}
107-
if (defaultProject) {
108-
log.info(` Project: ${defaultProject}`);
109-
}
114+
return {
115+
organization: org ?? undefined,
116+
project: project ?? undefined,
117+
};
110118
}
111119

112120
/**
113121
* Verify credentials by fetching organizations.
122+
* Captures success/failure into data rather than throwing.
114123
*/
115-
async function verifyCredentials(): Promise<void> {
116-
log.info("Verifying credentials...");
117-
124+
async function verifyCredentials(): Promise<AuthStatusData["verification"]> {
118125
try {
119126
const orgs = await listOrganizations();
120-
log.success(
121-
`Access verified. You have access to ${orgs.length} organization(s):`
122-
);
123-
124-
const maxDisplay = 5;
125-
for (const org of orgs.slice(0, maxDisplay)) {
126-
log.info(` - ${org.name} (${org.slug})`);
127-
}
128-
if (orgs.length > maxDisplay) {
129-
log.info(` ... and ${orgs.length - maxDisplay} more`);
130-
}
127+
return {
128+
success: true,
129+
organizations: orgs.map((o) => ({ name: o.name, slug: o.slug })),
130+
};
131131
} catch (err) {
132-
const message = stringifyUnknown(err);
133-
log.error(`Could not verify credentials: ${message}`);
132+
return {
133+
success: false,
134+
error: stringifyUnknown(err),
135+
};
134136
}
135137
}
136138

@@ -141,6 +143,7 @@ export const statusCommand = buildCommand({
141143
"Display information about your current authentication status, " +
142144
"including whether you're logged in and your default organization/project settings.",
143145
},
146+
output: { json: true, human: formatAuthStatus },
144147
parameters: {
145148
flags: {
146149
"show-token": {
@@ -152,17 +155,12 @@ export const statusCommand = buildCommand({
152155
},
153156
aliases: FRESH_ALIASES,
154157
},
155-
async func(this: SentryContext, flags: StatusFlags): Promise<void> {
158+
async func(this: SentryContext, flags: StatusFlags) {
156159
applyFreshFlag(flags);
157160

158-
const auth = await getAuthConfig();
161+
const auth = getAuthConfig();
159162
const authenticated = await isAuthenticated();
160-
const fromEnv = auth && isEnvSource(auth.source);
161-
162-
// Show config path only for stored (OAuth) tokens — irrelevant for env vars
163-
if (!fromEnv) {
164-
log.info(`Config: ${getDbPath()}`);
165-
}
163+
const fromEnv = auth ? isEnvSource(auth.source) : false;
166164

167165
if (!authenticated) {
168166
// Skip auto-login - user explicitly ran status to check auth state
@@ -171,17 +169,26 @@ export const statusCommand = buildCommand({
171169
});
172170
}
173171

174-
if (fromEnv) {
175-
log.success(
176-
`Authenticated via ${envVarName(auth.source)} environment variable`
177-
);
178-
} else {
179-
log.success("Authenticated");
180-
}
181-
logUserInfo();
182-
183-
logTokenInfo(auth, flags["show-token"]);
184-
await logDefaults();
185-
await verifyCredentials();
172+
// Build the user info
173+
const userInfo = getUserInfo();
174+
const user = userInfo
175+
? {
176+
name: userInfo.name,
177+
email: userInfo.email,
178+
username: userInfo.username,
179+
}
180+
: undefined;
181+
182+
const data: AuthStatusData = {
183+
authenticated: true,
184+
source: auth?.source ?? "oauth",
185+
configPath: fromEnv ? undefined : getDbPath(),
186+
user,
187+
token: collectTokenInfo(auth, flags["show-token"]),
188+
defaults: await collectDefaults(),
189+
verification: await verifyCredentials(),
190+
};
191+
192+
return { data };
186193
},
187194
});

0 commit comments

Comments
 (0)