diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d0ca6441..02c109f5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -55,6 +55,7 @@ How to disable it: - Fixed `Container.render()` stack overflow on long sessions by replacing `Array.push(...spread)` with a loop-based push, preventing `RangeError: Maximum call stack size exceeded` when child output exceeds the V8 call stack argument limit ([#2651](https://github.com/badlogic/pi-mono/issues/2651)) - Fixed editor sticky-column tracking around paste markers so vertical cursor navigation restores the column from before the cursor entered a paste marker instead of jumping inside or past pasted content ([#3092](https://github.com/badlogic/pi-mono/pull/3092) by [@Perlence](https://github.com/Perlence)) - Fixed queued messages typed during `/tree` branch summarization to flush automatically after navigation completes, so they no longer remain stuck in the steering queue ([#3091](https://github.com/badlogic/pi-mono/pull/3091) by [@Perlence](https://github.com/Perlence)) +- Fixed npm package update check to work with packages on non-default registries by using `npm view` instead of hardcoded `registry.npmjs.org` fetch ([#3164](https://github.com/badlogic/pi-mono/pull/3164) by [@aliou](https://github.com/aliou)) ## [0.67.0] - 2026-04-13 diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 10781274..a9ea8799 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1317,12 +1317,15 @@ export class DefaultPackageManager implements PackageManager { } private async getLatestNpmVersion(packageName: string): Promise { - const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { - signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`Failed to fetch npm registry: ${response.status}`); - const data = (await response.json()) as { version: string }; - return data.version; + const npmCommand = this.getNpmCommand(); + const stdout = await this.runCommandCapture( + npmCommand.command, + [...npmCommand.args, "view", packageName, "version", "--json"], + { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }, + ); + const raw = stdout.trim(); + if (!raw) throw new Error("Empty response from npm view"); + return JSON.parse(raw); } private async gitHasAvailableUpdate(installedPath: string): Promise { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index a7fa50cd..62d50fd2 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1400,18 +1400,18 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(refreshTemporaryGitSourceSpy).not.toHaveBeenCalled(); }); - it("should not fetch npm registry during resolve for installed unpinned packages", async () => { + it("should not run npm view during resolve for installed unpinned packages", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); 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() {};"); settingsManager.setProjectPackages(["npm:example"]); - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); const result = await packageManager.resolve(); expect(result.extensions.some((r) => pathEndsWith(r.path, "extensions/index.ts") && r.enabled)).toBe(true); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(runCommandCaptureSpy).not.toHaveBeenCalled(); }); it("should reinstall pinned npm packages when installed version does not match", async () => { @@ -1430,11 +1430,11 @@ export default function(api) { api.registerTool({ name: "test", description: "te it("should not check package updates when offline", async () => { process.env.PI_OFFLINE = "1"; - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); const updates = await packageManager.checkForAvailableUpdates(); expect(updates).toEqual([]); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(runCommandCaptureSpy).not.toHaveBeenCalled(); }); it("should report updates for installed unpinned npm packages", async () => { @@ -1443,11 +1443,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); settingsManager.setProjectPackages(["npm:example"]); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ version: "1.2.3" }), - }); - vi.stubGlobal("fetch", fetchMock); + vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); const updates = await packageManager.checkForAvailableUpdates(); expect(updates).toEqual([ @@ -1467,30 +1463,50 @@ export default function(api) { api.registerTool({ name: "test", description: "te const parsedGitSource = (packageManager as any).parseSource("git:github.com/example/repo@v1"); const installedGitPath = (packageManager as any).getGitInstallPath(parsedGitSource, "project") as string; mkdirSync(installedGitPath, { recursive: true }); + settingsManager.setProjectPackages(["npm:example@1.0.0", "git:github.com/example/repo@v1"]); - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); const gitUpdateSpy = vi.spyOn(packageManager as any, "gitHasAvailableUpdate"); const updates = await packageManager.checkForAvailableUpdates(); expect(updates).toEqual([]); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(runCommandCaptureSpy).not.toHaveBeenCalled(); expect(gitUpdateSpy).not.toHaveBeenCalled(); }); - it("should pass an AbortSignal timeout when fetching npm latest version", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ version: "1.2.3" }), - }); - vi.stubGlobal("fetch", fetchMock); + it("should use npm view to fetch latest version", async () => { + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); const latest = await (packageManager as any).getLatestNpmVersion("example"); expect(latest).toBe("1.2.3"); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runCommandCaptureSpy).toHaveBeenCalledTimes(1); + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); + }); - const [, options] = fetchMock.mock.calls[0] as [string, RequestInit | undefined]; - expect(options?.signal).toBeDefined(); + it("should use npmCommand argv for npm update checks", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "node@20", "--", "npm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + + const latest = await (packageManager as any).getLatestNpmVersion("@scope/pkg"); + expect(latest).toBe("1.2.3"); + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "mise", + ["exec", "node@20", "--", "npm", "view", "@scope/pkg", "version", "--json"], + expect.objectContaining({ cwd: tempDir }), + ); }); }); });