From 4275f98b3f3bac20b2cd3f0f1378cfc0e608d95d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 18 May 2026 00:58:40 +0200 Subject: [PATCH] feat(coding-agent): fetch portable git bash on windows --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/windows.md | 5 +- packages/coding-agent/src/core/tools/bash.ts | 14 +- .../src/modes/interactive/interactive-mode.ts | 12 +- packages/coding-agent/src/utils/shell.ts | 95 +++++++-- .../coding-agent/src/utils/tools-manager.ts | 194 +++++++++++++++++- 6 files changed, 287 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95ead56e395..907e7af7fa5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 007f649c125..4dd505e2b3c 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -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 diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1d6591d45f4..eabff23eb80 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -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", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a493bf6fa4a..2b71493f54c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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())) { diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 1c235802b5c..5ea2bdd4401 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -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 { + 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 { + 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"; diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 2ee60a0dca5..ef43447815d 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -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 { +// Fetch latest release metadata from GitHub +async function getLatestRelease(repo: string): Promise { 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 { 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 { + const data = await getLatestRelease(repo); return data.tag_name.replace(/^v/, ""); } @@ -133,7 +166,28 @@ async function downloadFile(url: string, dest: string): Promise { } const fileStream = createWriteStream(dest); - await pipeline(Readable.fromWeb(response.body as any), fileStream); + await pipeline(Readable.fromWeb(response.body as NodeReadableStream), fileStream); +} + +async function calculateFileSha256(filePath: string): Promise { + 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 { + 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): 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 { + 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 { + 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 { const config = TOOLS[tool];