/** * Rust binary helper - finds or downloads the appropriate prebuilt binary */ import { execFileSync } from "child_process"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from "fs"; import { chmod, copyFile } from "fs/promises"; import path from "path"; import { compareVersions, getInstalledPackage } from "./versionChecker.ts"; // Platform/arch to binary name mapping const PLATFORM_MAP: Record = { "linux-x64": "agent-yes-linux-x64-musl", // Use musl for better compatibility "linux-arm64": "agent-yes-linux-arm64-musl", "darwin-x64": "agent-yes-darwin-x64", "darwin-arm64": "agent-yes-darwin-arm64", "win32-x64": "agent-yes-win32-x64", }; /** * Get the binary name for the current platform */ export function getBinaryName(): string { const platform = process.platform; const arch = process.arch; const key = `${platform}-${arch}`; const binaryName = PLATFORM_MAP[key]; if (!binaryName) { throw new Error( `Unsupported platform: ${platform}-${arch}. ` + `Supported: ${Object.keys(PLATFORM_MAP).join(", ")}`, ); } return binaryName + (platform === "win32" ? ".exe" : ""); } /** * The version-scoped download cache root (`~/.cache/agent-yes/bin/`) WITHOUT * the version segment. Independent of `getBinDir()`'s npm-package shortcut: * GC only ever touches the user cache, never a packaged bin dir. */ function getCacheBinRoot(): string { const cacheDir = process.env.AGENT_YES_CACHE_DIR || path.join( process.env.XDG_CACHE_HOME || path.join(process.env.HOME || "/tmp", ".cache"), "agent-yes", ); return path.join(cacheDir, "bin"); } /** * Get the directory where binaries are stored */ export function getBinDir(): string { // First check for binaries in the npm package const packageBinDir = path.resolve(import.meta.dirname ?? import.meta.dir, "../bin"); if (existsSync(packageBinDir)) { return packageBinDir; } // Fall back to user's cache directory. // // IMPORTANT: scope the cache by package version. The download cache is shared // across every agent-yes version a user has ever run, but the binary filename // (e.g. agent-yes-win32-x64.exe) is NOT version-qualified — so without a // version segment, findRustBinary() would happily return a stale binary that a // previous (older) release downloaded, and a published binary fix would never // reach a machine that already has one cached. A version segment makes a new // release miss the path and re-download; old binaries sit harmlessly beside it. return path.join(getCacheBinRoot(), getInstalledPackage().version); } // Only a strict `x.y.z` release version is ever eligible for GC. A pre-release // or dev suffix (e.g. `1.270.0-beta.0`) is never deleted, even if semver would // order it below the current version — a user may still be running it. const STRICT_SEMVER = /^\d+\.\d+\.\d+$/; export interface BinaryGcResult { /** Version dir names that were removed. */ removed: string[]; /** Total bytes of removed files. */ freedBytes: number; } function dirSizeBytes(dir: string): number { let total = 0; try { for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) total += dirSizeBytes(p); else if (entry.isFile()) { try { total += statSync(p).size; } catch { /* raced with deletion — count what we can */ } } } } catch { /* unreadable dir — best effort */ } return total; } /** * Delete versioned binary cache dirs strictly older than the CURRENT package * version. * * `~/.cache/agent-yes/bin//` accumulates one dir per release a user * has ever run (PERFORMANCE-EVENT 2026-08-13), each holding a ~20-30 MiB * binary that nothing ever cleaned up. Keep the current version; delete only * dirs named `x.y.z` that compare strictly less than it (never pre-release / * dev, never anything newer or malformed). Idempotent and best-effort: a dir * that cannot be removed simply survives until the next run. */ export function gcOldBinaryDirs(): BinaryGcResult { const result: BinaryGcResult = { removed: [], freedBytes: 0 }; const current = getInstalledPackage().version; if (!STRICT_SEMVER.test(current)) return result; // dev/pre-release current — do nothing const root = getCacheBinRoot(); let entries: string[]; try { entries = readdirSync(root); } catch { return result; // no cache dir at all — nothing to collect } for (const name of entries) { if (!STRICT_SEMVER.test(name)) continue; if (compareVersions(name, current) >= 0) continue; const dir = path.join(root, name); const size = dirSizeBytes(dir); try { rmSync(dir, { recursive: true, force: true }); result.removed.push(name); result.freedBytes += size; } catch { // locked/in-use dir — leave it for the next run } } return result; } /** * Find the Rust binary, checking multiple locations */ export function findRustBinary(verbose = false): string | undefined { const binaryName = getBinaryName(); const ext = process.platform === "win32" ? ".exe" : ""; const searchPaths = [ // 1. Check relative to this script (in the repo during development) path.resolve(import.meta.dirname ?? import.meta.dir, `../rs/target/release/agent-yes${ext}`), path.resolve(import.meta.dirname ?? import.meta.dir, `../rs/target/debug/agent-yes${ext}`), // 2. Check in npm package bin directory path.join(getBinDir(), binaryName), // 3. Check in user's cache directory path.join(getBinDir(), binaryName), ]; if (verbose) { console.log(`[rust] Looking for binary: ${binaryName}`); console.log(`[rust] Search paths:`); } for (const p of searchPaths) { if (verbose) { console.log(`[rust] - ${p}: ${existsSync(p) ? "FOUND" : "not found"}`); } if (existsSync(p)) { return p; } } return undefined; } /** * Locate the `ay-spawn-hidden` launcher (Windows-only). Returns undefined off * Windows, or when the launcher isn't present — older installs, or a release * that predates it. Callers MUST fall back to spawning the program directly. * * The launcher is a second `[[bin]]` built alongside `agent-yes`, so it sits * next to the Rust binary in every install shape: the cargo `target/` build * dir, the downloaded release bin dir (getBinDir), and `~/.cargo/bin` on PATH. */ export function findSpawnHiddenLauncher(): string | undefined { if (process.platform !== "win32") return undefined; const dir = import.meta.dirname ?? import.meta.dir; const searchPaths = [ // 1. Dev build, right next to the target/release agent-yes.exe path.resolve(dir, "../rs/target/release/ay-spawn-hidden.exe"), path.resolve(dir, "../rs/target/debug/ay-spawn-hidden.exe"), // 2. npm package bin / version-scoped download cache (getBinDir), where the // win32 release zip extracts it beside agent-yes-win32-x64.exe. path.join(getBinDir(), "ay-spawn-hidden.exe"), // 3. cargo-installed (`bun run build:rs`) → on PATH. Bun.which("ay-spawn-hidden") ?? "", ]; for (const p of searchPaths) { if (p && existsSync(p)) return p; } return undefined; } /** * Locate the `agent-yes-tray` binary (the system-tray companion). Returns * undefined when it isn't present (not built / not shipped for this platform — * Windows-first today). Unlike the spawn-hidden launcher this is NOT gated to * Windows, so macOS/Linux builds are found once they exist. Lives in its OWN * crate (rs-tray), so it sits under rs-tray/target rather than rs/target. */ export function findTrayLauncher(): string | undefined { const exe = process.platform === "win32" ? "agent-yes-tray.exe" : "agent-yes-tray"; const dir = import.meta.dirname ?? import.meta.dir; const searchPaths = [ path.resolve(dir, `../rs-tray/target/release/${exe}`), path.resolve(dir, `../rs-tray/target/debug/${exe}`), path.join(getBinDir(), exe), Bun.which("agent-yes-tray") ?? "", ]; for (const p of searchPaths) { if (p && existsSync(p)) return p; } return undefined; } /** * Locate the `ayrs` binary (the Rust `serve` daemon). Same install shapes as * the agent runtime — a dev `rs/target` build, the downloaded release bin dir * (CI ships ayrs in every platform bundle since #473), or `~/.cargo/bin` on * PATH after `bun run build:rs`. Undefined when absent (an older release, or a * platform whose bundle predates it) — callers fall back to the TS server. */ export function findAyrsBinary(): string | undefined { const exe = process.platform === "win32" ? "ayrs.exe" : "ayrs"; const dir = import.meta.dirname ?? import.meta.dir; const searchPaths = [ path.resolve(dir, `../rs/target/release/${exe}`), path.resolve(dir, `../rs/target/debug/${exe}`), path.join(getBinDir(), exe), Bun.which("ayrs") ?? "", ]; for (const p of searchPaths) { if (p && existsSync(p)) return p; } return undefined; } /** * Get GitHub release download URL for the binary */ export function getDownloadUrl(version = "latest"): string { const binaryName = getBinaryName().replace(/\.exe$/, ""); const isWindows = process.platform === "win32"; const ext = isWindows ? ".zip" : ".tar.gz"; if (version === "latest") { return `https://github.com/snomiao/agent-yes/releases/latest/download/${binaryName}${ext}`; } return `https://github.com/snomiao/agent-yes/releases/download/v${version}/${binaryName}${ext}`; } /** * Download and extract the binary */ export async function downloadBinary(verbose = false): Promise { const binDir = getBinDir(); const binaryName = getBinaryName(); const binaryPath = path.join(binDir, binaryName); // Create bin directory if needed mkdirSync(binDir, { recursive: true }); // Pin the download to THIS package's version, not "latest". The shim is // version-pinned by bunx/npm, so its binary must match — pulling "latest" // could fetch a newer binary than the shim expects, and is incoherent with // the version-scoped cache dir above. The release flow guarantees vX's // binaries are uploaded before agent-yes@X is published. const url = getDownloadUrl(getInstalledPackage().version); if (verbose) { console.log(`[rust] Downloading binary from: ${url}`); } const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to download binary: ${response.status} ${response.statusText}`); } const isWindows = process.platform === "win32"; if (isWindows) { // For Windows, download and extract zip const tempZipPath = path.join(binDir, "temp.zip"); await Bun.write(tempZipPath, await response.arrayBuffer()); // Use PowerShell to extract zip const proc = Bun.spawn( [ "powershell", "-Command", `Expand-Archive -Path '${tempZipPath}' -DestinationPath '${binDir}' -Force`, ], { cwd: binDir, stdio: ["ignore", "pipe", "pipe"] }, ); await proc.exited; // Clean up try { unlinkSync(tempZipPath); } catch {} } else { // For Unix, download and extract tar.gz const tarPath = path.join(binDir, "temp.tar.gz"); await Bun.write(tarPath, await response.arrayBuffer()); // Extract using tar command const proc = Bun.spawn(["tar", "-xzf", tarPath, "-C", binDir], { cwd: binDir, stdio: ["ignore", "pipe", "pipe"], }); await proc.exited; // The extracted file might have a different name, find and rename it const extractedName = binaryName.replace(/-musl$/, "").replace(/-gnu$/, ""); const possibleNames = ["agent-yes", extractedName, binaryName]; for (const name of possibleNames) { const extractedPath = path.join(binDir, name); if (existsSync(extractedPath) && extractedPath !== binaryPath) { try { await copyFile(extractedPath, binaryPath); unlinkSync(extractedPath); } catch {} break; } } // Clean up tar file try { unlinkSync(tarPath); } catch {} // Make executable await chmod(binaryPath, 0o755); } if (verbose) { console.log(`[rust] Binary downloaded to: ${binaryPath}`); } return binaryPath; } /** * Get the version of a Rust binary by running it with --version */ function getRustBinaryVersion(binaryPath: string): string | null { try { const output = execFileSync(binaryPath, ["--version"], { timeout: 5000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); // Output is like "agent-yes 1.72.3" or "agent-yes v1.72.3" const match = output.match(/(\d+\.\d+\.\d+)/); return match ? (match[1] ?? null) : null; } catch { return null; } } /** * Locate a local dev build from its binary path: `/target/{release,debug}/agent-yes[.exe]`. * Accepts both `/` and `\` separators — `path.resolve` yields backslashes on * Windows, and a `/`-only match silently disabled the auto-rebuild there (the * stale `target/release` exe then kept shipping bugs already fixed on main). * Returns undefined for a downloaded/cached (non-dev) binary. */ export function devBuildInfo( binaryPath: string, ): { rsDir: string; isRelease: boolean } | undefined { const m = binaryPath.match(/^(.*)[\\/]target[\\/](release|debug)[\\/]agent-yes(?:\.exe)?$/); if (!m) return undefined; return { rsDir: m[1]!, isRelease: m[2] === "release" }; } /** * Check if a binary path is inside a git repo (dev build), and rebuild if outdated. * Returns the same path if up-to-date or rebuilt, undefined if rebuild failed. */ function autoRebuildIfOutdated(binaryPath: string, verbose: boolean): boolean { // Only auto-rebuild for local dev builds (target/release or target/debug) const dev = devBuildInfo(binaryPath); if (!dev) { return true; // not a dev build, skip } const binaryVersion = getRustBinaryVersion(binaryPath); const pkgVersion = getInstalledPackage().version; if (verbose) { console.log(`[rust] Binary version: ${binaryVersion}, package version: ${pkgVersion}`); } if (binaryVersion === pkgVersion) { return true; // up to date } // Find the rs/ directory relative to the binary (binary is at rs/target/release/agent-yes) const { rsDir, isRelease } = dev; if (!existsSync(path.join(rsDir, "Cargo.toml"))) { if (verbose) console.log(`[rust] Cannot find Cargo.toml at ${rsDir}, skipping rebuild`); return true; // can't rebuild, use as-is } process.stderr.write( `\x1b[33m[rust] Binary outdated (${binaryVersion ?? "unknown"} → ${pkgVersion}), rebuilding…\x1b[0m\n`, ); try { const args = ["build", ...(isRelease ? ["--release"] : [])]; execFileSync("cargo", args, { cwd: rsDir, stdio: "inherit", timeout: 300_000, // 5 min max }); // Only the target/ binary is rebuilt — we deliberately do NOT `cargo install` // a system-wide `agent-yes`. The launcher is the single global command (it's // what's linked onto PATH) and it spawns this binary from target/release via // findRustBinary(); a cargo-installed `agent-yes` would shadow that launcher. process.stderr.write(`\x1b[32m[rust] Rebuild complete\x1b[0m\n`); return true; } catch { process.stderr.write(`\x1b[31m[rust] Auto-rebuild failed, using outdated binary\x1b[0m\n`); return true; // still usable, just old } } /** * Get or download the Rust binary */ export async function getRustBinary( options: { verbose?: boolean; forceDownload?: boolean; } = {}, ): Promise { const { verbose = false, forceDownload = false } = options; // Startup GC: collect cache dirs left behind by OLDER package versions (each // release pins its own / subdir; nothing ever deleted them — see // PERFORMANCE-EVENT 2026-08-13). Safe and idempotent: only strict x.y.z dirs // that are strictly below the current version are removed, never this // version's dir, and all failures are swallowed. try { const freed = gcOldBinaryDirs(); if (verbose && freed.removed.length > 0) { console.log( `[rust] GC removed ${freed.removed.length} old binary cache dir(s): ${freed.removed.join(", ")}`, ); } } catch { /* never block the agent run on cache GC */ } // First try to find existing binary if (!forceDownload) { const existing = findRustBinary(verbose); if (existing) { if (verbose) { console.log(`[rust] Using existing binary: ${existing}`); } // Auto-rebuild if it's a dev build and version is outdated autoRebuildIfOutdated(existing, verbose); return existing; } } // Download if not found if (verbose) { console.log(`[rust] Binary not found, downloading...`); } try { return await downloadBinary(verbose); } catch (err) { throw new Error( `Failed to get Rust binary: ${err instanceof Error ? err.message : err}\n` + `You can build manually with: cd rs && cargo build --release`, ); } }