@@ -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,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+
111217type 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+
401541export 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 } ,
0 commit comments