diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 822b83ec..e772b3b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 901bd296..ad306b4c 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -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( diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 6516beb5..71c4b48b 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -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();