Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/commands/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
getCurlInstallPaths,
type InstallationMethod,
NIGHTLY_TAG,
type OfflineMode,
parseInstallationMethod,
VERSION_PREFIX_REGEX,
versionExists,
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -427,7 +428,7 @@ async function executeStandardUpgrade(opts: {
versionArg: string | undefined;
target: string;
execPath: string;
offline?: boolean;
offline?: OfflineMode;
json?: boolean;
}): Promise<void> {
const { method, channel, versionArg, target, execPath, offline, json } = opts;
Expand Down Expand Up @@ -608,7 +609,7 @@ function startChangelogFetch(
channel: ReleaseChannel,
currentVersion: string,
targetVersion: string,
offline: boolean
offline: OfflineMode
): Promise<ChangelogSummary | undefined> {
if (offline || currentVersion === targetVersion) {
return Promise.resolve(undefined);
Expand All @@ -631,7 +632,7 @@ async function buildCheckResultWithChangelog(opts: {
method: InstallationMethod;
channel: ReleaseChannel;
flags: UpgradeFlags;
offline: boolean;
offline: OfflineMode;
changelogPromise: Promise<ChangelogSummary | undefined>;
}): Promise<UpgradeResult> {
const result = buildCheckResult(opts);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -813,7 +814,7 @@ export const upgradeCommand = buildCommand({
channel,
method,
forced: flags.force,
offline: offline || undefined,
offline: offline ? true : undefined,
warnings,
changelog,
} satisfies UpgradeResult);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand Down
23 changes: 18 additions & 5 deletions src/lib/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -680,7 +689,7 @@ async function downloadStableToPath(
export async function downloadBinaryToTemp(
version: string,
downloadTag?: string,
offline?: boolean
offline?: OfflineMode
): Promise<DownloadResult> {
const { tempPath, lockPath } = getCurlInstallPaths();

Expand All @@ -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");
Expand Down Expand Up @@ -875,7 +888,7 @@ export async function executeUpgrade(
method: InstallationMethod,
version: string,
downloadTag?: string,
offline?: boolean
offline?: OfflineMode
): Promise<DownloadResult | null> {
switch (method) {
case "curl":
Expand Down
70 changes: 70 additions & 0 deletions test/lib/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { UpgradeError } from "../../src/lib/errors.js";
import {
detectInstallationMethod,
detectPackageManagerFromPath,
downloadBinaryToTemp,
executeUpgrade,
fetchLatestFromGitHub,
fetchLatestFromNpm,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
});
});
Loading