Merge branch 'main' into fix-bun-undici-install-clean
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
84
packages/coding-agent/src/utils/windows-self-update.ts
Normal file
84
packages/coding-agent/src/utils/windows-self-update.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user