fix(coding-agent): use npm view for update checks on non-default registries (#3164)
Previously, getLatestNpmVersion() hardcoded a fetch to registry.npmjs.org, which 404s for packages on alternate registries (private, Verdaccio, GitHub Packages, etc.). The error was silently swallowed, so users never saw update notifications for these packages. Replace the direct fetch with via the existing runCommandCapture helper. This uses the same registry resolution as npm install (reads .npmrc config chain), respects the npmCommand setting, and runs with this.cwd for correct .npmrc lookup.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1317,12 +1317,15 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
private async getLatestNpmVersion(packageName: string): Promise<string> {
|
||||
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<boolean> {
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user