diff --git a/README.md b/README.md index 5053726..16cf1c7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,28 @@ If Pi is already running, use `/reload`. Requires Node.js `>=22.20.0`. +### npm prefix permissions + +Pi global package installs use `npm install -g`. If `npm_config_prefix` points at an +unwritable prefix such as `/usr/local`, install or reload can fail with `EACCES`. +Use a writable npm prefix, configure Pi's `npmCommand` setting for your Node +version manager, or install project-local with `pi install npm:pi-extmgr -l`. + +`npmCommand` examples for `~/.pi/agent/settings.json`: + +```jsonc +{ + // mise: run npm through Node 22 + // "npmCommand": ["mise", "exec", "node@22", "--", "npm"] + + // bun: use Bun as Pi's package-manager command + // "npmCommand": ["bun"] + + // nvm: start Pi from an nvm-enabled shell and use npm on PATH + // "npmCommand": ["npm"] +} +``` + ## Features - **Unified manager UI** diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index e89ad87..8f5e11d 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -19,7 +19,7 @@ import { normalizeRelativePath, resolveRelativePathSelection, } from "../utils/relative-path-selection.js"; -import { resolveNpmCommand } from "../utils/npm-exec.js"; +import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; interface PackageSettingsObject { source: string; @@ -39,7 +39,7 @@ export interface PackageManifest { } const execFileAsync = promisify(execFile); -let globalNpmRootCache: string | null | undefined; +let globalNpmRootCache: { key: string; root: string | null } | undefined; function normalizeSource(source: string): string { return source @@ -58,24 +58,32 @@ function normalizePackageRootCandidate(candidate: string): string { return resolved; } -async function getGlobalNpmRoot(): Promise { - if (globalNpmRootCache !== undefined) { - return globalNpmRootCache ?? undefined; +async function getGlobalNpmRoot(cwd: string): Promise { + let npmCommand: ReturnType; + try { + npmCommand = resolveConfiguredNpmRootCommand(cwd); + } catch { + return undefined; + } + + const cacheKey = [npmCommand.command, ...npmCommand.args].join("\0"); + + if (globalNpmRootCache?.key === cacheKey) { + return globalNpmRootCache.root ?? undefined; } try { - const npmCommand = resolveNpmCommand(["root", "-g"]); const { stdout } = await execFileAsync(npmCommand.command, npmCommand.args, { timeout: 2_000, windowsHide: true, }); - const root = stdout.trim(); - globalNpmRootCache = root || null; + const root = npmCommand.getRoot(stdout); + globalNpmRootCache = { key: cacheKey, root: root || null }; } catch { - globalNpmRootCache = null; + globalNpmRootCache = { key: cacheKey, root: null }; } - return globalNpmRootCache ?? undefined; + return globalNpmRootCache.root ?? undefined; } async function resolveNpmPackageRoot( @@ -96,7 +104,7 @@ async function resolveNpmPackageRoot( const packageDir = process.env.PI_PACKAGE_DIR || join(homedir(), ".pi", "agent"); const globalCandidates = [join(packageDir, "npm", "node_modules", packageName)]; - const npmGlobalRoot = await getGlobalNpmRoot(); + const npmGlobalRoot = await getGlobalNpmRoot(cwd); if (npmGlobalRoot) { globalCandidates.unshift(join(npmGlobalRoot, packageName)); } diff --git a/src/utils/npm-exec.ts b/src/utils/npm-exec.ts index e7dff6d..d6b4f6a 100644 --- a/src/utils/npm-exec.ts +++ b/src/utils/npm-exec.ts @@ -1,10 +1,23 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; import { execPath, platform } from "node:process"; -import { type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { type ExtensionAPI, getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent"; interface NpmCommandResolutionOptions { platform?: NodeJS.Platform; nodeExecPath?: string; + npmCommand?: readonly string[] | undefined; + cwd?: string; +} + +interface ResolvedNpmCommand { + command: string; + args: string[]; +} + +interface ResolvedNpmRootCommand extends ResolvedNpmCommand { + getRoot(stdout: string): string; } interface NpmExecOptions { @@ -12,15 +25,148 @@ interface NpmExecOptions { signal?: AbortSignal; } +const settingsManagersByPath = new Map(); +let warnedAboutBunGlobalDirHeuristic = false; + function getNpmCliPath(nodeExecPath: string, runtimePlatform: NodeJS.Platform): string { const pathImpl = runtimePlatform === "win32" ? path.win32 : path; return pathImpl.join(pathImpl.dirname(nodeExecPath), "node_modules", "npm", "bin", "npm-cli.js"); } +function getConfiguredNpmBase( + npmCommand?: readonly string[] | undefined +): ResolvedNpmCommand | undefined { + if (!npmCommand || npmCommand.length === 0) { + return undefined; + } + + const [command, ...args] = npmCommand; + if (!command?.trim()) { + throw new Error("Invalid npmCommand: first array entry must be a non-empty command"); + } + + return { command, args: [...args] }; +} + +function getSettingsNpmCommand(cwd: string): string[] | undefined { + const agentDir = getAgentDir(); + const cacheKey = `${agentDir}\0${cwd}`; + const cached = settingsManagersByPath.get(cacheKey); + if (cached) { + return cached.getNpmCommand(); + } + + const settingsManager = SettingsManager.create(cwd, agentDir); + settingsManagersByPath.set(cacheKey, settingsManager); + return settingsManager.getNpmCommand(); +} + +function getCommandName(command: string): string { + return (command.split(/[\\/]/).pop() ?? "").replace(/\.(cmd|exe)$/i, ""); +} + +function expandHome(input: string): string { + if (input === "~") return homedir(); + if (input.startsWith("~/")) return path.join(homedir(), input.slice(2)); + return input; +} + +function resolveBunConfigPath(value: string, baseDir: string): string { + const expanded = expandHome(value.trim()); + return path.isAbsolute(expanded) ? expanded : path.resolve(baseDir, expanded); +} + +function stripTomlComment(line: string): string { + return line.replace(/\s+#.*$/, "").trim(); +} + +function readBunGlobalDirFromBunfig(configPath: string): string | undefined { + if (!existsSync(configPath)) return undefined; + + let content: string; + try { + content = readFileSync(configPath, "utf8"); + } catch { + return undefined; + } + + let inInstallSection = false; + for (const rawLine of content.split(/\r?\n/)) { + const line = stripTomlComment(rawLine); + if (!line) continue; + + const sectionMatch = line.match(/^\[([^\]]+)]$/); + if (sectionMatch) { + inInstallSection = sectionMatch[1]?.trim() === "install"; + continue; + } + + const keyMatch = line.match(/^(install\.)?globalDir\s*=\s*(["'])(.*?)\2/); + if (!keyMatch) continue; + if (!inInstallSection && !keyMatch[1]) continue; + + const value = keyMatch[3]?.trim(); + return value ? resolveBunConfigPath(value, path.dirname(configPath)) : undefined; + } + + return undefined; +} + +function getBunGlobalDir(cwd?: string): string | undefined { + const envGlobalDir = process.env.BUN_INSTALL_GLOBAL_DIR?.trim(); + if (envGlobalDir) { + return resolveBunConfigPath(envGlobalDir, process.cwd()); + } + + const candidates = [ + path.join(homedir(), ".bunfig.toml"), + ...(process.env.XDG_CONFIG_HOME + ? [path.join(process.env.XDG_CONFIG_HOME, ".bunfig.toml")] + : []), + ...(cwd ? [path.join(cwd, "bunfig.toml")] : []), + ]; + + let globalDir: string | undefined; + for (const configPath of candidates) { + globalDir = readBunGlobalDirFromBunfig(configPath) ?? globalDir; + } + return globalDir; +} + +function warnAboutBunGlobalDirHeuristic(): void { + if (warnedAboutBunGlobalDirHeuristic) return; + warnedAboutBunGlobalDirHeuristic = true; + console.warn( + "[extmgr] Could not read Bun globalDir from BUN_INSTALL_GLOBAL_DIR or bunfig.toml; " + + "guessing from `bun pm bin -g`. If Bun's globalDir is customized, set BUN_INSTALL_GLOBAL_DIR." + ); +} + +function getBunNodeModulesRoot(globalBinDir: string, cwd?: string): string { + const globalDir = getBunGlobalDir(cwd); + if (globalDir) { + return path.join(globalDir, "node_modules"); + } + + // Best-effort fallback for Bun's default layout: globalBinDir is usually + // ~/.bun/bin and globalDir is usually ~/.bun/install/global. This may be + // wrong when [install].globalDir is customized in bunfig.toml. + warnAboutBunGlobalDirHeuristic(); + return path.join(path.dirname(globalBinDir), "install", "global", "node_modules"); +} + export function resolveNpmCommand( npmArgs: string[], options?: NpmCommandResolutionOptions -): { command: string; args: string[] } { +): ResolvedNpmCommand { + const configured = getConfiguredNpmBase(options?.npmCommand); + if (configured) { + return { + command: configured.command, + args: [...configured.args, ...npmArgs], + }; + } + const runtimePlatform = options?.platform ?? platform; if (runtimePlatform === "win32") { @@ -34,13 +180,44 @@ export function resolveNpmCommand( return { command: "npm", args: npmArgs }; } +export function resolveConfiguredNpmCommand(npmArgs: string[], cwd: string): ResolvedNpmCommand { + return resolveNpmCommand(npmArgs, { npmCommand: getSettingsNpmCommand(cwd) }); +} + +export function resolveNpmRootCommand( + options?: NpmCommandResolutionOptions +): ResolvedNpmRootCommand { + const configured = getConfiguredNpmBase(options?.npmCommand); + + if (configured && getCommandName(configured.command) === "bun") { + return { + command: configured.command, + args: [...configured.args, "pm", "bin", "-g"], + getRoot: (stdout) => { + const binDir = stdout.trim(); + return binDir ? getBunNodeModulesRoot(binDir, options?.cwd) : ""; + }, + }; + } + + const resolved = resolveNpmCommand(["root", "-g"], options); + return { + ...resolved, + getRoot: (stdout) => stdout.trim(), + }; +} + +export function resolveConfiguredNpmRootCommand(cwd: string): ResolvedNpmRootCommand { + return resolveNpmRootCommand({ npmCommand: getSettingsNpmCommand(cwd), cwd }); +} + export async function execNpm( pi: ExtensionAPI, npmArgs: string[], ctx: { cwd: string }, options: NpmExecOptions ): Promise<{ code: number; stdout: string; stderr: string; killed: boolean }> { - const resolved = resolveNpmCommand(npmArgs); + const resolved = resolveConfiguredNpmCommand(npmArgs, ctx.cwd); return pi.exec(resolved.command, resolved.args, { timeout: options.timeout, cwd: ctx.cwd, diff --git a/src/utils/ui-helpers.ts b/src/utils/ui-helpers.ts index 7e6c18a..c678620 100644 --- a/src/utils/ui-helpers.ts +++ b/src/utils/ui-helpers.ts @@ -3,7 +3,7 @@ */ import { type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { UI } from "../constants.js"; -import { notify } from "./notify.js"; +import { error as notifyError, notify } from "./notify.js"; /** * Confirm and trigger reload @@ -20,12 +20,18 @@ export async function confirmReload( const confirmed = await ctx.ui.confirm("Reload Required", `${reason}\nReload pi now?`); - if (confirmed) { + if (!confirmed) { + return false; + } + + try { await ctx.reload(); return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + notifyError(ctx, `Reload failed: ${message}`); + return false; } - - return false; } /** diff --git a/test/npm-exec.test.ts b/test/npm-exec.test.ts index 8340423..a29d45e 100644 --- a/test/npm-exec.test.ts +++ b/test/npm-exec.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveNpmCommand } from "../src/utils/npm-exec.js"; +import { resolveNpmCommand, resolveNpmRootCommand } from "../src/utils/npm-exec.js"; void test("resolveNpmCommand uses npm directly on non-windows", () => { const resolved = resolveNpmCommand(["view", "pi-extmgr", "version", "--json"], { @@ -25,3 +25,59 @@ void test("resolveNpmCommand uses node + npm-cli.js on windows", () => { "pi-extmgr", ]); }); + +void test("resolveNpmCommand honors Pi npmCommand settings", () => { + const resolved = resolveNpmCommand(["view", "pi-extmgr", "version", "--json"], { + npmCommand: ["mise", "exec", "node@22", "--", "npm"], + }); + + assert.equal(resolved.command, "mise"); + assert.deepEqual(resolved.args, [ + "exec", + "node@22", + "--", + "npm", + "view", + "pi-extmgr", + "version", + "--json", + ]); +}); + +void test("resolveNpmRootCommand detects path-qualified bun commands", () => { + const oldGlobalDir = process.env.BUN_INSTALL_GLOBAL_DIR; + process.env.BUN_INSTALL_GLOBAL_DIR = "/opt/bun/global"; + + try { + const resolved = resolveNpmRootCommand({ npmCommand: ["/usr/local/bin/bun"] }); + + assert.equal(resolved.command, "/usr/local/bin/bun"); + assert.deepEqual(resolved.args, ["pm", "bin", "-g"]); + assert.equal(resolved.getRoot("/home/alice/.bun/bin\n"), "/opt/bun/global/node_modules"); + } finally { + if (oldGlobalDir === undefined) { + delete process.env.BUN_INSTALL_GLOBAL_DIR; + } else { + process.env.BUN_INSTALL_GLOBAL_DIR = oldGlobalDir; + } + } +}); + +void test("resolveNpmRootCommand detects bun.cmd commands", () => { + const oldGlobalDir = process.env.BUN_INSTALL_GLOBAL_DIR; + process.env.BUN_INSTALL_GLOBAL_DIR = "/opt/bun/global"; + + try { + const resolved = resolveNpmRootCommand({ npmCommand: ["bun.cmd"] }); + + assert.equal(resolved.command, "bun.cmd"); + assert.deepEqual(resolved.args, ["pm", "bin", "-g"]); + assert.equal(resolved.getRoot("/home/alice/.bun/bin\n"), "/opt/bun/global/node_modules"); + } finally { + if (oldGlobalDir === undefined) { + delete process.env.BUN_INSTALL_GLOBAL_DIR; + } else { + process.env.BUN_INSTALL_GLOBAL_DIR = oldGlobalDir; + } + } +}); diff --git a/test/ui-helpers.test.ts b/test/ui-helpers.test.ts new file mode 100644 index 0000000..2db285f --- /dev/null +++ b/test/ui-helpers.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { confirmReload } from "../src/utils/ui-helpers.js"; + +void test("confirmReload returns false without reloading when user cancels", async () => { + const notifications: { message: string; level: string | undefined }[] = []; + let reloadCalls = 0; + const ctx = { + hasUI: true, + ui: { + confirm: () => Promise.resolve(false), + notify: (message: string, level?: string) => { + notifications.push({ message, level }); + }, + }, + reload: () => { + reloadCalls += 1; + return Promise.resolve(); + }, + } as unknown as ExtensionCommandContext; + + const reloaded = await confirmReload(ctx, "Package updated."); + + assert.equal(reloaded, false); + assert.equal(reloadCalls, 0); + assert.deepEqual(notifications, []); +}); + +void test("confirmReload returns true after a successful reload", async () => { + const notifications: { message: string; level: string | undefined }[] = []; + let reloadCalls = 0; + const ctx = { + hasUI: true, + ui: { + confirm: () => Promise.resolve(true), + notify: (message: string, level?: string) => { + notifications.push({ message, level }); + }, + }, + reload: () => { + reloadCalls += 1; + return Promise.resolve(); + }, + } as unknown as ExtensionCommandContext; + + const reloaded = await confirmReload(ctx, "Package updated."); + + assert.equal(reloaded, true); + assert.equal(reloadCalls, 1); + assert.deepEqual(notifications, []); +}); + +void test("confirmReload reports reload failures without throwing", async () => { + const notifications: { message: string; level: string | undefined }[] = []; + const ctx = { + hasUI: true, + ui: { + confirm: () => Promise.resolve(true), + notify: (message: string, level?: string) => { + notifications.push({ message, level }); + }, + }, + reload: () => Promise.reject(new Error("npm install -g pi-extmgr failed with code 243")), + } as unknown as ExtensionCommandContext; + + const reloaded = await confirmReload(ctx, "Package updated."); + + assert.equal(reloaded, false); + assert.deepEqual(notifications, [ + { + message: "Reload failed: npm install -g pi-extmgr failed with code 243", + level: "error", + }, + ]); +});