From cd6a8f7f3ca8e12d5b84ba74ef436065f93a087d Mon Sep 17 00:00:00 2001 From: triagent Date: Sat, 24 Oct 2026 16:23:12 +0000 Subject: [PATCH] fix(coding-agent): refresh unpinned temporary npm extensions when a newer version exists Temporary -e npm: sources were installed once and reused forever. Like temporary git sources, check npm on load (unless offline) and reinstall with name@latest or the configured spec when a newer version exists, keeping the cached install if the refresh fails. --- .../coding-agent/src/core/package-manager.ts | 16 +++++++ .../coding-agent/test/package-manager.test.ts | 42 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 7397f80c1..199597574 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1409,6 +1409,22 @@ export class DefaultPackageManager implements PackageManager { let installedPath = this.getNpmInstallPath(parsed, resolvedScope); const needsInstall = !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); + if ( + !needsInstall && + resolvedScope === "temporary" && + !parsed.pinned && + (await this.npmHasAvailableUpdate(parsed, installedPath)) + ) { + // Temporary (-e) sources have no `pi update` path, so refresh unpinned ones on load like + // temporary git sources. Like `pi update`, install `name@latest` for unversioned specs because a bare + // `npm install name` keeps the range already saved in the temporary package.json. + const spec = parsed.version ? parsed.spec : `${parsed.name}@latest`; + try { + await this.installNpm({ ...parsed, spec }, resolvedScope, true); + } catch { + // Keep the cached install if the refresh fails. + } + } if (needsInstall) { const installed = await installMissing(); if (!installed) continue; diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 1264e9a7f..530bb48bf 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2940,6 +2940,48 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(result.extensions.some((r) => pathEndsWith(r.path, "extensions/old.ts"))).toBe(false); }); + // https://github.com/earendil-works/pi/issues/11375 + it("should refresh unpinned temporary npm sources when a newer version exists", async () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const source = "npm:example"; + const parsed = managerWithInternals.parseSource(source); + if (parsed.type !== "npm") throw new Error("Expected npm source"); + const installedPath = managerWithInternals.getNpmInstallPath(parsed, "temporary"); + mkdirSync(join(installedPath, "extensions"), { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); + writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};"); + + vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.1.0"'); + const runNpmCommandSpy = vi + .spyOn(packageManager as any, "runNpmCommand") + .mockRejectedValue(new Error("network down")); + + const result = await packageManager.resolveExtensionSources([source], { temporary: true }); + expect(runNpmCommandSpy).toHaveBeenCalledTimes(1); + expect(runNpmCommandSpy.mock.calls[0][0]).toContain("example@latest"); + // A failed refresh keeps loading the cached install. + expect(result.extensions.some((r) => pathEndsWith(r.path, "extensions/index.ts") && r.enabled)).toBe(true); + }); + + it("should not refresh temporary npm sources when already up to date or pinned", async () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + for (const source of ["npm:example", "npm:example@1.0.0"]) { + const parsed = managerWithInternals.parseSource(source); + if (parsed.type !== "npm") throw new Error("Expected npm source"); + const installedPath = managerWithInternals.getNpmInstallPath(parsed, "temporary"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); + } + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.0.0"'); + const runNpmCommandSpy = vi.spyOn(packageManager as any, "runNpmCommand").mockResolvedValue(undefined); + + await packageManager.resolveExtensionSources(["npm:example"], { temporary: true }); + await packageManager.resolveExtensionSources(["npm:example@1.0.0"], { temporary: true }); + expect(runCommandCaptureSpy).toHaveBeenCalledTimes(1); + expect(runNpmCommandSpy).not.toHaveBeenCalled(); + }); + it("should not run npm view during resolve for installed unpinned packages", async () => { process.env.PI_OFFLINE = "1"; const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");