diff --git a/package-lock.json b/package-lock.json index 11022066..4ee5233f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2337,6 +2337,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", @@ -4700,7 +4707,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6269,6 +6275,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -6283,6 +6290,7 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "3.2.4" diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 152ee9f0..71de722b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). +- Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). - Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). - Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index e660e9d7..74eff303 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -23,6 +23,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -1636,6 +1637,18 @@ } ] }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 851b80a3..ff4522ce 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -50,6 +50,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -70,6 +71,7 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "3.2.4" diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 5120c9ad..ebdf974b 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,6 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; +import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; @@ -44,6 +45,14 @@ function isOfflineModeEnabled(): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } +function isExactNpmVersion(version: string | undefined): boolean { + return valid(version ?? "") !== null; +} + +function getNpmVersionRange(version: string | undefined): string | undefined { + return version ? (validRange(version) ?? undefined) : undefined; +} + export interface PathMetadata { source: string; scope: SourceScope; @@ -119,6 +128,8 @@ type NpmSource = { type: "npm"; spec: string; name: string; + version?: string; + range?: string; pinned: boolean; }; @@ -1113,8 +1124,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1128,7 +1139,7 @@ export class DefaultPackageManager implements PackageManager { const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`; const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`; - const specs = sources.map((entry) => `${entry.parsed.name}@latest`); + const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`)); await this.withProgress("update", sourceLabel, message, async () => { await this.installNpmBatch(specs, scope); @@ -1241,8 +1252,7 @@ export class DefaultPackageManager implements PackageManager { if (parsed.type === "npm") { let installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = - !existsSync(installedPath) || - (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); + !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; @@ -1394,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager { type: "npm", spec, name, - pinned: Boolean(version), + version, + range: getNpmVersionRange(version), + pinned: isExactNpmVersion(version), }; } @@ -1411,18 +1423,12 @@ export class DefaultPackageManager implements PackageManager { return { type: "local", path: source }; } - private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise { + private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise { const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) { return false; } - - const { version: pinnedVersion } = this.parseNpmSpec(source.spec); - if (!pinnedVersion) { - return true; - } - - return installedVersion === pinnedVersion; + return source.range ? satisfies(installedVersion, source.range) : true; } private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise { @@ -1436,8 +1442,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { return false; } @@ -1455,16 +1461,25 @@ export class DefaultPackageManager implements PackageManager { } } - private async getLatestNpmVersion(packageName: string): Promise { + private async getLatestNpmVersion(packageSpec: string, range?: string): Promise { const npmCommand = this.getNpmCommand(); const stdout = await this.runCommandCapture( npmCommand.command, - [...npmCommand.args, "view", packageName, "version", "--json"], + [...npmCommand.args, "view", packageSpec, "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); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed === "string") { + return parsed; + } + if (Array.isArray(parsed)) { + const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0); + const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0]; + if (latest) return latest; + } + throw new Error("Unexpected response from npm view"); } private async gitHasAvailableUpdate(installedPath: string): Promise { diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index aec25bdb..a6a4f469 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -1,3 +1,4 @@ +import { compare, valid } from "semver"; import { getPiUserAgent } from "./pi-user-agent.ts"; const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; @@ -9,40 +10,13 @@ export interface LatestPiRelease { note?: string; } -interface ParsedVersion { - major: number; - minor: number; - patch: number; - prerelease?: string; -} - -function parsePackageVersion(version: string): ParsedVersion | undefined { - const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); - if (!match) { - return undefined; - } - return { - major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - patch: Number.parseInt(match[3], 10), - prerelease: match[4], - }; -} - export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined { - const left = parsePackageVersion(leftVersion); - const right = parsePackageVersion(rightVersion); + const left = valid(leftVersion.trim()); + const right = valid(rightVersion.trim()); if (!left || !right) { return undefined; } - - if (left.major !== right.major) return left.major - right.major; - if (left.minor !== right.minor) return left.minor - right.minor; - if (left.patch !== right.patch) return left.patch - right.patch; - if (left.prerelease === right.prerelease) return 0; - if (!left.prerelease) return 1; - if (!right.prerelease) return -1; - return left.prerelease.localeCompare(right.prerelease); + return compare(left, right); } export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index cd1f4532..9b1b0d7f 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1128,8 +1128,17 @@ Content`, }); it("should parse package source types from docs examples", () => { - expect((packageManager as any).parseSource("npm:@scope/pkg@1.2.3").type).toBe("npm"); - expect((packageManager as any).parseSource("npm:pkg").type).toBe("npm"); + const parseNpm = (source: string) => { + const parsed = (packageManager as any).parseSource(source); + if (parsed.type !== "npm") { + throw new Error(`Expected npm source: ${source}`); + } + return parsed; + }; + + expect(parseNpm("npm:@scope/pkg@1.2.3").pinned).toBe(true); + expect(parseNpm("npm:@scope/pkg@^1.2.3").pinned).toBe(false); + expect(parseNpm("npm:pkg").pinned).toBe(false); expect((packageManager as any).parseSource("git:github.com/user/repo@v1").type).toBe("git"); expect((packageManager as any).parseSource("https://github.com/user/repo@v1").type).toBe("git"); @@ -2052,25 +2061,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); describe("offline mode and network timeouts", () => { - it("should update project npm packages using @latest when newer version is available", async () => { + it("should update npm range packages using the configured spec", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); - settingsManager.setProjectPackages(["npm:example"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.2.0"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).toHaveBeenCalledWith( "npm", - ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], + ["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], undefined, ); }); @@ -2078,17 +2089,19 @@ export default function(api) { api.registerTool({ name: "test", description: "te it("should skip project npm update when installed version matches latest", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); - writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" })); - settingsManager.setProjectPackages(["npm:example"]); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.3.1" })); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.3.1","1.0.2"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).not.toHaveBeenCalled(); @@ -2298,11 +2311,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); 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"); 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"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index 70197fa5..b294c627 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -29,6 +29,7 @@ describe("version checks", () => { expect(comparePackageVersions("0.70.6", "0.70.5")).toBeGreaterThan(0); expect(comparePackageVersions("0.70.5", "0.70.5")).toBe(0); expect(comparePackageVersions("0.70.4", "0.70.5")).toBeLessThan(0); + expect(comparePackageVersions("5.0.0-beta.20", "5.0.0-beta.9")).toBeGreaterThan(0); expect(isNewerPackageVersion("0.70.5", "0.70.5")).toBe(false); expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true); });