@@ -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" ;
3233import { UpgradeError } from "../../lib/errors.js" ;
3334import { formatUpgradeResult } from "../../lib/formatters/human.js" ;
3435import { 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
7780type 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+
111242type 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+
401577export 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