/** * Update CLI command handler. * * Handles `omp update` to check for and install updates. * Uses bun if available, otherwise downloads binary from GitHub releases. */ import * as fs from "node:fs"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; /** * Official npm registry origin. * * Pinned across both the version check and the bun install step so the two * agree on which catalog they are talking to. A user's bun may be pointed at * an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream * registry by minutes-to-hours, in which case `getLatestRelease` would resolve * a version the mirror has not yet replicated and the install would fail with * `No version matching "X" found for specifier "" (but package exists)`. * See #1686. */ const NPM_REGISTRY = "https://registry.npmjs.org/"; interface ReleaseInfo { tag: string; version: string; } /** Result from running the installed binary and parsing its reported version. */ export interface InstalledVersionVerification { ok: boolean; actual?: string; path?: string; } /** Paths and verifier used while replacing a downloaded binary update. */ export interface BinaryReplacementOptions { targetPath: string; tempPath: string; backupPath: string; expectedVersion: string; verifyInstalledVersion: (expectedVersion: string) => Promise; } /** * Parse update subcommand arguments. * Returns undefined if not an update command. */ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined { if (args.length === 0 || args[0] !== "update") { return undefined; } return { force: args.includes("--force") || args.includes("-f"), check: args.includes("--check") || args.includes("-c"), }; } async function getBunGlobalBinDir(): Promise { if (!$which("bun")) return undefined; try { const result = await $`bun pm bin -g`.quiet().nothrow(); if (result.exitCode !== 0) return undefined; const output = result.text().trim(); return output.length > 0 ? output : undefined; } catch { return undefined; } } function normalizePathForComparison(filePath: string): string { const normalized = path.normalize(filePath); if (process.platform === "win32") return normalized.toLowerCase(); return normalized; } function tryRealpath(p: string): string | undefined { try { return fs.realpathSync.native(p); } catch { return undefined; } } function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean { const normalizedPath = normalizePathForComparison(path.resolve(filePath)); const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath)); const relativePath = path.relative(normalizedDirectory, normalizedPath); return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } function isPathInDirectory(filePath: string, directoryPath: string): boolean { if (isPathInDirectoryLexical(filePath, directoryPath)) return true; // Layer realpath resolution on top of the lexical guard. On Windows, ~/.bun // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the // PATH-resolved omp path can refer to the same directory through different // strings. path.resolve does not traverse junctions/symlinks; realpath does. // Resolve the file's parent directory to tolerate the file itself not yet // existing (e.g. a fresh install path) while still catching link-traversed // equality once the directory exists. const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); const dirReal = tryRealpath(path.resolve(directoryPath)); if (!fileDir || !dirReal) return false; const resolvedFile = path.join(fileDir, path.basename(filePath)); return isPathInDirectoryLexical(resolvedFile, dirReal); } type UpdateTarget = { method: "bun" } | { method: "binary"; path: string }; function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { if (!bunBinDir) return "binary"; return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; } export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { return resolveUpdateMethod(ompPath, bunBinDir); } async function resolveUpdateTarget(): Promise { const bunBinDir = await getBunGlobalBinDir(); const ompPath = resolveOmpPath(); if (ompPath) { const method = resolveUpdateMethod(ompPath, bunBinDir); if (method === "bun") return { method }; return { method, path: ompPath }; } if (bunBinDir) return { method: "bun" }; throw new Error(`Could not resolve ${APP_NAME} binary path in PATH`); } /** * Get the latest release info from the npm registry. * Uses npm instead of GitHub API to avoid unauthenticated rate limiting. */ async function getLatestRelease(): Promise { const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`); if (!response.ok) { throw new Error(`Failed to fetch release info: ${response.statusText}`); } const data = (await response.json()) as { version: string }; const version = data.version; const tag = `v${version}`; return { tag, version, }; } /** * Compare semver versions. Returns: * - negative if a < b * - 0 if a == b * - positive if a > b */ function compareVersions(a: string, b: string): number { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0; const nb = pb[i] || 0; if (na !== nb) return na - nb; } return 0; } interface BunInstallCachePruneResult { scannedPackages: number; removedEntries: number; } interface BunCachePackageGroup { actualDirs: Map; markerDir?: string; markerEntries: Map; } function stripBunCacheVersionSuffix(name: string): string { const suffixIndex = name.indexOf("@@@"); return suffixIndex === -1 ? name : name.slice(0, suffixIndex); } function compareSemverIdentifier(a: string, b: string): number { const aNumber = /^\d+$/.test(a); const bNumber = /^\d+$/.test(b); if (aNumber && bNumber) return Number(a) - Number(b); if (aNumber) return -1; if (bNumber) return 1; return a.localeCompare(b); } function compareSemverLikeVersions(a: string, b: string): number { const [aCoreWithPrerelease] = a.split("+", 1); const [bCoreWithPrerelease] = b.split("+", 1); const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2); const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2); const aParts = aCore.split("."); const bParts = bCore.split("."); for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0); if (diff !== 0 && Number.isFinite(diff)) return diff; } if (!aPrerelease && !bPrerelease) return 0; if (!aPrerelease) return 1; if (!bPrerelease) return -1; const aPrereleaseParts = aPrerelease.split("."); const bPrereleaseParts = bPrerelease.split("."); for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) { const aPart = aPrereleaseParts[i]; const bPart = bPrereleaseParts[i]; if (aPart === undefined) return -1; if (bPart === undefined) return 1; const diff = compareSemverIdentifier(aPart, bPart); if (diff !== 0) return diff; } return 0; } async function readdirIfExists(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); } catch (err) { if (isEnoent(err)) return []; throw err; } } function getBunCacheGroup(groups: Map, packageName: string): BunCachePackageGroup { let group = groups.get(packageName); if (!group) { group = { actualDirs: new Map(), markerEntries: new Map() }; groups.set(packageName, group); } return group; } function addVersionPath(entries: Map, version: string, entryPath: string): void { const paths = entries.get(version); if (paths) { paths.push(entryPath); return; } entries.set(version, [entryPath]); } async function addBunCacheActualDir( groups: Map, dirPath: string, packageNames: Set | undefined, ): Promise { try { const manifest = (await Bun.file(path.join(dirPath, "package.json")).json()) as Partial< Record<"name" | "version", unknown> >; if (typeof manifest.name !== "string" || typeof manifest.version !== "string") return; if (packageNames && !packageNames.has(manifest.name)) return; const group = getBunCacheGroup(groups, manifest.name); addVersionPath(group.actualDirs, manifest.version, dirPath); } catch (err) { if (isEnoent(err)) return; throw err; } } async function addBunCacheMarkerDir( groups: Map, packageName: string, markerDir: string, packageNames: Set | undefined, ): Promise { if (packageNames && !packageNames.has(packageName)) return; const markerEntries = await readdirIfExists(markerDir); const group = getBunCacheGroup(groups, packageName); group.markerDir = markerDir; for (const entry of markerEntries) { const cacheVersion = stripBunCacheVersionSuffix(entry.name); addVersionPath(group.markerEntries, cacheVersion, path.join(markerDir, entry.name)); } } async function collectBunCacheGroups( cacheDir: string, packageNames: Set | undefined, ): Promise> { const groups = new Map(); for (const entry of await readdirIfExists(cacheDir)) { if (!entry.isDirectory()) continue; const entryPath = path.join(cacheDir, entry.name); if (entry.name.startsWith("@")) { for (const scopedEntry of await readdirIfExists(entryPath)) { if (!scopedEntry.isDirectory()) continue; const scopedEntryPath = path.join(entryPath, scopedEntry.name); const versionSeparator = scopedEntry.name.lastIndexOf("@"); if (versionSeparator === -1) { await addBunCacheMarkerDir(groups, `${entry.name}/${scopedEntry.name}`, scopedEntryPath, packageNames); } else { await addBunCacheActualDir(groups, scopedEntryPath, packageNames); } } continue; } const versionSeparator = entry.name.lastIndexOf("@"); if (versionSeparator === -1) { await addBunCacheMarkerDir(groups, entry.name, entryPath, packageNames); } else { await addBunCacheActualDir(groups, entryPath, packageNames); } } return groups; } async function removeCacheEntries(paths: string[]): Promise { for (const entryPath of paths) { await fs.promises.rm(entryPath, { recursive: true, force: true }); } return paths.length; } /** * Prune Bun's package cache so each package keeps only its newest cached version. * * Bun stores package cache entries as both a package marker directory * (`react/19.2.6@@@1`) and a materialized package directory * (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per * release. The marker and materialized entries are removed together so the * cache stays internally consistent. */ export async function pruneBunInstallCache( cacheDir: string, packageNames?: Set, ): Promise { const groups = await collectBunCacheGroups(cacheDir, packageNames); let scannedPackages = 0; let removedEntries = 0; for (const group of groups.values()) { if (group.actualDirs.size === 0) continue; scannedPackages++; let latestVersion: string | undefined; for (const version of group.actualDirs.keys()) { if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version; } if (!latestVersion) continue; for (const [version, paths] of group.actualDirs) { if (version !== latestVersion) removedEntries += await removeCacheEntries(paths); } for (const [version, paths] of group.markerEntries) { if (version !== latestVersion) removedEntries += await removeCacheEntries(paths); } } return { scannedPackages, removedEntries }; } async function resolveBunInstallCacheDir(): Promise { try { const result = await $`bun pm cache`.quiet().nothrow(); if (result.exitCode !== 0) return undefined; const output = result.text().trim(); return output.length > 0 ? output : undefined; } catch { return undefined; } } export function resolveBunGlobalNodeModulesDirFromLocations( globalBinDir: string | undefined, cacheDir: string | undefined, ): string | undefined { if (globalBinDir && globalBinDir.length > 0) { return path.join(path.dirname(globalBinDir), "install", "global", "node_modules"); } if (cacheDir && cacheDir.length > 0) { return path.join(path.dirname(cacheDir), "global", "node_modules"); } return undefined; } async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise { try { const result = await $`bun pm bin -g`.quiet().nothrow(); const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined; return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir); } catch { return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir); } } async function collectInstalledPackageNames(nodeModulesDir: string): Promise> { const packageNames = new Set(); for (const entry of await readdirIfExists(nodeModulesDir)) { if (!entry.isDirectory() || entry.name === ".bin") continue; if (entry.name.startsWith("@")) { for (const scopedEntry of await readdirIfExists(path.join(nodeModulesDir, entry.name))) { if (scopedEntry.isDirectory()) packageNames.add(`${entry.name}/${scopedEntry.name}`); } continue; } packageNames.add(entry.name); } return packageNames; } async function pruneBunCacheAfterGlobalInstall(): Promise { const cacheDir = await resolveBunInstallCacheDir(); if (!cacheDir) return undefined; const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(cacheDir); const packageNames = globalNodeModulesDir ? await collectInstalledPackageNames(globalNodeModulesDir) : new Set(); if (packageNames.size === 0 && !path.basename(cacheDir).toLowerCase().includes("omp")) return undefined; return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames); } /** * Get the appropriate binary name for this platform. */ function getBinaryName(): string { const platform = process.platform; const arch = process.arch; let os: string; switch (platform) { case "linux": os = "linux"; break; case "darwin": os = "darwin"; break; case "win32": os = "windows"; break; default: throw new Error(`Unsupported platform: ${platform}`); } let archName: string; switch (arch) { case "x64": archName = "x64"; break; case "arm64": archName = "arm64"; break; default: throw new Error(`Unsupported architecture: ${arch}`); } if (os === "windows") { return `${APP_NAME}-${os}-${archName}.exe`; } return `${APP_NAME}-${os}-${archName}`; } /** * Resolve the path that `omp` maps to in the user's PATH. */ function resolveOmpPath(): string | undefined { return $which(APP_NAME) ?? undefined; } /** * Run the resolved omp binary and check if it reports the expected version. */ async function verifyInstalledVersion(expectedVersion: string): Promise { const ompPath = resolveOmpPath(); if (!ompPath) return { ok: false }; try { const result = await $`${ompPath} --version`.quiet().nothrow(); if (result.exitCode !== 0) return { ok: false, path: ompPath }; const output = result.text().trim(); // Output format: "omp/X.Y.Z" const match = output.match(/\/(\d+\.\d+\.\d+)/); const actual = match?.[1]; return { ok: actual === expectedVersion, actual, path: ompPath }; } catch { return { ok: false, path: ompPath }; } } function printVerifiedVersion(expectedVersion: string): void { console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`)); } function formatVerificationFailure(result: InstalledVersionVerification, expectedVersion: string): string { if (result.actual) { return `${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`; } return `could not verify updated version${result.path ? ` at ${result.path}` : ""}`; } /** * Print post-update verification result. */ async function printVerification(expectedVersion: string): Promise { const result = await verifyInstalledVersion(expectedVersion); if (result.ok) { printVerifiedVersion(expectedVersion); return; } console.log(chalk.yellow(`\nWarning: ${formatVerificationFailure(result, expectedVersion)}`)); console.log(chalk.yellow(`You may need to reinstall: curl -fsSL https://omp.sh/install | sh`)); } async function unlinkIfExists(filePath: string): Promise { try { await fs.promises.unlink(filePath); } catch (err) { if (!isEnoent(err)) throw err; } } /** * Atomically replace the installed binary and roll back if version verification fails. */ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise { let backupReady = false; try { await unlinkIfExists(options.backupPath); await fs.promises.rename(options.targetPath, options.backupPath); backupReady = true; await fs.promises.rename(options.tempPath, options.targetPath); const verification = await options.verifyInstalledVersion(options.expectedVersion); if (!verification.ok) { throw new Error( `${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`, ); } backupReady = false; await unlinkIfExists(options.backupPath); return verification; } catch (err) { if (backupReady) { await unlinkIfExists(options.targetPath); await fs.promises.rename(options.backupPath, options.targetPath); } await unlinkIfExists(options.tempPath); throw err; } } /** * Build the bun argv used to globally install a specific omp version. * * The version is selected by hitting {@link NPM_REGISTRY} directly in * {@link getLatestRelease}, so the install MUST observe the same catalog: * * - `--registry=${NPM_REGISTRY}` pins the install to the official registry * regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy, * Taobao, …) that hasn't yet replicated the release would otherwise reject * a version the upstream registry already advertises. * - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it * re-fetches metadata from that registry on every invocation. * * Together these two flags make `omp update` produce exactly the registry * lookup the version check just performed. See #1686. */ export function buildBunInstallArgs(expectedVersion: string): string[] { return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`]; } /** * Update via bun package manager. */ async function updateViaBun(expectedVersion: string): Promise { console.log(chalk.dim("Updating via bun...")); const args = buildBunInstallArgs(expectedVersion); const result = await $`bun ${args}`.nothrow(); if (result.exitCode !== 0) { throw new Error(`bun install failed with exit code ${result.exitCode}`); } await printVerification(expectedVersion); try { const pruneResult = await pruneBunCacheAfterGlobalInstall(); if (pruneResult && pruneResult.removedEntries > 0) { console.log(chalk.dim(`Pruned ${pruneResult.removedEntries} stale Bun cache entries`)); } } catch (err) { console.log(chalk.yellow(`Warning: could not prune stale Bun cache entries: ${err}`)); } } /** * Download a release binary to a target path, replacing an existing file. */ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): Promise { const binaryName = getBinaryName(); const tag = `v${expectedVersion}`; const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`; const tempPath = `${targetPath}.new`; const backupPath = `${targetPath}.bak`; console.log(chalk.dim(`Downloading ${binaryName}…`)); const response = await fetch(url, { redirect: "follow" }); if (!response.ok || !response.body) { throw new Error(`Download failed: ${response.statusText}`); } const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 }); await pipeline(response.body, fileStream); console.log(chalk.dim("Installing update...")); await replaceBinaryForUpdate({ targetPath, tempPath, backupPath, expectedVersion, verifyInstalledVersion, }); printVerifiedVersion(expectedVersion); console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); } /** * Run the update command. */ export async function runUpdateCommand(opts: { force: boolean; check: boolean }): Promise { console.log(chalk.dim(`Current version: ${VERSION}`)); // Check for updates let release: ReleaseInfo; try { release = await getLatestRelease(); } catch (err) { console.error(chalk.red(`Failed to check for updates: ${err}`)); process.exit(1); } const comparison = compareVersions(release.version, VERSION); if (comparison <= 0 && !opts.force) { console.log(chalk.green(`${theme.status.success} Already up to date`)); return; } if (comparison > 0) { console.log(chalk.cyan(`New version available: ${release.version}`)); } else { console.log(chalk.yellow(`Forcing reinstall of ${release.version}`)); } if (opts.check) { // Just check, don't install return; } // Choose update method based on the prioritized omp binary in PATH try { const target = await resolveUpdateTarget(); if (target.method === "bun") { await updateViaBun(release.version); } else { await updateViaBinaryAt(target.path, release.version); } } catch (err) { console.error(chalk.red(`Update failed: ${err}`)); process.exit(1); } } /** * Print update command help. */ export function printUpdateHelp(): void { console.log(`${chalk.bold(`${APP_NAME} update`)} - Check for and install updates ${chalk.bold("Usage:")} ${APP_NAME} update [options] ${chalk.bold("Options:")} -c, --check Check for updates without installing -f, --force Force reinstall even if up to date ${chalk.bold("Examples:")} ${APP_NAME} update Update to latest version ${APP_NAME} update --check Check if updates are available ${APP_NAME} update --force Force reinstall `); }