From eda1082d4b61539aabf91847bdc18f30e72de498 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 20 Mar 2026 19:36:06 +0100 Subject: [PATCH] fix(coding-agent): update project npm packages closes #2459 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 54 ++++++++++++++++++- .../test/package-command-paths.test.ts | 26 ++++++++- .../coding-agent/test/package-manager.test.ts | 30 +++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d84d211c..2b10f88e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed `ctrl+z` suspend and `fg` resume reliability by keeping the process alive until the `SIGCONT` handler restores the TUI, avoiding immediate process exit in environments with no other live event-loop handles ([#2454](https://github.com/badlogic/pi-mono/issues/2454)) - Fixed `createAgentSession({ agentDir })` to derive the default persisted session path from the provided `agentDir`, keeping session storage aligned with settings, auth, models, and resource loading ([#2457](https://github.com/badlogic/pi-mono/issues/2457)) +- Fixed project-local npm package updates to install npm `latest` instead of reusing stale saved dependency ranges, and added `Did you mean ...?` suggestions when `pi update ` omits the configured npm or git source prefix ([#2459](https://github.com/badlogic/pi-mono/issues/2459)) ## [0.61.0] - 2026-03-20 diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 4277785e..cacd5e1c 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -860,17 +860,29 @@ export class DefaultPackageManager implements PackageManager { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); const identity = source ? this.getPackageIdentity(source) : undefined; + let matched = false; for (const pkg of globalSettings.packages ?? []) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue; + matched = true; await this.updateSourceForScope(sourceStr, "user"); } for (const pkg of projectSettings.packages ?? []) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue; + matched = true; await this.updateSourceForScope(sourceStr, "project"); } + + if (source && !matched) { + throw new Error( + this.buildNoMatchingPackageMessage(source, [ + ...(globalSettings.packages ?? []), + ...(projectSettings.packages ?? []), + ]), + ); + } } private async updateSourceForScope(source: string, scope: SourceScope): Promise { @@ -881,7 +893,14 @@ export class DefaultPackageManager implements PackageManager { if (parsed.type === "npm") { if (parsed.pinned) return; await this.withProgress("update", source, `Updating ${source}...`, async () => { - await this.installNpm(parsed, scope, false); + await this.installNpm( + { + ...parsed, + spec: `${parsed.name}@latest`, + }, + scope, + false, + ); }); return; } @@ -1088,6 +1107,39 @@ export class DefaultPackageManager implements PackageManager { return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; } + private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string { + const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages); + if (!suggestion) { + return `No matching package found for ${source}`; + } + return `No matching package found for ${source}. Did you mean ${suggestion}?`; + } + + private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined { + const trimmedSource = source.trim(); + const suggestions = new Set(); + + for (const pkg of configuredPackages) { + const sourceStr = this.getPackageSourceString(pkg); + const parsed = this.parseSource(sourceStr); + if (parsed.type === "npm") { + if (trimmedSource === parsed.name || trimmedSource === parsed.spec) { + suggestions.add(sourceStr); + } + continue; + } + if (parsed.type === "git") { + const shorthand = `${parsed.host}/${parsed.path}`; + const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined; + if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) { + suggestions.add(sourceStr); + } + } + } + + return suggestions.values().next().value; + } + private packageSourcesMatch(existing: PackageSource, inputSource: string, scope: SourceScope): boolean { const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope); const right = this.getSourceMatchKeyForInput(inputSource); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 95270396..cdf9d889 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -117,4 +117,28 @@ describe("package commands", () => { errorSpy.mockRestore(); } }); + + it("suggests the configured source when update input omits the npm prefix", async () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2)); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["update", "pi-formatter"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Did you mean npm:pi-formatter?"); + expect(stdout).not.toContain("Updated pi-formatter"); + expect(process.exitCode).toBe(1); + + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; + expect(settings.packages).toContain("npm:pi-formatter"); + } finally { + errorSpy.mockRestore(); + logSpy.mockRestore(); + } + }); }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 385342f0..77f91f2e 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1293,6 +1293,36 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); describe("offline mode and network timeouts", () => { + it("should update project npm packages using @latest", async () => { + settingsManager.setProjectPackages(["npm:example"]); + + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.update("npm:example"); + + expect(runCommandSpy).toHaveBeenCalledWith( + "npm", + ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm")], + undefined, + ); + }); + + it("should suggest npm source prefixes for update lookups", async () => { + settingsManager.setProjectPackages(["npm:example"]); + + await expect(packageManager.update("example")).rejects.toThrow( + "No matching package found for example. Did you mean npm:example?", + ); + }); + + it("should suggest git source prefixes for update lookups", async () => { + settingsManager.setProjectPackages(["git:github.com/example/repo"]); + + await expect(packageManager.update("github.com/example/repo")).rejects.toThrow( + "No matching package found for github.com/example/repo. Did you mean git:github.com/example/repo?", + ); + }); + it("should skip installing missing package sources when offline", async () => { process.env.PI_OFFLINE = "1"; settingsManager.setProjectPackages(["npm:missing-package", "git:github.com/example/missing-repo"]);