mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(coding-agent): fetch portable git bash on windows
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added automatic Portable Git Bash downloads on Windows when no bash shell is installed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed npm-family package commands on Windows to avoid shell argument splitting when install prefixes contain spaces ([#4623](https://github.com/earendil-works/pi/issues/4623)).
|
||||
|
||||
@@ -4,9 +4,10 @@ Pi requires a bash shell on Windows. Checked locations (in order):
|
||||
|
||||
1. Custom path from `~/.pi/agent/settings.json`
|
||||
2. Git Bash (`C:\Program Files\Git\bin\bash.exe`)
|
||||
3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
|
||||
3. Managed Portable Git Bash (`~/.pi/agent/bin/portable-git/bin/bash.exe`)
|
||||
4. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
|
||||
|
||||
For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.
|
||||
If no bash is found, Pi downloads Git for Windows Portable Git into `~/.pi/agent/bin/portable-git`. Set `PI_OFFLINE=1` to disable automatic downloads.
|
||||
|
||||
## Custom Shell Path
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { truncateToVisualLines } from "../../modes/interactive/components/visual
|
||||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import { waitForChildProcess } from "../../utils/child-process.js";
|
||||
import {
|
||||
getShellConfig,
|
||||
ensureShellConfig,
|
||||
getShellEnv,
|
||||
killProcessTree,
|
||||
trackDetachedChildPid,
|
||||
@@ -64,13 +64,13 @@ export interface BashOperations {
|
||||
*/
|
||||
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
|
||||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
||||
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
||||
if (!existsSync(cwd)) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
||||
}
|
||||
const { shell, args } = await ensureShellConfig(options?.shellPath, { silent: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig(options?.shellPath);
|
||||
if (!existsSync(cwd)) {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
|
||||
@@ -89,7 +89,7 @@ import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipb
|
||||
import { parseGitUrl } from "../../utils/git.js";
|
||||
import { getCwdRelativePath } from "../../utils/paths.js";
|
||||
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
|
||||
import { killTrackedDetachedChildren } from "../../utils/shell.js";
|
||||
import { ensureWindowsBash, killTrackedDetachedChildren } from "../../utils/shell.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import { checkForNewPiVersion } from "../../utils/version-check.js";
|
||||
import { ArminComponent } from "./components/armin.js";
|
||||
@@ -567,9 +567,13 @@ export class InteractiveMode {
|
||||
// Load changelog (only show new entries, skip for resumed sessions)
|
||||
this.changelogMarkdown = this.getChangelogForDisplay();
|
||||
|
||||
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
|
||||
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
|
||||
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
|
||||
// Ensure fd, rg, and Windows bash are available, downloading missing tools under getBinDir.
|
||||
// fd is needed for autocomplete, rg for grep and bash commands, bash for shell execution on Windows.
|
||||
const [fdPath] = await Promise.all([
|
||||
ensureTool("fd"),
|
||||
ensureTool("rg"),
|
||||
ensureWindowsBash(this.settingsManager.getShellPath()),
|
||||
]);
|
||||
this.fdPath = fdPath;
|
||||
|
||||
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
|
||||
|
||||
@@ -2,12 +2,23 @@ import { existsSync } from "node:fs";
|
||||
import { delimiter } from "node:path";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { getBinDir } from "../config.js";
|
||||
import {
|
||||
ensureWindowsPortableGitBash,
|
||||
getManagedWindowsPortableGitBashCandidates,
|
||||
getManagedWindowsPortableGitBashPath,
|
||||
} from "./tools-manager.js";
|
||||
|
||||
export interface ShellConfig {
|
||||
shell: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function isSystemBashLookupDisabled(): boolean {
|
||||
const value = process.env.__PI_DISABLE_SYSTEM_BASH_LOOKUP;
|
||||
if (!value) return false;
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bash executable on PATH (cross-platform)
|
||||
*/
|
||||
@@ -47,7 +58,7 @@ function findBashOnPath(): string | null {
|
||||
* Resolve shell configuration based on platform and an optional explicit shell path.
|
||||
* Resolution order:
|
||||
* 1. User-specified shellPath
|
||||
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
||||
* 2. On Windows: Git Bash in known locations, managed Portable Git Bash, then bash on PATH
|
||||
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
|
||||
*/
|
||||
export function getShellConfig(customShellPath?: string): ShellConfig {
|
||||
@@ -60,35 +71,51 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const skipSystemBashLookup = isSystemBashLookupDisabled();
|
||||
|
||||
// 2. Try Git Bash in known locations
|
||||
const paths: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
if (!skipSystemBashLookup) {
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
return { shell: path, args: ["-c"] };
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
return { shell: path, args: ["-c"] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const managedPortableGitBash = getManagedWindowsPortableGitBashPath();
|
||||
if (managedPortableGitBash) {
|
||||
return { shell: managedPortableGitBash, args: ["-c"] };
|
||||
}
|
||||
|
||||
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
||||
const bashOnPath = findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return { shell: bashOnPath, args: ["-c"] };
|
||||
if (!skipSystemBashLookup) {
|
||||
const bashOnPath = findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return { shell: bashOnPath, args: ["-c"] };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No bash shell found. Options:\n` +
|
||||
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
||||
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||
" 3. Set shellPath in settings.json\n\n" +
|
||||
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||
` 2. Let pi download Portable Git Bash automatically\n` +
|
||||
` 3. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||
" 4. Set shellPath in settings.json\n\n" +
|
||||
(skipSystemBashLookup ? "System bash lookup disabled by __PI_DISABLE_SYSTEM_BASH_LOOKUP.\n" : "") +
|
||||
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}\n` +
|
||||
`Searched managed Portable Git Bash in:\n${getManagedWindowsPortableGitBashCandidates()
|
||||
.map((p) => ` ${p}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +132,38 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
|
||||
return { shell: "sh", args: ["-c"] };
|
||||
}
|
||||
|
||||
export async function ensureShellConfig(
|
||||
customShellPath?: string,
|
||||
options: { silent?: boolean } = {},
|
||||
): Promise<ShellConfig> {
|
||||
try {
|
||||
return getShellConfig(customShellPath);
|
||||
} catch (error) {
|
||||
if (customShellPath || process.platform !== "win32") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const installedBash = await ensureWindowsPortableGitBash(options.silent ?? false);
|
||||
if (installedBash) {
|
||||
return { shell: installedBash, args: ["-c"] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWindowsBash(
|
||||
customShellPath?: string,
|
||||
silent: boolean = false,
|
||||
): Promise<string | undefined> {
|
||||
if (process.platform !== "win32") return undefined;
|
||||
try {
|
||||
const { shell } = await ensureShellConfig(customShellPath, { silent });
|
||||
return shell;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getShellEnv(): NodeJS.ProcessEnv {
|
||||
const binDir = getBinDir();
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import chalk from "chalk";
|
||||
import { type SpawnSyncReturns, spawnSync } from "child_process";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { arch, platform } from "os";
|
||||
import { join } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "stream/web";
|
||||
import { APP_NAME, getBinDir } from "../config.js";
|
||||
|
||||
const TOOLS_DIR = getBinDir();
|
||||
const PORTABLE_GIT_DIR = join(TOOLS_DIR, "portable-git");
|
||||
const GIT_FOR_WINDOWS_REPO = "git-for-windows/git";
|
||||
const NETWORK_TIMEOUT_MS = 10_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -17,6 +30,17 @@ function isOfflineModeEnabled(): boolean {
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
assets: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
name: string;
|
||||
repo: string; // GitHub repo (e.g., "sharkdp/fd")
|
||||
@@ -103,8 +127,8 @@ export function getToolPath(tool: "fd" | "rg"): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch latest release version from GitHub
|
||||
async function getLatestVersion(repo: string): Promise<string> {
|
||||
// Fetch latest release metadata from GitHub
|
||||
async function getLatestRelease(repo: string): Promise<GitHubRelease> {
|
||||
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
||||
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
|
||||
@@ -114,7 +138,16 @@ async function getLatestVersion(repo: string): Promise<string> {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tag_name: string };
|
||||
const data = (await response.json()) as { tag_name?: string; assets?: GitHubReleaseAsset[] };
|
||||
if (!data.tag_name) {
|
||||
throw new Error("GitHub API response did not include a release tag");
|
||||
}
|
||||
return { tag_name: data.tag_name, assets: data.assets ?? [] };
|
||||
}
|
||||
|
||||
// Fetch latest release version from GitHub
|
||||
async function getLatestVersion(repo: string): Promise<string> {
|
||||
const data = await getLatestRelease(repo);
|
||||
return data.tag_name.replace(/^v/, "");
|
||||
}
|
||||
|
||||
@@ -133,7 +166,28 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(dest);
|
||||
await pipeline(Readable.fromWeb(response.body as any), fileStream);
|
||||
await pipeline(Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>), fileStream);
|
||||
}
|
||||
|
||||
async function calculateFileSha256(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyGitHubAssetDigest(filePath: string, digest: string | undefined, assetName: string): Promise<void> {
|
||||
if (!digest) return;
|
||||
const [algorithm, expectedHash] = digest.split(":", 2);
|
||||
if (algorithm !== "sha256" || !expectedHash) return;
|
||||
|
||||
const actualHash = await calculateFileSha256(filePath);
|
||||
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
||||
throw new Error(`Checksum mismatch for ${assetName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {
|
||||
@@ -174,7 +228,7 @@ function formatSpawnFailure(result: SpawnSyncReturns<Buffer>): string {
|
||||
}
|
||||
|
||||
function runExtractionCommand(command: string, args: string[]): string | null {
|
||||
const result = spawnSync(command, args, { stdio: "pipe" });
|
||||
const result = spawnSync(command, args, { stdio: "pipe", windowsHide: true });
|
||||
if (!result.error && result.status === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -237,6 +291,134 @@ function extractZipArchive(archivePath: string, extractDir: string, assetName: s
|
||||
throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`);
|
||||
}
|
||||
|
||||
function getPortableGitBashCandidates(rootDir: string): string[] {
|
||||
return [join(rootDir, "bin", "bash.exe"), join(rootDir, "usr", "bin", "bash.exe")];
|
||||
}
|
||||
|
||||
export function getManagedWindowsPortableGitBashCandidates(): string[] {
|
||||
return getPortableGitBashCandidates(PORTABLE_GIT_DIR);
|
||||
}
|
||||
|
||||
function getPortableGitBashPath(rootDir: string): string | null {
|
||||
return getPortableGitBashCandidates(rootDir).find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function getManagedWindowsPortableGitBashPath(): string | null {
|
||||
if (platform() !== "win32") return null;
|
||||
return getPortableGitBashPath(PORTABLE_GIT_DIR);
|
||||
}
|
||||
|
||||
function selectPortableGitAsset(release: GitHubRelease, architecture: string): GitHubReleaseAsset {
|
||||
let suffix: string;
|
||||
switch (architecture) {
|
||||
case "x64":
|
||||
suffix = "-64-bit.7z.exe";
|
||||
break;
|
||||
case "arm64":
|
||||
suffix = "-arm64.7z.exe";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported Windows architecture: ${architecture}`);
|
||||
}
|
||||
|
||||
const asset = release.assets.find((asset) => asset.name.startsWith("PortableGit-") && asset.name.endsWith(suffix));
|
||||
if (!asset) {
|
||||
throw new Error(`Portable Git release asset not found for ${architecture}`);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
function validatePortableGitBash(bashPath: string): void {
|
||||
const result = spawnSync(bashPath, ["--version"], { stdio: "pipe" });
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(`Portable Git bash validation failed: ${formatSpawnFailure(result)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPortableGitBash(): Promise<string> {
|
||||
if (platform() !== "win32") {
|
||||
throw new Error("Portable Git Bash is only available on Windows");
|
||||
}
|
||||
|
||||
const release = await getLatestRelease(GIT_FOR_WINDOWS_REPO);
|
||||
const asset = selectPortableGitAsset(release, arch());
|
||||
|
||||
mkdirSync(TOOLS_DIR, { recursive: true });
|
||||
const stagingRoot = join(
|
||||
TOOLS_DIR,
|
||||
`portable_git_tmp_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
||||
);
|
||||
const extractDir = join(stagingRoot, "extract");
|
||||
const installerPath = join(stagingRoot, asset.name);
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await downloadFile(asset.browser_download_url, installerPath);
|
||||
await verifyGitHubAssetDigest(installerPath, asset.digest, asset.name);
|
||||
|
||||
const failure = runExtractionCommand(installerPath, ["-y", `-o${extractDir}`]);
|
||||
if (failure) {
|
||||
throw new Error(`Failed to extract ${asset.name}: ${failure}`);
|
||||
}
|
||||
|
||||
const extractedBash = getPortableGitBashPath(extractDir);
|
||||
if (!extractedBash) {
|
||||
throw new Error(`Portable Git Bash not found in ${asset.name}`);
|
||||
}
|
||||
validatePortableGitBash(extractedBash);
|
||||
|
||||
const existingPath = getManagedWindowsPortableGitBashPath();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
if (existsSync(PORTABLE_GIT_DIR)) {
|
||||
rmSync(PORTABLE_GIT_DIR, { recursive: true, force: true });
|
||||
}
|
||||
renameSync(extractDir, PORTABLE_GIT_DIR);
|
||||
|
||||
const installedBash = getManagedWindowsPortableGitBashPath();
|
||||
if (!installedBash) {
|
||||
throw new Error(`Portable Git Bash install did not create ${getManagedWindowsPortableGitBashCandidates()[0]}`);
|
||||
}
|
||||
validatePortableGitBash(installedBash);
|
||||
return installedBash;
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWindowsPortableGitBash(silent: boolean = false): Promise<string | undefined> {
|
||||
if (platform() !== "win32") return undefined;
|
||||
|
||||
const existingPath = getManagedWindowsPortableGitBashPath();
|
||||
if (existingPath) return existingPath;
|
||||
|
||||
if (isOfflineModeEnabled()) {
|
||||
if (!silent) {
|
||||
console.log(chalk.yellow("bash not found. Offline mode enabled, skipping Portable Git download."));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
console.log(chalk.dim("bash not found. Downloading Portable Git..."));
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await downloadPortableGitBash();
|
||||
if (!silent) {
|
||||
console.log(chalk.dim(`Portable Git Bash installed to ${path}`));
|
||||
}
|
||||
return path;
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
console.log(chalk.yellow(`Failed to download Portable Git Bash: ${e instanceof Error ? e.message : e}`));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Download and install a tool
|
||||
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
|
||||
const config = TOOLS[tool];
|
||||
|
||||
Reference in New Issue
Block a user