feat(coding-agent): support renamed self-update package

This commit is contained in:
Armin Ronacher
2026-05-07 16:11:06 +02:00
parent 801db80b65
commit 5e1e4c3c88
7 changed files with 460 additions and 56 deletions

View File

@@ -28,12 +28,36 @@ export const isBunRuntime = !!process.versions.bun;
export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown";
export interface SelfUpdateCommand {
interface SelfUpdateCommandStep {
command: string;
args: string[];
display: string;
}
export interface SelfUpdateCommand extends SelfUpdateCommandStep {
steps?: SelfUpdateCommandStep[];
}
function makeSelfUpdateCommand(
installStep: SelfUpdateCommandStep,
uninstallStep?: SelfUpdateCommandStep,
): SelfUpdateCommand {
if (!uninstallStep) return installStep;
return {
...installStep,
display: `${uninstallStep.display} && ${installStep.display}`,
steps: [uninstallStep, installStep],
};
}
function makeSelfUpdateCommandStep(command: string, args: string[]): SelfUpdateCommandStep {
return {
command,
args,
display: [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "),
};
}
export function detectInstallMethod(): InstallMethod {
if (isBunBinary) {
return "bun-binary";
@@ -83,24 +107,44 @@ function getInferredNpmInstall(packageName: string): { root: string; prefix: str
function getSelfUpdateCommandForMethod(
method: InstallMethod,
packageName: string,
installedPackageName: string,
updatePackageName = installedPackageName,
npmCommand?: string[],
): SelfUpdateCommand | undefined {
switch (method) {
case "bun-binary":
return undefined;
case "pnpm":
return { command: "pnpm", args: ["install", "-g", packageName], display: `pnpm install -g ${packageName}` };
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("pnpm", ["install", "-g", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
);
case "yarn":
return { command: "yarn", args: ["global", "add", packageName], display: `yarn global add ${packageName}` };
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("yarn", ["global", "add", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]),
);
case "bun":
return { command: "bun", args: ["install", "-g", packageName], display: `bun install -g ${packageName}` };
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("bun", ["install", "-g", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
);
case "npm": {
const [command = "npm", ...npmArgs] = npmCommand ?? [];
const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(packageName);
const args = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : []), "install", "-g", packageName];
const display = [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
return { command, args, display };
const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(installedPackageName);
const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])];
const installStep = makeSelfUpdateCommandStep(command, [...prefixArgs, "install", "-g", updatePackageName]);
const uninstallStep =
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]);
return makeSelfUpdateCommand(installStep, uninstallStep);
}
case "unknown":
return undefined;
@@ -210,28 +254,36 @@ function isManagedByGlobalPackageManager(method: InstallMethod, packageName: str
);
}
export function getSelfUpdateCommand(packageName: string, npmCommand?: string[]): SelfUpdateCommand | undefined {
export function getSelfUpdateCommand(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
): SelfUpdateCommand | undefined {
const method = detectInstallMethod();
const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) {
return undefined;
}
return command;
}
export function getSelfUpdateUnavailableInstruction(packageName: string, npmCommand?: string[]): string {
export function getSelfUpdateUnavailableInstruction(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
): string {
const method = detectInstallMethod();
if (method === "bun-binary") {
return `Download from: https://github.com/badlogic/pi-mono/releases/latest`;
}
const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
if (command) {
if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) {
return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`;
}
return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`;
}
return `Update ${packageName} using the package manager, wrapper, or source checkout that provides this installation.`;
return `Update ${updatePackageName} using the package manager, wrapper, or source checkout that provides this installation.`;
}
export function getUpdateInstruction(packageName: string): string {

View File

@@ -13,7 +13,7 @@ import {
import { DefaultPackageManager } from "./core/package-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { shouldUseWindowsShell } from "./utils/child-process.js";
import { getLatestPiVersion, isNewerPackageVersion } from "./utils/version-check.js";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js";
export type PackageCommand = "install" | "remove" | "update" | "list";
@@ -273,9 +273,9 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean {
return target.type === "all" || target.type === "extensions";
}
function printSelfUpdateUnavailable(npmCommand?: string[]): void {
function printSelfUpdateUnavailable(npmCommand?: string[], updatePackageName = PACKAGE_NAME): void {
console.error(`error: ${APP_NAME} cannot self-update this installation.`);
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand));
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageName));
const entrypoint = process.argv[1];
if (entrypoint) {
@@ -288,47 +288,53 @@ function printSelfUpdateFallback(command: SelfUpdateCommand): void {
console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`));
}
async function shouldRunSelfUpdate(force: boolean): Promise<boolean> {
interface SelfUpdatePlan {
packageName: string;
shouldRun: boolean;
}
async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
if (force) {
return true;
return { packageName: PACKAGE_NAME, shouldRun: true };
}
let latestVersion: string | undefined;
try {
latestVersion = await getLatestPiVersion(VERSION);
const latestRelease = await getLatestPiRelease(VERSION);
const packageName = latestRelease?.packageName ?? PACKAGE_NAME;
if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return { packageName, shouldRun: true };
}
} catch {
return true;
}
if (!latestVersion || isNewerPackageVersion(latestVersion, VERSION)) {
return true;
return { packageName: PACKAGE_NAME, shouldRun: true };
}
console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`));
return false;
return { packageName: PACKAGE_NAME, shouldRun: false };
}
async function runSelfUpdate(command: SelfUpdateCommand): Promise<void> {
console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`));
await new Promise<void>((resolve, reject) => {
// Windows package managers are commonly .cmd shims. Use the shell so Node can execute them.
const child = spawn(command.command, command.args, {
stdio: "inherit",
shell: shouldUseWindowsShell(command.command),
for (const step of command.steps ?? [command]) {
await new Promise<void>((resolve, reject) => {
// Windows package managers are commonly .cmd shims. Use the shell so Node can execute them.
const child = spawn(step.command, step.args, {
stdio: "inherit",
shell: shouldUseWindowsShell(step.command),
});
child.on("error", (error) => {
reject(error);
});
child.on("close", (code, signal) => {
if (code === 0) {
resolve();
} else if (signal) {
reject(new Error(`${step.display} terminated by signal ${signal}`));
} else {
reject(new Error(`${step.display} exited with code ${code ?? "unknown"}`));
}
});
});
child.on("error", (error) => {
reject(error);
});
child.on("close", (code, signal) => {
if (code === 0) {
resolve();
} else if (signal) {
reject(new Error(`${command.display} terminated by signal ${signal}`));
} else {
reject(new Error(`${command.display} exited with code ${code ?? "unknown"}`));
}
});
});
}
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
@@ -480,13 +486,18 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
}
}
if (updateTargetIncludesSelf(target)) {
const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand);
if (!selfUpdateCommand) {
printSelfUpdateUnavailable(selfUpdateNpmCommand);
process.exitCode = 1;
const selfUpdatePlan = await getSelfUpdatePlan(options.force);
if (!selfUpdatePlan.shouldRun) {
return true;
}
if (!(await shouldRunSelfUpdate(options.force))) {
const selfUpdateCommand = getSelfUpdateCommand(
PACKAGE_NAME,
selfUpdateNpmCommand,
selfUpdatePlan.packageName,
);
if (!selfUpdateCommand) {
printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdatePlan.packageName);
process.exitCode = 1;
return true;
}
try {

View File

@@ -3,6 +3,11 @@ import { getPiUserAgent } from "./pi-user-agent.js";
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000;
export interface LatestPiRelease {
version: string;
packageName?: string;
}
interface ParsedVersion {
major: number;
minor: number;
@@ -47,10 +52,10 @@ export function isNewerPackageVersion(candidateVersion: string, currentVersion:
return candidateVersion.trim() !== currentVersion.trim();
}
export async function getLatestPiVersion(
export async function getLatestPiRelease(
currentVersion: string,
options: { timeoutMs?: number } = {},
): Promise<string | undefined> {
): Promise<LatestPiRelease | undefined> {
if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined;
const response = await fetch(LATEST_VERSION_URL, {
@@ -62,8 +67,20 @@ export async function getLatestPiVersion(
});
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: unknown };
return typeof data.version === "string" && data.version.trim() ? data.version.trim() : undefined;
const data = (await response.json()) as { packageName?: unknown; version?: unknown };
if (typeof data.version !== "string" || !data.version.trim()) {
return undefined;
}
const packageName =
typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined;
return { version: data.version.trim(), packageName };
}
export async function getLatestPiVersion(
currentVersion: string,
options: { timeoutMs?: number } = {},
): Promise<string | undefined> {
return (await getLatestPiRelease(currentVersion, options))?.version;
}
export async function checkForNewPiVersion(currentVersion: string): Promise<string | undefined> {