fix(coding-agent): repair self-update detection

Fixes #3942
Fixes #3980
Fixes #3922
This commit is contained in:
Armin Ronacher
2026-04-30 19:10:06 +02:00
parent def47ece92
commit ade08de14c
6 changed files with 349 additions and 117 deletions

View File

@@ -1,7 +1,18 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { delimiter, join } from "path";
import { afterEach, describe, expect, test } from "vitest";
import { detectInstallMethod, getSelfUpdateCommand, getUpdateInstruction } from "../src/config.js";
import {
detectInstallMethod,
getSelfUpdateCommand,
getSelfUpdateUnavailableInstruction,
getUpdateInstruction,
} from "../src/config.js";
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath");
const originalPath = process.env.PATH;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
let tempDir: string | undefined;
function setExecPath(value: string): void {
Object.defineProperty(process, "execPath", {
@@ -14,8 +25,61 @@ afterEach(() => {
if (execPathDescriptor) {
Object.defineProperty(process, "execPath", execPathDescriptor);
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalPiPackageDir === undefined) {
delete process.env.PI_PACKAGE_DIR;
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
if (tempDir) {
chmodSync(tempDir, 0o700);
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});
function createNpmPrefixInstall(template = "pi-prefix-"): { prefix: string; packageDir: string } {
const prefix = mkdtempSync(join(tmpdir(), template));
const root = join(prefix, "lib", "node_modules");
const scopeDir = join(root, "@mariozechner");
const packageDir = join(scopeDir, "pi-coding-agent");
mkdirSync(packageDir, { recursive: true });
tempDir = prefix;
process.env.PI_PACKAGE_DIR = packageDir;
setExecPath(join(packageDir, "dist", "cli.js"));
return { prefix, packageDir };
}
function createBunGlobalInstall(): { packageDir: string } {
const temp = mkdtempSync(join(tmpdir(), "pi-bun-"));
const prefix = join(temp, ".bun");
const bunBin = join(prefix, "bin");
const root = join(prefix, "install", "global", "node_modules");
const scopeDir = join(root, "@mariozechner");
const packageDir = join(scopeDir, "pi-coding-agent");
mkdirSync(packageDir, { recursive: true });
mkdirSync(bunBin, { recursive: true });
writeFileSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), createFakeBunScript(bunBin));
chmodSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), 0o755);
tempDir = temp;
process.env.PATH = `${bunBin}${delimiter}${originalPath ?? ""}`;
process.env.PI_PACKAGE_DIR = packageDir;
setExecPath(join(packageDir, "dist", "cli.js"));
return { packageDir };
}
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`;
}
const escapedBunBin = bunBin.replaceAll("'", "'\\''");
return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`;
}
describe("detectInstallMethod", () => {
test("detects pnpm from Windows .pnpm install paths", () => {
setExecPath(
@@ -37,4 +101,79 @@ describe("detectInstallMethod", () => {
"Update @mariozechner/pi-coding-agent using the package manager, wrapper, or source checkout that provides this installation.",
);
});
test("self-updates npm installs from custom prefixes", () => {
const { prefix } = createNpmPrefixInstall();
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent");
expect(detectInstallMethod()).toBe("npm");
expect(command).toEqual({
command: "npm",
args: ["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"],
display: `npm --prefix ${prefix} install -g @mariozechner/pi-coding-agent`,
});
});
test("self-update respects configured npmCommand", () => {
const { prefix } = createNpmPrefixInstall();
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", ["npm", "--prefix", prefix]);
expect(command).toEqual({
command: "npm",
args: ["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"],
display: `npm --prefix ${prefix} install -g @mariozechner/pi-coding-agent`,
});
});
test("self-update treats empty npmCommand as unset", () => {
const { prefix } = createNpmPrefixInstall();
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", []);
expect(command?.args).toEqual(["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"]);
});
test("quotes npm self-update display paths", () => {
const { prefix } = createNpmPrefixInstall("pi prefix ");
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent");
expect(command?.display).toBe(`npm --prefix "${prefix}" install -g @mariozechner/pi-coding-agent`);
});
test("does not infer Windows npm custom prefixes from package paths", () => {
const packageDir = "C:\\Users\\Admin\\npm prefix\\node_modules\\@mariozechner\\pi-coding-agent";
process.env.PI_PACKAGE_DIR = packageDir;
setExecPath(`${packageDir}\\dist\\cli.js`);
expect(detectInstallMethod()).toBe("npm");
expect(getUpdateInstruction("@mariozechner/pi-coding-agent")).toBe(
"Run: npm install -g @mariozechner/pi-coding-agent",
);
});
test("self-updates bun global installs from bun pm bin", () => {
createBunGlobalInstall();
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent");
expect(detectInstallMethod()).toBe("bun");
expect(command).toEqual({
command: "bun",
args: ["install", "-g", "@mariozechner/pi-coding-agent"],
display: "bun install -g @mariozechner/pi-coding-agent",
});
});
test("does not self-update when npm install path is not writable", () => {
const { packageDir } = createNpmPrefixInstall();
chmodSync(packageDir, 0o500);
expect(getSelfUpdateCommand("@mariozechner/pi-coding-agent")).toBeUndefined();
expect(getSelfUpdateUnavailableInstruction("@mariozechner/pi-coding-agent")).toContain(
"the install path is not writable",
);
});
});

View File

@@ -12,7 +12,9 @@ describe("package commands", () => {
let packageDir: string;
let originalCwd: string;
let originalAgentDir: string | undefined;
let originalPiPackageDir: string | undefined;
let originalExitCode: typeof process.exitCode;
let originalExecPath: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@@ -25,7 +27,9 @@ describe("package commands", () => {
originalCwd = process.cwd();
originalAgentDir = process.env[ENV_AGENT_DIR];
originalPiPackageDir = process.env.PI_PACKAGE_DIR;
originalExitCode = process.exitCode;
originalExecPath = process.execPath;
process.exitCode = undefined;
process.env[ENV_AGENT_DIR] = agentDir;
process.chdir(projectDir);
@@ -39,6 +43,12 @@ describe("package commands", () => {
} else {
process.env[ENV_AGENT_DIR] = originalAgentDir;
}
if (originalPiPackageDir === undefined) {
delete process.env.PI_PACKAGE_DIR;
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
rmSync(tempDir, { recursive: true, force: true });
});
@@ -118,6 +128,52 @@ describe("package commands", () => {
}
});
it("uses global npmCommand for self updates", async () => {
const globalPrefix = join(tempDir, "global-prefix");
const projectPrefix = join(tempDir, "project-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 });
mkdirSync(join(projectDir, ".pi"), { 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),
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", projectPrefix] }, null, 2),
);
process.env.PI_PACKAGE_DIR = selfPackageDir;
Object.defineProperty(process, "execPath", {
value: join(selfPackageDir, "dist", "cli.js"),
configurable: true,
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
expect(recordedArgs).toContain(globalPrefix);
expect(recordedArgs).not.toContain(projectPrefix);
} 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));