/** * Environment — cross-platform probe of OS / shell. * * Detection is a pure function of injected probes (`platform` / `arch` / * `release` / `env` / `isFile` / `execFileText`) so the same suite runs * identically on any host OS. `detectEnvironmentFromNode()` bundles the Node * defaults for production callers. * * On Windows the probe expects bash from Git for Windows or MSYS2. If it * cannot be located the function throws `KaosShellNotFoundError`; the SDK * layer can wrap that into a user-facing install hint. Set * `KIMI_SHELL_PATH` to override. */ import { execFile as nodeExecFile } from 'node:child_process'; import { constants as fsConstants } from 'node:fs'; import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; import * as nodePath from 'node:path'; import { KaosShellNotFoundError } from './errors'; // `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and // falls back to the raw `process.platform` string for unknown ones (e.g. // 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. export type OsKind = string; export type ShellName = 'bash' | 'sh'; export interface Environment { readonly osKind: OsKind; readonly osArch: string; readonly osVersion: string; readonly shellName: ShellName; readonly shellPath: string; } export interface EnvironmentDeps { // Accepts the full Node `Platform` enum plus arbitrary strings for // forward-compatible OS kinds. readonly platform: string; readonly arch: string; readonly release: string; readonly env: Record; readonly isFile: (path: string) => Promise; readonly execFileText: ( file: string, args: readonly string[], timeoutMs: number, ) => Promise; } const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; const MINGW_PREFIX_SET: ReadonlySet = new Set([ 'mingw32', 'mingw64', 'ucrt64', 'clang64', 'clangarm64', ]); function resolveOsKind(platform: string): OsKind { switch (platform) { case 'darwin': return 'macOS'; case 'linux': return 'Linux'; case 'win32': return 'Windows'; default: return platform; } } export async function detectEnvironment(deps: EnvironmentDeps): Promise { const osKind = resolveOsKind(deps.platform); const osArch = deps.arch; const osVersion = deps.release; if (deps.platform === 'win32') { const shellPath = await locateWindowsGitBash(deps); return { osKind, osArch, osVersion, shellName: 'bash', shellPath }; } const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; let found: string | undefined; for (const p of candidates) { if (await deps.isFile(p)) { found = p; break; } } if (found !== undefined) { return { osKind, osArch, osVersion, shellName: 'bash', shellPath: found }; } return { osKind, osArch, osVersion, shellName: 'sh', shellPath: '/bin/sh' }; } async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { const checked: string[] = []; const override = deps.env['KIMI_SHELL_PATH']?.trim(); if (override !== undefined && override.length > 0) { checked.push(override); if (await deps.isFile(override)) { return override; } } const gitExecutables = await findExecutablesOnPath( 'git.exe', deps.env['PATH'], deps.platform, deps.isFile, ); for (const gitExe of gitExecutables) { const inferred = gitBashCandidatesFromGitExe(gitExe); if (inferred !== undefined) { for (const candidate of inferred) { checked.push(candidate); if (await deps.isFile(candidate)) { return candidate; } } } const gitExecPath = await readGitExecPath(deps, gitExe); if (gitExecPath === undefined) { continue; } for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { checked.push(candidate); if (await deps.isFile(candidate)) { return candidate; } } } const candidates: string[] = [ 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', ]; const localAppData = deps.env['LOCALAPPDATA']?.trim(); if (localAppData !== undefined && localAppData.length > 0) { candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`); candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`); } for (const candidate of candidates) { checked.push(candidate); if (await deps.isFile(candidate)) { return candidate; } } throw new KaosShellNotFoundError( `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, ); } async function readGitExecPath( deps: EnvironmentDeps, gitExe: string, ): Promise { if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); if (stdout === undefined) return undefined; for (const line of stdout.split(/\r?\n/)) { const execPath = line.trim(); if (execPath.length > 0) { return execPath; } } return undefined; } // Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, // with bash at `\bin\bash.exe`. Portable installs sometimes put // both in `\bin\`. Only infer from those anchored layouts; package // manager shims live elsewhere and must resolve through `git --exec-path`. function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); const gitDir = nodePath.win32.dirname(normalizedGitExe); const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); if (gitDirName !== 'cmd' && gitDirName !== 'bin') { return undefined; } return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir)); } function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); const parts = normalized.split('\\'); for (let i = parts.length - 1; i >= 0; i -= 1) { const segment = parts[i]?.toLowerCase(); if (segment !== undefined && MINGW_PREFIX_SET.has(segment)) { const root = parts.slice(0, i).join('\\'); if (root.length > 0) { return gitBashCandidatesFromGitRoot(root); } } } return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..')); } function gitBashCandidatesFromGitRoot(root: string): readonly string[] { return [ nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')), nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')), ]; } function normalizeWindowsPath(path: string): string { return path.replaceAll('/', '\\'); } function isAbsoluteWindowsPath(path: string): boolean { return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); } function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { const deduped: string[] = []; const seen = new Set(); for (const path of paths) { const key = normalizeWindowsPath(path).toLowerCase(); if (seen.has(key)) continue; seen.add(key); deduped.push(path); } return deduped; } /** * Production convenience — derive the deps bag from Node's ambient surface. * * The result is memoised: subsequent calls return the original promise. * `Environment` is immutable for the lifetime of the process (it derives * from `process.platform`, `process.arch`, `os.release()`, and one-time * shell-path discovery), so caching is sound. Tests that need to probe * with different inputs should call {@link detectEnvironment} directly * with an injected deps bag. */ let detectedEnvironment: Promise | undefined; export function detectEnvironmentFromNode(): Promise { if (detectedEnvironment !== undefined) return detectedEnvironment; const platform = process.platform; const env = process.env as Record; const isFile = async (path: string): Promise => { try { await access(path, fsConstants.F_OK); return true; } catch { return false; } }; detectedEnvironment = detectEnvironment({ platform, arch: process.arch, release: nodeOs.release(), env, isFile, execFileText, }); return detectedEnvironment; } async function findExecutablesOnPath( name: string, pathEnv: string | undefined, platform: string, isFile: (p: string) => Promise, ): Promise { if (pathEnv === undefined || pathEnv.length === 0) return []; const listSep = platform === 'win32' ? ';' : ':'; const dirSep = platform === 'win32' ? '\\' : '/'; const paths: string[] = []; for (const rawDir of pathEnv.split(listSep)) { const dir = rawDir.trim(); if (dir.length === 0) continue; if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; if (await isFile(candidate)) { paths.push(candidate); } } return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; } export async function execFileText( file: string, args: readonly string[], timeoutMs: number, ): Promise { return new Promise((resolve) => { nodeExecFile( file, [...args], { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, (error, stdout) => { if (error !== null) { resolve(undefined); return; } resolve(stdout); }, ); }); }