diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 8cd1baa9..27c18fd4 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1221,13 +1221,14 @@ export class DefaultPackageManager implements PackageManager { }; if (parsed.type === "npm") { - const installedPath = this.getNpmInstallPath(parsed, scope); + let installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = !existsSync(installedPath) || (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; + installedPath = this.getNpmInstallPath(parsed, scope); } metadata.baseDir = installedPath; this.collectPackageResources(installedPath, accumulator, filter, metadata); @@ -1855,6 +1856,27 @@ export class DefaultPackageManager implements PackageManager { return this.globalNpmRoot; } + private getPnpmGlobalPackagePath(packageName: string): string | undefined { + const npmCommand = this.getNpmCommand(); + const commandParts = [npmCommand.command, ...npmCommand.args]; + const separatorIndex = commandParts.lastIndexOf("--"); + const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command; + const packageManagerName = packageManagerCommand + ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") + : ""; + if (packageManagerName !== "pnpm") { + return undefined; + } + + const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]); + const entries = JSON.parse(output) as Array<{ dependencies?: Record }>; + for (const entry of entries) { + const path = entry.dependencies?.[packageName]?.path; + if (path) return path; + } + return undefined; + } + private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { if (scope === "temporary") { return join(this.getTemporaryDir("npm"), "node_modules", source.name); @@ -1862,7 +1884,7 @@ export class DefaultPackageManager implements PackageManager { if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } - return join(this.getGlobalNpmRoot(), source.name); + return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name); } private getGitInstallPath(source: GitSource, scope: SourceScope): string { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 18128a83..7c0fef67 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -798,6 +798,105 @@ Content`, expect(packageManager.getInstalledPath("npm:@scope/pkg", "user")).toBeUndefined(); expect(runCommandSyncSpy).toHaveBeenNthCalledWith(2, "mise", ["exec", "node@22", "--", "npm", "root", "-g"]); }); + + it("should resolve pnpm global package paths from pnpm list output", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["pnpm"], + packages: ["npm:pnpm-pkg"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const pnpmRoot = join(tempDir, "pnpm", "global", "v11"); + const packagePath = join(pnpmRoot, "20-hash", "node_modules", "pnpm-pkg"); + let installed = false; + + vi.spyOn(packageManager as any, "runCommandSync").mockImplementation((...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + if (command !== "pnpm") { + throw new Error(`unexpected command ${command}`); + } + if (args.join(" ") === "list -g --depth 0 --json") { + return JSON.stringify([ + { + path: pnpmRoot, + dependencies: installed ? { "pnpm-pkg": { version: "1.0.0", path: packagePath } } : {}, + }, + ]); + } + if (args.join(" ") === "root -g") { + return pnpmRoot; + } + throw new Error(`unexpected args ${args.join(" ")}`); + }); + const runCommandSpy = vi + .spyOn(packageManager as any, "runCommand") + .mockImplementation(async (...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + expect(command).toBe("pnpm"); + expect(args).toEqual(["install", "-g", "pnpm-pkg"]); + mkdirSync(join(packagePath, "extensions"), { recursive: true }); + writeFileSync(join(packagePath, "package.json"), JSON.stringify({ name: "pnpm-pkg", version: "1.0.0" })); + writeFileSync(join(packagePath, "extensions", "index.ts"), "export default function() {};"); + installed = true; + }); + + const first = await packageManager.resolve(); + const second = await packageManager.resolve(); + + expect(first.extensions.some((r) => r.path === join(packagePath, "extensions", "index.ts") && r.enabled)).toBe( + true, + ); + expect( + second.extensions.some((r) => r.path === join(packagePath, "extensions", "index.ts") && r.enabled), + ).toBe(true); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBe(packagePath); + }); + + it("should resolve wrapped pnpm global package paths from pnpm list output", () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "node@20", "--", "pnpm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const pnpmRoot = join(tempDir, "pnpm", "global", "v11"); + const packagePath = join(pnpmRoot, "20-hash", "node_modules", "pnpm-pkg"); + mkdirSync(packagePath, { recursive: true }); + + vi.spyOn(packageManager as any, "runCommandSync").mockImplementation((...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + expect(command).toBe("mise"); + if (args.join(" ") === "exec node@20 -- pnpm list -g --depth 0 --json") { + return JSON.stringify([{ path: pnpmRoot, dependencies: { "pnpm-pkg": { path: packagePath } } }]); + } + throw new Error(`unexpected args ${args.join(" ")}`); + }); + + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBe(packagePath); + }); + + it("should fail when pnpm global package list is malformed", () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["pnpm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + vi.spyOn(packageManager as any, "runCommandSync").mockReturnValue("not json"); + + expect(() => packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toThrow(); + }); }); describe("source parsing", () => {