Merge branch 'main' into fix-bun-undici-install-clean

This commit is contained in:
Mario Zechner
2026-05-18 10:13:16 +02:00
committed by GitHub
9 changed files with 247 additions and 36 deletions

View File

@@ -5,6 +5,10 @@
### Fixed
- Fixed Bun-compiled release binaries failing to start when Bun's built-in undici shim lacks npm undici's `install` export ([#4657](https://github.com/earendil-works/pi/issues/4657)).
- Fixed Windows external editor handoff so vim/nvim can receive input after opening from the TUI ([#4612](https://github.com/earendil-works/pi/issues/4612)).
- Fixed Windows npm self-updates to move loaded native dependency packages out of the active install before reinstalling pi ([#4157](https://github.com/earendil-works/pi/issues/4157)).
- Fixed `pi update --self` detection for pnpm v11 global installs whose package path resolves through the pnpm store ([#4647](https://github.com/earendil-works/pi/issues/4647)).
- Fixed Windows pnpm self-updates to resolve pnpm command shims and run through pnpm instead of requiring manual updates ([#4157](https://github.com/earendil-works/pi/issues/4157)).
## [0.75.1] - 2026-05-18

View File

@@ -215,16 +215,18 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
}
function normalizeExistingPathForComparison(path: string): string | undefined {
function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return undefined;
}
let normalizedPath: string;
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
let normalizedPath = resolvedPath;
if (resolveSymlinks) {
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
}
}
if (process.platform === "win32") {
normalizedPath = normalizedPath.toLowerCase();
@@ -232,6 +234,29 @@ function normalizeExistingPathForComparison(path: string): string | undefined {
return normalizedPath;
}
function getPathComparisonCandidates(path: string): string[] {
return Array.from(
new Set(
[normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter(
(candidate): candidate is string => !!candidate,
),
),
);
}
function getEntrypointPackageDir(): string | undefined {
const entrypoint = process.argv[1];
if (!entrypoint) return undefined;
let dir = dirname(entrypoint);
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
return dir;
}
dir = dirname(dir);
}
return undefined;
}
function isSelfUpdatePathWritable(): boolean {
const packageDir = getPackageDir();
try {
@@ -244,17 +269,14 @@ function isSelfUpdatePathWritable(): boolean {
}
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}`)
);
})
);
const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir);
const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir));
return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
return getPathComparisonCandidates(root).some((normalizedRoot) => {
const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix));
});
});
}
export function getSelfUpdateCommand(

View File

@@ -15,7 +15,7 @@ import { processFileArguments } from "./cli/file-processor.js";
import { buildInitialMessage } from "./cli/initial-message.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from "./config.js";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.js";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.js";
import {
type AgentSessionRuntimeDiagnostic,
@@ -46,6 +46,7 @@ import { ExtensionSelectorComponent } from "./modes/interactive/components/exten
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js";
import { isLocalPath } from "./utils/paths.js";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.js";
/**
* Read all content from piped stdin.
@@ -428,6 +429,10 @@ export async function main(args: string[], options?: MainOptions) {
process.env.PI_SKIP_VERSION_CHECK = "1";
}
if (process.platform === "win32") {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
if (await handlePackageCommand(args)) {
return;
}

View File

@@ -3,7 +3,7 @@
* Supports Ctrl+G for external editor.
*/
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -110,7 +110,7 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.editor.handleInput(keyData);
}
private openExternalEditor(): void {
private async openExternalEditor(): Promise<void> {
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
return;
@@ -124,12 +124,21 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
const result = spawnSync(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after tui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
if (result.status === 0) {
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}

View File

@@ -3497,7 +3497,7 @@ export class InteractiveMode {
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
private openExternalEditor(): void {
private async openExternalEditor(): Promise<void> {
// Determine editor (respect $VISUAL, then $EDITOR)
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
@@ -3518,14 +3518,22 @@ export class InteractiveMode {
// Split by space to support editor arguments (e.g., "code --wait")
const [editor, ...editorArgs] = editorCmd.split(" ");
// Spawn editor synchronously with inherited stdio for interactive editing
const result = spawnSync(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after ui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
// On successful exit (status 0), replace editor content
if (result.status === 0) {
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}

View File

@@ -3,7 +3,9 @@ import { spawn } from "child_process";
import { selectConfig } from "./cli/config-selector.js";
import {
APP_NAME,
detectInstallMethod,
getAgentDir,
getPackageDir,
getSelfUpdateCommand,
getSelfUpdateUnavailableInstruction,
PACKAGE_NAME,
@@ -14,6 +16,10 @@ import { DefaultPackageManager } from "./core/package-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { resolveSpawnCommand } from "./utils/child-process.js";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js";
import {
cleanupWindowsSelfUpdateQuarantine,
quarantineWindowsNativeDependencies,
} from "./utils/windows-self-update.js";
export type PackageCommand = "install" | "remove" | "update" | "list";
@@ -336,6 +342,16 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise<void> {
}
}
function prepareWindowsNpmSelfUpdate(): void {
if (process.platform !== "win32") {
return;
}
const packageDir = getPackageDir();
cleanupWindowsSelfUpdateQuarantine(packageDir);
quarantineWindowsNativeDependencies(packageDir);
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") {
return false;
@@ -489,6 +505,15 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
if (!selfUpdatePlan.shouldRun) {
return true;
}
const installMethod = detectInstallMethod();
if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") {
console.error(
chalk.red(`${APP_NAME} self-update on Windows is only supported for npm and pnpm installs.`),
);
console.error(chalk.dim(`Detected install method: ${installMethod}. Update ${APP_NAME} manually.`));
process.exitCode = 1;
return true;
}
const selfUpdateCommand = getSelfUpdateCommand(
PACKAGE_NAME,
selfUpdateNpmCommand,
@@ -500,6 +525,9 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
return true;
}
try {
if (installMethod === "npm") {
prepareWindowsNpmSelfUpdate();
}
await runSelfUpdate(selfUpdateCommand);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown package command error";

View File

@@ -3,9 +3,9 @@ import { existsSync, readFileSync } from "node:fs";
import { dirname, extname, join, resolve, sep } from "node:path";
const EXIT_STDIO_GRACE_MS = 100;
const WINDOWS_COMMAND_EXTENSIONS = ["", ".exe", ".cmd", ".bat"];
const WINDOWS_COMMAND_EXTENSIONS = [".exe", ".cmd", ".bat", ""];
const WINDOWS_COMMAND_SHIM_RE = /\.(?:cmd|bat)$/i;
const NODE_SHIM_SCRIPT_RE = /(?:%~dp0|%dp0%|%basedir%)[^"'\r\n<>|&]*?\.(?:cjs|mjs|js)/i;
const NODE_SHIM_SCRIPT_RE = /(?:%~dp0|%dp0%|%basedir%)[^"'\r\n<>|&]*?\.(?:cjs|mjs|js)/gi;
export interface ResolvedSpawnCommand {
command: string;
@@ -45,10 +45,12 @@ function expandShimPath(path: string, shimPath: string): string {
}
function findNodeShimScript(shimPath: string): string | undefined {
const match = readFileSync(shimPath, "utf-8").match(NODE_SHIM_SCRIPT_RE);
if (!match) return undefined;
const scriptPath = expandShimPath(match[0], shimPath);
return existsSync(scriptPath) ? scriptPath : undefined;
const matches = [...readFileSync(shimPath, "utf-8").matchAll(NODE_SHIM_SCRIPT_RE)];
for (const match of matches.reverse()) {
const scriptPath = expandShimPath(match[0], shimPath);
if (existsSync(scriptPath)) return scriptPath;
}
return undefined;
}
export function resolveSpawnCommand(

View File

@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { basename, dirname, join, relative, resolve, toNamespacedPath } from "node:path";
import { getCwdRelativePath } from "./paths.js";
const QUARANTINE_DIR_NAME = ".pi-native-quarantine";
function normalizePath(path: string): string {
return toNamespacedPath(resolve(path));
}
function getQuarantineRoot(packageDir: string): string | undefined {
let current = resolve(packageDir);
while (true) {
if (basename(current).toLowerCase() === "node_modules") {
return join(current, QUARANTINE_DIR_NAME);
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
function getLoadedSharedObjectsInPackageDir(packageDir: string): string[] {
const sharedObjects = (process.report.getReport() as { sharedObjects?: unknown }).sharedObjects;
if (!Array.isArray(sharedObjects)) {
return [];
}
const root = normalizePath(packageDir).toLowerCase();
const seen = new Set<string>();
const loadedFiles: string[] = [];
for (const value of sharedObjects) {
if (typeof value !== "string") {
continue;
}
const filePath = normalizePath(value);
const comparisonPath = filePath.toLowerCase();
if (getCwdRelativePath(comparisonPath, root) === undefined || seen.has(comparisonPath)) {
continue;
}
seen.add(comparisonPath);
loadedFiles.push(filePath);
}
return loadedFiles;
}
export function cleanupWindowsSelfUpdateQuarantine(packageDir: string): void {
const quarantineRoot = getQuarantineRoot(packageDir);
if (!quarantineRoot) {
return;
}
try {
rmSync(quarantineRoot, { recursive: true, force: true });
} catch {
// A previous pi process may still be exiting and holding a native addon.
}
}
export function quarantineWindowsNativeDependencies(packageDir: string): void {
const resolvedPackageDir = normalizePath(packageDir);
const quarantineRoot = getQuarantineRoot(resolvedPackageDir);
if (!quarantineRoot) {
return;
}
const loadedFiles = getLoadedSharedObjectsInPackageDir(resolvedPackageDir);
if (loadedFiles.length === 0) {
return;
}
const quarantineRunDir = join(quarantineRoot, `${Date.now()}-${process.pid}-${randomUUID()}`);
for (const loadedFile of loadedFiles) {
if (!existsSync(loadedFile)) {
continue;
}
const quarantinePath = join(quarantineRunDir, relative(resolvedPackageDir, loadedFile));
mkdirSync(dirname(quarantinePath), { recursive: true });
renameSync(loadedFile, quarantinePath);
copyFileSync(quarantinePath, loadedFile);
}
}

View File

@@ -12,6 +12,7 @@ import {
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath");
const originalPath = process.env.PATH;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
const originalArgv1 = process.argv[1];
let tempDir: string | undefined;
function setExecPath(value: string): void {
@@ -35,6 +36,11 @@ afterEach(() => {
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
if (originalArgv1 === undefined) {
process.argv.splice(1, 1);
} else {
process.argv[1] = originalArgv1;
}
if (tempDir) {
chmodSync(tempDir, 0o700);
rmSync(tempDir, { recursive: true, force: true });
@@ -275,6 +281,49 @@ describe("detectInstallMethod", () => {
});
});
test("self-updates pnpm v11 global installs resolved through the store", () => {
const temp = mkdtempSync(join(tmpdir(), "pi-pnpm11-"));
const binDir = join(temp, "bin");
const root = join(temp, "Library", "pnpm", "global", "v11");
const packageName = "@earendil-works/pi-coding-agent";
const globalPackageDir = join(root, "11e9a", "node_modules", "@earendil-works", "pi-coding-agent");
const storePackageDir = join(
temp,
"Library",
"pnpm",
"store",
"v11",
"links",
"@earendil-works",
"pi-coding-agent",
"0.75.0",
"hash",
"node_modules",
"@earendil-works",
"pi-coding-agent",
);
mkdirSync(globalPackageDir, { recursive: true });
mkdirSync(storePackageDir, { recursive: true });
mkdirSync(binDir, { recursive: true });
writeFileSync(join(globalPackageDir, "package.json"), "{}");
writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root));
chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755);
tempDir = temp;
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
process.env.PI_PACKAGE_DIR = storePackageDir;
process.argv[1] = join(globalPackageDir, "dist", "cli.js");
setExecPath(join(storePackageDir, "dist", "cli.js"));
const command = getSelfUpdateCommand(packageName);
expect(detectInstallMethod()).toBe("pnpm");
expect(command).toEqual({
command: "pnpm",
args: ["install", "-g", packageName],
display: `pnpm install -g ${packageName}`,
});
});
test("self-updates renamed yarn global installs by removing the old package first", () => {
createYarnGlobalInstall();