Skip to content

Commit 062bb74

Browse files
authored
fix: improve CLI output for auth login and upgrade flows (#454)
Migrate diagnostic output from cluttered stderr `log.info()` calls to the markdown-rendering pipeline for cleaner, more consistent formatting. ## Auth login (`interactive-login.ts`) - Show `verification_uri_complete` (with `user_code` embedded) instead of bare `verification_uri` so the URL works in headless environments (SSH, CI) - Use `log.log()` for URL/code detail lines instead of `log.info()` to avoid repeated ℹ icon prefixes - Only show copy hint when browser didn't open - Render user code as inline code span via `renderInlineMarkdown()` ## CLI upgrade (`upgrade.ts` + `human.ts`) - Demote method/version/channel/latest `log.info()` calls to `log.debug()` - Add metadata key-value table (method, channel) to `formatUpgradeResult` via `mdKvTable()`, rendering through the standard markdown pipeline - Keep `log.info()` only for progress indicators that fire before the result is ready (`Upgrading to...`, nightly migration) Closes #452
1 parent 701aab8 commit 062bb74

File tree

6 files changed

+239
-29
lines changed

6 files changed

+239
-29
lines changed

src/commands/cli/upgrade.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,10 @@ async function resolveTargetVersion(
271271
const latest = await fetchLatestVersion(method, channel);
272272
const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest;
273273

274-
log.info(`Channel: ${channel}`);
275-
log.info(`Latest version: ${latest}`);
274+
log.debug(`Channel: ${channel}`);
275+
log.debug(`Latest version: ${latest}`);
276276
if (versionArg) {
277-
log.info(`Target version: ${target}`);
277+
log.debug(`Target version: ${target}`);
278278
}
279279

280280
if (flags.check) {
@@ -639,8 +639,8 @@ export const upgradeCommand = buildCommand({
639639
const { channel, versionArg, channelChanged, method } =
640640
await resolveContext(version, flags);
641641

642-
log.info(`Installation method: ${method}`);
643-
log.info(`Current version: ${CLI_VERSION}`);
642+
log.debug(`Installation method: ${method}`);
643+
log.debug(`Current version: ${CLI_VERSION}`);
644644

645645
const resolved = await resolveTargetWithFallback({
646646
resolveOpts: { method, channel, versionArg, channelChanged, flags },

src/lib/formatters/human.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2123,6 +2123,16 @@ export function formatUpgradeResult(data: UpgradeResult): string {
21232123
}
21242124
}
21252125

2126+
// Metadata table — renders method, channel, and version info below the
2127+
// action line so diagnostics go to stdout (via the markdown pipeline)
2128+
// instead of cluttering stderr with repeated log.info() lines.
2129+
const kvRows: [string, string][] = [
2130+
["Method", escapeMarkdownInline(data.method)],
2131+
["Channel", escapeMarkdownInline(data.channel)],
2132+
];
2133+
lines.push("");
2134+
lines.push(mdKvTable(kvRows));
2135+
21262136
// Append warnings with ⚠ markers
21272137
if (data.warnings && data.warnings.length > 0) {
21282138
lines.push("");

src/lib/interactive-login.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { setupCopyKeyListener } from "./clipboard.js";
1212
import { getDbPath } from "./db/index.js";
1313
import { setUserInfo } from "./db/user.js";
1414
import { formatError } from "./errors.js";
15-
import { muted } from "./formatters/colors.js";
15+
import { renderInlineMarkdown } from "./formatters/markdown.js";
1616
import { logger } from "./logger.js";
1717
import { completeOAuthFlow, performDeviceFlow } from "./oauth.js";
1818
import { generateQRCode } from "./qrcode.js";
@@ -37,6 +37,45 @@ export type InteractiveLoginOptions = {
3737
timeout?: number;
3838
};
3939

40+
/**
41+
* Build the user-facing display lines for the device flow verification step.
42+
*
43+
* Shows the complete verification URL (with embedded user code) so it works
44+
* in environments where the browser can't auto-open (SSH, CI, etc.).
45+
* Uses `renderInlineMarkdown()` for styled output that respects `NO_COLOR`.
46+
*
47+
* Extracted from the `onUserCode` callback for testability.
48+
*
49+
* @param userCode - The device flow user code (e.g., "ABCD-EFGH")
50+
* @param verificationUriComplete - Full URL with `?user_code=...` embedded
51+
* @param browserOpened - Whether the browser was successfully opened
52+
* @param isTTY - Whether stdin is a TTY (controls copy-to-clipboard hint)
53+
* @returns Array of strings to emit via `log.log()`, one per call
54+
*/
55+
export function buildDeviceFlowDisplay(
56+
userCode: string,
57+
verificationUriComplete: string,
58+
browserOpened: boolean,
59+
isTTY: boolean
60+
): string[] {
61+
const lines: string[] = [];
62+
63+
lines.push("");
64+
// URL is emitted as plain text (no markdown rendering) so it stays
65+
// intact for copy-paste in all output modes including plain/CI.
66+
lines.push(` URL: ${verificationUriComplete}`);
67+
lines.push(renderInlineMarkdown(` Code: \`${userCode}\``));
68+
lines.push("");
69+
70+
if (!browserOpened) {
71+
const copyHint = isTTY ? renderInlineMarkdown(" *(`c` to copy URL)*") : "";
72+
lines.push(` Copy the URL above to sign in.${copyHint}`);
73+
lines.push("");
74+
}
75+
76+
return lines;
77+
}
78+
4079
/**
4180
* Run the interactive OAuth device flow login.
4281
*
@@ -69,7 +108,7 @@ export async function runInteractiveLogin(
69108
{
70109
onUserCode: async (
71110
userCode,
72-
verificationUri,
111+
_verificationUri,
73112
verificationUriComplete
74113
) => {
75114
urlToCopy = verificationUriComplete;
@@ -86,21 +125,24 @@ export async function runInteractiveLogin(
86125
log.log(qr);
87126
}
88127

89-
log.info(`URL: ${verificationUri}`);
90-
log.info(`Code: ${userCode}`);
91-
const stdin = process.stdin;
92-
const copyHint = stdin.isTTY ? ` ${muted("(c to copy)")}` : "";
93-
log.info(
94-
`Browser didn't open? Use the url above to sign in${copyHint}`
128+
// Show the complete URL (with user_code embedded) and optional copy hint
129+
const displayLines = buildDeviceFlowDisplay(
130+
userCode,
131+
verificationUriComplete,
132+
browserOpened,
133+
process.stdin.isTTY ?? false
95134
);
135+
for (const line of displayLines) {
136+
log.log(line);
137+
}
96138

97139
// Use a spinner for the "waiting" state instead of raw polling dots
98140
log.start("Waiting for authorization...");
99141

100142
// Setup keyboard listener for 'c' to copy URL
101-
if (stdin.isTTY) {
143+
if (process.stdin.isTTY) {
102144
keyListener.cleanup = setupCopyKeyListener(
103-
stdin as NodeJS.ReadStream & { fd: 0 },
145+
process.stdin as NodeJS.ReadStream & { fd: 0 },
104146
() => urlToCopy
105147
);
106148
}

test/commands/cli/upgrade.test.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ function createMockContext(
6363
...overrides.env,
6464
};
6565

66+
// Force plain output so formatUpgradeResult renders raw markdown tables
67+
// (ASCII pipes) instead of Unicode box-drawing characters in TTY mode.
68+
const origPlain = process.env.SENTRY_PLAIN_OUTPUT;
69+
process.env.SENTRY_PLAIN_OUTPUT = "1";
70+
6671
// Capture consola output (routed to process.stderr)
6772
const origWrite = process.stderr.write.bind(process.stderr);
6873
process.stderr.write = ((chunk: string | Uint8Array) => {
@@ -123,6 +128,11 @@ function createMockContext(
123128
errors,
124129
restore: () => {
125130
process.stderr.write = origWrite;
131+
if (origPlain === undefined) {
132+
delete process.env.SENTRY_PLAIN_OUTPUT;
133+
} else {
134+
process.env.SENTRY_PLAIN_OUTPUT = origPlain;
135+
}
126136
},
127137
};
128138
}
@@ -268,9 +278,8 @@ describe("sentry cli upgrade", () => {
268278
);
269279

270280
const combined = getOutput();
271-
expect(combined).toContain("Installation method: curl");
272-
expect(combined).toContain(`Current version: ${CLI_VERSION}`);
273-
expect(combined).toContain(`Latest version: ${CLI_VERSION}`);
281+
expect(combined).toContain("| curl |");
282+
expect(combined).toContain(CLI_VERSION);
274283
expect(combined).toContain("You are already on the target version");
275284
});
276285

@@ -289,7 +298,7 @@ describe("sentry cli upgrade", () => {
289298
);
290299

291300
const combined = getOutput();
292-
expect(combined).toContain("Latest version: 99.99.99");
301+
expect(combined).toContain("99.99.99");
293302
expect(combined).toContain("Run 'sentry cli upgrade' to update.");
294303
});
295304

@@ -308,7 +317,7 @@ describe("sentry cli upgrade", () => {
308317
);
309318

310319
const combined = getOutput();
311-
expect(combined).toContain("Target version: 88.88.88");
320+
expect(combined).toContain("88.88.88");
312321
expect(combined).toContain(
313322
"Run 'sentry cli upgrade 88.88.88' to update."
314323
);
@@ -363,8 +372,8 @@ describe("sentry cli upgrade", () => {
363372
);
364373

365374
const combined = getOutput();
366-
expect(combined).toContain("Installation method: brew");
367-
expect(combined).toContain("Latest version: 99.99.99");
375+
expect(combined).toContain("| brew |");
376+
expect(combined).toContain("99.99.99");
368377
expect(combined).toContain("Run 'sentry cli upgrade' to update.");
369378
});
370379
});
@@ -500,7 +509,7 @@ describe("sentry cli upgrade — nightly channel", () => {
500509
);
501510

502511
const combined = getOutput();
503-
expect(combined).toContain("Channel: nightly");
512+
expect(combined).toContain("| nightly |");
504513
});
505514

506515
test("'stable' positional sets channel to stable", async () => {
@@ -519,7 +528,7 @@ describe("sentry cli upgrade — nightly channel", () => {
519528
);
520529

521530
const combined = getOutput();
522-
expect(combined).toContain("Channel: stable");
531+
expect(combined).toContain("| stable |");
523532
});
524533

525534
test("without positional, uses persisted channel", async () => {
@@ -538,7 +547,7 @@ describe("sentry cli upgrade — nightly channel", () => {
538547
);
539548

540549
const combined = getOutput();
541-
expect(combined).toContain("Channel: nightly");
550+
expect(combined).toContain("| nightly |");
542551
});
543552
});
544553

@@ -593,8 +602,8 @@ describe("sentry cli upgrade — nightly channel", () => {
593602
);
594603

595604
const combined = getOutput();
596-
expect(combined).toContain("Channel: nightly");
597-
expect(combined).toContain(`Latest version: ${CLI_VERSION}`);
605+
expect(combined).toContain("| nightly |");
606+
expect(combined).toContain(CLI_VERSION);
598607
expect(combined).toContain("You are already on the target version");
599608
});
600609

@@ -613,8 +622,8 @@ describe("sentry cli upgrade — nightly channel", () => {
613622
);
614623

615624
const combined = getOutput();
616-
expect(combined).toContain("Channel: nightly");
617-
expect(combined).toContain("Latest version: 0.99.0-dev.9999999999");
625+
expect(combined).toContain("| nightly |");
626+
expect(combined).toContain("0.99.0-dev.9999999999");
618627
expect(combined).toContain("Run 'sentry cli upgrade' to update.");
619628
});
620629
});

test/lib/formatters/human.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
formatIssueSubtitle,
1515
formatProjectCreated,
1616
formatShortId,
17+
formatUpgradeResult,
1718
formatUserIdentity,
1819
type IssueTableRow,
1920
type ProjectCreatedResult,
@@ -738,3 +739,66 @@ describe("formatDashboardView", () => {
738739
expect(result).toContain("No widgets.");
739740
});
740741
});
742+
743+
describe("formatUpgradeResult", () => {
744+
// Force plain output so we get raw markdown (no ANSI codes)
745+
const origPlain = process.env.SENTRY_PLAIN_OUTPUT;
746+
function withPlain(fn: () => void) {
747+
process.env.SENTRY_PLAIN_OUTPUT = "1";
748+
try {
749+
fn();
750+
} finally {
751+
if (origPlain === undefined) {
752+
delete process.env.SENTRY_PLAIN_OUTPUT;
753+
} else {
754+
process.env.SENTRY_PLAIN_OUTPUT = origPlain;
755+
}
756+
}
757+
}
758+
759+
test("renders metadata table with method and channel", () => {
760+
withPlain(() => {
761+
const result = formatUpgradeResult({
762+
action: "upgraded",
763+
currentVersion: "0.5.0",
764+
targetVersion: "0.6.0",
765+
channel: "stable",
766+
method: "curl",
767+
forced: false,
768+
});
769+
expect(result).toContain("| **Method** | curl |");
770+
expect(result).toContain("| **Channel** | stable |");
771+
});
772+
});
773+
774+
test("renders nightly channel in metadata table", () => {
775+
withPlain(() => {
776+
const result = formatUpgradeResult({
777+
action: "checked",
778+
currentVersion: "0.5.0",
779+
targetVersion: "0.6.0-dev.123",
780+
channel: "nightly",
781+
method: "npm",
782+
forced: false,
783+
});
784+
expect(result).toContain("| **Method** | npm |");
785+
expect(result).toContain("| **Channel** | nightly |");
786+
});
787+
});
788+
789+
test("up-to-date action includes metadata table", () => {
790+
withPlain(() => {
791+
const result = formatUpgradeResult({
792+
action: "up-to-date",
793+
currentVersion: "0.5.0",
794+
targetVersion: "0.5.0",
795+
channel: "stable",
796+
method: "brew",
797+
forced: false,
798+
});
799+
expect(result).toContain("Already up to date");
800+
expect(result).toContain("| **Method** | brew |");
801+
expect(result).toContain("| **Channel** | stable |");
802+
});
803+
});
804+
});

0 commit comments

Comments
 (0)