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.
This commit is contained in:
triagent 2026-10-24 16:23:12 +00:00 • committed by Petr Houška
parent 31ade0e0dc
commit cd6a8f7f3c
2 changed files with 58 additions and 0 deletions

View file

@ -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;

View file

@ -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");