Adding skills
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const EFFORT_LEVELS = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
||||
|
||||
const FAST_STATE_ENTRY = "effort-controls.fast-state";
|
||||
|
||||
function supportedEfforts(ctx: ExtensionContext): EffortLevel[] {
|
||||
const model = ctx.model;
|
||||
if (!model?.reasoning) return ["off"];
|
||||
|
||||
return EFFORT_LEVELS.filter((level) => {
|
||||
const mapped = model.thinkingLevelMap?.[level];
|
||||
if (mapped === null) return false;
|
||||
return level !== "xhigh" && level !== "max"
|
||||
? true
|
||||
: mapped !== undefined && mapped !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function supportsFastMode(ctx: ExtensionContext): boolean {
|
||||
const model = ctx.model;
|
||||
if (!model) return false;
|
||||
|
||||
const isOpenAIResponses =
|
||||
(model.provider === "openai" && model.api === "openai-responses") ||
|
||||
(model.provider === "openai-codex" && model.api === "openai-codex-responses");
|
||||
|
||||
return isOpenAIResponses && /^gpt-5\.(?:[4-9]|\d{2,})(?:-|$)/.test(model.id);
|
||||
}
|
||||
|
||||
function updateFastStatus(ctx: ExtensionContext, fastEnabled: boolean): void {
|
||||
ctx.ui.setStatus(
|
||||
"effort-controls.fast",
|
||||
fastEnabled && supportsFastMode(ctx)
|
||||
? ctx.ui.theme.fg("accent", "fast:on")
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export default function effortControlsExtension(pi: ExtensionAPI) {
|
||||
let fastEnabled = false;
|
||||
|
||||
pi.registerCommand("effort", {
|
||||
description: "Choose the model reasoning effort",
|
||||
getArgumentCompletions: (prefix) => {
|
||||
const levels = EFFORT_LEVELS.filter((level) =>
|
||||
level.startsWith(prefix.toLowerCase()),
|
||||
);
|
||||
return levels.length > 0
|
||||
? levels.map((level) => ({ value: level, label: level }))
|
||||
: null;
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const requested = args.trim().toLowerCase();
|
||||
const supported = supportedEfforts(ctx);
|
||||
|
||||
if (requested) {
|
||||
if (!EFFORT_LEVELS.includes(requested as EffortLevel)) {
|
||||
ctx.ui.notify(
|
||||
`Unknown effort "${requested}". Choose: ${supported.join(", ")}`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!supported.includes(requested as EffortLevel)) {
|
||||
ctx.ui.notify(
|
||||
`Effort "${requested}" is unavailable for ${ctx.model?.id ?? "this model"}.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
pi.setThinkingLevel(requested as EffortLevel);
|
||||
ctx.ui.notify(`Reasoning effort: ${pi.getThinkingLevel()}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
`Available efforts: ${supported.join(", ")}. Use /effort <level>.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select("Reasoning effort", supported);
|
||||
if (!choice) return;
|
||||
|
||||
pi.setThinkingLevel(choice as EffortLevel);
|
||||
ctx.ui.notify(`Reasoning effort: ${pi.getThinkingLevel()}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("fast", {
|
||||
description:
|
||||
"Toggle OpenAI priority (fast) mode for eligible GPT-5.4+ models",
|
||||
getArgumentCompletions: (prefix) => {
|
||||
const values = ["on", "off"];
|
||||
const matches = values.filter((value) =>
|
||||
value.startsWith(prefix.toLowerCase()),
|
||||
);
|
||||
return matches.length > 0
|
||||
? matches.map((value) => ({ value, label: value }))
|
||||
: null;
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
if (!supportsFastMode(ctx)) {
|
||||
ctx.ui.notify(
|
||||
"Fast mode requires an OpenAI Responses or Codex Responses GPT-5.4+ model.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requested = args.trim().toLowerCase();
|
||||
if (requested && requested !== "on" && requested !== "off") {
|
||||
ctx.ui.notify("Use /fast, /fast on, or /fast off.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
fastEnabled = requested ? requested === "on" : !fastEnabled;
|
||||
pi.appendEntry(FAST_STATE_ENTRY, { enabled: fastEnabled });
|
||||
updateFastStatus(ctx, fastEnabled);
|
||||
ctx.ui.notify(`Fast mode ${fastEnabled ? "on" : "off"}.`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("before_provider_request", (event, ctx) => {
|
||||
if (
|
||||
!fastEnabled ||
|
||||
!supportsFastMode(ctx) ||
|
||||
typeof event.payload !== "object" ||
|
||||
event.payload === null ||
|
||||
Array.isArray(event.payload)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return { ...event.payload, service_tier: "priority" };
|
||||
});
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
const latestState = ctx.sessionManager
|
||||
.getBranch()
|
||||
.filter(
|
||||
(entry) => entry.type === "custom" && entry.customType === FAST_STATE_ENTRY,
|
||||
)
|
||||
.pop() as { data?: { enabled?: unknown } } | undefined;
|
||||
fastEnabled = latestState?.data?.enabled === true;
|
||||
updateFastStatus(ctx, fastEnabled);
|
||||
});
|
||||
|
||||
pi.on("model_select", (_event, ctx) => {
|
||||
updateFastStatus(ctx, fastEnabled);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { UpdateFailure, updatePiAndExtensions, type UpdateReporter, type UpdateResult } from "./updater.ts";
|
||||
|
||||
const STATUS_KEY = "pi-update";
|
||||
|
||||
function conciseError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lines = message.split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(Math.max(0, lines.length - 12)).join("\n");
|
||||
}
|
||||
|
||||
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(message, level);
|
||||
} else if (level === "error") {
|
||||
console.error(message);
|
||||
} else if (level === "warning") {
|
||||
console.warn(message);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
function makeReporter(ctx: ExtensionContext): UpdateReporter {
|
||||
return {
|
||||
info(message) {
|
||||
ctx.ui.setStatus(STATUS_KEY, message);
|
||||
notify(ctx, message, "info");
|
||||
},
|
||||
retry(message) {
|
||||
ctx.ui.setStatus(STATUS_KEY, message);
|
||||
notify(ctx, message, "warning");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatResult(result: UpdateResult): string {
|
||||
const versionChange =
|
||||
result.beforeVersion === result.afterVersion
|
||||
? `Pi remains at v${result.afterVersion}`
|
||||
: `Pi updated: v${result.beforeVersion} → v${result.afterVersion}`;
|
||||
const extensionStatus = result.extensionsChecked
|
||||
? "Configured Pi extension packages were checked successfully."
|
||||
: "Extension package checking did not complete.";
|
||||
return `${versionChange}\nInstall method: ${result.method}\n${extensionStatus}\nRestart Pi to load updated code.`;
|
||||
}
|
||||
|
||||
export default function updateExtension(pi: ExtensionAPI): void {
|
||||
let activeUpdate: Promise<UpdateResult> | undefined;
|
||||
let updateFlagHandled = false;
|
||||
|
||||
pi.registerFlag("update", {
|
||||
description: "Update Pi and all configured extension packages, then exit",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
async function runUpdate(ctx: ExtensionContext): Promise<UpdateResult> {
|
||||
if (!activeUpdate) {
|
||||
ctx.ui.setStatus(STATUS_KEY, "Preparing Pi update");
|
||||
activeUpdate = updatePiAndExtensions(pi, makeReporter(ctx), {
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
}).finally(() => {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
activeUpdate = undefined;
|
||||
});
|
||||
}
|
||||
return activeUpdate;
|
||||
}
|
||||
|
||||
async function handleUpdate(ctx: ExtensionContext, exitAfterUpdate: boolean): Promise<void> {
|
||||
try {
|
||||
const result = await runUpdate(ctx);
|
||||
notify(ctx, formatResult(result), "info");
|
||||
if (exitAfterUpdate || result.requiresShutdown) ctx.shutdown();
|
||||
} catch (error) {
|
||||
notify(ctx, `Update failed\n${conciseError(error)}`, "error");
|
||||
if (exitAfterUpdate) process.exitCode = 1;
|
||||
if (exitAfterUpdate || (error instanceof UpdateFailure && error.requiresShutdown)) ctx.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("update", {
|
||||
description: "Update Pi and all configured extension packages",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
await ctx.waitForIdle();
|
||||
await handleUpdate(ctx, false);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (updateFlagHandled || pi.getFlag("update") !== true) return;
|
||||
updateFlagHandled = true;
|
||||
await handleUpdate(ctx, true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user