diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index f046b9ff8..81f9ecde1 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -46,6 +46,7 @@ import { getCurlInstallPaths, type InstallationMethod, NIGHTLY_TAG, + type OfflineMode, parseInstallationMethod, VERSION_PREFIX_REGEX, versionExists, @@ -163,7 +164,7 @@ async function resolveTargetWithFallback(opts: { * clearing the version cache before the offline path can read it). */ persistChannelFn: () => void; }): Promise< - | { kind: "target"; target: string; offline: boolean } + | { kind: "target"; target: string; offline: OfflineMode } | { kind: "done"; result: UpgradeResult } > { const { resolveOpts, versionArg, offline, method, persistChannelFn } = opts; @@ -183,7 +184,7 @@ async function resolveTargetWithFallback(opts: { const target = resolveOfflineTarget(versionArg); persistChannelFn(); log.info(`Offline mode: using cached target ${target}`); - return { kind: "target", target, offline: true }; + return { kind: "target", target, offline: "explicit" }; } // Non-offline: persist channel upfront (no cache dependency) @@ -209,7 +210,7 @@ async function resolveTargetWithFallback(opts: { const target = resolveOfflineTarget(versionArg); log.warn("Network unavailable, falling back to cached upgrade target"); log.info(`Using cached target: ${target}`); - return { kind: "target", target, offline: true }; + return { kind: "target", target, offline: "network-fallback" }; } catch { // No cached version either — re-throw original network error throw error; @@ -427,7 +428,7 @@ async function executeStandardUpgrade(opts: { versionArg: string | undefined; target: string; execPath: string; - offline?: boolean; + offline?: OfflineMode; json?: boolean; }): Promise { const { method, channel, versionArg, target, execPath, offline, json } = opts; @@ -608,7 +609,7 @@ function startChangelogFetch( channel: ReleaseChannel, currentVersion: string, targetVersion: string, - offline: boolean + offline: OfflineMode ): Promise { if (offline || currentVersion === targetVersion) { return Promise.resolve(undefined); @@ -631,7 +632,7 @@ async function buildCheckResultWithChangelog(opts: { method: InstallationMethod; channel: ReleaseChannel; flags: UpgradeFlags; - offline: boolean; + offline: OfflineMode; changelogPromise: Promise; }): Promise { const result = buildCheckResult(opts); @@ -776,7 +777,7 @@ export const upgradeCommand = buildCommand({ channel, method, forced: false, - offline: offline || undefined, + offline: offline ? true : undefined, } satisfies UpgradeResult); } const downgrade = isDowngrade(CLI_VERSION, target); @@ -813,7 +814,7 @@ export const upgradeCommand = buildCommand({ channel, method, forced: flags.force, - offline: offline || undefined, + offline: offline ? true : undefined, warnings, changelog, } satisfies UpgradeResult); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 502267092..7e058a051 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -392,7 +392,8 @@ export type UpgradeErrorReason = | "unsupported_operation" | "network_error" | "execution_failed" - | "version_not_found"; + | "version_not_found" + | "offline_cache_miss"; /** * Upgrade-related errors. @@ -412,6 +413,8 @@ export class UpgradeError extends CliError { network_error: "Failed to fetch version information.", execution_failed: "Upgrade command failed.", version_not_found: "The specified version was not found.", + offline_cache_miss: + "Cannot upgrade offline — no pre-downloaded update is available.", }; super(message ?? defaultMessages[reason]); this.name = "UpgradeError"; diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 0b6a4ed2c..62760722f 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -57,6 +57,15 @@ export type InstallationMethod = /** Package managers that can be used for global installs */ type PackageManager = "npm" | "pnpm" | "bun" | "yarn"; +/** + * How the current upgrade reached the offline code path. + * + * - `false` — online upgrade (network available) + * - `"explicit"` — user passed `--offline` flag + * - `"network-fallback"` — network failed, auto-fell back to cache + */ +export type OfflineMode = false | "explicit" | "network-fallback"; + // Constants /** The git tag used for the rolling nightly GitHub release (stable fallback only). */ @@ -680,7 +689,7 @@ async function downloadStableToPath( export async function downloadBinaryToTemp( version: string, downloadTag?: string, - offline?: boolean + offline?: OfflineMode ): Promise { const { tempPath, lockPath } = getCurlInstallPaths(); @@ -696,14 +705,18 @@ export async function downloadBinaryToTemp( // Try delta upgrade first — downloads tiny patches instead of full binary. // Falls back to full download on any failure (missing patches, hash mismatch, etc.) - const deltaResult = await tryDeltaUpgrade(version, tempPath, offline); + const deltaResult = await tryDeltaUpgrade(version, tempPath, !!offline); let patchBytes: number | undefined; if (deltaResult) { patchBytes = deltaResult.patchBytes; } else if (offline) { throw new UpgradeError( - "network_error", - `No cached patches available for upgrade to ${version}. Run 'sentry cli upgrade' with network access first.` + "offline_cache_miss", + offline === "explicit" + ? `Cannot upgrade to ${version} in offline mode — no pre-downloaded update is available. ` + + "Run `sentry cli upgrade` without `--offline` to download the update directly." + : `Cannot upgrade to ${version} — the network is unavailable and no pre-downloaded update was found. ` + + "Check your internet connection and try again." ); } else { log.debug("Downloading full binary"); @@ -875,7 +888,7 @@ export async function executeUpgrade( method: InstallationMethod, version: string, downloadTag?: string, - offline?: boolean + offline?: OfflineMode ): Promise { switch (method) { case "curl": diff --git a/test/lib/upgrade.test.ts b/test/lib/upgrade.test.ts index b843f6d79..6179090d1 100644 --- a/test/lib/upgrade.test.ts +++ b/test/lib/upgrade.test.ts @@ -25,6 +25,7 @@ import { UpgradeError } from "../../src/lib/errors.js"; import { detectInstallationMethod, detectPackageManagerFromPath, + downloadBinaryToTemp, executeUpgrade, fetchLatestFromGitHub, fetchLatestFromNpm, @@ -270,6 +271,14 @@ describe("UpgradeError", () => { ); }); + test("creates error with default message for offline_cache_miss", () => { + const error = new UpgradeError("offline_cache_miss"); + expect(error.reason).toBe("offline_cache_miss"); + expect(error.message).toBe( + "Cannot upgrade offline — no pre-downloaded update is available." + ); + }); + test("allows custom message", () => { const error = new UpgradeError("network_error", "Custom error message"); expect(error.reason).toBe("network_error"); @@ -1471,3 +1480,64 @@ describe("executeUpgrade with curl method (nightly)", () => { expect(new Uint8Array(content)).toEqual(mockBinaryContent); }); }); + +describe("downloadBinaryToTemp offline errors", () => { + const offlineBinDir = join(TEST_TMP_DIR, "upgrade-offline-test"); + const offlineInstallPath = join(offlineBinDir, "sentry"); + + beforeEach(() => { + clearInstallInfo(); + mkdirSync(offlineBinDir, { recursive: true }); + setInstallInfo({ + method: "curl", + path: offlineInstallPath, + version: "0.0.0", + }); + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + const paths = getCurlInstallPaths(); + for (const p of [ + paths.installPath, + paths.tempPath, + paths.oldPath, + paths.lockPath, + ]) { + try { + await unlink(p); + } catch { + // Ignore + } + } + clearInstallInfo(); + }); + + test("explicit offline: throws offline_cache_miss with actionable message", async () => { + try { + await downloadBinaryToTemp("0.26.1", undefined, "explicit"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(UpgradeError); + const upgradeError = error as UpgradeError; + expect(upgradeError.reason).toBe("offline_cache_miss"); + expect(upgradeError.message).toContain("in offline mode"); + expect(upgradeError.message).toContain("without `--offline`"); + expect(upgradeError.message).not.toContain("cached patches"); + } + }); + + test("network fallback: throws offline_cache_miss with connection message", async () => { + try { + await downloadBinaryToTemp("0.26.1", undefined, "network-fallback"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(UpgradeError); + const upgradeError = error as UpgradeError; + expect(upgradeError.reason).toBe("offline_cache_miss"); + expect(upgradeError.message).toContain("network is unavailable"); + expect(upgradeError.message).toContain("Check your internet connection"); + expect(upgradeError.message).not.toContain("cached patches"); + } + }); +});