diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f63f83c0..efaee914 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed npm package installs and lookups being tied to the active repository Node version by adding `npmCommand` as an argv-style settings override for package manager operations ([#2072](https://github.com/badlogic/pi-mono/issues/2072)) - Fixed `ctx.ui.getEditorText()` in the extension API returning paste markers (e.g., `[paste #1 +24 lines]`) instead of the actual pasted content ([#2084](https://github.com/badlogic/pi-mono/issues/2084)) - Fixed startup crash when downloading `fd`/`ripgrep` on first run by using `pipeline()` instead of `finished(readable.pipe(writable))` so stream errors from timeouts are caught properly, and increased the download timeout from 10s to 120s ([#2066](https://github.com/badlogic/pi-mono/issues/2066)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 56961e85..6593bf4e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -348,7 +348,7 @@ pi update # skips pinned packages pi config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.pi/agent/git/` (git) or global npm. Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). +Packages install to `~/.pi/agent/git/` (git) or global npm. Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding a `pi` key to `package.json`: diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index bf801fdd..7dee6389 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -54,6 +54,15 @@ npm:pkg - Versioned specs are pinned and skipped by `pi update`. - Global installs use `npm install -g`. - Project installs go under `.pi/npm/`. +- Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. + +Example: + +```json +{ + "npmCommand": ["mise", "exec", "node@20", "--", "npm"] +} +``` ### git diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3c65dacf..bcbf05f6 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -117,6 +117,15 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's |---------|------|---------|-------------| | `shellPath` | string | - | Custom shell path (e.g., for Cygwin on Windows) | | `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) | +| `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `["mise", "exec", "node@20", "--", "npm"]`) | + +```json +{ + "npmCommand": ["mise", "exec", "node@20", "--", "npm"] +} +``` + +`npmCommand` is used for all npm package-manager operations, including `npm root -g`, installs, uninstalls, and `npm install` inside git packages. Use argv-style entries exactly as the process should be launched. ### Model Cycling diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 48375820..76286f5e 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -637,6 +637,7 @@ export class DefaultPackageManager implements PackageManager { private agentDir: string; private settingsManager: SettingsManager; private globalNpmRoot: string | undefined; + private globalNpmRootCommandKey: string | undefined; private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { @@ -1156,26 +1157,48 @@ export class DefaultPackageManager implements PackageManager { return { name, version }; } + private getNpmCommand(): { command: string; args: string[] } { + const configuredCommand = this.settingsManager.getNpmCommand(); + if (!configuredCommand || configuredCommand.length === 0) { + return { command: "npm", args: [] }; + } + const [command, ...args] = configuredCommand; + if (!command) { + throw new Error("Invalid npmCommand: first array entry must be a non-empty command"); + } + return { command, args }; + } + + private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise { + const npmCommand = this.getNpmCommand(); + await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options); + } + + private runNpmCommandSync(args: string[]): string { + const npmCommand = this.getNpmCommand(); + return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]); + } + private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { if (scope === "user" && !temporary) { - await this.runCommand("npm", ["install", "-g", source.spec]); + await this.runNpmCommand(["install", "-g", source.spec]); return; } const installRoot = this.getNpmInstallRoot(scope, temporary); this.ensureNpmProject(installRoot); - await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]); + await this.runNpmCommand(["install", source.spec, "--prefix", installRoot]); } private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise { if (scope === "user") { - await this.runCommand("npm", ["uninstall", "-g", source.name]); + await this.runNpmCommand(["uninstall", "-g", source.name]); return; } const installRoot = this.getNpmInstallRoot(scope, false); if (!existsSync(installRoot)) { return; } - await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]); + await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]); } private async installGit(source: GitSource, scope: SourceScope): Promise { @@ -1195,7 +1218,7 @@ export class DefaultPackageManager implements PackageManager { } const packageJsonPath = join(targetDir, "package.json"); if (existsSync(packageJsonPath)) { - await this.runCommand("npm", ["install"], { cwd: targetDir }); + await this.runNpmCommand(["install"], { cwd: targetDir }); } } @@ -1222,7 +1245,7 @@ export class DefaultPackageManager implements PackageManager { const packageJsonPath = join(targetDir, "package.json"); if (existsSync(packageJsonPath)) { - await this.runCommand("npm", ["install"], { cwd: targetDir }); + await this.runNpmCommand(["install"], { cwd: targetDir }); } } @@ -1301,11 +1324,14 @@ export class DefaultPackageManager implements PackageManager { } private getGlobalNpmRoot(): string { - if (this.globalNpmRoot) { + const npmCommand = this.getNpmCommand(); + const commandKey = [npmCommand.command, ...npmCommand.args].join("\0"); + if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) { return this.globalNpmRoot; } - const result = this.runCommandSync("npm", ["root", "-g"]); + const result = this.runNpmCommandSync(["root", "-g"]); this.globalNpmRoot = result.trim(); + this.globalNpmRootCommandKey = commandKey; return this.globalNpmRoot; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 97b3d2fc..78dce147 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -76,6 +76,7 @@ export interface Settings { shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) + npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) extensions?: string[]; // Array of local extension file paths or directories @@ -709,6 +710,16 @@ export class SettingsManager { this.save(); } + getNpmCommand(): string[] | undefined { + return this.settings.npmCommand ? [...this.settings.npmCommand] : undefined; + } + + setNpmCommand(command: string[] | undefined): void { + this.globalSettings.npmCommand = command ? [...command] : undefined; + this.markModified("npmCommand"); + this.save(); + } + getCollapseChangelog(): boolean { return this.settings.collapseChangelog ?? false; } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index a15e6c8d..97ac0e5b 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -352,6 +352,68 @@ Content`, }); }); + describe("npmCommand", () => { + it("should use npmCommand argv for npm installs", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "node@20", "--", "npm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.install("npm:@scope/pkg"); + + expect(runCommandSpy).toHaveBeenCalledWith( + "mise", + ["exec", "node@20", "--", "npm", "install", "-g", "@scope/pkg"], + undefined, + ); + }); + + it("should use npmCommand argv for npm root lookup and invalidate cached root when npmCommand changes", () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "node@20", "--", "npm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const root20 = join(tempDir, "node20", "lib", "node_modules"); + const root22 = join(tempDir, "node22", "lib", "node_modules"); + mkdirSync(join(root20, "@scope", "pkg"), { recursive: true }); + + const runCommandSyncSpy = vi + .spyOn(packageManager as any, "runCommandSync") + .mockImplementation((...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + if (command !== "mise") { + throw new Error(`unexpected command ${command}`); + } + if (args[1] === "node@20") { + return root20; + } + if (args[1] === "node@22") { + return root22; + } + throw new Error(`unexpected args ${args.join(" ")}`); + }); + + expect(packageManager.getInstalledPath("npm:@scope/pkg", "user")).toBe(join(root20, "@scope", "pkg")); + expect(runCommandSyncSpy).toHaveBeenNthCalledWith(1, "mise", ["exec", "node@20", "--", "npm", "root", "-g"]); + + settingsManager.setNpmCommand(["mise", "exec", "node@22", "--", "npm"]); + + expect(packageManager.getInstalledPath("npm:@scope/pkg", "user")).toBeUndefined(); + expect(runCommandSyncSpy).toHaveBeenNthCalledWith(2, "mise", ["exec", "node@22", "--", "npm", "root", "-g"]); + }); + }); + describe("source parsing", () => { it("should emit progress events on install attempt", async () => { const events: ProgressEvent[] = [];