Dashboard › cli › Distillation
d3cc0535-f54f-48b2-b28d-5a78247ac89b["lore_tm_v1__dnwF5OYOFskQYAHK7oATsMVzxMo4P0D7ul7KPFYnIM"]
Date: Sep 9, 2026
packages/cli/src/lib/upgrade.ts defines upgrade detection and self-upgrade behavior; shared binary-management helpers—including download URLs, locking, replacement, install-directory detection, and platform binary naming—come from ./binary.js.packages/cli/src/lib/upgrade.ts re-exports InstallationMethod and parseInstallationMethod from ./binary.js for backward compatibility."npm" | "pnpm" | "bun" | "yarn"; OfflineMode is exactly false | "explicit" | "network-fallback".NIGHTLY_TAG = "nightly", NPM_REGISTRY_URL = "https://registry.npmjs.org/sentry", and VERSION_PREFIX_REGEX = /^v/.buildKnownCurlPaths(homeDir, env) builds directory-boundary-safe paths from KNOWN_CURL_DIRS, adds an absolute XDG_BIN_HOME when present, and normalizes it with join(xdgBinHome, ".") + sep to avoid a double trailing separator; getKnownCurlPaths() lazily memoizes the result to avoid circular-import TDZ issues.getCurlInstallPaths() resolves curl paths in this order: 1. stored DB path when stored.method === "curl" and dirname(stored.path) still exists, 2. process.execPath when under a known curl directory, 3. XDG-aware default from determineInstallDir(homedir(), process.env) plus getBinaryFilename().SENTRY_INSTALL_DIR such as /tmp/sentry-test-install could leave a DB row after its directory was purged, causing upgrade to lock/install in a dead location and crash with ENOENT ... open '.../sentry.lock'. Fix: only trust the stored curl path when its containing directory exists; otherwise fall through to process.execPath or the default path. existsSync failures such as EACCES or a transiently unmounted parent likewise cause fallback.startCleanupOldBinary() is a fire-and-forget, non-blocking startup cleanup that gets oldPath from getCurlInstallPaths() and calls cleanupOldBinary(oldPath).runCommand(command, args) uses spawn() with stdio: ["ignore", "pipe", "pipe"] and shell: process.platform === "win32" so Windows .cmd package-manager executables work; it captures trimmed stdout, drains and discards stderr, and maps a null close code to exit code 1.isInstalledWith(pm) runs yarn global list --depth=0 for Yarn or <pm> list -g sentry for npm/pnpm/bun, and reports installed only when exit code is 0 and stdout contains sentry@; errors return false.isHomebrewInstall() resolves process.execPath through realpathSync() before checking for "/Cellar/", because Homebrew normally exposes a Cellar binary through a prefix-bin symlink; if realpath resolution fails because the binary moved or was deleted, it checks the unresolved path.detectPackageManagerFromPath() inspects process.argv[1]: no node_modules segment returns null, a .pnpm segment returns "pnpm", a .bun segment returns "bun", and other node_modules layouts—including npm and Yarn Classic—default to "npm".["npm", "pnpm", "bun", "yarn"], then detectPackageManagerFromPath(), and finally returns "unknown". Path detection remains after subprocess checks so Yarn can be identified correctly where its subprocess works.detectInstallationMethod() therefore prioritizes: 1. authoritative Homebrew realpath detection, 2. stored DB install info for non-Homebrew installs, 3. legacy detection, 4. best-effort persistence of a non-"unknown" result through setInstallInfo({ method, path: process.execPath, version: CLI_VERSION }); persistence failure logs Failed to persist install info (DB may be read-only) without blocking detection.fetchLatestFromGitHub(signal?) requests ${GITHUB_RELEASES_URL}/latest with GitHub headers, requires an OK response and tag_name, and strips a leading v; failures use UpgradeError("network_error", ...).fetchLatestFromNpm() requests https://registry.npmjs.org/sentry/latest with Accept: application/json, requires an OK response and version, and otherwise throws UpgradeError("network_error", ...).fetchLatestNightlyVersion(signal?) performs exactly 2 HTTP requests—anonymous GHCR token exchange and rolling :nightly OCI-manifest fetch—then reads the version annotation with getNightlyVersion(manifest); it checks signal.aborted before each network call and throws AbortError.fetchLatestVersion(method, channel = "stable") chooses GHCR manifest lookup for "nightly"; on stable it chooses GitHub Releases for "curl" and "brew", and npm registry lookup for package-manager methods.nightlyVersionExists(version) checks GHCR tag nightly-${version} after anonymous token exchange; HTTP 404 and 403 are interpreted as absence and return false, while other errors propagate. versionExists(method, version) routes nightly versions to GHCR, stable curl/brew versions to a GitHub Releases HEAD on ${GITHUB_RELEASES_URL}/tags/${version}, and stable package-manager versions to an npm HEAD on ${NPM_REGISTRY_URL}/${version}.DownloadResult contains tempBinaryPath, lockPath, and optional patchBytes; the caller must retain and later release the download lock after the spawned child exits.writeChunkSync(fd, chunk) loops until the complete Uint8Array has been written, handling short writes; a writeSync() result of 0 or less throws rather than spinning indefinitely. drainBodyToFd(body, fd, onBytes) pipes through new DecompressionStream("gzip"), synchronously writes each decompressed chunk, reports byte counts, and separately returns streamError and writeError without closing the caller-owned fd.ETXTBSY during full-download fallback: fs.createWriteStream/writer.end() can signal 'finish' after data flush but before the fd is released, so immediately spawning the output binary races with fd closure. Fix: streamDecompressToFile() uses openSync(destPath, "w", 0o644), writeSync, and closeSync, awaits body draining, closes the fd synchronously before returning, prioritizes a prior write error over a close error, and then surfaces any stream error.streamDecompressToFile() uses makeByteProgress("Downloading", null, setMessage) because decompressed size is unknown and compressed Content-Length would make a fraction misleading; progress updates are cosmetic and cannot abort the download.${getPlatformBinaryName()}.gz, obtains an anonymous GHCR token, fetches either nightly-${version} or rolling :nightly, finds the OCI layer by its filename annotation, downloads the blob by digest, requires a response body, and streams gzip decompression to the destination.${getBinaryDownloadUrl(version)}.gz because the compressed asset is approximately 37 MB versus approximately 99 MB and about 60% smaller; any compressed-path failure falls back to the raw URL. The raw response is fully consumed with response.arrayBuffer() and written via writeFile(destPath, new Uint8Array(body)) to avoid Bun issue https://github.com/oven-sh/bun/issues/13237, where Bun.write(path, Response) can let the process exit before a large streaming body completes.1.3.9 issue CLI-1D3 is documented: Bun.file().writer().end() can return before a freshly written executable becomes visible by path, causing Bun.spawn to fail with Executable not found in $PATH.probeBinaryFile(path) accepts only a present regular file with size greater than 0; otherwise it returns null. waitForBinaryVisible(path) uses VERIFY_MAX_ATTEMPTS = 6 and VERIFY_BASE_DELAY_MS = 100, probing at cumulative times 0 ms, 100 ms, 300 ms, 700 ms, 1500 ms, and 3100 ms, with sleeps of 100 ms, 200 ms, 400 ms, 800 ms, and 1600 ms.waitForBinaryVisible() logs the attempt count; if all 6 probes fail because the path is absent, non-regular, or zero-length, it throws UpgradeError("execution_failed", "Downloaded binary is missing or empty at ${path}. This is usually transient — rerun sentry cli upgrade to retry.").downloadBinaryToTemp(version, downloadTag?, offline?, setMessage?) gets curl temp/lock paths, acquires the lock, removes an interrupted leftover temp file, attempts delta upgrade first, and falls back to a full binary download on any delta failure unless offline mode prohibits it."explicit" mode—telling the user to rerun sentry cli upgrade without --offline—from "network-fallback" mode—telling the user the network is unavailable, no pre-downloaded update was found, and to check the connection.downloadBinaryToTemp() calls waitForBinaryVisible(tempPath), logs the verified size through formatBytes, performs best-effort asynchronous clearPatchCache() because old-version patches are no longer useful, applies mode 0o755 outside Windows, and returns { tempBinaryPath: tempPath, lockPath, patchBytes }; any thrown error releases the lock before rethrowing.attemptDeltaUpgrade(version, process.execPath, destPath, offline, setMessage) with the currently running executable as the patch base and returns DeltaResult | null; null signals that full download should be attempted.downloadFullBinary() routes nightly versions to downloadNightlyToPath(destPath, version, setMessage) and stable versions to downloadStableToPath(downloadTag ?? version, destPath, setMessage).brew upgrade getsentry/tools/sentry; the requested version is intentionally ignored because the tap formula controls the version and Homebrew cannot pin an arbitrary release. A nonzero exit yields UpgradeError("execution_failed", "brew upgrade failed with exit code ${code}"); spawn errors yield UpgradeError("execution_failed", "brew failed: ${err.message}").yarn global add sentry@${version} for Yarn and <pm> install -g sentry@${version} for npm, pnpm, or bun; spawned output is inherited, and Windows uses shell: true for .cmd executables.executeUpgrade(method, version, downloadTag?, offline?, setMessage?) returns downloadBinaryToTemp(...) for "curl", executes Homebrew and returns null for "brew", globally installs and returns null for "npm", "pnpm", "bun", or "yarn", and throws UpgradeError("unknown_method") otherwise.acquireLock recognizes process.ppid and takes over, making the parent’s later release a harmless no-op. A child using a different install directory may use a different lock and therefore cannot reliably release the parent’s lock.