Files
oh-my-pi/packages/coding-agent/src/cli/update-cli.ts
T

692 lines
22 KiB
TypeScript

/**
* 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 "<pkg>" (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<InstalledVersionVerification>;
}
/**
* 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<string | undefined> {
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<UpdateTarget> {
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<ReleaseInfo> {
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<string, string[]>;
markerDir?: string;
markerEntries: Map<string, string[]>;
}
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<fs.Dirent[]> {
try {
return await fs.promises.readdir(dir, { withFileTypes: true });
} catch (err) {
if (isEnoent(err)) return [];
throw err;
}
}
function getBunCacheGroup(groups: Map<string, BunCachePackageGroup>, 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<string, string[]>, 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<string, BunCachePackageGroup>,
dirPath: string,
packageNames: Set<string> | undefined,
): Promise<void> {
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<string, BunCachePackageGroup>,
packageName: string,
markerDir: string,
packageNames: Set<string> | undefined,
): Promise<void> {
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<string> | undefined,
): Promise<Map<string, BunCachePackageGroup>> {
const groups = new Map<string, BunCachePackageGroup>();
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<number> {
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<string>,
): Promise<BunInstallCachePruneResult> {
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<string | undefined> {
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<string | undefined> {
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<Set<string>> {
const packageNames = new Set<string>();
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<BunInstallCachePruneResult | undefined> {
const cacheDir = await resolveBunInstallCacheDir();
if (!cacheDir) return undefined;
const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(cacheDir);
const packageNames = globalNodeModulesDir
? await collectInstalledPackageNames(globalNodeModulesDir)
: new Set<string>();
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<InstalledVersionVerification> {
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<void> {
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<void> {
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<InstalledVersionVerification> {
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<void> {
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<void> {
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<void> {
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
`);
}