Skip to content

Commit d8c0589

Browse files
feat(upgrade): add --offline flag and automatic offline fallback (#450)
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, automatically falls back to the offline path if cached data is available. ### How it works The infrastructure for offline upgrades already existed: - Background version checks store the latest version in SQLite - Background patch prefetch downloads and caches patch chains to disk - Delta upgrade already checks the cache before hitting the network This change wires those pieces together: 1. `--offline` → uses `getVersionCheckInfo().latestVersion` from SQLite instead of `fetchLatestVersion()`, then lets `tryDeltaUpgrade()` use cached patches only 2. **Auto-fallback** → wraps `resolveTargetVersion()` with a catch for `UpgradeError("network_error")`, falling back to the cached version + patches 3. When `offline` is true and no cached patches exist, throws a clear error instead of falling through to `downloadFullBinary()` ### Flag interactions | Combo | Behavior | |---|---| | `--offline` | Use cached version + cached patches | | `--offline --check` | Show cached version info | | `--offline --method npm` | Error: offline only for curl | | (no flag, network fails) | Auto-fallback with warning | --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent b71258d commit d8c0589

6 files changed

Lines changed: 319 additions & 88 deletions

File tree

AGENTS.md

Lines changed: 42 additions & 38 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ Update the Sentry CLI to the latest version
501501
**Flags:**
502502
- `--check - Check for updates without installing`
503503
- `--force - Force upgrade even if already on the latest version`
504+
- `--offline - Upgrade using only cached version info and patches (no network)`
504505
- `--method <value> - Installation method to use (curl, brew, npm, pnpm, bun, yarn)`
505506
- `--json - Output as JSON`
506507
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

src/commands/cli/upgrade.ts

Lines changed: 224 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,133 @@ 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+
/** Only curl-installed binaries support offline fallback */
152+
method: InstallationMethod;
153+
/** Persist the channel after offline target resolution (deferred to avoid
154+
* clearing the version cache before the offline path can read it). */
155+
persistChannelFn: () => void;
156+
}): Promise<
157+
| { kind: "target"; target: string; offline: boolean }
158+
| { kind: "done"; result: UpgradeResult }
159+
> {
160+
const { resolveOpts, versionArg, offline, method, persistChannelFn } = opts;
161+
162+
if (offline) {
163+
// Channel switching with --offline is not supported: the cached version
164+
// belongs to the old channel and would install the wrong binary type.
165+
if (resolveOpts.channelChanged && !versionArg) {
166+
throw new UpgradeError(
167+
"unsupported_operation",
168+
"Cannot switch channels in offline mode — the cached version belongs to the current channel. " +
169+
"Run 'sentry cli upgrade' with network access to switch channels."
170+
);
171+
}
172+
// Read the cached version BEFORE persisting the channel — setReleaseChannel
173+
// clears the version cache on channel changes.
174+
const target = resolveOfflineTarget(versionArg);
175+
persistChannelFn();
176+
log.info(`Offline mode: using cached target ${target}`);
177+
return { kind: "target", target, offline: true };
178+
}
179+
180+
// Non-offline: persist channel upfront (no cache dependency)
181+
persistChannelFn();
182+
183+
try {
184+
const resolved = await resolveTargetVersion(resolveOpts);
185+
if (resolved.kind === "done") {
186+
return resolved;
187+
}
188+
return { kind: "target", target: resolved.target, offline: false };
189+
} catch (error) {
190+
// Automatic offline fallback: only for curl-installed binaries (package
191+
// managers need the network for the actual install, not just version
192+
// discovery), and only for network errors (not version_not_found etc.)
193+
if (
194+
method !== "curl" ||
195+
!(error instanceof UpgradeError && error.reason === "network_error")
196+
) {
197+
throw error;
198+
}
199+
try {
200+
const target = resolveOfflineTarget(versionArg);
201+
log.warn("Network unavailable, falling back to cached upgrade target");
202+
log.info(`Using cached target: ${target}`);
203+
return { kind: "target", target, offline: true };
204+
} catch {
205+
// No cached version either — re-throw original network error
206+
throw error;
207+
}
208+
}
209+
}
210+
211+
/**
212+
* Validate the installation method against the requested flags and channel.
213+
* Throws on unsupported combinations.
214+
*/
215+
function validateMethod(
216+
method: InstallationMethod,
217+
versionArg: string | undefined,
218+
channel: ReleaseChannel,
219+
offline: boolean
220+
): void {
221+
if (method === "unknown") {
222+
throw new UpgradeError("unknown_method");
223+
}
224+
// Homebrew manages versioning through the formula — pinning a specific
225+
// stable version is not supported via this command.
226+
if (method === "brew" && versionArg && channel === "stable") {
227+
throw new UpgradeError(
228+
"unsupported_operation",
229+
"Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version."
230+
);
231+
}
232+
// Offline mode is only supported for curl-installed binaries — package
233+
// managers always need network to fetch and install packages.
234+
if (offline && method !== "curl") {
235+
throw new UpgradeError(
236+
"unsupported_operation",
237+
"Offline upgrade is only supported for curl-installed binaries."
238+
);
239+
}
240+
}
241+
111242
type ResolveTargetOptions = {
112243
method: InstallationMethod;
113244
channel: ReleaseChannel;
@@ -286,15 +417,21 @@ async function executeStandardUpgrade(opts: {
286417
versionArg: string | undefined;
287418
target: string;
288419
execPath: string;
420+
offline?: boolean;
289421
}): Promise<void> {
290-
const { method, channel, versionArg, target, execPath } = opts;
422+
const { method, channel, versionArg, target, execPath, offline } = opts;
291423

292424
// Use the rolling "nightly" tag only when upgrading to latest nightly
293425
// (no specific version was requested). A specific version arg always
294426
// uses its own tag so the correct release is downloaded.
295427
const downloadTag =
296428
channel === "nightly" && !versionArg ? NIGHTLY_TAG : undefined;
297-
const downloadResult = await executeUpgrade(method, target, downloadTag);
429+
const downloadResult = await executeUpgrade(
430+
method,
431+
target,
432+
downloadTag,
433+
offline
434+
);
298435

299436
// Run setup on the new binary to update completions, agent skills,
300437
// and record installation metadata.
@@ -398,6 +535,45 @@ async function migrateToStandaloneForNightly(
398535
return warnings;
399536
}
400537

538+
/**
539+
* Resolve the channel, version arg, method, and channel-changed flag from
540+
* the positional version argument and flags. Extracted to keep `func()`
541+
* complexity under the biome limit.
542+
*/
543+
async function resolveContext(
544+
version: string | undefined,
545+
flags: UpgradeFlags
546+
): Promise<{
547+
channel: ReleaseChannel;
548+
versionArg: string | undefined;
549+
channelChanged: boolean;
550+
method: InstallationMethod;
551+
}> {
552+
const { channel, versionArg } = resolveChannelAndVersion(version);
553+
const currentChannel = getReleaseChannel();
554+
const channelChanged = channel !== currentChannel;
555+
556+
const method = flags.method ?? (await detectInstallationMethod());
557+
validateMethod(method, versionArg, channel, flags.offline);
558+
return { channel, versionArg, channelChanged, method };
559+
}
560+
561+
/**
562+
* Persist the release channel preference. Must be called **after** offline
563+
* target resolution since `setReleaseChannel()` clears the version check
564+
* cache on channel changes, which would prevent `resolveOfflineTarget()`
565+
* from reading the cached version.
566+
*/
567+
function persistChannel(
568+
channel: ReleaseChannel,
569+
channelChanged: boolean,
570+
version: string | undefined
571+
): void {
572+
if (channelChanged || CHANNEL_VERSIONS.has(version ?? "")) {
573+
setReleaseChannel(channel);
574+
}
575+
}
576+
401577
export const upgradeCommand = buildCommand({
402578
docs: {
403579
brief: "Update the Sentry CLI to the latest version",
@@ -416,7 +592,8 @@ export const upgradeCommand = buildCommand({
416592
" sentry cli upgrade 0.5.0 # Install a specific stable version\n" +
417593
" sentry cli upgrade --check # Check for updates without installing\n" +
418594
" sentry cli upgrade --force # Force re-download even if up to date\n" +
419-
" sentry cli upgrade --method npm # Force using npm to upgrade",
595+
" sentry cli upgrade --method npm # Force using npm to upgrade\n" +
596+
" sentry cli upgrade --offline # Upgrade from cached patches (no network)",
420597
},
421598
output: { human: formatUpgradeResult },
422599
parameters: {
@@ -443,6 +620,12 @@ export const upgradeCommand = buildCommand({
443620
brief: "Force upgrade even if already on the latest version",
444621
default: false,
445622
},
623+
offline: {
624+
kind: "boolean",
625+
brief:
626+
"Upgrade using only cached version info and patches (no network)",
627+
default: false,
628+
},
446629
method: {
447630
kind: "parsed",
448631
parse: parseInstallationMethod,
@@ -453,51 +636,52 @@ export const upgradeCommand = buildCommand({
453636
},
454637
},
455638
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-
}
639+
const { channel, versionArg, channelChanged, method } =
640+
await resolveContext(version, flags);
485641

486642
log.info(`Installation method: ${method}`);
487643
log.info(`Current version: ${CLI_VERSION}`);
488644

489-
const resolved = await resolveTargetVersion({
490-
method,
491-
channel,
645+
const resolved = await resolveTargetWithFallback({
646+
resolveOpts: { method, channel, versionArg, channelChanged, flags },
492647
versionArg,
493-
channelChanged,
494-
flags,
648+
offline: flags.offline,
649+
method,
650+
persistChannelFn: () => persistChannel(channel, channelChanged, version),
495651
});
496652
if (resolved.kind === "done") {
497653
return yield new CommandOutput(resolved.result);
498654
}
499655

500-
const { target } = resolved;
656+
const { target, offline } = resolved;
657+
658+
// --check with offline just reports the cached version
659+
if (flags.check) {
660+
const checkResult = buildCheckResult({
661+
target,
662+
versionArg,
663+
method,
664+
channel,
665+
flags,
666+
});
667+
if (offline) {
668+
checkResult.offline = true;
669+
}
670+
return yield new CommandOutput(checkResult);
671+
}
672+
673+
// Skip if already on target — unless forced or switching channels
674+
if (CLI_VERSION === target && !flags.force && !channelChanged) {
675+
return yield new CommandOutput({
676+
action: "up-to-date",
677+
currentVersion: CLI_VERSION,
678+
targetVersion: target,
679+
channel,
680+
method,
681+
forced: false,
682+
offline: offline || undefined,
683+
} satisfies UpgradeResult);
684+
}
501685
const downgrade = isDowngrade(CLI_VERSION, target);
502686
log.info(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}...`);
503687

@@ -528,6 +712,7 @@ export const upgradeCommand = buildCommand({
528712
versionArg,
529713
target,
530714
execPath: this.process.execPath,
715+
offline,
531716
});
532717

533718
yield new CommandOutput({
@@ -537,6 +722,7 @@ export const upgradeCommand = buildCommand({
537722
channel,
538723
method,
539724
forced: flags.force,
725+
offline: offline || undefined,
540726
} satisfies UpgradeResult);
541727
return;
542728
},

0 commit comments

Comments
 (0)