Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`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**
Expand Down
30 changes: 19 additions & 11 deletions src/packages/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -58,24 +58,32 @@ function normalizePackageRootCandidate(candidate: string): string {
return resolved;
}

async function getGlobalNpmRoot(): Promise<string | undefined> {
if (globalNpmRootCache !== undefined) {
return globalNpmRootCache ?? undefined;
async function getGlobalNpmRoot(cwd: string): Promise<string | undefined> {
let npmCommand: ReturnType<typeof resolveConfiguredNpmRootCommand>;
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(
Expand All @@ -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));
}
Expand Down
183 changes: 180 additions & 3 deletions src/utils/npm-exec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,172 @@
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 {
timeout: number;
signal?: AbortSignal;
}

const settingsManagersByPath = new Map<string, SettingsManager>();
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") {
Expand All @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions src/utils/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
Loading
Loading