fix(coding-agent): support configurable npm wrapper command closes #2072

This commit is contained in:
Mario Zechner
2026-03-14 04:38:01 +01:00
parent ad32089f0c
commit 7ddaf150d6
7 changed files with 127 additions and 9 deletions

View File

@@ -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))

View File

@@ -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`:

View File

@@ -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

View File

@@ -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

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[] = [];