/** * Runtime detection footer segment producer. * * Detects project language runtime from config files in cwd or parents * under $HOME and emits a footer:segment with zone "workspace". */ import { execFile } from "node:child_process"; import { readdir, realpath } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { promisify } from "node:util"; import type { ExtensionAPI, ExtensionContext, ThemeColor, } from "@earendil-works/pi-coding-agent"; import { closeDebug, dbg, span } from "./src/debug.ts"; import { type FooterSegment, offerFooterSegment, removeFooterSegment, } from "./src/pi-ext-footer-segment.ts"; import { MAX_TIMER_MS, parseNumberSetting, resolveSetting, type SettingDeclaration, } from "./src/pi-ext-settings.ts"; import { isStaleContextError } from "./src/pi-ext-stale-context.ts"; const execFileAsync = promisify(execFile); const VERSION_TIMEOUT_SETTING: SettingDeclaration = { key: "runtime.versionTimeoutMs", env: "PI_RUNTIME_VERSION_TIMEOUT_MS", default: 2500, parse(raw, source) { const value = parseNumberSetting(raw, source); if (value === undefined) return undefined; if (!Number.isInteger(value) || value <= 0 || value > MAX_TIMER_MS) throw new Error(`expected an integer from 1 to ${MAX_TIMER_MS}`); return value; }, }; // Resolved per session_start; shared by all version lookups. let versionTimeoutMs = VERSION_TIMEOUT_SETTING.default; const SEGMENT_ID = "runtime"; const STATUS_ID = "runtime"; const SEGMENT_ZONE = "workspace"; const SEGMENT_ORDER = 3; const RUNTIME_FILES = { bun: ["bun.lock", "bun.lockb"], deno: ["deno.json", "deno.jsonc", "deno.lock"], lua: [ "stylua.toml", ".stylua.toml", ".luarc.json", ".luarc.jsonc", "init.lua", ], nodejs: ["package.json", ".nvmrc", ".node-version"], python: [ "pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile", ".python-version", ], golang: ["go.mod"], rust: ["Cargo.toml"], java: ["pom.xml", "build.gradle", "build.gradle.kts"], ruby: ["Gemfile", ".ruby-version"], php: ["composer.json"], } as const; type RuntimeName = keyof typeof RUNTIME_FILES; type DirEntries = { names: readonly string[]; set: ReadonlySet }; type RuntimeInfo = { name: RuntimeName; icon: string; version?: string }; type RuntimeDef = { name: RuntimeName; icon: string; files: readonly string[]; entries?: readonly string[]; suffixes?: readonly string[]; version: () => Promise; }; async function commandOutput( cmd: string, args: readonly string[] = [], ): Promise { try { const { stdout, stderr } = await execFileAsync(cmd, [...args], { timeout: versionTimeoutMs, }); const text = `${stdout}\n${stderr}`.trim(); return text || undefined; } catch { return undefined; } } function prefixedVersion(version?: string): string | undefined { if (!version) return undefined; return version.startsWith("v") ? version : `v${version}`; } async function exactVersion( cmd: string, args: readonly string[] = [], ): Promise { return prefixedVersion(await commandOutput(cmd, args)); } async function matchedVersion( cmd: string, args: readonly string[], regex: RegExp, ): Promise { return prefixedVersion((await commandOutput(cmd, args))?.match(regex)?.[1]); } async function firstMatchedVersion( commands: readonly [string, readonly string[], RegExp][], ): Promise { for (const [cmd, args, regex] of commands) { const version = await matchedVersion(cmd, args, regex); if (version) return version; } return undefined; } async function canonicalPath(path: string): Promise { try { return await realpath(path); } catch { return resolve(path); } } function isInside(path: string, root: string): boolean { const rel = relative(root, path); return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); } async function searchDirs(cwd: string): Promise { const start = await canonicalPath(cwd); const homeRaw = process.env.HOME; if (!homeRaw) return [start]; const home = await canonicalPath(homeRaw); if (!isInside(start, home)) return [start]; const dirs: string[] = []; let dir = start; while (true) { dirs.push(dir); if (dir === home) return dirs; const parent = dirname(dir); if (parent === dir || !isInside(parent, home)) return dirs; dir = parent; } } const runtimes: readonly RuntimeDef[] = [ { name: "bun", icon: "", files: RUNTIME_FILES.bun, version: () => exactVersion("bun", ["--version"]), }, { name: "deno", icon: "", files: RUNTIME_FILES.deno, version: () => matchedVersion("deno", ["--version"], /deno\s+([0-9][^\s]*)/i), }, { name: "lua", icon: "", files: RUNTIME_FILES.lua, entries: ["lua"], suffixes: [".lua"], version: () => firstMatchedVersion([ ["lua", ["-v"], /Lua\s+([0-9][^\s]*)/i], ["luajit", ["-v"], /LuaJIT\s+([0-9][^\s]*)/i], ]), }, { name: "nodejs", icon: "", files: RUNTIME_FILES.nodejs, version: () => Promise.resolve(process.version), }, { name: "python", icon: "", files: RUNTIME_FILES.python, version: () => firstMatchedVersion([ ["python3", ["--version"], /Python\s+([0-9][^\s]*)/i], ["python", ["--version"], /Python\s+([0-9][^\s]*)/i], ]), }, { name: "golang", icon: "", files: RUNTIME_FILES.golang, version: () => matchedVersion("go", ["version"], /go version go([0-9][^\s]*)/i), }, { name: "rust", icon: "", files: RUNTIME_FILES.rust, version: () => matchedVersion("rustc", ["--version"], /rustc\s+([0-9][^\s]*)/i), }, { name: "java", icon: "", files: RUNTIME_FILES.java, version: () => matchedVersion("java", ["-version"], /"([0-9][^"]*)"/), }, { name: "ruby", icon: "", files: RUNTIME_FILES.ruby, version: () => matchedVersion("ruby", ["--version"], /ruby\s+([0-9][^\s]*)/i), }, { name: "php", icon: "", files: RUNTIME_FILES.php, version: () => matchedVersion("php", ["--version"], /PHP\s+([0-9][^\s]*)/i), }, ]; const RUNTIME_COLORS: Record = { nodejs: "success", deno: "syntaxType", golang: "syntaxType", bun: "warning", python: "warning", java: "warning", rust: "error", ruby: "error", lua: "accent", php: "accent", }; async function entriesFor(dir: string): Promise { try { const names = await readdir(dir); return { names, set: new Set(names) }; } catch { return { names: [], set: new Set() }; } } function matchesRuntime(rt: RuntimeDef, entries: DirEntries): boolean { if (rt.files.some((file) => entries.set.has(file))) return true; if (rt.entries?.some((entry) => entries.set.has(entry))) return true; if ( rt.suffixes?.some((suffix) => entries.names.some((entry) => entry.endsWith(suffix)), ) ) return true; return false; } async function detectInDir(dir: string): Promise { const entries = await entriesFor(dir); for (const rt of runtimes) { if (!matchesRuntime(rt, entries)) continue; return { name: rt.name, icon: rt.icon, version: await rt.version() }; } return undefined; } async function detect(cwd: string): Promise { for (const dir of await searchDirs(cwd)) { const rt = await detectInDir(dir); if (rt) return rt; } return undefined; } function ignoreStaleContext(fn: () => void) { try { fn(); } catch (error) { if (!isStaleContextError(error)) throw error; } } export default function runtimeExtension(pi: ExtensionAPI) { let generation = 0; let hasUI = false; function clearSegment() { ignoreStaleContext(() => removeFooterSegment(pi, SEGMENT_ID)); } function offerSegment(segment: FooterSegment): boolean { let accepted = false; ignoreStaleContext(() => { accepted = offerFooterSegment(pi, segment); }); return accepted; } async function refresh(ctx: ExtensionContext) { const ticket = generation; let cwd: string; try { cwd = ctx.cwd; } catch (error) { if (isStaleContextError(error)) return; throw error; } if (!hasUI) return; const finish = span?.("runtime.detect"); let rt: RuntimeInfo | undefined; try { rt = await detect(cwd); } catch (error) { finish?.("error"); throw error; } if (ticket !== generation) { finish?.("finish", { kind: rt?.name ?? "none", outcome: "superseded" }); return; } finish?.("finish", { kind: rt?.name ?? "none", outcome: rt ? "detected" : "none", }); if (!rt) { clearSegment(); ignoreStaleContext(() => ctx.ui.setStatus(STATUS_ID, undefined)); return; } const text = rt.version ?? rt.name; const accepted = ctx.mode === "tui" && offerSegment({ id: SEGMENT_ID, icon: rt.icon, text, color: RUNTIME_COLORS[rt.name], zone: SEGMENT_ZONE, order: SEGMENT_ORDER, }); if (!accepted) clearSegment(); ignoreStaleContext(() => ctx.ui.setStatus(STATUS_ID, accepted ? undefined : `${rt.icon} ${text}`), ); } pi.on("session_start", async (_e, ctx) => { dbg?.("session.start"); generation++; hasUI = ctx.hasUI; const setting = resolveSetting(pi, ctx, VERSION_TIMEOUT_SETTING); versionTimeoutMs = setting.ok ? setting.value : VERSION_TIMEOUT_SETTING.default; if (!setting.ok && hasUI) ctx.ui.notify( `${setting.error}; using ${VERSION_TIMEOUT_SETTING.default} ms`, "warning", ); void refresh(ctx); }); pi.on("agent_end", async (_e, ctx) => { if (hasUI) void refresh(ctx); }); pi.on("session_shutdown", async (_e, ctx) => { generation++; clearSegment(); ignoreStaleContext(() => { if (ctx.hasUI) ctx.ui.setStatus(STATUS_ID, undefined); }); dbg?.("session.shutdown"); closeDebug(); }); }