Files
2026-08-22 20:11:11 -04:00

599 lines
22 KiB
TypeScript

import { createHash } from "node:crypto";
import {
access,
chmod,
cp,
mkdir,
lstat,
mkdtemp,
readFile,
readdir,
readlink,
realpath,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
const BREW_FORMULA = "pi-coding-agent";
const RELEASE_API = "https://api.github.com/repos/earendil-works/pi/releases/latest";
const MAX_ATTEMPTS = 3;
const COMMAND_TIMEOUT_MS = 15 * 60_000;
export type InstallMethod = "vp" | "bun" | "npm" | "brew" | "native";
export interface UpdateReporter {
info(message: string): void;
retry(message: string): void;
}
export interface UpdateResult {
method: InstallMethod;
beforeVersion: string;
afterVersion: string;
extensionsChecked: boolean;
requiresShutdown: boolean;
}
export class UpdateFailure extends Error {
constructor(message: string, readonly requiresShutdown: boolean) {
super(message);
this.name = "UpdateFailure";
}
}
class NativeReplacementFailure extends Error {
readonly requiresShutdown = true;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "NativeReplacementFailure";
}
}
interface CommandSpec {
command: string;
args: string[];
display: string;
}
interface NativeUpdateResult {
version: string;
requiresShutdown: boolean;
}
interface GitHubAsset {
name: string;
browser_download_url: string;
}
interface GitHubRelease {
tag_name: string;
assets: GitHubAsset[];
}
function normalizePath(value: string | undefined): string {
return (value ?? "").replaceAll("\\", "/").toLowerCase();
}
function isGenericRuntime(executable: string): boolean {
return /^(node|bun)(\.exe)?$/i.test(path.basename(executable));
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function resolveIfPossible(filePath: string | undefined): Promise<string | undefined> {
if (!filePath) return undefined;
try {
return await realpath(filePath);
} catch {
return filePath;
}
}
function findLauncherOnPath(): string | undefined {
const names = process.platform === "win32" ? ["pi.exe", "pi.cmd", "pi.bat", "pi"] : ["pi"];
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
if (!directory) continue;
for (const name of names) {
const candidate = path.join(directory, name);
if (existsSync(candidate)) return candidate;
}
}
return undefined;
}
export function detectInstallMethodFromPaths(input: {
launcher?: string;
resolvedLauncher?: string;
currentScript?: string;
execPath?: string;
vpHome?: string;
isBunRuntime?: boolean;
}): InstallMethod {
const paths = [input.currentScript, input.execPath, input.resolvedLauncher, input.launcher]
.map(normalizePath)
.filter(Boolean);
const joined = paths.join("\n");
const vpHome = normalizePath(input.vpHome);
if (
(vpHome && paths.some((candidate) => candidate.startsWith(`${vpHome}/`))) ||
joined.includes("/.vite-plus/packages/") ||
joined.includes("/.vite-plus/bin/")
) {
return "vp";
}
if (joined.includes("/cellar/pi-coding-agent/") || joined.includes("/homebrew/cellar/pi-coding-agent/")) {
return "brew";
}
if (
joined.includes("/.bun/install/global/") ||
joined.includes("/bun/install/global/") ||
(input.isBunRuntime === true && path.basename(input.execPath ?? "").toLowerCase() === "bun")
) {
return "bun";
}
if (joined.includes("/node_modules/")) {
return "npm";
}
return "native";
}
export async function detectInstallMethod(): Promise<InstallMethod> {
const launcher = findLauncherOnPath();
const resolvedLauncher = await resolveIfPossible(launcher);
const resolvedCurrentScript = await resolveIfPossible(process.argv[1]);
const resolvedExecPath = await resolveIfPossible(process.execPath);
const normalizedLauncher = normalizePath(resolvedLauncher);
const launcherBelongsToCurrentProcess =
normalizedLauncher === normalizePath(resolvedCurrentScript) || normalizedLauncher === normalizePath(resolvedExecPath);
return detectInstallMethodFromPaths({
launcher: launcherBelongsToCurrentProcess ? launcher : undefined,
resolvedLauncher: launcherBelongsToCurrentProcess ? resolvedLauncher : undefined,
currentScript: resolvedCurrentScript,
execPath: resolvedExecPath,
vpHome: process.env.VP_HOME ?? path.join(homedir(), ".vite-plus"),
isBunRuntime: Boolean(process.versions.bun),
});
}
export function isTransientRegistryError(output: string): boolean {
return /(?:(?:HTTP|status(?: code)?)\s*(?:408|425|429|500|502|503|504)\b|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENETDOWN|ENETUNREACH|EHOSTUNREACH|ERR_SOCKET_TIMEOUT|UND_ERR_CONNECT_TIMEOUT|socket hang up|network timeout|timed out|aborted due to timeout|temporary failure in name resolution|temporarily unavailable)/i.test(
output,
);
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function quoteArgument(argument: string): string {
return /\s/.test(argument) ? JSON.stringify(argument) : argument;
}
function commandSpec(command: string, args: string[]): CommandSpec {
return { command, args, display: [command, ...args].map(quoteArgument).join(" ") };
}
export function getPiInvocation(args: string[]): CommandSpec {
const currentScript = process.argv[1];
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
return commandSpec(process.execPath, [currentScript, ...args]);
}
if (!isGenericRuntime(process.execPath)) {
return commandSpec(process.execPath, args);
}
return commandSpec(process.platform === "win32" ? "pi.exe" : "pi", args);
}
async function runWithRetry(
pi: ExtensionAPI,
spec: CommandSpec,
reporter: UpdateReporter,
): Promise<{ stdout: string; stderr: string }> {
let lastOutput = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const result = await pi.exec(spec.command, spec.args, { timeout: COMMAND_TIMEOUT_MS });
if (result.code === 0 && !result.killed) return { stdout: result.stdout, stderr: result.stderr };
lastOutput = [result.stdout, result.stderr, result.killed ? "Process timed out and was killed" : ""]
.filter(Boolean)
.join("\n")
.trim();
if (result.killed) {
throw new Error(`${spec.display} timed out and was killed; refusing to replay a mutating update.\n${lastOutput}`.trim());
}
if (attempt === MAX_ATTEMPTS || !isTransientRegistryError(lastOutput)) {
throw new Error(`${spec.display} failed with exit code ${result.code ?? "unknown"}\n${lastOutput}`.trim());
}
const waitSeconds = 2 ** (attempt - 1);
reporter.retry(`Transient registry error; retrying ${spec.display} in ${waitSeconds}s (${attempt}/${MAX_ATTEMPTS})`);
await delay(waitSeconds * 1_000);
}
throw new Error(`${spec.display} failed\n${lastOutput}`.trim());
}
async function readPiVersion(pi: ExtensionAPI): Promise<string> {
const invocation = getPiInvocation(["--version"]);
const result = await pi.exec(invocation.command, invocation.args, { timeout: 30_000 });
if (result.code !== 0 || result.killed) {
throw new Error(`Could not read Pi version: ${result.stderr || result.stdout || "process timed out"}`.trim());
}
const version = result.stdout.trim().replace(/^v/, "");
if (!version) throw new Error("Could not read Pi version: empty output");
return version;
}
function describeError(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const cause = "cause" in error ? (error as Error & { cause?: unknown }).cause : undefined;
return cause === undefined ? error.message : `${error.message}: ${describeError(cause)}`;
}
async function fetchWithRetry(url: string, reporter: UpdateReporter): Promise<Response> {
let lastError = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-update-extension" },
signal: AbortSignal.timeout(30_000),
});
if (response.ok) return response;
lastError = `HTTP ${response.status} ${response.statusText}`;
if (!isTransientRegistryError(String(response.status))) throw new Error(lastError);
} catch (error) {
lastError = describeError(error);
if (attempt === MAX_ATTEMPTS || !isTransientRegistryError(lastError)) throw error;
}
if (attempt < MAX_ATTEMPTS) {
const waitSeconds = 2 ** (attempt - 1);
reporter.retry(`Transient download error; retrying in ${waitSeconds}s (${attempt}/${MAX_ATTEMPTS})`);
await delay(waitSeconds * 1_000);
}
}
throw new Error(`Download failed: ${lastError}`);
}
async function downloadFile(url: string, destination: string, reporter: UpdateReporter): Promise<void> {
const response = await fetchWithRetry(url, reporter);
const data = new Uint8Array(await response.arrayBuffer());
await writeFile(destination, data, { mode: 0o600 });
}
function releaseAssetName(): string {
const platform = process.platform === "win32" ? "windows" : process.platform;
const architecture = process.arch === "x64" || process.arch === "arm64" ? process.arch : undefined;
if (!architecture || !["linux", "darwin", "windows"].includes(platform)) {
throw new Error(`No native Pi release is available for ${process.platform}/${process.arch}`);
}
return `pi-${platform}-${architecture}.${process.platform === "win32" ? "zip" : "tar.gz"}`;
}
async function sha256(filePath: string): Promise<string> {
const data = await readFile(filePath);
return createHash("sha256").update(data).digest("hex");
}
async function verifyReleaseChecksum(archivePath: string, assetName: string, checksumsPath: string): Promise<void> {
const checksums = await readFile(checksumsPath, "utf8");
const expected = checksums
.split(/\r?\n/)
.map((line) => line.trim().split(/\s+/))
.find((parts) => parts.at(-1)?.replace(/^\*/, "") === assetName)?.[0];
if (!expected) throw new Error(`SHA256SUMS does not contain ${assetName}`);
const actual = await sha256(archivePath);
if (actual.toLowerCase() !== expected.toLowerCase()) {
throw new Error(`Checksum mismatch for ${assetName}: expected ${expected}, received ${actual}`);
}
}
const NATIVE_BUNDLE_ENTRIES = new Set([
"pi",
"pi.exe",
"package.json",
"README.md",
"CHANGELOG.md",
"photon_rs_bg.wasm",
"theme",
"assets",
"export-html",
"docs",
"examples",
"node_modules",
"native",
]);
async function validateNativeBundle(bundleDir: string, executableName: string, expectedVersion?: string): Promise<void> {
const manifestPath = path.join(bundleDir, "package.json");
let manifest: { name?: string; version?: string };
try {
const manifestJson = await readFile(manifestPath, "utf8");
manifest = JSON.parse(manifestJson) as { name?: string; version?: string };
} catch (error) {
throw new Error(`Could not validate native Pi manifest at ${manifestPath}`, { cause: error });
}
if (manifest.name !== PI_PACKAGE) throw new Error(`Unexpected native bundle package: ${manifest.name ?? "missing"}`);
if (expectedVersion && manifest.version !== expectedVersion) {
throw new Error(`Unexpected native bundle version: ${manifest.version ?? "missing"}`);
}
for (const requiredPath of [executableName, "theme", "assets", "export-html", "node_modules"]) {
if (!(await pathExists(path.join(bundleDir, requiredPath)))) {
throw new Error(`Native Pi bundle is missing ${requiredPath}`);
}
}
const unexpectedEntries = (await readdir(bundleDir)).filter((entry) => !NATIVE_BUNDLE_ENTRIES.has(entry));
if (unexpectedEntries.length > 0) {
throw new Error(`Native Pi bundle contains unowned entries: ${unexpectedEntries.join(", ")}`);
}
}
async function runOnce(pi: ExtensionAPI, spec: CommandSpec): Promise<void> {
const result = await pi.exec(spec.command, spec.args, { timeout: COMMAND_TIMEOUT_MS });
if (result.code !== 0 || result.killed) {
const output = [result.stdout, result.stderr, result.killed ? "Process timed out and was killed" : ""]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(`${spec.display} failed with exit code ${result.code ?? "unknown"}\n${output}`.trim());
}
}
async function extractNativeArchive(pi: ExtensionAPI, archivePath: string, extractDir: string): Promise<string> {
await mkdir(extractDir, { recursive: true });
await runOnce(pi, commandSpec("tar", ["-xzf", archivePath, "-C", extractDir]));
return path.join(extractDir, "pi");
}
async function validateNewNativeExecutable(pi: ExtensionAPI, bundleDir: string, version: string): Promise<void> {
const executable = path.join(bundleDir, process.platform === "win32" ? "pi.exe" : "pi");
if (process.platform !== "win32") await chmod(executable, 0o755);
const result = await pi.exec(executable, ["--version"], { timeout: 30_000 });
if (result.code !== 0 || result.killed || result.stdout.trim().replace(/^v/, "") !== version) {
throw new Error(`Downloaded native Pi failed validation: ${result.stderr || result.stdout}`.trim());
}
}
function safeVersion(version: string): string {
return version.replace(/[^0-9A-Za-z._-]/g, "-");
}
function compareSemver(left: string, right: string): number {
const parse = (value: string) => {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
if (!match) throw new Error(`Invalid semantic version: ${value}`);
return { numbers: match.slice(1, 4).map(Number), prerelease: match[4] };
};
const a = parse(left);
const b = parse(right);
for (let index = 0; index < 3; index += 1) {
const difference = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
if (a.prerelease === b.prerelease) return 0;
if (a.prerelease === undefined) return 1;
if (b.prerelease === undefined) return -1;
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true });
}
async function bundleInventory(root: string): Promise<Map<string, string>> {
const inventory = new Map<string, string>();
async function visit(relativeDir: string): Promise<void> {
const absoluteDir = path.join(root, relativeDir);
for (const entry of await readdir(absoluteDir)) {
const relativePath = path.join(relativeDir, entry);
const absolutePath = path.join(root, relativePath);
const metadata = await lstat(absolutePath);
if (metadata.isDirectory()) {
inventory.set(relativePath, "directory");
await visit(relativePath);
} else if (metadata.isSymbolicLink()) {
inventory.set(relativePath, `symlink:${await readlink(absolutePath)}`);
} else if (metadata.isFile()) {
inventory.set(relativePath, "file");
} else {
inventory.set(relativePath, "unsupported");
}
}
}
await visit("");
return inventory;
}
async function assertReplacementPreservesOwnedPaths(currentBundle: string, newBundle: string): Promise<void> {
const [currentInventory, newInventory] = await Promise.all([
bundleInventory(currentBundle),
bundleInventory(newBundle),
]);
const removedOrChanged = [...currentInventory].filter(
([relativePath, kind]) => newInventory.get(relativePath) !== kind,
);
if (removedOrChanged.length > 0) {
const preview = removedOrChanged.slice(0, 10).map(([relativePath]) => relativePath).join(", ");
throw new Error(
`Native update would remove or change unrecognized bundle paths: ${preview}` +
(removedOrChanged.length > 10 ? ` (+${removedOrChanged.length - 10} more)` : ""),
);
}
}
async function replaceNativeBundleUnix(currentBundle: string, extractedBundle: string, oldVersion: string): Promise<void> {
await assertReplacementPreservesOwnedPaths(currentBundle, extractedBundle);
const parent = path.dirname(currentBundle);
const stagingRoot = await mkdtemp(path.join(parent, ".pi-update-"));
const stagedBundle = path.join(stagingRoot, "bundle");
const backup = `${currentBundle}.previous-${safeVersion(oldVersion)}-${Date.now()}`;
try {
await cp(extractedBundle, stagedBundle, { recursive: true, force: false, errorOnExist: true });
await rename(currentBundle, backup);
try {
await rename(stagedBundle, currentBundle);
} catch (error) {
try {
await rename(backup, currentBundle);
} catch (rollbackError) {
throw new NativeReplacementFailure(
`Native Pi replacement and rollback both failed. Backup remains at ${backup}: ${describeError(rollbackError)}`,
{ cause: error },
);
}
throw error;
}
} finally {
await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
async function updateNativePi(
pi: ExtensionAPI,
currentVersion: string,
reporter: UpdateReporter,
): Promise<NativeUpdateResult> {
if (isGenericRuntime(process.execPath)) {
throw new Error("Native Pi detection selected a generic runtime; refusing an unsafe binary replacement.");
}
if (process.platform === "win32") {
throw new Error(
"Automatic updates for a running native Windows bundle are unsafe. Download and verify the latest Windows archive from https://github.com/earendil-works/pi/releases/latest.",
);
}
const currentBundle = path.dirname(await realpath(process.execPath));
try {
await validateNativeBundle(currentBundle, "pi", currentVersion);
} catch (error) {
throw new Error(
`The native Pi executable is not inside an exact dedicated release bundle (${currentBundle}). ` +
`Automatic replacement is unsafe: ${error instanceof Error ? error.message : String(error)}`,
);
}
const release = (await (await fetchWithRetry(RELEASE_API, reporter)).json()) as GitHubRelease;
const version = release.tag_name.replace(/^v/, "");
if (compareSemver(version, currentVersion) <= 0) return { version: currentVersion, requiresShutdown: false };
const assetName = releaseAssetName();
const archive = release.assets.find((asset) => asset.name === assetName);
const checksums = release.assets.find((asset) => asset.name === "SHA256SUMS");
if (!archive || !checksums) throw new Error(`Release ${release.tag_name} is missing ${assetName} or SHA256SUMS`);
const workingDir = await mkdtemp(path.join(tmpdir(), "pi-native-update-"));
try {
const archivePath = path.join(workingDir, assetName);
const checksumsPath = path.join(workingDir, "SHA256SUMS");
reporter.info(`Downloading verified native Pi ${version}`);
await downloadFile(archive.browser_download_url, archivePath, reporter);
await downloadFile(checksums.browser_download_url, checksumsPath, reporter);
await verifyReleaseChecksum(archivePath, assetName, checksumsPath);
const extractedBundle = await extractNativeArchive(pi, archivePath, path.join(workingDir, "extract"));
await validateNativeBundle(extractedBundle, "pi", version);
await validateNewNativeExecutable(pi, extractedBundle, version);
await replaceNativeBundleUnix(currentBundle, extractedBundle, currentVersion);
return { version, requiresShutdown: true };
} finally {
await rm(workingDir, { recursive: true, force: true });
}
}
async function updateExtensions(
pi: ExtensionAPI,
reporter: UpdateReporter,
projectTrusted: boolean,
): Promise<void> {
reporter.info("Updating configured Pi extension packages");
const args = ["update", "--extensions", projectTrusted ? "--approve" : "--no-approve"];
await runWithRetry(pi, getPiInvocation(args), reporter);
}
async function updateSelf(
pi: ExtensionAPI,
method: InstallMethod,
beforeVersion: string,
reporter: UpdateReporter,
): Promise<NativeUpdateResult> {
reporter.info(`Updating Pi (${method} install)`);
switch (method) {
case "vp":
await runWithRetry(pi, commandSpec("vp", ["update", "-g", `${PI_PACKAGE}@latest`]), reporter);
return { version: await readPiVersion(pi), requiresShutdown: false };
case "brew":
await runWithRetry(pi, commandSpec("brew", ["upgrade", BREW_FORMULA]), reporter);
return { version: await readPiVersion(pi), requiresShutdown: false };
case "bun":
case "npm":
await runWithRetry(pi, getPiInvocation(["update", "--self"]), reporter);
return { version: await readPiVersion(pi), requiresShutdown: false };
case "native":
return updateNativePi(pi, beforeVersion, reporter);
default:
throw new Error(`Unsupported Pi install method: ${String(method)}`);
}
}
export async function updatePiAndExtensions(
pi: ExtensionAPI,
reporter: UpdateReporter,
options: { projectTrusted?: boolean } = {},
): Promise<UpdateResult> {
if (/^(?:1|true|yes)$/i.test(process.env.PI_OFFLINE ?? "")) {
throw new Error("Pi updates are disabled because PI_OFFLINE is enabled.");
}
const method = await detectInstallMethod();
const beforeVersion = await readPiVersion(pi);
let extensionError: Error | undefined;
let selfError: Error | undefined;
let extensionsChecked = false;
let selfResult: NativeUpdateResult = { version: beforeVersion, requiresShutdown: false };
try {
await updateExtensions(pi, reporter, options.projectTrusted === true);
extensionsChecked = true;
} catch (error) {
extensionError = error instanceof Error ? error : new Error(String(error));
}
try {
selfResult = await updateSelf(pi, method, beforeVersion, reporter);
} catch (error) {
selfError = error instanceof Error ? error : new Error(String(error));
if (error instanceof NativeReplacementFailure) {
selfResult = { version: beforeVersion, requiresShutdown: true };
}
}
if (extensionError || selfError) {
const extensionReport = extensionError
? `Extension update failed: ${extensionError.message}`
: "Configured extension packages were checked successfully.";
let selfReport: string;
if (selfError) {
selfReport = `Pi update failed: ${selfError.message}`;
} else if (beforeVersion === selfResult.version) {
selfReport = `Pi remains at v${selfResult.version}.`;
} else {
selfReport = `Pi updated: v${beforeVersion} → v${selfResult.version}.`;
}
const report = [extensionReport, selfReport];
throw new UpdateFailure(report.join("\n\n"), selfResult.requiresShutdown);
}
return {
method,
beforeVersion,
afterVersion: selfResult.version,
extensionsChecked,
requiresShutdown: selfResult.requiresShutdown,
};
}