Dashboard › cli › Session 0GF9HJQkQP5C
0GF9HJQkQP5CCU5DLIndependent READ-ONLY correctness merge gate. You MUST use these literal revisions and NO OTHERS: BASE: ec83887a16f780f32fba4b7d710bad262dba3a22 HEAD: 9525f72c70fabbb23ec21d13cf86add58f6287cc REPO: /home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade
First run exactly:
git -C /home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade cat-file -e 'ec83887a16f780f32fba4b7d710bad262dba3a22^{commit}' && git -C /home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade cat-file -e '9525f72c70fabbb23ec21d13cf86add58f6287cc^{commit}'
If your command contains any different SHA, stop and correct it before execution.
Then use immutable Git objects only. Never edit files or mutate VCS. Inspect complete BASE..HEAD diff and every changed source/test object. Verify merge base, tree SHAs, changed paths, diff SHA-256, and diff --check. Review correctness of dual-source fallback, source affinity, pagination, response-body/cancellation typing, npm/GHCR runtime validation, pins/tags, package-manager no-spawn failures, tracking versus artifact channels, offline behavior, redirect auth isolation, and delta integrity. Source-neutral patch caching is accepted under mandatory byte-identical dual publishing and final SHA verification.
Return a NON-EMPTY final report: findings first with MUST-FIX/CONCERN/PASS and exact HEAD file:line citations; deterministic regression/fix for defects; immutable evidence; residual risks. If the literal first command fails, quote its exact output under BLOCKED. End exactly MERGE or DO-NOT-MERGE.
(no output)
ec83887a16f780f32fba4b7d710bad262dba3a22 ancestor_exit=0
BASE tree: 110d6530e57ca4fbfb2a3f98e52013d2668b5b5e HEAD tree: 22f49363a13e32c36dcd9e1ca95916809c2dc271 BASE commit=ec83887a16f780f32fba4b7d710bad262dba3a22 tree=110d6530e57ca4fbfb2a3f98e52013d2668b5b5e parents=e7beceb6ba09b64dc277ae1732df04e65500062b BASE subject=fix(event): Prevent immediate retry of rate-limited org in event view fallback (#1566) HEAD commit=9525f72c70fabbb23ec21d13cf86add58f6287cc tree=22f49363a13e32c36dcd9e1ca95916809c2dc271 parents=a84012184c79c2566c7466aa6beda5f767199218 HEAD subject=fix(cli): harden upgrade metadata validation
M packages/cli/src/commands/cli/upgrade.ts M packages/cli/src/lib/binary.ts M packages/cli/src/lib/delta-upgrade.ts M packages/cli/src/lib/errors.ts M packages/cli/src/lib/ghcr.ts M packages/cli/src/lib/release-notes.ts M packages/cli/src/lib/upgrade.ts M packages/cli/src/lib/version-check.ts M packages/cli/test/commands/cli.test.ts M packages/cli/test/commands/cli/upgrade.test.ts M packages/cli/test/lib/binary.test.ts M packages/cli/test/lib/delta-upgrade.mocked.test.ts M packages/cli/test/lib/delta-upgrade.test.ts M packages/cli/test/lib/ghcr.test.ts M packages/cli/test/lib/release-notes.test.ts M packages/cli/test/lib/upgrade.test.ts M packages/cli/test/lib/version-check.test.ts --- numstat ...
4950719273dc17d5a9e061b8cfe70c62131fe3299dc1799d9c09bb522b112193 - --- diff check --- diff_check_exit=0 --- commits --- 04867eaf9b412a62d4b1ea1c59c2b382c557bd0b feat(cli): add toolkit upgrade bridge 9c874298e3b57515ba36d35140627a668a46dc7b fix(cli): address toolkit bridge review 723375b6ce4e37884e760fa0f848e7e7e3effcc9 fix(cli): address upgrade review findings dbee7e9f2a4555e044f74dfebad002cccfa43a1f test(cli): update toolkit upgrade fixtures 5ffa5cd0fe02c0a5dc5bec9a8a099f4135c0b447 fix(cli): harden upgrade source resolution c8c815bbecf472061ec02ab96d1a31f23f078d01 fix(cli): validate GitHub release pagination b6484e0fb1eb0f8bce68272d538f5c3b26cd5bd5 fix(cli): validate resolved upgrade metadata c6030fbf4d0c190f037c86d832edb6680d3e54ab fix(cli): bind nightly manifests to versions 2f9b5d0d26124d445a916b8bfc584ad518720584 fix(cli): isolate pinned upgrade resolution 4a1d5bad105c006193c68459175f5eb3c7f78e08 test(cli): update pinned version fixture e565b1afb39a341605adc883de8812851de7d581 ...
diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 8d8282368..b3039d86b 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -1,414 +1,465 @@ /** * sentry cli upgrade * * Self-update the Sentry CLI to the latest or a specific version. * After upgrading, spawns the NEW binary with `cli setup` to update * completions, agent skills, and record installation metadata. * * Supports two release channels: * - stable (default): tracks the latest GitHub release * - nightly: tracks the rolling nightly prerelease built from main * * The channel can be set via --channel or by passing "nightly"/"stable" * as the version argument. The choice is persisted in the local database * so that subsequent bare `sentry cli upgrade` calls use the same channel. ...
diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts
index 75582612a..e64f5059e 100644
--- a/packages/cli/src/lib/binary.ts
+++ b/packages/cli/src/lib/binary.ts
@@ -1,450 +1,621 @@
/**
* Binary Management
*
* Shared utilities for installing, replacing, and managing the CLI binary.
* Used by both `setup --install` (fresh installs) and `upgrade` (self-updates).
*/
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { chmod, copyFile, mkdir, realpath, unlink } from "node:fs/promises";
import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { compare as semverCompare } from "semver";
import { getUserAgent } from "./constants.js";
import {
buildTlsErrorDetail,
customFetch,
isTlsCertError,
} from "./custom-ca.js";
-import { stringifyUnknown, UpgradeError } from "./errors.js";
+import ...
diff --git a/packages/cli/src/lib/delta-upgrade.ts b/packages/cli/src/lib/delta-upgrade.ts
index ec709b8c8..204875dce 100644
--- a/packages/cli/src/lib/delta-upgrade.ts
+++ b/packages/cli/src/lib/delta-upgrade.ts
@@ -1,368 +1,461 @@
/** Delta upgrade discovery and application backed by binpatch. */
import { join } from "node:path";
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import {
applyPatchChainInMemory,
extractStableChain as binpatchExtractStableChain,
filterAndSortChainTags as binpatchFilterAndSortChainTags,
validateChainStep as binpatchValidateChainStep,
type DeltaTelemetry,
type DeltaUnavailableReason,
type ExtractStableChainOpts,
type GitHubRelease,
getPatchFromVersion,
getPatchTargetSha256,
ghcrSource,
githubReleaseSource,
type InstrumentHook,
MAX_NIGHTLY_CHAIN_DEPTH,
makeCache,
OciClient,
type OciManifest,
...
diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts
index f81b8c70c..b21505cd1 100644
--- a/packages/cli/src/lib/errors.ts
+++ b/packages/cli/src/lib/errors.ts
@@ -541,160 +541,168 @@ export function validationError(
headline: string,
examples: string[],
field?: string,
note?: string
): ValidationError {
return new ValidationError(
buildValidationMessage(headline, examples, note),
field
);
}
/**
* Input validation errors.
*
* @param message - Validation failure description
* @param field - Name of the invalid field
*/
export class ValidationError extends CliError {
readonly field?: string;
constructor(message: string, field?: string) {
super(message, EXIT.VALIDATION);
this.name = "ValidationError";
this.field = field;
}
}
/**
* OAuth device flow errors (RFC 8628).
*
* @param code - OAuth error code (e.g., "authorization_pending", "slow_down")
* @param description - Human-readable ...
diff --git a/packages/cli/src/lib/ghcr.ts b/packages/cli/src/lib/ghcr.ts index 43d6deb7e..5bca7c061 100644 --- a/packages/cli/src/lib/ghcr.ts +++ b/packages/cli/src/lib/ghcr.ts @@ -1,520 +1,659 @@ /** * GHCR (GitHub Container Registry) Client * * Encapsulates the OCI download protocol for fetching nightly CLI binaries * from ghcr.io/getsentry/cli. Nightly builds are pushed as OCI artifacts * via ORAS with the version baked into the manifest annotation. * * Key design decisions: * - Anonymous access: nightly package is public; no token needed beyond the * standard ghcr.io anonymous token exchange. * - Version discovery from manifest annotation: `annotations.version` in the * OCI manifest holds the nightly version. Checking the latest version only * requires a token exchange + manifest fetch (2 HTTP requests total). * - Redirect quirk: ghcr.io blob downloads return 307 to Azure Blob Storage. ...
diff --git a/packages/cli/src/lib/release-notes.ts b/packages/cli/src/lib/release-notes.ts index ddf617535..903ca51f6 100644 --- a/packages/cli/src/lib/release-notes.ts +++ b/packages/cli/src/lib/release-notes.ts @@ -1,102 +1,109 @@ /** * Release Notes Parser & Aggregation * * Extracts user-facing changelog entries from GitHub Release bodies (stable) * or conventional commit messages (nightly). Uses `marked.lexer()` for * AST-based section extraction and produces structured data that can be * re-serialized as filtered markdown for rendering via `renderMarkdown()`. ...
diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts
index 945adc48d..b29d67ff2 100644
--- a/packages/cli/src/lib/upgrade.ts
+++ b/packages/cli/src/lib/upgrade.ts
@@ -1,169 +1,267 @@
/**
* Upgrade Module
*
* Detects how the CLI was installed and provides self-upgrade functionality.
* Binary management helpers (download URLs, locking, replacement) live in
* binary.ts and are shared with the setup --install flow.
*/
import { spawn } from "node:child_process";
import {
chmodSync,
closeSync,
existsSync,
openSync,
realpathSync,
statSync,
unlinkSync,
writeSync,
} from "node:fs";
import { writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, sep } from "node:path";
import { setTimeout } from "node:timers/promises";
+import { prerelease as semverPrerelease, valid as semverValid } from "semver";
import {
acquireLock,
cleanupOldBinary,
+ compareVersions,
...
diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts
index c69ff0466..34393b1c7 100644
--- a/packages/cli/src/lib/version-check.ts
+++ b/packages/cli/src/lib/version-check.ts
@@ -1,110 +1,114 @@
/**
* Background version check for "new version available" notifications.
*
* For nightly builds (CLI_VERSION contains "-dev.<timestamp>"), checks GHCR for the
* latest nightly version via the OCI manifest annotation. For stable builds,
* checks GitHub Releases. Results are cached in the database and shown on
* subsequent runs.
*/
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import { compare as semverCompare } from "semver";
+import type { UpgradeSource } from "./binary.js";
import { CLI_VERSION } from "./constants.js";
import { getReleaseChannel } from "./db/release-channel.js";
import {
getVersionCheckInfo,
markUpdateNotified,
...
diff --git a/packages/cli/test/commands/cli.test.ts b/packages/cli/test/commands/cli.test.ts
index cd1514eb0..4eec11e9d 100644
--- a/packages/cli/test/commands/cli.test.ts
+++ b/packages/cli/test/commands/cli.test.ts
@@ -77,160 +77,154 @@ describe("feedbackCommand.func", () => {
});
test("throws ValidationError for whitespace-only message", async () => {
const func = await feedbackCommand.loader();
const mockContext = {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
};
await expect(func.call(mockContext, {}, " ")).rejects.toThrow(
"Please provide a feedback message."
);
});
test("throws ConfigError when Sentry is disabled", async () => {
const func = await feedbackCommand.loader();
const mockContext = {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
};
// Sentry is disabled in test environment (no DSN)
await expect(
...
diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts
index ca9688e46..b7fead58f 100644
--- a/packages/cli/test/commands/cli/upgrade.test.ts
+++ b/packages/cli/test/commands/cli/upgrade.test.ts
@@ -8,70 +8,71 @@
* Status messages go through consola (→ process.stderr). Tests capture stderr
* via a spy on process.stderr.write and assert on the collected output.
*/
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as child_process from "node:child_process";
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
import { gzipSync } from "node:zlib";
import { run } from "@stricli/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Make child_process namespace mutable so vi.spyOn works on ESM exports
...
diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts
index 6fcfb3b1a..29ead3828 100644
--- a/packages/cli/test/lib/binary.test.ts
+++ b/packages/cli/test/lib/binary.test.ts
@@ -1,98 +1,225 @@
/**
* Binary Management Tests
*
* Tests for shared binary helpers: install directory selection, paths,
* download URLs, locking, and binary installation.
*/
import {
chmodSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
acquireLock,
compareVersions,
determineInstallDir,
fetchWithUpgradeError,
getBinaryDownloadUrl,
getBinaryFilename,
getBinaryPaths,
+ getGitHubReleaseByTagUrl,
getLegacyInstallDirs,
getPlatformBinaryName,
installBinary,
isDowngrade,
...
diff --git a/packages/cli/test/lib/delta-upgrade.mocked.test.ts b/packages/cli/test/lib/delta-upgrade.mocked.test.ts
index cd0a2fddc..f6186ab59 100644
--- a/packages/cli/test/lib/delta-upgrade.mocked.test.ts
+++ b/packages/cli/test/lib/delta-upgrade.mocked.test.ts
@@ -52,125 +52,125 @@ import {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function mockFetch(
fn: (url: string | URL | Request, init?: RequestInit) => Promise<Response>
): void {
globalThis.fetch = fn as typeof globalThis.fetch;
}
// ============================================================================
// Helpers
// ============================================================================
const BINARY_NAME = getPlatformBinaryName();
function versionHex(version: string): string {
return Array.from(version)
.map((c) => c.charCodeAt(0).toString(16).padStart(2, ...
diff --git a/packages/cli/test/lib/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts
index 3752d4ae6..68d1cfbdf 100644
--- a/packages/cli/test/lib/delta-upgrade.test.ts
+++ b/packages/cli/test/lib/delta-upgrade.test.ts
@@ -1,75 +1,84 @@
/**
* Unit Tests for Delta Upgrade Module
*
* Tests the exported pure-computation functions that drive chain resolution
* for both stable (GitHub Releases) and nightly (GHCR) channels, plus
* async orchestration functions tested via fetch mocking.
*/
import { createHash } from "node:crypto";
import { existsSync, unlinkSync } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, test } from "vitest";
-import { getPlatformBinaryName } from "../../src/lib/binary.js";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import {
+ getPlatformBinaryName,
+ ...
diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts
index e5c81d532..a55612b5a 100644
--- a/packages/cli/test/lib/ghcr.test.ts
+++ b/packages/cli/test/lib/ghcr.test.ts
@@ -1,242 +1,350 @@
/**
* GHCR Client Tests
*
* Unit tests for the GHCR/OCI download protocol helpers.
* All HTTP calls are mocked via globalThis.fetch to avoid network access.
*/
import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { UPGRADE_SOURCES } from "../../src/lib/binary.js";
import { UpgradeError } from "../../src/lib/errors.js";
import {
downloadLayerBlob,
downloadNightlyBlob,
fetchManifest,
fetchNightlyManifest,
findLayerByFilename,
GHCR_REPO,
GHCR_TAG,
+ GhcrManifestHttpError,
getAnonymousToken,
getNightlyVersion,
listTags,
type OciManifest,
} from "../../src/lib/ghcr.js";
/** Store original fetch for restoration */
let originalFetch: typeof globalThis.fetch;
/** Helper to mock fetch without ...
diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts
index 78963b471..ffccf4901 100644
--- a/packages/cli/test/lib/release-notes.test.ts
+++ b/packages/cli/test/lib/release-notes.test.ts
@@ -1,56 +1,62 @@
/**
* Unit Tests for Release Notes Parser & Aggregation
*
* Tests core invariants (section extraction, version filtering, truncation)
* that are hard to express as property-based tests due to format specifics.
*
* Core random-input invariants (category validity, filtering, commit parsing)
* are tested via property-based tests in release-notes.property.test.ts.
*/
import { marked } from "marked";
-import { describe, expect, test } from "vitest";
-import type { GitHubRelease } from "../../src/lib/delta-upgrade.js";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { UPGRADE_SOURCES } from "../../src/lib/binary.js";
+import {
+ fetchRecentReleases,
+ type GitHubRelease,
+} from ...
diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts
index 7c6f63a4b..d10f21dd6 100644
--- a/packages/cli/test/lib/upgrade.test.ts
+++ b/packages/cli/test/lib/upgrade.test.ts
@@ -103,60 +103,62 @@ const { spawnImpl } = vi.hoisted(() => ({
// Initialize with the real default now that fakeProcess is defined
spawnImpl.fn = () => fakeProcess(0);
vi.mock("node:child_process", async (importOriginal) => {
const orig = await importOriginal<typeof import("node:child_process")>();
return {
...orig,
spawn: (cmd: string, args: string[], opts: object) =>
spawnImpl.fn(cmd, args, opts),
};
});
// Dynamic imports: must run AFTER vi.mock() so upgrade.ts picks up the
// mocked spawn.
import { isEnoentSpawnError } from "../../src/commands/cli/upgrade.js";
import {
acquireLock,
getBinaryDownloadUrl,
isNightlyVersion,
releaseLock,
+ UPGRADE_SOURCES,
} from "../../src/lib/binary.js";
import {
clearInstallInfo,
...
diff --git a/packages/cli/test/lib/version-check.test.ts b/packages/cli/test/lib/version-check.test.ts
index 20172cd83..dd047a5cb 100644
--- a/packages/cli/test/lib/version-check.test.ts
+++ b/packages/cli/test/lib/version-check.test.ts
@@ -1,46 +1,49 @@
/**
* Version Check Logic Tests
*/
import { setTimeout as sleep } from "node:timers/promises";
-import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { UPGRADE_SOURCES } from "../../src/lib/binary.js";
import { setReleaseChannel } from "../../src/lib/db/release-channel.js";
import {
getVersionCheckInfo,
setVersionCheckInfo,
} from "../../src/lib/db/version-check.js";
+// biome-ignore lint/performance/noNamespaceImport: Vitest requires the module namespace to spy on an ESM export
+import * as deltaUpgrade from "../../src/lib/delta-upgrade.js";
import {
ApiError,
ContextError,
ValidationError,
} from ...
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/commands/cli/upgrade.ts:341: ? await resolveLatestUpgradeVersion(channel) 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:497:export async function fetchLatestFromGitHubWithSource( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:549:export async function fetchLatestFromGitHub( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:554: await fetchLatestFromGitHubWithSource( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:629:export async function fetchLatestNightlyVersionWithSource( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:676:export async function fetchLatestNightlyVersion( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:681: await fetchLatestNightlyVersionWithSource( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/upgrade.ts:705: return ...
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/commands/cli/upgrade.ts:695: executeUpgrade(method, target, downloadTag, offline, setMessage, source)
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/commands/cli/upgrade.ts:783: executeUpgrade("curl", target, downloadTag, undefined, setMessage, source)
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:580:export function resolveStableDelta(
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:599:export function resolveNightlyDelta(
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:618:export function attemptDeltaUpgrade(
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:711:export function prefetchNightlyPatches(
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:719:export function ...
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:387: (await client.listTags(opts.token, PATCH_TAG_PREFIX, opts.signal)); 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/delta-upgrade.ts:401: chainTags.map((tag) => client.fetchManifest(opts.token, tag, opts.signal)) 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:258:export async function getAnonymousToken( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:310:export async function fetchManifest( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:357:export async function fetchNightlyManifest( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:362: return await fetchManifest(token, GHCR_TAG, signal, source); 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:432:export async function downloadNightlyBlob( 9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/ghcr.ts:599:export ...
/usr/bin/bash: line 1: python: command not found
260 "Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version."
261 );
262 }
263 // Offline mode is only supported for curl-installed binaries — package
264 // managers always need network to fetch and install packages.
265 if (offline && method !== "curl") {
266 throw new UpgradeError(
267 "unsupported_operation",
268 "Offline upgrade is only supported for curl-installed binaries."
269 );
270 }
271 }
272
273 function getArtifactChannel(target: string): ReleaseChannel {
274 return isNightlyVersion(target) ? "nightly" : "stable";
275 }
276
277 type ResolveTargetOptions = {
278 method: InstallationMethod;
279 channel: ReleaseChannel;
280 versionArg: string | undefined;
281 channelChanged: boolean;
282 flags: UpgradeFlags;
283 };
284
285 /**
286 * Result of resolving the target version. ...
150 const { latestVersion } = getVersionCheckInfo();
151 if (!latestVersion) {
152 throw new UpgradeError(
153 "network_error",
154 "No cached version available. Run any command to trigger a background version check, then retry."
155 );
156 }
157 return latestVersion;
158 }
159
160 /**
161 * Resolve the target version, trying the network first and falling back to
162 * the local cache when offline or when the network is unavailable.
163 *
164 * @returns `{ target, offline }` — the resolved version and whether the
165 * resolution used the offline path (explicit or automatic fallback).
166 * Returns `null` when `resolveTargetVersion` returns a "done" result
167 * (check-only or already up-to-date); the caller should yield that result. ...
650 ) {
651 return xdgInstallDir;
652 }
653 return currentInstallDir;
654 }
655
656 /**
657 * Execute the standard upgrade path: download via curl or package manager,
658 * then run setup on the new binary.
659 */
660 async function executeStandardUpgrade(opts: {
661 method: InstallationMethod;
662 channel: ReleaseChannel;
663 versionArg: string | undefined;
664 target: string;
665 execPath: string;
666 entryPath?: string;
667 pathEnv?: string;
668 offline?: OfflineMode;
669 json?: boolean;
670 noAgentSkills: boolean;
671 source?: UpgradeSource;
672 }): Promise<void> {
673 const {
674 method,
675 channel,
676 versionArg,
677 target,
678 execPath,
679 entryPath,
680 pathEnv,
681 offline,
682 json,
683 noAgentSkills,
684 source,
685 } = opts;
686
687 // Use the rolling "nightly" tag only when upgrading to latest nightly
688 ...
835
836 /**
837 * Resolve the channel, version arg, method, and channel-changed flag from
838 * the positional version argument and flags. Extracted to keep `func()`
839 * complexity under the biome limit.
840 */
841 async function resolveContext(
842 version: string | undefined,
843 flags: UpgradeFlags
844 ): Promise<{
845 channel: ReleaseChannel;
846 versionArg: string | undefined;
847 channelChanged: boolean;
848 method: InstallationMethod;
849 }> {
850 const { channel, versionArg } = resolveChannelAndVersion(version);
851 const currentChannel = getReleaseChannel();
852 const channelChanged = channel !== currentChannel;
853
854 const method = flags.method ?? (await detectInstallationMethod());
855 validateMethod(method, versionArg, flags.offline);
856 return { channel, versionArg, channelChanged, method };
857 }
858
859 /**
860 * Persist the release channel preference. ...
1038
1039 // --check with offline fallback: resolveTargetWithFallback returns
1040 // kind: "target" for offline check, so guard against actual upgrade.
1041 if (flags.check) {
1042 return yield new CommandOutput(
1043 await buildCheckResultWithChangelog({
1044 target,
1045 versionArg,
1046 method,
1047 channel,
1048 flags,
1049 offline,
1050 changelogPromise,
1051 })
1052 );
1053 }
1054
1055 // Skip if already on target — unless forced or switching channels
1056 if (CLI_VERSION === target && !flags.force && !channelChanged) {
1057 return yield new CommandOutput({
1058 action: "up-to-date",
1059 currentVersion: CLI_VERSION,
1060 targetVersion: target,
1061 channel,
1062 method,
1063 forced: false,
1064 offline: offline ? ...
105 | "bun"
106 | "yarn"
107 | "unknown";
108
109 /** A repository pair that hosts CLI stable releases and nightly OCI images. */
110 export type UpgradeSource = {
111 /** GitHub `owner/repository` containing CLI release assets. */
112 readonly githubRepo: string;
113 /** GHCR `owner/package` containing CLI nightly images and delta patches. */
114 readonly ghcrRepo: string;
115 /** Prefix attached to CLI release tags in this repository. */
116 readonly tagPrefix: string;
117 };
118
119 /** Ordered CLI release sources. The resolver falls through only on HTTP 404. */
120 export const UPGRADE_SOURCES = [
121 {
122 githubRepo: "getsentry/toolkit",
123 ghcrRepo: "getsentry/toolkit",
124 tagPrefix: "cli@",
125 },
126 {
127 githubRepo: "getsentry/cli",
128 ghcrRepo: "getsentry/cli",
129 tagPrefix: "",
130 },
131 ] as const satisfies readonly [UpgradeSource, ...UpgradeSource[]];
132
...
337 /**
338 * Select the first available upgrade source.
339 *
340 * The caller receives the successful probe response so it never repeats the
341 * request. Only HTTP 404 advances to the next source. Every other HTTP or
342 * network failure aborts immediately.
343 */
344 export async function resolveUpgradeSource(
345 options: ResolveUpgradeSourceOptions
346 ): Promise<ResolvedUpgradeSource> {
347 for (const source of options.sources ?? UPGRADE_SOURCES) {
348 const response = await fetchUpgradeProbe(source, options);
349 if (response.ok) {
350 return { source, response };
351 }
352 if (response.status !== 404) {
353 throw new UpgradeError(
354 "network_error",
355 `Failed to fetch from GitHub: HTTP ${response.status}`
356 );
357 }
358 }
359
360 throw new UpgradeSourceNotFoundError();
361 }
362
363 /**
364 * Detect whether a version string identifies a ...
490 * @param init - Fetch options
491 * @param serviceName - Service name for error messages (e.g., "GitHub")
492 * @returns Response object
493 * @throws {UpgradeError} On network failure
494 * @throws {Error} AbortError if signal is aborted (re-thrown as-is)
495 */
496 export async function fetchWithUpgradeError(
497 url: string,
498 init: RequestInit,
499 serviceName: string
500 ): Promise<Response> {
501 try {
502 return await customFetch(url, init);
503 } catch (error) {
504 if (init.signal?.aborted) {
505 throw init.signal.reason;
506 }
507 // Re-throw AbortError as-is so callers can handle it specifically
508 if (error instanceof Error && error.name === "AbortError") {
509 throw error;
510 }
511 if (error instanceof Error && isTlsCertError(error)) {
512 throw new UpgradeTransportError(buildTlsErrorDetail(error));
513 }
514 const msg = stringifyUnknown(error);
...
75 export type { InstallationMethod } from "./binary.js";
76 // biome-ignore lint/performance/noBarrelFile: backward-compat re-export, not a barrel
77 export { parseInstallationMethod } from "./binary.js";
78
79 /** Package managers that can be used for global installs */
80 type PackageManager = "npm" | "pnpm" | "bun" | "yarn";
81
82 /**
83 * How the current upgrade reached the offline code path.
84 *
85 * - `false` — online upgrade (network available)
86 * - `"explicit"` — user passed `--offline` flag
87 * - `"network-fallback"` — network failed, auto-fell back to cache
88 */
89 export type OfflineMode = false | "explicit" | "network-fallback";
90
91 // Constants
92
93 /** The git tag used for the rolling nightly GitHub release (stable fallback only). */
94 export const NIGHTLY_TAG = "nightly";
95
96 /** npm registry base URL */
97 const NPM_REGISTRY_URL = "https://registry.npmjs.org/sentry";
98
...
490 * Fetch the latest version from GitHub releases.
491 *
492 * @param signal - Optional AbortSignal to cancel the request
493 * @returns Latest version string (without 'v' prefix)
494 * @throws {UpgradeError} When fetch fails or response is invalid
495 * @throws {Error} AbortError if signal is aborted
496 */
497 export async function fetchLatestFromGitHubWithSource(
498 signal?: AbortSignal,
499 sources: readonly UpgradeSource[] = UPGRADE_SOURCES
500 ): Promise<ResolvedUpgradeVersion> {
501 const resolved = await resolveUpgradeSource({
502 getProbeUrl: getGitHubLatestReleaseUrl,
503 signal,
504 sources,
505 });
506 let response = resolved.response;
507 const visitedPages = new Set([getGitHubLatestReleaseUrl(resolved.source)]);
508 const versions: string[] = [];
509 while (true) {
510 const data = await parseUpgradeJson(
511 response,
512 signal,
513 "GitHub returned invalid ...
827 async function standaloneVersionExists(
828 version: string,
829 source?: UpgradeSource
830 ): Promise<boolean> {
831 if (!isNightlyVersion(version)) {
832 validateStableVersion(version, "Requested standalone version");
833 }
834 if (source) {
835 if (isNightlyVersion(version)) {
836 return nightlyVersionExists(version, source);
837 }
838 const response = await fetchWithUpgradeError(
839 getGitHubReleaseByTagUrl(version, source),
840 { headers: getGitHubHeaders() },
841 "GitHub"
842 );
843 if (response.ok) {
844 await validatePinnedGitHubRelease(response, version, source);
845 return true;
846 }
847 if (response.status === 404) {
848 return false;
849 }
850 throw new UpgradeError(
851 "network_error",
852 `Failed to fetch from GitHub: HTTP ${response.status}`
853 );
854 }
855 const resolved = await ...
1030 * @returns Filename of the gzip-compressed binary for this platform
1031 */
1032 function getNightlyGzFilename(): string {
1033 return `${getPlatformBinaryName()}.gz`;
1034 }
1035
1036 /**
1037 * Download a nightly binary from GHCR and decompress it to `destPath`.
1038 *
1039 * Fetches an anonymous token, retrieves the OCI manifest, finds the layer
1040 * matching this platform's `.gz` filename, then downloads and decompresses
1041 * the blob in-stream.
1042 *
1043 * When `version` is provided, fetches the pinned versioned tag
1044 * (`nightly-{version}`). Otherwise fetches the rolling `:nightly` tag.
1045 *
1046 * @param destPath - File path to write the decompressed binary
1047 * @param version - Specific nightly version to download (omit for latest)
1048 * @throws {UpgradeError} When GHCR fetch or blob download fails
1049 */
1050 async function downloadNightlyToPath(
1051 destPath: string,
1052 version?: string,
1053 setMessage?: ...
1090 * @throws {UpgradeError} When both download attempts fail
1091 */
1092 async function downloadStableToPath(
1093 version: string,
1094 destPath: string,
1095 setMessage?: SetMessage,
1096 source: UpgradeSource = PRIMARY_UPGRADE_SOURCE
1097 ): Promise<void> {
1098 const url = getBinaryDownloadUrl(version, source);
1099 const headers = getGitHubHeaders();
1100
1101 // Try gzip-compressed download first (~60% smaller)
1102 // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
1103 try {
1104 const gzResponse = await fetchWithUpgradeError(
1105 `${url}.gz`,
1106 { headers },
1107 "GitHub"
1108 );
1109 if (gzResponse.ok && gzResponse.body) {
1110 await streamDecompressToFile(gzResponse.body, destPath, setMessage);
1111 return;
1112 }
1113 } catch {
1114 // Fall through to raw download
1115 }
1116
1117 // Fall ...
25 } from "./binary.js";
26 import { getUserAgent } from "./constants.js";
27 import { customFetch } from "./custom-ca.js";
28 import { UpgradeError, UpgradeTransportError } from "./errors.js";
29
30 /** Default timeout for GHCR HTTP requests (10 seconds) */
31 const GHCR_REQUEST_TIMEOUT = 10_000;
32
33 /** Maximum number of retry attempts for transient failures */
34 const GHCR_MAX_RETRIES = 1;
35
36 /** Nightly versions use a numeric build timestamp as the prerelease value. */
37 const NIGHTLY_VERSION_REGEX = /^\d+\.\d+\.\d+-dev\.\d+$/;
38
39 /** Timeout for large blob downloads (30 seconds) */
40 const GHCR_BLOB_TIMEOUT = 30_000;
41
42 /**
43 * Check if an error is a transient network/timeout failure worth retrying.
44 *
45 * Matches timeout/abort errors from `AbortSignal.timeout()`, connection
46 * resets, and generic network failures. ...
230 }
231
232 function isOciManifest(value: unknown): value is OciManifest {
233 if (typeof value !== "object" || value === null) {
234 return false;
235 }
236 const manifest = value as Partial<OciManifest>;
237 return (
238 manifest.schemaVersion === 2 &&
239 Array.isArray(manifest.layers) &&
240 manifest.layers.every(isOciLayer) &&
241 (manifest.mediaType === undefined ||
242 typeof manifest.mediaType === "string") &&
243 (manifest.config === undefined || isOciLayer(manifest.config)) &&
244 (manifest.annotations === undefined || isStringRecord(manifest.annotations))
245 );
246 }
247
248 /**
249 * Fetch a short-lived anonymous bearer token for read-only access to the
250 * public `ghcr.io/getsentry/cli` package.
251 *
252 * The token exchange endpoint returns a JSON object with a `token` field.
253 * No credentials are required for public packages. ...
420 *
421 * The blob endpoint returns a 307 redirect to a signed Azure Blob Storage URL.
422 * `fetch` with `redirect: "follow"` would forward the Authorization header
423 * to Azure, which returns 404. We must:
424 * 1. Fetch the blob URL without following redirects to get the redirect URL.
425 * 2. Follow the redirect URL without the Authorization header.
426 *
427 * @param token - Anonymous bearer token from {@link getAnonymousToken}
428 * @param digest - Layer digest to download (e.g., "sha256:abc123...")
429 * @returns Raw response body (gzip-compressed binary)
430 * @throws {UpgradeError} On network failure or bad response
431 */
432 export async function downloadNightlyBlob(
433 token: string,
434 digest: string,
435 signal?: AbortSignal,
436 source: UpgradeSource = PRIMARY_UPGRADE_SOURCE
437 ): Promise<Response> {
438 const blobUrl = `${GHCR_REGISTRY}/v2/${source.ghcrRepo}/blobs/${digest}`;
439
440 // Step 1: ...
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/src/lib/custom-ca.ts:305:export function customFetch(
1 /**
2 * Custom CA certificate loading for corporate TLS proxies.
3 *
4 * Reads CA bundles from (in priority order):
5 * 1. `sentry cli defaults ca-cert` (stored path in SQLite)
6 * 2. `NODE_EXTRA_CA_CERTS` env var
7 *
8 * Returns a `tls` options object for Bun's `fetch()`. On the Node.js npm
9 * distribution, Node natively honors `NODE_EXTRA_CA_CERTS` so the extra
10 * `tls.ca` option is harmless (ignored by Node's fetch).
11 *
12 * Security model: When the CA source is an env var (not a stored default)
13 * AND the target is SaaS (`*.sentry.io`), a one-time warning is logged.
14 * `sentry cli defaults ca-cert` silences the warning — the user has
15 * explicitly acknowledged the custom CA. See CLI-1K6 plan for the full
16 * threat model discussion. ...
285 * Get the combined CA certificate PEM string for Node.js `http.request()`.
286 * Returns undefined when no custom CAs are configured.
287 *
288 * Unlike {@link getCustomTlsOptions} (which returns Bun's `{ tls: { ca } }` shape),
289 * this returns the raw PEM string suitable for Node's `https.RequestOptions.ca`
290 * and the Sentry SDK's `NodeTransportOptions.caCerts`.
291 */
292 export function getCustomCaCerts(): string | undefined {
293 resolve();
294 return resolved?.tls.ca;
295 }
296
297 /**
298 * Drop-in replacement for `fetch()` that injects custom CA certificates
299 * when configured. All non-authenticated fetch call sites should use this
300 * instead of bare `fetch()`.
301 *
302 * Authenticated API calls go through `fetchWithTimeout()` in sentry-client.ts
303 * which already applies TLS options directly alongside the SaaS warning. ...
55 PatchChain,
56 StableChainInfo,
57 } from "binpatch";
58 // biome-ignore lint/performance/noBarrelFile: preserve the existing public API
59 export {
60 extractSha256,
61 getPatchFromVersion,
62 getPatchTargetSha256,
63 getStableTargetSha256,
64 PATCH_TAG_PREFIX,
65 } from "binpatch";
66
67 export type DeltaResult = {
68 sha256: string;
69 patchBytes: number;
70 chainLength: number;
71 };
72
73 const log = logger.withTag("delta-upgrade");
74
75 const NORMALIZED_RELEASE_SOURCE = Symbol("normalizedReleaseSource");
76
77 /** Stable GitHub releases normalized for one explicit upgrade source. */
78 export type NormalizedGitHubReleases = GitHubRelease[] & {
79 /** Stable key for the source that produced these normalized tags. */
80 readonly [NORMALIZED_RELEASE_SOURCE]: string;
81 };
82
83 function upgradeSourceKey(source: UpgradeSource): string {
84 return ...
485 );
486 if (sha256 !== chain.expectedSha256) {
487 throw new Error(
488 `SHA-256 mismatch after patching: got ${sha256}, expected ${chain.expectedSha256}`
489 );
490 }
491 return sha256;
492 }
493 );
494 }
495
496 function makeProgressHandler(setMessage?: SetMessage): ProgressHandler {
497 let progress: ReturnType<typeof makeByteProgress> | undefined;
498 let phase: string | undefined;
499 let previousWritten = 0;
500 return (event) => {
501 if (
502 event.type === "bytes" &&
503 (progress === undefined || phase !== event.phase)
504 ) {
505 // New phase: spin up a fresh bar. The apply phase totals bytes across
506 // every hop's `newSize`, which for multi-hop chains far exceeds the
507 // final binary (e.g. 930 MB shown for a 310 MB install). ...
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:153:class SetCachedDsnCommand implements AsyncCommand<CacheModel, RealCache> {
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:187:class GetCachedDsnCommand implements AsyncCommand<CacheModel, RealCache> {
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:218:class UpdateCachedResolutionCommand
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:246:class ClearDsnCacheCommand implements AsyncCommand<CacheModel, RealCache> {
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:280:class SetCachedProjectCommand implements AsyncCommand<CacheModel, RealCache> {
9525f72c70fabbb23ec21d13cf86add58f6287cc:packages/cli/test/lib/db/dsn-cache.model-based.test.ts:307:class GetCachedProjectCommand implements ...
217 packages/cli/src/commands/cli/upgrade.ts 204 packages/cli/src/lib/binary.ts 206 packages/cli/src/lib/delta-upgrade.ts 13 packages/cli/src/lib/errors.ts 252 packages/cli/src/lib/ghcr.ts 160 packages/cli/src/lib/release-notes.ts 576 packages/cli/src/lib/upgrade.ts 33 packages/cli/src/lib/version-check.ts 57 packages/cli/test/commands/cli.test.ts 545 packages/cli/test/commands/cli/upgrade.test.ts 201 packages/cli/test/lib/binary.test.ts 25 packages/cli/test/lib/delta-upgrade.mocked.test.ts 243 packages/cli/test/lib/delta-upgrade.test.ts 256 packages/cli/test/lib/ghcr.test.ts 229 packages/cli/test/lib/release-notes.test.ts 804 packages/cli/test/lib/upgrade.test.ts 133 packages/cli/test/lib/version-check.test.ts
diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts
index 7c6f63a4b..d10f21dd6 100644
--- a/packages/cli/test/lib/upgrade.test.ts
+++ b/packages/cli/test/lib/upgrade.test.ts
@@ -122,0 +123 @@ import {
+ UPGRADE_SOURCES,
@@ -142,0 +144 @@ const {
+ resolveExistingUpgradeVersion,
@@ -191,7 +193,33 @@ describe("fetchLatestFromGitHub", () => {
- test("returns version from GitHub API", async () => {
- mockFetch(
- async () =>
- new Response(
- JSON.stringify({
- tag_name: "v1.2.3",
- }),
+ test("selects the latest CLI-prefixed Toolkit release", async () => {
+ const requests: string[] = [];
+ mockFetch(async (url) => {
+ requests.push(String(url));
+ return new Response(
+ JSON.stringify([
+ { tag_name: "mcp@9.0.0" },
+ { tag_name: "cli@not-a-version" },
+ { tag_name: "cli@99.0.0-dev.1", prerelease: false },
+ { tag_name: "cli@1.2.3" },
+ ...
+ await expect(resolveExistingUpgradeVersion("1.0.0")).rejects.toThrow(
+ "Failed to connect to GitHub"
+ );
+ });
+
+ test.each([
+ ["empty body", ""],
+ ["invalid JSON", "{"],
+ ["missing tag", JSON.stringify({})],
+ ["mismatched tag", JSON.stringify({ tag_name: "mcp@1.0.0" })],
+ ])("rejects pinned Toolkit %s without legacy fallback", async (_name, body) => {
+ const requests: string[] = [];
+ mockFetch(async (url) => {
+ requests.push(String(url));
+ return new Response(body, { status: 200 });
+ });
+
+ await expect(resolveExistingUpgradeVersion("1.0.0")).rejects.toMatchObject({
+ reason: "network_error",
+ });
+ expect(requests).toEqual([
+ "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%401.0.0",
+ ]);
+ });
+
+ test.each([
+ undefined,
+ "not-semver",
+ "0.14.0-dev.124",
+ ])("rejects pinned nightly manifest annotation %s without legacy fallback", async (annotation) => {
+ const ...
diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts
index ca9688e46..b7fead58f 100644
--- a/packages/cli/test/commands/cli/upgrade.test.ts
+++ b/packages/cli/test/commands/cli/upgrade.test.ts
@@ -42,0 +43 @@ import {
+import { setVersionCheckInfo } from "../../../src/lib/db/version-check.js";
@@ -174,0 +176,4 @@ function mockGhcrNightlyVersion(version: string): void {
+ if (urlStr === "https://api.github.com/repos/getsentry/toolkit") {
+ return new Response(null, { status: 200 });
+ }
+
@@ -212,3 +217,2 @@ function mockGitHubVersion(version: string): void {
- // GitHub latest release endpoint — returns JSON with tag_name
- if (urlStr.includes("releases/latest")) {
- return new Response(JSON.stringify({ tag_name: version }), {
+ if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) {
+ return new Response(JSON.stringify([{ tag_name: `cli@${version}` }]), {
@@ -220 +223,0 @@ function ...
+ await run(app, ["cli", "upgrade", "--method", method, "1.2.3"], context);
+
+ expect(errors.join("\n")).toContain("Version 1.2.3 not found");
+ expect(requests).toEqual(["https://registry.npmjs.org/sentry/1.2.3"]);
+ expect(spawnedArgs).toEqual([]);
+ });
+
+ test.each([
+ "npm",
+ "pnpm",
+ "bun",
+ "yarn",
+ ] as const)("preserves non-404 HTTP failures for a pinned %s version", async (method) => {
+ for (const status of [401, 403, 429, 500]) {
+ const requests: string[] = [];
+ mockFetch(async (url) => {
+ requests.push(String(url));
+ return new Response(null, { status });
+ });
+ const { context, errors, restore } = createMockContext({
+ homeDir: testDir,
+ });
+
+ await run(app, ["cli", "upgrade", "--method", method, "1.2.3"], context);
+
+ restore();
+ expect(errors.join("\n")).toContain(
+ `Failed to fetch from npm: ${status}`
+ );
+ ...
diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts
index e5c81d532..a55612b5a 100644
--- a/packages/cli/test/lib/ghcr.test.ts
+++ b/packages/cli/test/lib/ghcr.test.ts
@@ -8,0 +9 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { UPGRADE_SOURCES } from "../../src/lib/binary.js";
@@ -17,0 +19 @@ import {
+ GhcrManifestHttpError,
@@ -40 +42 @@ function makeManifest(overrides: Partial<OciManifest> = {}): OciManifest {
- digest: "sha256:config",
+ digest: `sha256:${"0".repeat(64)}`,
@@ -46 +48 @@ function makeManifest(overrides: Partial<OciManifest> = {}): OciManifest {
- digest: "sha256:abc123",
+ digest: `sha256:${"a".repeat(64)}`,
@@ -54 +56 @@ function makeManifest(overrides: Partial<OciManifest> = {}): OciManifest {
- digest: "sha256:def456",
+ digest: `sha256:${"d".repeat(64)}`,
@@ -93,0 +96,13 @@ describe("getAnonymousToken", () => {
+ test("uses the selected source's GHCR ...
Return your FINAL non-empty correctness report now for literal BASE ec83887a16f780f32fba4b7d710bad262dba3a22 and HEAD 9525f72c70fabbb23ec21d13cf86add58f6287cc. Do not return progress or defer work. Include immutable merge base, base/head trees, changed-file scope, diff hash, git diff --check, findings with exact HEAD file:line evidence, and residual risks. If no defect exists, state PASS — no correctness defect found and end exactly MERGE. If blocked or defective, quote evidence and end exactly DO-NOT-MERGE. Never edit files or VCS. Empty output is forbidden.
PASS — no correctness defect found.
PASS — fallback and source affinity: Toolkit precedes legacy, and only HTTP 404 permits fallback. Other HTTP, transport, parsing, and metadata failures stop resolution. The selected source flows through validation, download, delta, changelog, and prefetch paths:
/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/binary.ts:119-134/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/binary.ts:344-360/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/upgrade.ts:497-545/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/upgrade.ts:643-672PASS — pagination and response validation: GitHub pagination validates origin, path, positive page numbers, and cycles. GHCR pagination rejects repeated cursors. JSON body failures preserve caller cancellation and distinguish malformed metadata from transport failure:
/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/upgrade.ts:149-185/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/upgrade.ts:497-545/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/ghcr.ts:599-636/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade/packages/cli/src/lib/binary.ts:496-539PASS — pins, npm/GHCR validation, and no-spawn failures: Stable npm versions require canonical non-prerelease SemVer. ...
* 🟡 (13:01) `packages/cli/test/commands/cli.test.ts` updated upgrade-command GitHub mocks from single release objects such as `{ tag_name: "v99.0.0" }` to Toolkit release-list responses using product-prefixed tags such as `[{ tag_name: "cli@99.0.0" }]`. * 🟡 (13:01) Test `"shows installation info with specified method"` now invokes `func.call(context, { check: true, method: "curl", json: true })…
Date: Sep 10, 2026 * 🔴 (13:01) User specified installation detection must always check for Homebrew first because stored install information may be stale; the cheap, authoritative realpath check must override previously recorded methods, such as a former curl installation after switching to Homebrew. * 🟡 (13:01) `packages/cli/src/lib/upgrade.ts` expanded from 169 to 267+ lines and added source-…
* 🟡 (13:01) `packages/cli/src/lib/release-notes.ts` replaced `GITHUB_RELEASES_URL` with `getGitHubReleasesUrl` and added `PRIMARY_UPGRADE_SOURCE` plus `UpgradeSource`, making release-note retrieval source-aware. * 🟡 (13:01) `packages/cli/src/lib/release-notes.ts` now imports `isNormalizedForSource`, `NormalizedGitHubReleases`, and `normalizeStableReleases` from `./delta-upgrade.js` to normalize…
* 🟡 (13:00) `packages/cli/src/lib/delta-upgrade.ts` added `semverPrerelease`/`semverValid`, replaced `GITHUB_RELEASES_URL` with `getGitHubReleasesUrl`, and added `PRIMARY_UPGRADE_SOURCE` plus `UpgradeSource` to support source-specific delta discovery. * 🟡 (13:00) `packages/cli/src/lib/delta-upgrade.ts` added the private symbol `NORMALIZED_RELEASE_SOURCE` and logic described as filtering and nor…
Date: Sep 10, 2026 * 🔴 [enforced-workflow] (12:58) User required an independent READ-ONLY correctness merge gate for repository `/home/byk/.local/share/opencode/worktree/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0/toolkit-bridge-upgrade`, using literal revisions BASE `ec83887a16f780f32fba4b7d710bad262dba3a22` and HEAD `9525f72c70fabbb23ec21d13cf86add58f6287cc`, with no other revisions. * 🔴 [enforc…