/** * Update CLI command handler. * * Handles `omp update` to check for and install updates. * Uses bun if available, otherwise downloads binary from GitHub releases. */ import { execSync, spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils/dirs"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; /** * Detect if we're running as a Bun compiled binary. */ const isBunBinary = Bun.env.PI_COMPILED || import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; interface ReleaseInfo { tag: string; version: string; assets: Array<{ name: string; url: string }>; } /** * 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"), }; } /** * Check if bun is available in PATH. */ function hasBun(): boolean { try { const result = spawnSync("bun", ["--version"], { encoding: "utf-8", stdio: "pipe" }); return result.status === 0; } catch { return false; } } /** * Get the latest release info from GitHub. */ async function getLatestRelease(): Promise { const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`); if (!response.ok) { throw new Error(`Failed to fetch release info: ${response.statusText}`); } const data = (await response.json()) as { tag_name: string; assets: Array<{ name: string; browser_download_url: string }>; }; return { tag: data.tag_name, version: data.tag_name.replace(/^v/, ""), assets: data.assets.map(a => ({ name: a.name, url: a.browser_download_url })), }; } /** * 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}`; } /** * Get the appropriate native addon name for this platform. * Uses process.platform directly (linux, darwin, win32). */ function getNativeAddonName(): string { const platform = process.platform; const arch = process.arch; if (!["linux", "darwin", "win32"].includes(platform)) { throw new Error(`Unsupported platform: ${platform}`); } if (!["x64", "arm64"].includes(arch)) { throw new Error(`Unsupported architecture: ${arch}`); } return `pi_natives.${platform}-${arch}.node`; } /** * Update via bun package manager. */ async function updateViaBun(expectedVersion: string): Promise { console.log(chalk.dim("Updating via bun...")); try { execSync(`bun install -g ${PACKAGE}@${expectedVersion}`, { stdio: "inherit" }); } catch (error) { throw new Error("bun install failed", { cause: error }); } // Verify the update actually took effect try { const result = spawnSync("bun", ["pm", "ls", "-g"], { encoding: "utf-8", stdio: "pipe" }); const output = result.stdout || ""; const match = output.match(new RegExp(`${PACKAGE.replace("/", "\\/")}@(\\S+)`)); if (match) { const installedVersion = match[1]; if (compareVersions(installedVersion, expectedVersion) < 0) { console.log( chalk.yellow(`\nWarning: bun reports ${installedVersion} installed, expected ${expectedVersion}`), ); console.log(chalk.yellow(`Try: bun install -g ${PACKAGE}@latest`)); return; } } } catch { // Verification is best-effort, don't fail the update } console.log(chalk.green(`\n${theme.status.success} Update complete`)); } /** * Update by downloading binary from GitHub releases. */ async function updateViaBinary(release: ReleaseInfo): Promise { const binaryName = getBinaryName(); const nativeAddonName = getNativeAddonName(); const asset = release.assets.find(a => a.name === binaryName); const nativeAsset = release.assets.find(a => a.name === nativeAddonName); if (!asset) { throw new Error(`No binary found for ${binaryName}`); } if (!nativeAsset) { throw new Error(`No native addon found for ${nativeAddonName}`); } const execPath = process.execPath; const execDir = path.dirname(execPath); const tempPath = `${execPath}.new`; const backupPath = `${execPath}.bak`; const nativePath = path.join(execDir, nativeAddonName); const nativeTempPath = `${nativePath}.new`; console.log(chalk.dim(`Downloading ${binaryName}…`)); // Download to temp file const response = await fetch(asset.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); // Download native addon console.log(chalk.dim(`Downloading ${nativeAddonName}…`)); const nativeResponse = await fetch(nativeAsset.url, { redirect: "follow" }); if (!nativeResponse.ok || !nativeResponse.body) { throw new Error(`Native addon download failed: ${nativeResponse.statusText}`); } const nativeFileStream = fs.createWriteStream(nativeTempPath, { mode: 0o755 }); await pipeline(nativeResponse.body, nativeFileStream); // Replace current binary console.log(chalk.dim("Installing update...")); try { try { await fs.promises.unlink(backupPath); } catch (err) { if (!isEnoent(err)) throw err; } await fs.promises.rename(execPath, backupPath); await fs.promises.rename(tempPath, execPath); await fs.promises.unlink(backupPath); // Replace native addon (no backup needed, just overwrite) await fs.promises.rename(nativeTempPath, nativePath); console.log(chalk.green(`\n${theme.status.success} Updated to ${release.version}`)); console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); } catch (err) { if (fs.existsSync(backupPath) && !fs.existsSync(execPath)) { await fs.promises.rename(backupPath, execPath); } if (fs.existsSync(tempPath)) { await fs.promises.unlink(tempPath); } if (fs.existsSync(nativeTempPath)) { await fs.promises.unlink(nativeTempPath); } throw err; } } /** * 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 try { if (!isBunBinary && hasBun()) { await updateViaBun(release.version); } else { await updateViaBinary(release); } } 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 `); }