fix(coding-agent): support configurable npm wrapper command closes #2072
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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 `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))
|
- 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
|
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`:
|
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`.
|
- Versioned specs are pinned and skipped by `pi update`.
|
||||||
- Global installs use `npm install -g`.
|
- Global installs use `npm install -g`.
|
||||||
- Project installs go under `.pi/npm/`.
|
- 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
|
### 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) |
|
| `shellPath` | string | - | Custom shell path (e.g., for Cygwin on Windows) |
|
||||||
| `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) |
|
| `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
|
### Model Cycling
|
||||||
|
|
||||||
|
|||||||
@@ -637,6 +637,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
private agentDir: string;
|
private agentDir: string;
|
||||||
private settingsManager: SettingsManager;
|
private settingsManager: SettingsManager;
|
||||||
private globalNpmRoot: string | undefined;
|
private globalNpmRoot: string | undefined;
|
||||||
|
private globalNpmRootCommandKey: string | undefined;
|
||||||
private progressCallback: ProgressCallback | undefined;
|
private progressCallback: ProgressCallback | undefined;
|
||||||
|
|
||||||
constructor(options: PackageManagerOptions) {
|
constructor(options: PackageManagerOptions) {
|
||||||
@@ -1156,26 +1157,48 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
return { name, version };
|
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> {
|
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||||
if (scope === "user" && !temporary) {
|
if (scope === "user" && !temporary) {
|
||||||
await this.runCommand("npm", ["install", "-g", source.spec]);
|
await this.runNpmCommand(["install", "-g", source.spec]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const installRoot = this.getNpmInstallRoot(scope, temporary);
|
const installRoot = this.getNpmInstallRoot(scope, temporary);
|
||||||
this.ensureNpmProject(installRoot);
|
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> {
|
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
|
||||||
if (scope === "user") {
|
if (scope === "user") {
|
||||||
await this.runCommand("npm", ["uninstall", "-g", source.name]);
|
await this.runNpmCommand(["uninstall", "-g", source.name]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const installRoot = this.getNpmInstallRoot(scope, false);
|
const installRoot = this.getNpmInstallRoot(scope, false);
|
||||||
if (!existsSync(installRoot)) {
|
if (!existsSync(installRoot)) {
|
||||||
return;
|
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> {
|
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
|
||||||
@@ -1195,7 +1218,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
}
|
}
|
||||||
const packageJsonPath = join(targetDir, "package.json");
|
const packageJsonPath = join(targetDir, "package.json");
|
||||||
if (existsSync(packageJsonPath)) {
|
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");
|
const packageJsonPath = join(targetDir, "package.json");
|
||||||
if (existsSync(packageJsonPath)) {
|
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 {
|
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;
|
return this.globalNpmRoot;
|
||||||
}
|
}
|
||||||
const result = this.runCommandSync("npm", ["root", "-g"]);
|
const result = this.runNpmCommandSync(["root", "-g"]);
|
||||||
this.globalNpmRoot = result.trim();
|
this.globalNpmRoot = result.trim();
|
||||||
|
this.globalNpmRootCommandKey = commandKey;
|
||||||
return this.globalNpmRoot;
|
return this.globalNpmRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface Settings {
|
|||||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||||
quietStartup?: boolean;
|
quietStartup?: boolean;
|
||||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
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)
|
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||||
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
||||||
extensions?: string[]; // Array of local extension file paths or directories
|
extensions?: string[]; // Array of local extension file paths or directories
|
||||||
@@ -709,6 +710,16 @@ export class SettingsManager {
|
|||||||
this.save();
|
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 {
|
getCollapseChangelog(): boolean {
|
||||||
return this.settings.collapseChangelog ?? false;
|
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", () => {
|
describe("source parsing", () => {
|
||||||
it("should emit progress events on install attempt", async () => {
|
it("should emit progress events on install attempt", async () => {
|
||||||
const events: ProgressEvent[] = [];
|
const events: ProgressEvent[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user