diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 20876fa1..4c9eb97c 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,8 +1,9 @@ import { spawnSync } from "child_process"; -import { existsSync, readFileSync, realpathSync } from "fs"; +import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs"; import { homedir } from "os"; -import { dirname, join, resolve, sep } from "path"; +import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; +import { shouldUseWindowsShell } from "./utils/child-process.js"; // ============================================================================= // Package Detection @@ -46,7 +47,7 @@ export function detectInstallMethod(): InstallMethod { if (resolvedPath.includes("/yarn/") || resolvedPath.includes("/.yarn/")) { return "yarn"; } - if (isBunRuntime) { + if (isBunRuntime || resolvedPath.includes("/install/global/node_modules/")) { return "bun"; } if (resolvedPath.includes("/npm/") || resolvedPath.includes("/node_modules/")) { @@ -56,58 +57,99 @@ export function detectInstallMethod(): InstallMethod { return "unknown"; } -function getSelfUpdateCommandForMethod(method: InstallMethod, packageName: string): SelfUpdateCommand | undefined { +function getNpmCommand(npmCommand?: string[]): { command: string; args: string[] } { + const [command = "npm", ...args] = npmCommand ?? []; + return { command, args }; +} + +function getInferredNpmInstall(packageName: string): { root: string; prefix: string } | undefined { + const packageDir = getPackageDir(); + const path = process.platform === "win32" || packageDir.includes("\\") ? win32 : { basename, dirname }; + const [scope, name] = packageName.split("/"); + let root: string | undefined; + if ( + name && + scope?.startsWith("@") && + path.basename(path.dirname(packageDir)) === scope && + path.basename(packageDir) === name + ) { + root = path.dirname(path.dirname(packageDir)); + } else if (!name && path.basename(packageDir) === packageName) { + root = path.dirname(packageDir); + } + if (!root || path.basename(root) !== "node_modules") return undefined; + const parent = path.dirname(root); + if (path.basename(parent) === "lib") return { root, prefix: path.dirname(parent) }; + // Windows global npm prefixes use `\\node_modules`, which is + // indistinguishable from local project installs by path shape alone. Do not + // infer unsupported Windows custom prefixes without `npm root -g` evidence. + return undefined; +} + +function getSelfUpdateCommandForMethod( + method: InstallMethod, + packageName: string, + npmCommand?: string[], +): SelfUpdateCommand | undefined { switch (method) { case "bun-binary": return undefined; case "pnpm": - return { - command: "pnpm", - args: ["install", "-g", packageName], - display: `pnpm install -g ${packageName}`, - }; + return { command: "pnpm", args: ["install", "-g", packageName], display: `pnpm install -g ${packageName}` }; case "yarn": - return { - command: "yarn", - args: ["global", "add", packageName], - display: `yarn global add ${packageName}`, - }; + return { command: "yarn", args: ["global", "add", packageName], display: `yarn global add ${packageName}` }; case "bun": - return { - command: "bun", - args: ["install", "-g", packageName], - display: `bun install -g ${packageName}`, - }; - case "npm": - return { - command: "npm", - args: ["install", "-g", packageName], - display: `npm install -g ${packageName}`, - }; + return { command: "bun", args: ["install", "-g", packageName], display: `bun install -g ${packageName}` }; + case "npm": { + const npm = getNpmCommand(npmCommand); + const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(packageName); + const args = [...npm.args, ...(inferred ? ["--prefix", inferred.prefix] : []), "install", "-g", packageName]; + const display = [npm.command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); + return { command: npm.command, args, display }; + } case "unknown": return undefined; } } -function readCommandOutput(command: string, args: string[]): string | undefined { +function readCommandOutput( + command: string, + args: string[], + options: { requireSuccess?: boolean } = {}, +): string | undefined { const result = spawnSync(command, args, { encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 2000, - // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them; - // command and args are fixed literals from getGlobalPackageRoots(), not user input. - shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + shell: shouldUseWindowsShell(command), }); - if (result.status !== 0) return undefined; - const stdout = result.stdout.trim(); - return stdout || undefined; + if (result.status === 0) return result.stdout.trim() || undefined; + if (options.requireSuccess) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Failed to run ${[command, ...args].join(" ")}: ${reason}`); + } + return undefined; } -function getGlobalPackageRoots(method: InstallMethod): string[] { +function getGlobalPackageRoots(method: InstallMethod, packageName: string, npmCommand?: string[]): string[] { switch (method) { case "npm": { - const root = readCommandOutput("npm", ["root", "-g"]); - return root ? [root] : []; + const npm = getNpmCommand(npmCommand); + const configured = !!npmCommand?.length; + if (configured && npm.command === "bun") { + const bunBin = readCommandOutput(npm.command, [...npm.args, "pm", "bin", "-g"], { + requireSuccess: true, + }); + const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; + if (bunBin) { + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); + } + return roots; + } + const root = readCommandOutput(npm.command, [...npm.args, "root", "-g"], { + requireSuccess: configured, + }); + const inferred = configured ? undefined : getInferredNpmInstall(packageName); + return [root, inferred?.root].filter((x): x is string => !!x); } case "pnpm": { const root = readCommandOutput("pnpm", ["root", "-g"]); @@ -121,7 +163,7 @@ function getGlobalPackageRoots(method: InstallMethod): string[] { const bunBin = readCommandOutput("bun", ["pm", "bin", "-g"]); const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; if (bunBin) { - roots.push(join(dirname(dirname(bunBin)), "install", "global", "node_modules")); + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); } return roots; } @@ -148,36 +190,50 @@ function normalizeExistingPathForComparison(path: string): string | undefined { return normalizedPath; } -function isManagedByGlobalPackageManager(method: InstallMethod): boolean { - const packageDir = normalizeExistingPathForComparison(getPackageDir()); - if (!packageDir) { +function isSelfUpdatePathWritable(): boolean { + const packageDir = getPackageDir(); + try { + accessSync(packageDir, constants.W_OK); + accessSync(dirname(packageDir), constants.W_OK); + return true; + } catch { return false; } - return getGlobalPackageRoots(method).some((root) => { - const normalizedRoot = normalizeExistingPathForComparison(root); - return ( - normalizedRoot !== undefined && - (packageDir === normalizedRoot || - packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`)) - ); - }); } -export function getSelfUpdateCommand(packageName: string): SelfUpdateCommand | undefined { +function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean { + const packageDir = normalizeExistingPathForComparison(getPackageDir()); + return ( + !!packageDir && + getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { + const normalizedRoot = normalizeExistingPathForComparison(root); + return ( + !!normalizedRoot && + packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`) + ); + }) + ); +} + +export function getSelfUpdateCommand(packageName: string, npmCommand?: string[]): SelfUpdateCommand | undefined { const method = detectInstallMethod(); - const command = getSelfUpdateCommandForMethod(method, packageName); - if (!command || !isManagedByGlobalPackageManager(method)) { + const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand); + if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) { return undefined; } return command; } -export function getSelfUpdateUnavailableInstruction(packageName: string): string { +export function getSelfUpdateUnavailableInstruction(packageName: string, npmCommand?: string[]): string { const method = detectInstallMethod(); if (method === "bun-binary") { return `Download from: https://github.com/badlogic/pi-mono/releases/latest`; } - if (getSelfUpdateCommandForMethod(method, packageName)) { + const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand); + if (command) { + if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) { + return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`; + } return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`; } return `Update ${packageName} using the package manager, wrapper, or source checkout that provides this installation.`; diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index a8cbd5d2..981f2d92 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -28,6 +28,7 @@ import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.js"; +import { shouldUseWindowsShell } from "../utils/child-process.js"; import { type GitSource, parseGitUrl } from "../utils/git.js"; import { canonicalizePath, isLocalPath } from "../utils/paths.js"; import { isStdoutTakenOver } from "./output-guard.js"; @@ -2329,28 +2330,11 @@ export class DefaultPackageManager implements PackageManager { }; } - private shouldUseWindowsShell(command: string): boolean { - if (process.platform !== "win32") { - return false; - } - const commandName = basename(command).toLowerCase(); - return ( - commandName === "npm" || - commandName === "npx" || - commandName === "pnpm" || - commandName === "yarn" || - commandName === "yarnpkg" || - commandName === "corepack" || - commandName.endsWith(".cmd") || - commandName.endsWith(".bat") - ); - } - private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { return spawn(command, args, { cwd: options?.cwd, stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", - shell: this.shouldUseWindowsShell(command), + shell: shouldUseWindowsShell(command), env: getEnv(), }); } @@ -2364,7 +2348,7 @@ export class DefaultPackageManager implements PackageManager { return spawn(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], - shell: this.shouldUseWindowsShell(command), + shell: shouldUseWindowsShell(command), env: options?.env ? { ...baseEnv, ...options.env } : baseEnv, }); } @@ -2431,7 +2415,7 @@ export class DefaultPackageManager implements PackageManager { const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", - shell: this.shouldUseWindowsShell(command), + shell: shouldUseWindowsShell(command), env: getEnv(), }); if (result.error || result.status !== 0) { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 3473d6cc..d9153ac7 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -7,10 +7,12 @@ import { getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, + type SelfUpdateCommand, VERSION, } from "./config.js"; import { DefaultPackageManager } from "./core/package-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; +import { shouldUseWindowsShell } from "./utils/child-process.js"; import { getLatestPiVersion, isNewerPackageVersion } from "./utils/version-check.js"; export type PackageCommand = "install" | "remove" | "update" | "list"; @@ -271,13 +273,9 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean { return target.type === "all" || target.type === "extensions"; } -function canSelfUpdate(): boolean { - return getSelfUpdateCommand(PACKAGE_NAME) !== undefined; -} - -function printSelfUpdateUnavailable(): void { +function printSelfUpdateUnavailable(npmCommand?: string[]): void { console.error(`error: ${APP_NAME} cannot self-update this installation.`); - console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME)); + console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand)); const entrypoint = process.argv[1]; if (entrypoint) { @@ -286,9 +284,7 @@ function printSelfUpdateUnavailable(): void { } } -function printSelfUpdateFallback(): void { - const command = getSelfUpdateCommand(PACKAGE_NAME); - if (!command) return; +function printSelfUpdateFallback(command: SelfUpdateCommand): void { console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); } @@ -312,19 +308,14 @@ async function shouldRunSelfUpdate(force: boolean): Promise { return false; } -async function runSelfUpdate(): Promise { - const command = getSelfUpdateCommand(PACKAGE_NAME); - if (!command) { - throw new Error( - `${APP_NAME} cannot self-update this installation. ${getSelfUpdateUnavailableInstruction(PACKAGE_NAME)}`, - ); - } - +async function runSelfUpdate(command: SelfUpdateCommand): Promise { console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); await new Promise((resolve, reject) => { - // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them; - // command and args come from getSelfUpdateCommandForMethod(), not user input. - const child = spawn(command.command, command.args, { stdio: "inherit", shell: process.platform === "win32" }); + // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them. + const child = spawn(command.command, command.args, { + stdio: "inherit", + shell: shouldUseWindowsShell(command.command), + }); child.on("error", (error) => { reject(error); }); @@ -409,16 +400,12 @@ export async function handlePackageCommand(args: string[]): Promise { return true; } - if (options.command === "update" && options.updateTarget?.type === "self" && !canSelfUpdate()) { - printSelfUpdateUnavailable(); - process.exitCode = 1; - return true; - } - const cwd = process.cwd(); const agentDir = getAgentDir(); const settingsManager = SettingsManager.create(cwd, agentDir); reportSettingsErrors(settingsManager, "package command"); + const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); packageManager.setProgressCallback((event) => { @@ -493,24 +480,25 @@ export async function handlePackageCommand(args: string[]): Promise { } } if (updateTargetIncludesSelf(target)) { - if (canSelfUpdate()) { - if (!(await shouldRunSelfUpdate(options.force))) { - return true; - } - try { - await runSelfUpdate(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown package command error"; - console.error(chalk.red(`Error: ${message}`)); - printSelfUpdateFallback(); - process.exitCode = 1; - return true; - } - console.log(chalk.green(`Updated ${APP_NAME}`)); - } else { - printSelfUpdateUnavailable(); + const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand); + if (!selfUpdateCommand) { + printSelfUpdateUnavailable(selfUpdateNpmCommand); process.exitCode = 1; + return true; } + if (!(await shouldRunSelfUpdate(options.force))) { + return true; + } + try { + await runSelfUpdate(selfUpdateCommand); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown package command error"; + console.error(chalk.red(`Error: ${message}`)); + printSelfUpdateFallback(selfUpdateCommand); + process.exitCode = 1; + return true; + } + console.log(chalk.green(`Updated ${APP_NAME}`)); } return true; } diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index 118027e6..9f75a285 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -1,7 +1,16 @@ import type { ChildProcess } from "node:child_process"; +import { basename } from "node:path"; const EXIT_STDIO_GRACE_MS = 100; +const WINDOWS_SHELL_COMMANDS = new Set(["npm", "npx", "pnpm", "yarn", "yarnpkg", "corepack"]); + +export function shouldUseWindowsShell(command: string): boolean { + if (process.platform !== "win32") return false; + const commandName = basename(command).toLowerCase(); + return commandName.endsWith(".cmd") || commandName.endsWith(".bat") || WINDOWS_SHELL_COMMANDS.has(commandName); +} + /** * Wait for a child process to terminate without hanging on inherited stdio handles. * diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index fe63b0a5..e0a556c5 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -1,7 +1,18 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { delimiter, join } from "path"; import { afterEach, describe, expect, test } from "vitest"; -import { detectInstallMethod, getSelfUpdateCommand, getUpdateInstruction } from "../src/config.js"; +import { + detectInstallMethod, + getSelfUpdateCommand, + getSelfUpdateUnavailableInstruction, + getUpdateInstruction, +} from "../src/config.js"; const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath"); +const originalPath = process.env.PATH; +const originalPiPackageDir = process.env.PI_PACKAGE_DIR; +let tempDir: string | undefined; function setExecPath(value: string): void { Object.defineProperty(process, "execPath", { @@ -14,8 +25,61 @@ afterEach(() => { if (execPathDescriptor) { Object.defineProperty(process, "execPath", execPathDescriptor); } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPiPackageDir === undefined) { + delete process.env.PI_PACKAGE_DIR; + } else { + process.env.PI_PACKAGE_DIR = originalPiPackageDir; + } + if (tempDir) { + chmodSync(tempDir, 0o700); + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } }); +function createNpmPrefixInstall(template = "pi-prefix-"): { prefix: string; packageDir: string } { + const prefix = mkdtempSync(join(tmpdir(), template)); + const root = join(prefix, "lib", "node_modules"); + const scopeDir = join(root, "@mariozechner"); + const packageDir = join(scopeDir, "pi-coding-agent"); + mkdirSync(packageDir, { recursive: true }); + tempDir = prefix; + process.env.PI_PACKAGE_DIR = packageDir; + setExecPath(join(packageDir, "dist", "cli.js")); + return { prefix, packageDir }; +} + +function createBunGlobalInstall(): { packageDir: string } { + const temp = mkdtempSync(join(tmpdir(), "pi-bun-")); + const prefix = join(temp, ".bun"); + const bunBin = join(prefix, "bin"); + const root = join(prefix, "install", "global", "node_modules"); + const scopeDir = join(root, "@mariozechner"); + const packageDir = join(scopeDir, "pi-coding-agent"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(bunBin, { recursive: true }); + writeFileSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), createFakeBunScript(bunBin)); + chmodSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), 0o755); + tempDir = temp; + process.env.PATH = `${bunBin}${delimiter}${originalPath ?? ""}`; + process.env.PI_PACKAGE_DIR = packageDir; + setExecPath(join(packageDir, "dist", "cli.js")); + return { packageDir }; +} + +function createFakeBunScript(bunBin: string): string { + if (process.platform === "win32") { + return `@echo off\r\nif "%1"=="pm" if "%2"=="bin" if "%3"=="-g" echo ${bunBin}\r\n`; + } + const escapedBunBin = bunBin.replaceAll("'", "'\\''"); + return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`; +} + describe("detectInstallMethod", () => { test("detects pnpm from Windows .pnpm install paths", () => { setExecPath( @@ -37,4 +101,79 @@ describe("detectInstallMethod", () => { "Update @mariozechner/pi-coding-agent using the package manager, wrapper, or source checkout that provides this installation.", ); }); + + test("self-updates npm installs from custom prefixes", () => { + const { prefix } = createNpmPrefixInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent"); + + expect(detectInstallMethod()).toBe("npm"); + expect(command).toEqual({ + command: "npm", + args: ["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"], + display: `npm --prefix ${prefix} install -g @mariozechner/pi-coding-agent`, + }); + }); + + test("self-update respects configured npmCommand", () => { + const { prefix } = createNpmPrefixInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", ["npm", "--prefix", prefix]); + + expect(command).toEqual({ + command: "npm", + args: ["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"], + display: `npm --prefix ${prefix} install -g @mariozechner/pi-coding-agent`, + }); + }); + + test("self-update treats empty npmCommand as unset", () => { + const { prefix } = createNpmPrefixInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", []); + + expect(command?.args).toEqual(["--prefix", prefix, "install", "-g", "@mariozechner/pi-coding-agent"]); + }); + + test("quotes npm self-update display paths", () => { + const { prefix } = createNpmPrefixInstall("pi prefix "); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent"); + + expect(command?.display).toBe(`npm --prefix "${prefix}" install -g @mariozechner/pi-coding-agent`); + }); + + test("does not infer Windows npm custom prefixes from package paths", () => { + const packageDir = "C:\\Users\\Admin\\npm prefix\\node_modules\\@mariozechner\\pi-coding-agent"; + process.env.PI_PACKAGE_DIR = packageDir; + setExecPath(`${packageDir}\\dist\\cli.js`); + + expect(detectInstallMethod()).toBe("npm"); + expect(getUpdateInstruction("@mariozechner/pi-coding-agent")).toBe( + "Run: npm install -g @mariozechner/pi-coding-agent", + ); + }); + + test("self-updates bun global installs from bun pm bin", () => { + createBunGlobalInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent"); + + expect(detectInstallMethod()).toBe("bun"); + expect(command).toEqual({ + command: "bun", + args: ["install", "-g", "@mariozechner/pi-coding-agent"], + display: "bun install -g @mariozechner/pi-coding-agent", + }); + }); + + test("does not self-update when npm install path is not writable", () => { + const { packageDir } = createNpmPrefixInstall(); + chmodSync(packageDir, 0o500); + + expect(getSelfUpdateCommand("@mariozechner/pi-coding-agent")).toBeUndefined(); + expect(getSelfUpdateUnavailableInstruction("@mariozechner/pi-coding-agent")).toContain( + "the install path is not writable", + ); + }); }); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index cdf9d889..1c1ceab4 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -12,7 +12,9 @@ describe("package commands", () => { let packageDir: string; let originalCwd: string; let originalAgentDir: string | undefined; + let originalPiPackageDir: string | undefined; let originalExitCode: typeof process.exitCode; + let originalExecPath: string; beforeEach(() => { tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -25,7 +27,9 @@ describe("package commands", () => { originalCwd = process.cwd(); originalAgentDir = process.env[ENV_AGENT_DIR]; + originalPiPackageDir = process.env.PI_PACKAGE_DIR; originalExitCode = process.exitCode; + originalExecPath = process.execPath; process.exitCode = undefined; process.env[ENV_AGENT_DIR] = agentDir; process.chdir(projectDir); @@ -39,6 +43,12 @@ describe("package commands", () => { } else { process.env[ENV_AGENT_DIR] = originalAgentDir; } + if (originalPiPackageDir === undefined) { + delete process.env.PI_PACKAGE_DIR; + } else { + process.env.PI_PACKAGE_DIR = originalPiPackageDir; + } + Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true }); rmSync(tempDir, { recursive: true, force: true }); }); @@ -118,6 +128,52 @@ describe("package commands", () => { } }); + it("uses global npmCommand for self updates", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const projectPrefix = join(tempDir, "project-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + mkdirSync(selfPackageDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", projectPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; + expect(recordedArgs).toContain(globalPrefix); + expect(recordedArgs).not.toContain(projectPrefix); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("suggests the configured source when update input omits the npm prefix", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2));