Skip to content

Commit 0940f03

Browse files
committed
feat(upgrade): add --offline flag and automatic offline fallback
Add --offline flag to 'sentry cli upgrade' that uses only cached version info (from background checks) and cached patches (from background prefetch) — no network calls at all. When a normal upgrade encounters a network error during version discovery, automatically falls back to the offline path if cached data is available, logging a warning about the fallback. The infrastructure for offline upgrades already existed: - Background version checks store latest version in SQLite - Background patch prefetch caches patch chains to disk - Delta upgrade already checks cache before network This change wires those pieces together with a new entry point. Changes: - Add --offline flag to upgrade command - Add resolveOfflineTarget() for cached version lookup - Add resolveTargetWithFallback() for auto network→cache fallback - Thread offline param through executeUpgrade/downloadBinaryToTemp to prevent full-binary download fallback when offline - Show '(offline, from cache)' in human output for offline upgrades - Validate --offline is only used with curl-installed binaries
1 parent a68826f commit 0940f03

3 files changed

Lines changed: 190 additions & 42 deletions

File tree

src/commands/cli/upgrade.ts

Lines changed: 178 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type ReleaseChannel,
3030
setReleaseChannel,
3131
} from "../../lib/db/release-channel.js";
32+
import { getVersionCheckInfo } from "../../lib/db/version-check.js";
3233
import { UpgradeError } from "../../lib/errors.js";
3334
import { formatUpgradeResult } from "../../lib/formatters/human.js";
3435
import { CommandOutput } from "../../lib/formatters/output.js";
@@ -70,13 +71,16 @@ export type UpgradeResult = {
7071
method: string;
7172
/** Whether the user forced the upgrade */
7273
forced: boolean;
74+
/** Whether the upgrade was performed offline (from cache) */
75+
offline?: boolean;
7376
/** Warnings to display (e.g., PATH shadowing from old package manager install) */
7477
warnings?: string[];
7578
};
7679

7780
type UpgradeFlags = {
7881
readonly check: boolean;
7982
readonly force: boolean;
83+
readonly offline: boolean;
8084
readonly method?: InstallationMethod;
8185
};
8286

@@ -108,6 +112,108 @@ function resolveChannelAndVersion(positional: string | undefined): {
108112
};
109113
}
110114

115+
/**
116+
* Resolve the target version from the local cache (SQLite) instead of
117+
* fetching from the network. Used by `--offline` and as automatic
118+
* fallback when `fetchLatestVersion()` hits a network error.
119+
*
120+
* @param versionArg - Explicit version from the user, bypasses the cache lookup
121+
* @returns The target version string
122+
* @throws {UpgradeError} When no cached version is available
123+
*/
124+
function resolveOfflineTarget(versionArg: string | undefined): string {
125+
if (versionArg) {
126+
return versionArg.replace(VERSION_PREFIX_REGEX, "");
127+
}
128+
const { latestVersion } = getVersionCheckInfo();
129+
if (!latestVersion) {
130+
throw new UpgradeError(
131+
"network_error",
132+
"No cached version available. Run any command to trigger a background version check, then retry."
133+
);
134+
}
135+
return latestVersion;
136+
}
137+
138+
/**
139+
* Resolve the target version, trying the network first and falling back to
140+
* the local cache when offline or when the network is unavailable.
141+
*
142+
* @returns `{ target, offline }` — the resolved version and whether the
143+
* resolution used the offline path (explicit or automatic fallback).
144+
* Returns `null` when `resolveTargetVersion` returns a "done" result
145+
* (check-only or already up-to-date); the caller should yield that result.
146+
*/
147+
async function resolveTargetWithFallback(opts: {
148+
resolveOpts: ResolveTargetOptions;
149+
versionArg: string | undefined;
150+
offline: boolean;
151+
}): Promise<
152+
| { kind: "target"; target: string; offline: boolean }
153+
| { kind: "done"; result: UpgradeResult }
154+
> {
155+
const { resolveOpts, versionArg, offline } = opts;
156+
157+
if (offline) {
158+
const target = resolveOfflineTarget(versionArg);
159+
log.info(`Offline mode: using cached target ${target}`);
160+
return { kind: "target", target, offline: true };
161+
}
162+
163+
try {
164+
const resolved = await resolveTargetVersion(resolveOpts);
165+
if (resolved.kind === "done") {
166+
return resolved;
167+
}
168+
return { kind: "target", target: resolved.target, offline: false };
169+
} catch (error) {
170+
// Automatic offline fallback: if the network fails, try the cache
171+
if (!(error instanceof UpgradeError && error.reason === "network_error")) {
172+
throw error;
173+
}
174+
try {
175+
const target = resolveOfflineTarget(versionArg);
176+
log.warn("Network unavailable, falling back to cached upgrade target");
177+
log.info(`Using cached target: ${target}`);
178+
return { kind: "target", target, offline: true };
179+
} catch {
180+
// No cached version either — re-throw original network error
181+
throw error;
182+
}
183+
}
184+
}
185+
186+
/**
187+
* Validate the installation method against the requested flags and channel.
188+
* Throws on unsupported combinations.
189+
*/
190+
function validateMethod(
191+
method: InstallationMethod,
192+
versionArg: string | undefined,
193+
channel: ReleaseChannel,
194+
offline: boolean
195+
): void {
196+
if (method === "unknown") {
197+
throw new UpgradeError("unknown_method");
198+
}
199+
// Homebrew manages versioning through the formula — pinning a specific
200+
// stable version is not supported via this command.
201+
if (method === "brew" && versionArg && channel === "stable") {
202+
throw new UpgradeError(
203+
"unsupported_operation",
204+
"Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version."
205+
);
206+
}
207+
// Offline mode is only supported for curl-installed binaries — package
208+
// managers always need network to fetch and install packages.
209+
if (offline && method !== "curl") {
210+
throw new UpgradeError(
211+
"unsupported_operation",
212+
"Offline upgrade is only supported for curl-installed binaries."
213+
);
214+
}
215+
}
216+
111217
type ResolveTargetOptions = {
112218
method: InstallationMethod;
113219
channel: ReleaseChannel;
@@ -286,15 +392,21 @@ async function executeStandardUpgrade(opts: {
286392
versionArg: string | undefined;
287393
target: string;
288394
execPath: string;
395+
offline?: boolean;
289396
}): Promise<void> {
290-
const { method, channel, versionArg, target, execPath } = opts;
397+
const { method, channel, versionArg, target, execPath, offline } = opts;
291398

292399
// Use the rolling "nightly" tag only when upgrading to latest nightly
293400
// (no specific version was requested). A specific version arg always
294401
// uses its own tag so the correct release is downloaded.
295402
const downloadTag =
296403
channel === "nightly" && !versionArg ? NIGHTLY_TAG : undefined;
297-
const downloadResult = await executeUpgrade(method, target, downloadTag);
404+
const downloadResult = await executeUpgrade(
405+
method,
406+
target,
407+
downloadTag,
408+
offline
409+
);
298410

299411
// Run setup on the new binary to update completions, agent skills,
300412
// and record installation metadata.
@@ -398,6 +510,34 @@ async function migrateToStandaloneForNightly(
398510
return warnings;
399511
}
400512

513+
/**
514+
* Resolve the channel, version arg, method, and channel-changed flag from
515+
* the positional version argument and flags. Extracted to keep `func()`
516+
* complexity under the biome limit.
517+
*/
518+
async function resolveContext(
519+
version: string | undefined,
520+
flags: UpgradeFlags
521+
): Promise<{
522+
channel: ReleaseChannel;
523+
versionArg: string | undefined;
524+
channelChanged: boolean;
525+
method: InstallationMethod;
526+
}> {
527+
const { channel, versionArg } = resolveChannelAndVersion(version);
528+
const currentChannel = getReleaseChannel();
529+
const channelChanged = channel !== currentChannel;
530+
531+
// Persist the channel so version-check and future upgrades respect it.
532+
if (channelChanged || CHANNEL_VERSIONS.has(version ?? "")) {
533+
setReleaseChannel(channel);
534+
}
535+
536+
const method = flags.method ?? (await detectInstallationMethod());
537+
validateMethod(method, versionArg, channel, flags.offline);
538+
return { channel, versionArg, channelChanged, method };
539+
}
540+
401541
export const upgradeCommand = buildCommand({
402542
docs: {
403543
brief: "Update the Sentry CLI to the latest version",
@@ -416,7 +556,8 @@ export const upgradeCommand = buildCommand({
416556
" sentry cli upgrade 0.5.0 # Install a specific stable version\n" +
417557
" sentry cli upgrade --check # Check for updates without installing\n" +
418558
" sentry cli upgrade --force # Force re-download even if up to date\n" +
419-
" sentry cli upgrade --method npm # Force using npm to upgrade",
559+
" sentry cli upgrade --method npm # Force using npm to upgrade\n" +
560+
" sentry cli upgrade --offline # Upgrade from cached patches (no network)",
420561
},
421562
output: { human: formatUpgradeResult },
422563
parameters: {
@@ -443,6 +584,12 @@ export const upgradeCommand = buildCommand({
443584
brief: "Force upgrade even if already on the latest version",
444585
default: false,
445586
},
587+
offline: {
588+
kind: "boolean",
589+
brief:
590+
"Upgrade using only cached version info and patches (no network)",
591+
default: false,
592+
},
446593
method: {
447594
kind: "parsed",
448595
parse: parseInstallationMethod,
@@ -453,51 +600,42 @@ export const upgradeCommand = buildCommand({
453600
},
454601
},
455602
async *func(this: SentryContext, flags: UpgradeFlags, version?: string) {
456-
// Resolve effective channel and version from positional
457-
const { channel, versionArg } = resolveChannelAndVersion(version);
458-
459-
// Track whether the user is deliberately switching channels
460-
const currentChannel = getReleaseChannel();
461-
const channelChanged = channel !== currentChannel;
462-
463-
// Persist the channel so version-check and future upgrades respect it.
464-
// We do this upfront — even if the download is skipped (e.g. --check) —
465-
// so the preference is always recorded.
466-
if (channelChanged || CHANNEL_VERSIONS.has(version ?? "")) {
467-
setReleaseChannel(channel);
468-
}
469-
470-
// Resolve installation method (detects or uses user-specified)
471-
const method = flags.method ?? (await detectInstallationMethod());
472-
473-
if (method === "unknown") {
474-
throw new UpgradeError("unknown_method");
475-
}
476-
477-
// Homebrew manages versioning through the formula — pinning a specific
478-
// stable version is not supported via this command.
479-
if (method === "brew" && versionArg && channel === "stable") {
480-
throw new UpgradeError(
481-
"unsupported_operation",
482-
"Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version."
483-
);
484-
}
603+
const { channel, versionArg, channelChanged, method } =
604+
await resolveContext(version, flags);
485605

486606
log.info(`Installation method: ${method}`);
487607
log.info(`Current version: ${CLI_VERSION}`);
488608

489-
const resolved = await resolveTargetVersion({
490-
method,
491-
channel,
609+
const resolved = await resolveTargetWithFallback({
610+
resolveOpts: { method, channel, versionArg, channelChanged, flags },
492611
versionArg,
493-
channelChanged,
494-
flags,
612+
offline: flags.offline,
495613
});
496614
if (resolved.kind === "done") {
497615
return yield new CommandOutput(resolved.result);
498616
}
499617

500-
const { target } = resolved;
618+
const { target, offline } = resolved;
619+
620+
// --check with offline just reports the cached version
621+
if (flags.check) {
622+
return yield new CommandOutput(
623+
buildCheckResult({ target, versionArg, method, channel, flags })
624+
);
625+
}
626+
627+
// Skip if already on target — unless forced or switching channels
628+
if (CLI_VERSION === target && !flags.force && !channelChanged) {
629+
return yield new CommandOutput({
630+
action: "up-to-date",
631+
currentVersion: CLI_VERSION,
632+
targetVersion: target,
633+
channel,
634+
method,
635+
forced: false,
636+
offline: offline || undefined,
637+
} satisfies UpgradeResult);
638+
}
501639
const downgrade = isDowngrade(CLI_VERSION, target);
502640
log.info(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}...`);
503641

@@ -528,6 +666,7 @@ export const upgradeCommand = buildCommand({
528666
versionArg,
529667
target,
530668
execPath: this.process.execPath,
669+
offline,
531670
});
532671

533672
yield new CommandOutput({
@@ -537,6 +676,7 @@ export const upgradeCommand = buildCommand({
537676
channel,
538677
method,
539678
forced: flags.force,
679+
offline: offline || undefined,
540680
} satisfies UpgradeResult);
541681
return;
542682
},

src/lib/formatters/human.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2036,8 +2036,9 @@ export function formatUpgradeResult(data: UpgradeResult): string {
20362036
case "upgraded":
20372037
case "downgraded": {
20382038
const verb = ACTION_DESCRIPTIONS[data.action];
2039+
const offlineNote = data.offline ? " (offline, from cache)" : "";
20392040
lines.push(
2040-
`${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)}`
2041+
`${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)}${escapeMarkdownInline(offlineNote)}`
20412042
);
20422043
if (data.currentVersion !== data.targetVersion) {
20432044
lines.push(

src/lib/upgrade.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,8 @@ async function downloadStableToPath(
616616
*/
617617
export async function downloadBinaryToTemp(
618618
version: string,
619-
downloadTag?: string
619+
downloadTag?: string,
620+
offline?: boolean
620621
): Promise<DownloadResult> {
621622
const { tempPath, lockPath } = getCurlInstallPaths();
622623

@@ -636,6 +637,11 @@ export async function downloadBinaryToTemp(
636637
if (deltaResult) {
637638
const kb = (deltaResult.patchBytes / 1024).toFixed(1);
638639
log.info(`Applied delta patch (${kb} KB downloaded)`);
640+
} else if (offline) {
641+
throw new UpgradeError(
642+
"network_error",
643+
`No cached patches available for upgrade to ${version}. Run 'sentry cli upgrade' with network access first.`
644+
);
639645
} else {
640646
log.debug("Downloading full binary");
641647
await downloadFullBinary(version, downloadTag, tempPath);
@@ -793,11 +799,12 @@ function executeUpgradePackageManager(
793799
export async function executeUpgrade(
794800
method: InstallationMethod,
795801
version: string,
796-
downloadTag?: string
802+
downloadTag?: string,
803+
offline?: boolean
797804
): Promise<DownloadResult | null> {
798805
switch (method) {
799806
case "curl":
800-
return downloadBinaryToTemp(version, downloadTag);
807+
return downloadBinaryToTemp(version, downloadTag, offline);
801808
case "brew":
802809
await executeUpgradeHomebrew();
803810
return null;

0 commit comments

Comments
 (0)