Skip to content

Commit dff7cad

Browse files
authored
fix(upgrade): contextual error messages for offline cache miss (CLI-13Z) (#752)
## Summary - Replace the generic "No cached patches available" error with contextual messages for offline upgrade failures - Add `OfflineMode` type (`false | "explicit" | "network-fallback"`) to distinguish why the upgrade is offline - Use new `offline_cache_miss` error reason instead of misusing `network_error` ## Before Both explicit `--offline` and automatic network-failure fallback showed the same message: ``` Error: No cached patches available for upgrade to 0.26.1. Run 'sentry cli upgrade' with network access first. ``` Problems: leaks internal "cached patches" terminology, gives circular advice, wrong error reason. ## After **Explicit `--offline`:** ``` Error: Cannot upgrade to 0.26.1 in offline mode — no pre-downloaded update is available. Run `sentry cli upgrade` without `--offline` to download the update directly. ``` **Automatic network-failure fallback:** ``` Error: Cannot upgrade to 0.26.1 — the network is unavailable and no pre-downloaded update was found. Check your internet connection and try again. ``` Fixes CLI-13Z
1 parent 407015d commit dff7cad

File tree

4 files changed

+101
-14
lines changed

4 files changed

+101
-14
lines changed

src/commands/cli/upgrade.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
getCurlInstallPaths,
4747
type InstallationMethod,
4848
NIGHTLY_TAG,
49+
type OfflineMode,
4950
parseInstallationMethod,
5051
VERSION_PREFIX_REGEX,
5152
versionExists,
@@ -163,7 +164,7 @@ async function resolveTargetWithFallback(opts: {
163164
* clearing the version cache before the offline path can read it). */
164165
persistChannelFn: () => void;
165166
}): Promise<
166-
| { kind: "target"; target: string; offline: boolean }
167+
| { kind: "target"; target: string; offline: OfflineMode }
167168
| { kind: "done"; result: UpgradeResult }
168169
> {
169170
const { resolveOpts, versionArg, offline, method, persistChannelFn } = opts;
@@ -183,7 +184,7 @@ async function resolveTargetWithFallback(opts: {
183184
const target = resolveOfflineTarget(versionArg);
184185
persistChannelFn();
185186
log.info(`Offline mode: using cached target ${target}`);
186-
return { kind: "target", target, offline: true };
187+
return { kind: "target", target, offline: "explicit" };
187188
}
188189

189190
// Non-offline: persist channel upfront (no cache dependency)
@@ -209,7 +210,7 @@ async function resolveTargetWithFallback(opts: {
209210
const target = resolveOfflineTarget(versionArg);
210211
log.warn("Network unavailable, falling back to cached upgrade target");
211212
log.info(`Using cached target: ${target}`);
212-
return { kind: "target", target, offline: true };
213+
return { kind: "target", target, offline: "network-fallback" };
213214
} catch {
214215
// No cached version either — re-throw original network error
215216
throw error;
@@ -427,7 +428,7 @@ async function executeStandardUpgrade(opts: {
427428
versionArg: string | undefined;
428429
target: string;
429430
execPath: string;
430-
offline?: boolean;
431+
offline?: OfflineMode;
431432
json?: boolean;
432433
}): Promise<void> {
433434
const { method, channel, versionArg, target, execPath, offline, json } = opts;
@@ -608,7 +609,7 @@ function startChangelogFetch(
608609
channel: ReleaseChannel,
609610
currentVersion: string,
610611
targetVersion: string,
611-
offline: boolean
612+
offline: OfflineMode
612613
): Promise<ChangelogSummary | undefined> {
613614
if (offline || currentVersion === targetVersion) {
614615
return Promise.resolve(undefined);
@@ -631,7 +632,7 @@ async function buildCheckResultWithChangelog(opts: {
631632
method: InstallationMethod;
632633
channel: ReleaseChannel;
633634
flags: UpgradeFlags;
634-
offline: boolean;
635+
offline: OfflineMode;
635636
changelogPromise: Promise<ChangelogSummary | undefined>;
636637
}): Promise<UpgradeResult> {
637638
const result = buildCheckResult(opts);
@@ -776,7 +777,7 @@ export const upgradeCommand = buildCommand({
776777
channel,
777778
method,
778779
forced: false,
779-
offline: offline || undefined,
780+
offline: offline ? true : undefined,
780781
} satisfies UpgradeResult);
781782
}
782783
const downgrade = isDowngrade(CLI_VERSION, target);
@@ -813,7 +814,7 @@ export const upgradeCommand = buildCommand({
813814
channel,
814815
method,
815816
forced: flags.force,
816-
offline: offline || undefined,
817+
offline: offline ? true : undefined,
817818
warnings,
818819
changelog,
819820
} satisfies UpgradeResult);

src/lib/errors.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ export type UpgradeErrorReason =
392392
| "unsupported_operation"
393393
| "network_error"
394394
| "execution_failed"
395-
| "version_not_found";
395+
| "version_not_found"
396+
| "offline_cache_miss";
396397

397398
/**
398399
* Upgrade-related errors.
@@ -412,6 +413,8 @@ export class UpgradeError extends CliError {
412413
network_error: "Failed to fetch version information.",
413414
execution_failed: "Upgrade command failed.",
414415
version_not_found: "The specified version was not found.",
416+
offline_cache_miss:
417+
"Cannot upgrade offline — no pre-downloaded update is available.",
415418
};
416419
super(message ?? defaultMessages[reason]);
417420
this.name = "UpgradeError";

src/lib/upgrade.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export type InstallationMethod =
5757
/** Package managers that can be used for global installs */
5858
type PackageManager = "npm" | "pnpm" | "bun" | "yarn";
5959

60+
/**
61+
* How the current upgrade reached the offline code path.
62+
*
63+
* - `false` — online upgrade (network available)
64+
* - `"explicit"` — user passed `--offline` flag
65+
* - `"network-fallback"` — network failed, auto-fell back to cache
66+
*/
67+
export type OfflineMode = false | "explicit" | "network-fallback";
68+
6069
// Constants
6170

6271
/** The git tag used for the rolling nightly GitHub release (stable fallback only). */
@@ -680,7 +689,7 @@ async function downloadStableToPath(
680689
export async function downloadBinaryToTemp(
681690
version: string,
682691
downloadTag?: string,
683-
offline?: boolean
692+
offline?: OfflineMode
684693
): Promise<DownloadResult> {
685694
const { tempPath, lockPath } = getCurlInstallPaths();
686695

@@ -696,14 +705,18 @@ export async function downloadBinaryToTemp(
696705

697706
// Try delta upgrade first — downloads tiny patches instead of full binary.
698707
// Falls back to full download on any failure (missing patches, hash mismatch, etc.)
699-
const deltaResult = await tryDeltaUpgrade(version, tempPath, offline);
708+
const deltaResult = await tryDeltaUpgrade(version, tempPath, !!offline);
700709
let patchBytes: number | undefined;
701710
if (deltaResult) {
702711
patchBytes = deltaResult.patchBytes;
703712
} else if (offline) {
704713
throw new UpgradeError(
705-
"network_error",
706-
`No cached patches available for upgrade to ${version}. Run 'sentry cli upgrade' with network access first.`
714+
"offline_cache_miss",
715+
offline === "explicit"
716+
? `Cannot upgrade to ${version} in offline mode — no pre-downloaded update is available. ` +
717+
"Run `sentry cli upgrade` without `--offline` to download the update directly."
718+
: `Cannot upgrade to ${version} — the network is unavailable and no pre-downloaded update was found. ` +
719+
"Check your internet connection and try again."
707720
);
708721
} else {
709722
log.debug("Downloading full binary");
@@ -875,7 +888,7 @@ export async function executeUpgrade(
875888
method: InstallationMethod,
876889
version: string,
877890
downloadTag?: string,
878-
offline?: boolean
891+
offline?: OfflineMode
879892
): Promise<DownloadResult | null> {
880893
switch (method) {
881894
case "curl":

test/lib/upgrade.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { UpgradeError } from "../../src/lib/errors.js";
2525
import {
2626
detectInstallationMethod,
2727
detectPackageManagerFromPath,
28+
downloadBinaryToTemp,
2829
executeUpgrade,
2930
fetchLatestFromGitHub,
3031
fetchLatestFromNpm,
@@ -270,6 +271,14 @@ describe("UpgradeError", () => {
270271
);
271272
});
272273

274+
test("creates error with default message for offline_cache_miss", () => {
275+
const error = new UpgradeError("offline_cache_miss");
276+
expect(error.reason).toBe("offline_cache_miss");
277+
expect(error.message).toBe(
278+
"Cannot upgrade offline — no pre-downloaded update is available."
279+
);
280+
});
281+
273282
test("allows custom message", () => {
274283
const error = new UpgradeError("network_error", "Custom error message");
275284
expect(error.reason).toBe("network_error");
@@ -1471,3 +1480,64 @@ describe("executeUpgrade with curl method (nightly)", () => {
14711480
expect(new Uint8Array(content)).toEqual(mockBinaryContent);
14721481
});
14731482
});
1483+
1484+
describe("downloadBinaryToTemp offline errors", () => {
1485+
const offlineBinDir = join(TEST_TMP_DIR, "upgrade-offline-test");
1486+
const offlineInstallPath = join(offlineBinDir, "sentry");
1487+
1488+
beforeEach(() => {
1489+
clearInstallInfo();
1490+
mkdirSync(offlineBinDir, { recursive: true });
1491+
setInstallInfo({
1492+
method: "curl",
1493+
path: offlineInstallPath,
1494+
version: "0.0.0",
1495+
});
1496+
});
1497+
1498+
afterEach(async () => {
1499+
globalThis.fetch = originalFetch;
1500+
const paths = getCurlInstallPaths();
1501+
for (const p of [
1502+
paths.installPath,
1503+
paths.tempPath,
1504+
paths.oldPath,
1505+
paths.lockPath,
1506+
]) {
1507+
try {
1508+
await unlink(p);
1509+
} catch {
1510+
// Ignore
1511+
}
1512+
}
1513+
clearInstallInfo();
1514+
});
1515+
1516+
test("explicit offline: throws offline_cache_miss with actionable message", async () => {
1517+
try {
1518+
await downloadBinaryToTemp("0.26.1", undefined, "explicit");
1519+
expect.unreachable("Should have thrown");
1520+
} catch (error) {
1521+
expect(error).toBeInstanceOf(UpgradeError);
1522+
const upgradeError = error as UpgradeError;
1523+
expect(upgradeError.reason).toBe("offline_cache_miss");
1524+
expect(upgradeError.message).toContain("in offline mode");
1525+
expect(upgradeError.message).toContain("without `--offline`");
1526+
expect(upgradeError.message).not.toContain("cached patches");
1527+
}
1528+
});
1529+
1530+
test("network fallback: throws offline_cache_miss with connection message", async () => {
1531+
try {
1532+
await downloadBinaryToTemp("0.26.1", undefined, "network-fallback");
1533+
expect.unreachable("Should have thrown");
1534+
} catch (error) {
1535+
expect(error).toBeInstanceOf(UpgradeError);
1536+
const upgradeError = error as UpgradeError;
1537+
expect(upgradeError.reason).toBe("offline_cache_miss");
1538+
expect(upgradeError.message).toContain("network is unavailable");
1539+
expect(upgradeError.message).toContain("Check your internet connection");
1540+
expect(upgradeError.message).not.toContain("cached patches");
1541+
}
1542+
});
1543+
});

0 commit comments

Comments
 (0)