feat(coding-agent): support renamed self-update package
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `pi update --self` to honor the active package name returned by the Pi version check endpoint, defaulting to the current package when omitted and uninstalling the old global package before installing a renamed package.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -54,6 +54,49 @@ function createNpmPrefixInstall(template = "pi-prefix-"): { prefix: string; pack
|
||||
return { prefix, packageDir };
|
||||
}
|
||||
|
||||
function createPnpmGlobalInstall(): { root: string; packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-pnpm-"));
|
||||
const binDir = join(temp, "bin");
|
||||
const root = join(temp, "pnpm", "global", "5", "node_modules");
|
||||
const packageDir = join(root, "@mariozechner", "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root));
|
||||
chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(
|
||||
join(
|
||||
root,
|
||||
".pnpm",
|
||||
"@mariozechner+pi-coding-agent@0.0.0",
|
||||
"node_modules",
|
||||
"@mariozechner",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js",
|
||||
),
|
||||
);
|
||||
return { root, packageDir };
|
||||
}
|
||||
|
||||
function createYarnGlobalInstall(): { globalDir: string; packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-yarn-"));
|
||||
const binDir = join(temp, "bin");
|
||||
const globalDir = join(temp, "yarn", "global");
|
||||
const packageDir = join(globalDir, "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), createFakeYarnScript(globalDir));
|
||||
chmodSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(join(globalDir, ".yarn", "@mariozechner", "pi-coding-agent", "dist", "cli.js"));
|
||||
return { globalDir, packageDir };
|
||||
}
|
||||
|
||||
function createBunGlobalInstall(): { packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-bun-"));
|
||||
const prefix = join(temp, ".bun");
|
||||
@@ -72,6 +115,22 @@ function createBunGlobalInstall(): { packageDir: string } {
|
||||
return { packageDir };
|
||||
}
|
||||
|
||||
function createFakePnpmScript(root: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="root" if "%2"=="-g" echo ${root}\r\n`;
|
||||
}
|
||||
const escapedRoot = root.replaceAll("'", "'\\''");
|
||||
return `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${escapedRoot}'\n\texit 0\nfi\nexit 1\n`;
|
||||
}
|
||||
|
||||
function createFakeYarnScript(globalDir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="global" if "%2"=="dir" echo ${globalDir}\r\n`;
|
||||
}
|
||||
const escapedGlobalDir = globalDir.replaceAll("'", "'\\''");
|
||||
return `#!/bin/sh\nif [ "$1" = "global" ] && [ "$2" = "dir" ]; then\n\tprintf '%s\\n' '${escapedGlobalDir}'\n\texit 0\nfi\nexit 1\n`;
|
||||
}
|
||||
|
||||
function createFakeBunScript(bunBin: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="pm" if "%2"=="bin" if "%3"=="-g" echo ${bunBin}\r\n`;
|
||||
@@ -115,6 +174,30 @@ describe("detectInstallMethod", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed packages from the current install prefix", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"],
|
||||
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g @new-scope/pi`,
|
||||
steps: [
|
||||
{
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "uninstall", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent`,
|
||||
},
|
||||
{
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"],
|
||||
display: `npm --prefix ${prefix} install -g @new-scope/pi`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-update respects configured npmCommand", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
@@ -167,6 +250,81 @@ describe("detectInstallMethod", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed pnpm global installs by removing the old package first", () => {
|
||||
createPnpmGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("pnpm");
|
||||
expect(command).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["install", "-g", "@new-scope/pi"],
|
||||
display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "pnpm",
|
||||
args: ["remove", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: "pnpm remove -g @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "pnpm",
|
||||
args: ["install", "-g", "@new-scope/pi"],
|
||||
display: "pnpm install -g @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed yarn global installs by removing the old package first", () => {
|
||||
createYarnGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("yarn");
|
||||
expect(command).toEqual({
|
||||
command: "yarn",
|
||||
args: ["global", "add", "@new-scope/pi"],
|
||||
display: "yarn global remove @mariozechner/pi-coding-agent && yarn global add @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "yarn",
|
||||
args: ["global", "remove", "@mariozechner/pi-coding-agent"],
|
||||
display: "yarn global remove @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "yarn",
|
||||
args: ["global", "add", "@new-scope/pi"],
|
||||
display: "yarn global add @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed bun global installs by removing the old package first", () => {
|
||||
createBunGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("bun");
|
||||
expect(command).toEqual({
|
||||
command: "bun",
|
||||
args: ["install", "-g", "@new-scope/pi"],
|
||||
display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "bun",
|
||||
args: ["uninstall", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: "bun uninstall -g @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["install", "-g", "@new-scope/pi"],
|
||||
display: "bun install -g @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not self-update when npm install path is not writable", () => {
|
||||
const { packageDir } = createNpmPrefixInstall();
|
||||
chmodSync(packageDir, 0o500);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "no
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.js";
|
||||
import { ENV_AGENT_DIR, PACKAGE_NAME } from "../src/config.js";
|
||||
import { main } from "../src/main.js";
|
||||
|
||||
describe("package commands", () => {
|
||||
@@ -36,6 +36,7 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.chdir(originalCwd);
|
||||
process.exitCode = originalExitCode;
|
||||
if (originalAgentDir === undefined) {
|
||||
@@ -128,7 +129,7 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses global npmCommand for self updates", async () => {
|
||||
it("uses global npmCommand and current package name for forced self updates without checking the api", async () => {
|
||||
const globalPrefix = join(tempDir, "global-prefix");
|
||||
const projectPrefix = join(tempDir, "project-prefix");
|
||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
@@ -156,6 +157,8 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -165,8 +168,10 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
|
||||
expect(recordedArgs).toContain(globalPrefix);
|
||||
expect(recordedArgs).toContain(PACKAGE_NAME);
|
||||
expect(recordedArgs).not.toContain(projectPrefix);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
@@ -174,6 +179,155 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the current package name when the update check omits packageName", async () => {
|
||||
const globalPrefix = join(tempDir, "global-prefix");
|
||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
const fakeNpmPath = join(tempDir, "fake-npm.cjs");
|
||||
const recordPath = join(tempDir, "self-update.json");
|
||||
mkdirSync(selfPackageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
`const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1];
|
||||
if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules"));
|
||||
else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(agentDir, "settings.json"),
|
||||
JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2),
|
||||
);
|
||||
process.env.PI_PACKAGE_DIR = selfPackageDir;
|
||||
Object.defineProperty(process, "execPath", {
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const fetchMock = vi.fn(async () => Response.json({ version: "0.73.1" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["update", "--self"])).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
|
||||
expect(recordedArgs).toContain(PACKAGE_NAME);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("installs the active package name from the update check during self-update", async () => {
|
||||
const globalPrefix = join(tempDir, "global-prefix");
|
||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
const fakeNpmPath = join(tempDir, "fake-npm.cjs");
|
||||
const recordPath = join(tempDir, "self-update.json");
|
||||
mkdirSync(selfPackageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
`const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1];
|
||||
if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules"));
|
||||
else {
|
||||
const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[];
|
||||
records.push(args);
|
||||
fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records));
|
||||
}
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(agentDir, "settings.json"),
|
||||
JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2),
|
||||
);
|
||||
process.env.PI_PACKAGE_DIR = selfPackageDir;
|
||||
Object.defineProperty(process, "execPath", {
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })),
|
||||
);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["update", "--self"])).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
|
||||
expect(recordedCalls).toEqual([
|
||||
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
|
||||
expect.arrayContaining(["install", "-g", activePackageName]),
|
||||
]);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails self-update when renamed npm package installation fails", async () => {
|
||||
const globalPrefix = join(tempDir, "global-prefix");
|
||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
const fakeNpmPath = join(tempDir, "fake-npm-fail.cjs");
|
||||
const recordPath = join(tempDir, "self-update-fail.json");
|
||||
mkdirSync(selfPackageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
`const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1];
|
||||
if(args.includes("root")) {
|
||||
console.log(path.join(prefix,"lib","node_modules"));
|
||||
process.exit(0);
|
||||
}
|
||||
const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[];
|
||||
records.push(args);
|
||||
fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records));
|
||||
if(args.includes("install")) process.exit(23);
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(agentDir, "settings.json"),
|
||||
JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2),
|
||||
);
|
||||
process.env.PI_PACKAGE_DIR = selfPackageDir;
|
||||
Object.defineProperty(process, "execPath", {
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })),
|
||||
);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["update", "--self"])).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).not.toContain(`Updated pi`);
|
||||
expect(stderr).toContain("exited with code 23");
|
||||
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
|
||||
expect(recordedCalls).toEqual([
|
||||
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
|
||||
expect.arrayContaining(["install", "-g", activePackageName]),
|
||||
]);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests the configured source when update input omits the npm prefix", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkForNewPiVersion,
|
||||
comparePackageVersions,
|
||||
getLatestPiRelease,
|
||||
getLatestPiVersion,
|
||||
isNewerPackageVersion,
|
||||
} from "../src/utils/version-check.js";
|
||||
@@ -56,6 +57,13 @@ describe("version checks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the active package name from the version check api", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: "1.2.4" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ packageName: "@new-scope/pi", version: "1.2.4" });
|
||||
});
|
||||
|
||||
it("skips api calls when version checks are disabled", async () => {
|
||||
process.env.PI_SKIP_VERSION_CHECK = "1";
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user