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

@@ -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);

View File

@@ -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));

View File

@@ -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();