Skip to content

Commit cd983b1

Browse files
committed
refactor(cli/upgrade): migrate to CommandOutput with markdown rendering
Convert cli/upgrade final-result messages to structured UpgradeResult data returned via CommandOutput, while keeping transient progress messages as log.info to stderr. Changes: - Define UpgradeResult type with action, versions, channel, method, and warnings fields - resolveTargetVersion returns discriminated union ResolveResult instead of string|null, enabling early-return paths to carry structured data - migrateToStandaloneForNightly returns warnings[] instead of logging - formatUpgradeResult() human formatter with ✓/⚠ markers and exhaustive action switch - All code paths return { data: UpgradeResult } - --json and --fields flags now supported automatically
1 parent faee0da commit cd983b1

File tree

4 files changed

+285
-82
lines changed

4 files changed

+285
-82
lines changed

src/commands/cli/upgrade.ts

Lines changed: 120 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
setReleaseChannel,
3131
} from "../../lib/db/release-channel.js";
3232
import { UpgradeError } from "../../lib/errors.js";
33+
import { formatUpgradeResult } from "../../lib/formatters/human.js";
3334
import { logger } from "../../lib/logger.js";
3435
import {
3536
detectInstallationMethod,
@@ -48,6 +49,30 @@ const log = logger.withTag("cli.upgrade");
4849
/** Special version strings that select a channel rather than a specific release. */
4950
const CHANNEL_VERSIONS = new Set(["nightly", "stable"]);
5051

52+
/**
53+
* Structured result of the upgrade command.
54+
*
55+
* Returned as `{ data: UpgradeResult }` and rendered via the output config.
56+
* In JSON mode the object is serialized as-is; in human mode it's passed to
57+
* {@link formatUpgradeResult}.
58+
*/
59+
export type UpgradeResult = {
60+
/** What action was taken */
61+
action: "upgraded" | "downgraded" | "up-to-date" | "checked" | "channel-only";
62+
/** Current CLI version before upgrade */
63+
currentVersion: string;
64+
/** Target version (the version we upgraded/downgraded to, or the latest available) */
65+
targetVersion: string;
66+
/** Release channel */
67+
channel: "stable" | "nightly";
68+
/** Installation method used */
69+
method: string;
70+
/** Whether the user forced the upgrade */
71+
forced: boolean;
72+
/** Warnings to display (e.g., PATH shadowing from old package manager install) */
73+
warnings?: string[];
74+
};
75+
5176
type UpgradeFlags = {
5277
readonly check: boolean;
5378
readonly force: boolean;
@@ -90,15 +115,26 @@ type ResolveTargetOptions = {
90115
flags: UpgradeFlags;
91116
};
92117

118+
/**
119+
* Result of resolving the target version.
120+
*
121+
* - `target`: the version string to upgrade/downgrade to (proceed with upgrade)
122+
* - `UpgradeResult`: structured result when no upgrade should proceed
123+
* (check-only mode, already up to date, or channel-only switch)
124+
*/
125+
type ResolveResult =
126+
| { kind: "target"; target: string }
127+
| { kind: "done"; result: UpgradeResult };
128+
93129
/**
94130
* Resolve the target version and handle check-only mode.
95131
*
96-
* @returns The target version string, or null if no upgrade should proceed
97-
* (check-only mode, already up to date without --force, or channel unchanged).
132+
* @returns A `ResolveResult` indicating whether to proceed with the upgrade
133+
* or return a completed result immediately.
98134
*/
99135
async function resolveTargetVersion(
100136
opts: ResolveTargetOptions
101-
): Promise<string | null> {
137+
): Promise<ResolveResult> {
102138
const { method, channel, versionArg, channelChanged, flags } = opts;
103139
const latest = await fetchLatestVersion(method, channel);
104140
const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest;
@@ -110,13 +146,25 @@ async function resolveTargetVersion(
110146
}
111147

112148
if (flags.check) {
113-
return handleCheckMode(target, versionArg);
149+
return {
150+
kind: "done",
151+
result: buildCheckResult({ target, versionArg, method, channel, flags }),
152+
};
114153
}
115154

116155
// Skip if already on target — unless forced or switching channels
117156
if (CLI_VERSION === target && !flags.force && !channelChanged) {
118-
log.success("Already up to date.");
119-
return null;
157+
return {
158+
kind: "done",
159+
result: {
160+
action: "up-to-date",
161+
currentVersion: CLI_VERSION,
162+
targetVersion: target,
163+
channel,
164+
method,
165+
forced: false,
166+
},
167+
};
120168
}
121169

122170
// Validate that a specific pinned version actually exists.
@@ -133,23 +181,39 @@ async function resolveTargetVersion(
133181
}
134182
}
135183

136-
return target;
184+
return { kind: "target", target };
137185
}
138186

139187
/**
140-
* Print check-only output and return null (no upgrade to perform).
188+
* Build the structured result for check-only mode.
141189
*/
142-
function handleCheckMode(target: string, versionArg: string | undefined): null {
143-
if (CLI_VERSION === target) {
144-
log.success("You are already on the target version.");
145-
} else {
190+
function buildCheckResult(opts: {
191+
target: string;
192+
versionArg: string | undefined;
193+
method: InstallationMethod;
194+
channel: ReleaseChannel;
195+
flags: UpgradeFlags;
196+
}): UpgradeResult {
197+
const { target, versionArg, method, channel, flags } = opts;
198+
const result: UpgradeResult = {
199+
action: "checked",
200+
currentVersion: CLI_VERSION,
201+
targetVersion: target,
202+
channel,
203+
method,
204+
forced: flags.force,
205+
};
206+
207+
// When already on target, no update hint needed
208+
if (CLI_VERSION !== target) {
146209
const cmd =
147210
versionArg && !CHANNEL_VERSIONS.has(versionArg)
148211
? `sentry cli upgrade ${target}`
149212
: "sentry cli upgrade";
150-
log.info(`Run '${cmd}' to update.`);
213+
result.warnings = [`Run '${cmd}' to update.`];
151214
}
152-
return null;
215+
216+
return result;
153217
}
154218

155219
/**
@@ -272,17 +336,18 @@ async function executeStandardUpgrade(opts: {
272336
* 1. Download the nightly binary to a temp path
273337
* 2. Install it to `determineInstallDir()` (same logic as the curl installer)
274338
* 3. Run setup on the new binary to update completions, PATH, and metadata
275-
* 4. Warn about the old package-manager installation that may still be in PATH
339+
* 4. Return warnings about the old package-manager installation that may still be in PATH
276340
*
277341
* @param versionArg - Specific version requested by the user, or undefined for
278342
* latest nightly. When a specific version is given, its release tag is used
279343
* instead of the rolling "nightly" tag so the correct binary is downloaded.
344+
* @returns Warnings about the old installation that may shadow the new one
280345
*/
281346
async function migrateToStandaloneForNightly(
282347
method: InstallationMethod,
283348
target: string,
284349
versionArg: string | undefined
285-
): Promise<void> {
350+
): Promise<string[]> {
286351
log.info("Nightly builds are only available as standalone binaries.");
287352
log.info("Migrating to standalone installation...");
288353

@@ -311,7 +376,7 @@ async function migrateToStandaloneForNightly(
311376
releaseLock(downloadResult.lockPath);
312377
}
313378

314-
// Warn about the potentially shadowing old installation
379+
// Build warnings about the potentially shadowing old installation.
315380
// Note: install info is already recorded by the child `setup --install`
316381
// process, so no redundant setInstallInfo call is needed here.
317382
const uninstallHints: Record<string, string> = {
@@ -321,11 +386,15 @@ async function migrateToStandaloneForNightly(
321386
yarn: "yarn global remove sentry",
322387
brew: "brew uninstall getsentry/tools/sentry",
323388
};
389+
const warnings: string[] = [];
390+
warnings.push(
391+
`Your ${method}-installed sentry may still appear earlier in PATH.`
392+
);
324393
const hint = uninstallHints[method];
325-
log.warn(`Your ${method}-installed sentry may still appear earlier in PATH.`);
326394
if (hint) {
327-
log.warn(`Consider removing it: ${hint}`);
395+
warnings.push(`Consider removing it: ${hint}`);
328396
}
397+
return warnings;
329398
}
330399

331400
export const upgradeCommand = buildCommand({
@@ -348,6 +417,7 @@ export const upgradeCommand = buildCommand({
348417
" sentry cli upgrade --force # Force re-download even if up to date\n" +
349418
" sentry cli upgrade --method npm # Force using npm to upgrade",
350419
},
420+
output: { json: true, human: formatUpgradeResult },
351421
parameters: {
352422
positional: {
353423
kind: "tuple",
@@ -381,11 +451,7 @@ export const upgradeCommand = buildCommand({
381451
},
382452
},
383453
},
384-
async func(
385-
this: SentryContext,
386-
flags: UpgradeFlags,
387-
version?: string
388-
): Promise<void> {
454+
async func(this: SentryContext, flags: UpgradeFlags, version?: string) {
389455
// Resolve effective channel and version from positional
390456
const { channel, versionArg } = resolveChannelAndVersion(version);
391457

@@ -419,27 +485,41 @@ export const upgradeCommand = buildCommand({
419485
log.info(`Installation method: ${method}`);
420486
log.info(`Current version: ${CLI_VERSION}`);
421487

422-
const target = await resolveTargetVersion({
488+
const resolved = await resolveTargetVersion({
423489
method,
424490
channel,
425491
versionArg,
426492
channelChanged,
427493
flags,
428494
});
429-
if (!target) {
430-
return;
495+
if (resolved.kind === "done") {
496+
return { data: resolved.result };
431497
}
432498

499+
const { target } = resolved;
433500
const downgrade = isDowngrade(CLI_VERSION, target);
434501
log.info(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}...`);
435502

436503
// Nightly is GitHub-only. If the current install method is not curl,
437504
// migrate to a standalone binary first then return — the migration
438505
// handles setup internally.
439506
if (channel === "nightly" && method !== "curl") {
440-
await migrateToStandaloneForNightly(method, target, versionArg);
441-
log.success(`Successfully installed nightly ${target}.`);
442-
return;
507+
const warnings = await migrateToStandaloneForNightly(
508+
method,
509+
target,
510+
versionArg
511+
);
512+
return {
513+
data: {
514+
action: downgrade ? "downgraded" : "upgraded",
515+
currentVersion: CLI_VERSION,
516+
targetVersion: target,
517+
channel,
518+
method,
519+
forced: flags.force,
520+
warnings,
521+
} satisfies UpgradeResult,
522+
};
443523
}
444524

445525
await executeStandardUpgrade({
@@ -450,8 +530,15 @@ export const upgradeCommand = buildCommand({
450530
execPath: this.process.execPath,
451531
});
452532

453-
log.success(
454-
`Successfully ${downgrade ? "downgraded" : "upgraded"} to ${target}.`
455-
);
533+
return {
534+
data: {
535+
action: downgrade ? "downgraded" : "upgraded",
536+
currentVersion: CLI_VERSION,
537+
targetVersion: target,
538+
channel,
539+
method,
540+
forced: flags.force,
541+
} satisfies UpgradeResult,
542+
};
456543
},
457544
});

src/lib/formatters/human.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,3 +2003,87 @@ export function formatFixResult(data: FixResult): string {
20032003

20042004
return renderMarkdown(lines.join("\n"));
20052005
}
2006+
2007+
// CLI Upgrade Formatting
2008+
2009+
/** Structured upgrade result (imported from the command module) */
2010+
type UpgradeResult = import("../../commands/cli/upgrade.js").UpgradeResult;
2011+
2012+
/** Action descriptions for human-readable output */
2013+
const ACTION_DESCRIPTIONS: Record<UpgradeResult["action"], string> = {
2014+
upgraded: "Upgraded",
2015+
downgraded: "Downgraded",
2016+
"up-to-date": "Already up to date",
2017+
checked: "Update check complete",
2018+
"channel-only": "Channel updated",
2019+
};
2020+
2021+
/**
2022+
* Format upgrade result as rendered markdown.
2023+
*
2024+
* Produces a concise summary line with the action taken, version info,
2025+
* and any warnings (e.g., PATH shadowing from old package manager install).
2026+
* Designed as the `human` formatter for the `cli upgrade` command's
2027+
* {@link OutputConfig}.
2028+
*
2029+
* @param data - Structured upgrade result collected by the command
2030+
* @returns Rendered terminal string
2031+
*/
2032+
export function formatUpgradeResult(data: UpgradeResult): string {
2033+
const lines: string[] = [];
2034+
2035+
switch (data.action) {
2036+
case "upgraded":
2037+
case "downgraded": {
2038+
const verb = ACTION_DESCRIPTIONS[data.action];
2039+
lines.push(
2040+
`${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)}`
2041+
);
2042+
if (data.currentVersion !== data.targetVersion) {
2043+
lines.push(
2044+
`${escapeMarkdownInline(data.currentVersion)}${escapeMarkdownInline(data.targetVersion)}`
2045+
);
2046+
}
2047+
break;
2048+
}
2049+
case "up-to-date":
2050+
lines.push(
2051+
`${colorTag("green", "✓")} Already up to date (${safeCodeSpan(data.currentVersion)})`
2052+
);
2053+
break;
2054+
case "checked": {
2055+
if (data.currentVersion === data.targetVersion) {
2056+
lines.push(
2057+
`${colorTag("green", "✓")} You are already on the target version (${safeCodeSpan(data.currentVersion)})`
2058+
);
2059+
} else {
2060+
lines.push(
2061+
`Latest: ${safeCodeSpan(data.targetVersion)} (current: ${safeCodeSpan(data.currentVersion)})`
2062+
);
2063+
}
2064+
break;
2065+
}
2066+
case "channel-only":
2067+
lines.push(
2068+
`${colorTag("green", "✓")} Channel set to ${escapeMarkdownInline(data.channel)}`
2069+
);
2070+
break;
2071+
default: {
2072+
// Exhaustive check — all action types should be handled above
2073+
const _: never = data.action;
2074+
lines.push(
2075+
`${ACTION_DESCRIPTIONS[_ as UpgradeResult["action"]] ?? "Done"}`
2076+
);
2077+
}
2078+
}
2079+
2080+
// Append warnings with ⚠ markers
2081+
if (data.warnings && data.warnings.length > 0) {
2082+
lines.push("");
2083+
for (const warning of data.warnings) {
2084+
lines.push(`${colorTag("yellow", "⚠")} ${escapeMarkdownInline(warning)}`);
2085+
}
2086+
}
2087+
2088+
return renderMarkdown(lines.join("\n"));
2089+
}

0 commit comments

Comments
 (0)