fix(coding-agent): detect pnpm v11 self-update installs

closes #4647
This commit is contained in:
Armin Ronacher
2026-05-18 08:56:24 +02:00
parent 89b64d9dd5
commit 96cad24a07
3 changed files with 89 additions and 17 deletions

View File

@@ -5,6 +5,7 @@
### Fixed
- Fixed Windows npm self-updates to move loaded native dependency packages out of the active install before reinstalling pi ([#4157](https://github.com/earendil-works/pi/issues/4157)).
- Fixed `pi update --self` detection for pnpm v11 global installs whose package path resolves through the pnpm store ([#4647](https://github.com/earendil-works/pi/issues/4647)).
## [0.75.1] - 2026-05-18

View File

@@ -215,16 +215,18 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
}
function normalizeExistingPathForComparison(path: string): string | undefined {
function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return undefined;
}
let normalizedPath: string;
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
let normalizedPath = resolvedPath;
if (resolveSymlinks) {
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
}
}
if (process.platform === "win32") {
normalizedPath = normalizedPath.toLowerCase();
@@ -232,6 +234,29 @@ function normalizeExistingPathForComparison(path: string): string | undefined {
return normalizedPath;
}
function getPathComparisonCandidates(path: string): string[] {
return Array.from(
new Set(
[normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter(
(candidate): candidate is string => !!candidate,
),
),
);
}
function getEntrypointPackageDir(): string | undefined {
const entrypoint = process.argv[1];
if (!entrypoint) return undefined;
let dir = dirname(entrypoint);
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
return dir;
}
dir = dirname(dir);
}
return undefined;
}
function isSelfUpdatePathWritable(): boolean {
const packageDir = getPackageDir();
try {
@@ -244,17 +269,14 @@ function isSelfUpdatePathWritable(): boolean {
}
function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean {
const packageDir = normalizeExistingPathForComparison(getPackageDir());
return (
!!packageDir &&
getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
const normalizedRoot = normalizeExistingPathForComparison(root);
return (
!!normalizedRoot &&
packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`)
);
})
);
const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir);
const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir));
return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
return getPathComparisonCandidates(root).some((normalizedRoot) => {
const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix));
});
});
}
export function getSelfUpdateCommand(

View File

@@ -12,6 +12,7 @@ import {
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath");
const originalPath = process.env.PATH;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
const originalArgv1 = process.argv[1];
let tempDir: string | undefined;
function setExecPath(value: string): void {
@@ -35,6 +36,11 @@ afterEach(() => {
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
if (originalArgv1 === undefined) {
process.argv.splice(1, 1);
} else {
process.argv[1] = originalArgv1;
}
if (tempDir) {
chmodSync(tempDir, 0o700);
rmSync(tempDir, { recursive: true, force: true });
@@ -275,6 +281,49 @@ describe("detectInstallMethod", () => {
});
});
test("self-updates pnpm v11 global installs resolved through the store", () => {
const temp = mkdtempSync(join(tmpdir(), "pi-pnpm11-"));
const binDir = join(temp, "bin");
const root = join(temp, "Library", "pnpm", "global", "v11");
const packageName = "@earendil-works/pi-coding-agent";
const globalPackageDir = join(root, "11e9a", "node_modules", "@earendil-works", "pi-coding-agent");
const storePackageDir = join(
temp,
"Library",
"pnpm",
"store",
"v11",
"links",
"@earendil-works",
"pi-coding-agent",
"0.75.0",
"hash",
"node_modules",
"@earendil-works",
"pi-coding-agent",
);
mkdirSync(globalPackageDir, { recursive: true });
mkdirSync(storePackageDir, { recursive: true });
mkdirSync(binDir, { recursive: true });
writeFileSync(join(globalPackageDir, "package.json"), "{}");
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 = storePackageDir;
process.argv[1] = join(globalPackageDir, "dist", "cli.js");
setExecPath(join(storePackageDir, "dist", "cli.js"));
const command = getSelfUpdateCommand(packageName);
expect(detectInstallMethod()).toBe("pnpm");
expect(command).toEqual({
command: "pnpm",
args: ["install", "-g", packageName],
display: `pnpm install -g ${packageName}`,
});
});
test("self-updates renamed yarn global installs by removing the old package first", () => {
createYarnGlobalInstall();