/** * Update CLI command handler. * * Handles `omp update` to check for and install updates. * Uses the installer that owns the active omp executable when it can be detected. */ import * as fs from "node:fs"; import * as os from "node:os"; 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"; const HOMEBREW_FORMULA = "can1357/tap/omp"; const MISE_TOOL = "github:can1357/oh-my-pi"; /** * 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/"; /** * Core native addon package. Bumped in lock-step with {@link PACKAGE} so the * version sentinel the loader looks up at runtime matches the `.node` on * disk; see {@link buildBunInstallArgs} for why this must be installed * explicitly rather than inherited as a transitive dependency. */ const NATIVES_PACKAGE = "@oh-my-pi/pi-natives"; /** * Platform tags the release pipeline publishes as * `@oh-my-pi/pi-natives-` leaves. Mirrors `SUPPORTED_PLATFORMS` in * `packages/natives/native/loader-state.js` and `LEAF_TARGETS` in * `packages/natives/scripts/gen-npm-packages.ts`; kept here as the local * source of truth so the update path stays free of cross-package imports. */ const SUPPORTED_NATIVE_TAGS: ReadonlySet = new Set([ "linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", ]); function currentNativeTag(): string { return `${process.platform}-${process.arch}`; } 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; } } async function getHomebrewFormulaPrefix(): Promise { if (!$which("brew")) return undefined; for (const formula of [HOMEBREW_FORMULA, APP_NAME]) { try { const result = await $`brew --prefix ${formula}`.quiet().nothrow(); if (result.exitCode !== 0) continue; const output = result.text().trim(); if (output.length > 0) return output; } catch {} } return undefined; } async function getMiseBinDirs(): Promise { if (!$which("mise")) return []; try { const result = await $`mise bin-paths ${MISE_TOOL}`.quiet().nothrow(); if (result.exitCode !== 0) return []; return result .text() .split(/\r?\n/) .map(line => line.trim()) .filter(line => line.length > 0); } catch { return []; } } function getMiseDataDir(): string { const override = process.env.MISE_DATA_DIR; if (override && override.length > 0) return override; if (process.platform === "win32") { const localAppData = process.env.LOCALAPPDATA; if (localAppData && localAppData.length > 0) return path.join(localAppData, "mise"); } const xdgDataHome = process.env.XDG_DATA_HOME; if (xdgDataHome && xdgDataHome.length > 0) return path.join(xdgDataHome, "mise"); return path.join(os.homedir(), ".local", "share", "mise"); } 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 both the file and its parent directory: the file catches manager // links like Homebrew's `bin/omp -> Cellar/.../bin/omp`; the parent fallback // still tolerates fresh install paths where the file does not exist yet. const dirReal = tryRealpath(path.resolve(directoryPath)); if (!dirReal) return false; const fileReal = tryRealpath(path.resolve(filePath)); if (fileReal && isPathInDirectoryLexical(fileReal, dirReal)) return true; const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); if (!fileDir) return false; const resolvedFile = path.join(fileDir, path.basename(filePath)); return isPathInDirectoryLexical(resolvedFile, dirReal); } type UpdateMethod = "brew" | "mise" | "bun" | "binary"; interface UpdateMethodResolutionOptions { homebrewPrefix?: string; miseBinDirs?: readonly string[]; miseDataDir?: string; } type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string }; function resolveUpdateMethod( ompPath: string, bunBinDir: string | undefined, options: UpdateMethodResolutionOptions = {}, ): UpdateMethod { const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options; if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew"; if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise"; if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise"; if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun"; return "binary"; } export function resolveUpdateMethodForTest( ompPath: string, bunBinDir: string | undefined, options: UpdateMethodResolutionOptions = {}, ): UpdateMethod { return resolveUpdateMethod(ompPath, bunBinDir, options); } async function resolveUpdateTarget(): Promise { const bunBinDir = await getBunGlobalBinDir(); const homebrewPrefix = await getHomebrewFormulaPrefix(); const miseAvailable = $which("mise") !== undefined; const miseBinDirs = miseAvailable ? await getMiseBinDirs() : []; const miseDataDir = miseAvailable ? getMiseDataDir() : undefined; const ompPath = resolveOmpPath(); if (ompPath) { const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir }); if (method === "binary") return { method, path: ompPath }; return { method }; } 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; } /** * 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; } } /** * Remove a backup binary without letting the removal abort a completed update. * * On Windows the executable that was just moved aside is still mapped as the * running process image, so unlinking it fails with EPERM/EACCES until this * process exits (issue #845). The replacement and verification already * succeeded by the time we get here, so every error is swallowed; the leftover * is reclaimed by {@link sweepStaleBackups} on the next update once it is no * longer in use. Returns whether the file is gone. */ async function removeBackupBestEffort(filePath: string): Promise { try { await fs.promises.unlink(filePath); return true; } catch (err) { return isEnoent(err); } } /** * Best-effort removal of binary-update backups left by earlier runs. * * Each self-update moves the previous executable to `...bak` * before swapping the new one in. On Windows that backup cannot be deleted * while the updating process is alive, so it is left for a later run to reclaim * once its owning process has exited. Also matches the legacy fixed * `.bak` name produced before backups were timestamped, so users * upgrading from a buggy release get the orphaned file cleaned up. */ export async function sweepStaleBackups(targetPath: string): Promise { const dir = path.dirname(targetPath); const base = path.basename(targetPath); let entries: string[]; try { entries = await fs.promises.readdir(dir); } catch { return; } for (const entry of entries) { if (!entry.startsWith(`${base}.`) || !entry.endsWith(".bak")) continue; // Legacy ".bak" → empty middle; new "...bak" // → dot-separated numeric run. Anything else is an unrelated *.bak file. const middle = entry.slice(base.length + 1, entry.length - ".bak".length); if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue; await removeBackupBestEffort(path.join(dir, entry)); } } /** * Atomically replace the installed binary and roll back if version verification fails. */ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise { let backupReady = false; try { // `backupPath` is unique per attempt (see updateViaBinaryAt), so this rename // never has to overwrite — or unlink — a possibly-locked leftover from an // earlier run. Renaming the running executable itself is permitted on // Windows; only deleting its still-mapped image is not. 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; // Swap done and verified. On Windows the backup is still the running // process image and cannot be unlinked until this process exits, so a // failure here must NOT fail an otherwise-successful update. await removeBackupBestEffort(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. * * Also pins {@link NATIVES_PACKAGE} and the platform-specific * `@oh-my-pi/pi-natives-` leaf to `expectedVersion`. `bun install -g` * does not reliably refresh transitive `optionalDependencies` when the * top-level package is the only one bumped, so the native addon and its * version sentinel can drift out of sync with the freshly installed * `@oh-my-pi/pi-coding-agent` and the loader aborts at * `validateLoadedBindings` on the next launch * (`The .node file on disk is from a different release than this loader`). * Listing the natives explicitly forces bun to replace them in lock-step. * The leaf is added only on tags the release pipeline actually publishes * ({@link SUPPORTED_NATIVE_TAGS}) so unsupported platforms still fail with * the original "no matching version" message instead of `EBADPLATFORM`. * See #1824. */ export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] { const args = [ "install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`, `${NATIVES_PACKAGE}@${expectedVersion}`, ]; if (SUPPORTED_NATIVE_TAGS.has(nativeTag)) { args.push(`${NATIVES_PACKAGE}-${nativeTag}@${expectedVersion}`); } return args; } export function buildHomebrewUpdateArgs(force: boolean): string[] { return [force ? "reinstall" : "upgrade", HOMEBREW_FORMULA]; } export function buildMiseUpgradeArgs(): string[] { return ["upgrade", MISE_TOOL, "--bump"]; } export function buildMiseForceInstallArgs(expectedVersion: string): string[] { return ["install", "--force", `${MISE_TOOL}@${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); } async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise { console.log(chalk.dim("Updating Homebrew formulae...")); const update = await $`brew update`.nothrow(); if (update.exitCode !== 0) { throw new Error(`brew update failed with exit code ${update.exitCode}`); } console.log(chalk.dim("Updating via Homebrew...")); const args = buildHomebrewUpdateArgs(force); const result = await $`brew ${args}`.nothrow(); if (result.exitCode !== 0) { throw new Error(`brew ${args[0]} failed with exit code ${result.exitCode}`); } await printVerification(expectedVersion); } async function updateViaMise(expectedVersion: string, force: boolean): Promise { console.log(chalk.dim("Updating via mise...")); const args = buildMiseUpgradeArgs(); const result = await $`mise ${args}`.nothrow(); if (result.exitCode !== 0) { throw new Error(`mise upgrade failed with exit code ${result.exitCode}`); } if (force) { const forceArgs = buildMiseForceInstallArgs(expectedVersion); const forceResult = await $`mise ${forceArgs}`.nothrow(); if (forceResult.exitCode !== 0) { throw new Error(`mise install --force failed with exit code ${forceResult.exitCode}`); } } await printVerification(expectedVersion); } /** * 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`; // Unique per attempt: a stale backup from an earlier update may still be // locked (it is the previous process image on Windows), and a fixed name // would force the move-aside rename to overwrite it. pid + timestamp keeps // two forced updates in the same millisecond from colliding. const backupPath = `${targetPath}.${Date.now()}.${process.pid}.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, }); // Reclaim backups from earlier updates whose owning process has since exited. await sweepStaleBackups(targetPath); 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 === "brew") { await updateViaHomebrew(release.version, opts.force); } else if (target.method === "mise") { await updateViaMise(release.version, opts.force); } else 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 `); }