fix(coding-agent): support configurable npm wrapper command closes #2072
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user