import { execFileSync } from "child_process"; import { existsSync, lstatSync, readFileSync, readlinkSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; import { homedir } from "os"; import path from "path"; import { fileURLToPath } from "url"; import bundledPkg from "../package.json" with { type: "json" }; const CACHE_DIR = path.join(homedir(), ".cache", "agent-yes"); const CACHE_FILE = path.join(CACHE_DIR, "update-check.json"); const TTL_MS = 60 * 60 * 1000; // 1 hour // The release pipeline publishes both `agent-yes` and `claude-yes` from the // same source by flipping `package.json#name` and re-running `npm publish` // (which now triggers `bun run build`, rebuilding dist with whichever name is // set). The auto-updater's registry lookup, install command, and shared // cache file must all stay pinned to the canonical package — otherwise a // `claude-yes` install would query `claude-yes/latest` while `runInstall` // still hard-codes `agent-yes`. const CANONICAL_PKG_NAME = "agent-yes"; let cachedInstalledPkg: { name: string; version: string } | null = null; /** * Read the live `package.json` from disk for the running module. * * The bundled `package.json` import is inlined at build time; if `dist/` is * published without a fresh build (issue #39), the inlined `version` lies * and the auto-update loop fires forever. Reading the on-disk manifest each * run keeps the version honest even when the bundle is stale. */ export function getInstalledPackage(): { name: string; version: string } { if (cachedInstalledPkg) return cachedInstalledPkg; let dir: string | null = null; try { dir = path.dirname(fileURLToPath(import.meta.url)); } catch { // import.meta.url malformed; fall through to bundled } if (dir) { for (let i = 0; i < 6; i++) { const candidate = path.join(dir, "package.json"); // A per-candidate try/catch: a transient read error, partial write, or // BOM on any single package.json must NOT abort the upward walk — // otherwise we'd silently fall back to the stale bundled manifest that // issue #39 was about. Keep walking until we either find a matching // manifest or exhaust parents. try { if (existsSync(candidate)) { const json = JSON.parse(readFileSync(candidate, "utf8")) as { name?: string; version?: string; }; if (json.name === bundledPkg.name && typeof json.version === "string") { cachedInstalledPkg = { name: json.name, version: json.version }; return cachedInstalledPkg; } } } catch { // unreadable / unparsable — continue walking } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } } cachedInstalledPkg = { name: bundledPkg.name, version: bundledPkg.version }; return cachedInstalledPkg; } /** Test-only: clear or seed the memoized lookup. */ export function _setInstalledPackageForTesting( value: { name: string; version: string } | null, ): void { cachedInstalledPkg = value; } type UpdateCache = { checkedAt: number; latestVersion: string }; async function readUpdateCache(): Promise { try { const raw = await readFile(CACHE_FILE, "utf8"); return JSON.parse(raw) as UpdateCache; } catch { return null; } } async function writeUpdateCache(data: UpdateCache): Promise { await mkdir(CACHE_DIR, { recursive: true }); await writeFile(CACHE_FILE, JSON.stringify(data)); } function detectPackageManager(): string { if ( process.env.BUN_INSTALL || process.execPath?.includes("bun") || process.env.npm_execpath?.includes("bun") ) return "bun"; return "npm"; } /** * Check for updates, auto-install if newer version is available, and re-exec * so the current invocation always runs the latest code. * * Uses a 1-hour TTL cache to avoid hitting the registry on every run. * All errors are swallowed — network issues must never break the tool. * Set AGENT_YES_NO_UPDATE=1 to opt out. * * The AGENT_YES_UPDATED env var prevents infinite re-exec loops: * after updating we re-exec with AGENT_YES_UPDATED= so the * new process skips the update check. */ export async function checkAndAutoUpdate(): Promise { if (process.env.AGENT_YES_NO_UPDATE) return; // Prevent infinite re-exec: if we just updated, skip if (process.env.AGENT_YES_UPDATED) return; // Skip auto-update when running from a linked local dev checkout (git repo) if (import.meta.url.startsWith("file://") && !import.meta.url.includes("node_modules")) { // Use fileURLToPath rather than `new URL(url).pathname`: on Windows the // pathname of file:///C:/foo is "/C:/foo" (leading slash before the drive // letter), which path.dirname/path.resolve misinterpret — the .git lookup // then runs against a non-existent path and auto-update fires on dev clones. const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); if (existsSync(path.join(repoRoot, ".git"))) return; } try { let latestVersion: string | undefined; // Check cache TTL const cache = await readUpdateCache(); if (cache && Date.now() - cache.checkedAt < TTL_MS) { latestVersion = cache.latestVersion; } else { // Fetch latest from registry const fetched = await fetchLatestVersion(); if (!fetched) return; latestVersion = fetched; await writeUpdateCache({ checkedAt: Date.now(), latestVersion }); } if (compareVersions(getInstalledPackage().version, latestVersion) < 0) { const installed = await runInstall(latestVersion); if (installed) { reExec(latestVersion); } } } catch { // Silently ignore all errors } } async function runInstall(latestVersion: string): Promise { const pm = detectPackageManager(); const installCmd = pm === "bun" ? `bun add -g ${CANONICAL_PKG_NAME}@${latestVersion}` : `npm install -g ${CANONICAL_PKG_NAME}@${latestVersion}`; process.stderr.write( `\x1b[33m[agent-yes] Updating ${getInstalledPackage().version} → ${latestVersion}…\x1b[0m\n`, ); try { const { execaCommand } = await import("execa"); await execaCommand(installCmd, { stdio: "inherit" }); process.stderr.write(`\x1b[32m[agent-yes] Updated to ${latestVersion}\x1b[0m\n`); return true; } catch { process.stderr.write(`\x1b[31m[agent-yes] Auto-update failed. Run: ${installCmd}\x1b[0m\n`); return false; } } /** * Re-exec the current process so the newly installed version runs. * Sets AGENT_YES_UPDATED= to prevent an infinite loop. */ function reExec(version: string): never { const [bin, ...args] = process.argv; process.stderr.write(`\x1b[36m[agent-yes] Restarting with v${version}…\x1b[0m\n`); try { execFileSync(bin, args, { stdio: "inherit", env: { ...process.env, AGENT_YES_UPDATED: version }, }); process.exit(0); } catch (err: any) { process.exit(err.status ?? 1); } } /** * Fetch the latest version of the package from npm registry */ export async function fetchLatestVersion(): Promise { try { const response = await fetch(`https://registry.npmjs.org/${CANONICAL_PKG_NAME}/latest`, { signal: AbortSignal.timeout(3000), // 3 second timeout }); if (!response.ok) { return null; } const data = (await response.json()) as { version: string }; return data.version; } catch { // Silently fail if network is unavailable or request times out return null; } } /** A version split into its numeric core and its prerelease identifiers. */ interface ParsedVersion { /** Kept as STRINGS — see `compareNumericStrings` for why, not `Number`. */ core: string[]; /** Empty for a release; `["beta", "719", "1"]` for `-beta.719.1`. */ pre: string[]; } /** * Compare two runs of digits as numbers, without going through `Number`. * * `Number` is exact only below 2^53, so a build counter past that compares EQUAL * to its neighbour and the ordering silently collapses — the same class of * mistake as the NaN this file is fixing, just further out. Digit strings have no * such ceiling: strip leading zeros, then longer wins, then lexicographic. */ function compareNumericStrings(a: string, b: string): number { const x = a.replace(/^0+(?=\d)/, ""); const y = b.replace(/^0+(?=\d)/, ""); if (x.length !== y.length) return x.length > y.length ? 1 : -1; return x === y ? 0 : x > y ? 1 : -1; } /** * Parse `1.2.3`, `v1.2.3`, `1.2.3-beta.4`, `1.2.3+build`. Returns null for * anything else — including an empty string, a range, or a dist-tag name — so a * caller can tell "older" from "I cannot read this". * * Build metadata is parsed and discarded: semver says it takes no part in * precedence. * * Deliberately more PERMISSIVE than strict semver about what it accepts — a * leading zero in an identifier, a core shorter or longer than three fields. * This is an ordering function, not a validator: the cost of rejecting a version * npm actually published would be paid as a refused upgrade, and the cost of * ordering an odd-but-real one is nothing. */ export function parseVersion(v: string): ParsedVersion | null { const m = /^v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v.trim()); if (!m) return null; return { core: m[1]!.split("."), pre: m[2] ? m[2].split(".") : [] }; } /** * Compare two versions. 1 if v1 > v2, -1 if v1 < v2, 0 if equal OR if either * side cannot be read. * * That last clause is the whole point of this rewrite. The previous version was * `v.split(".").map(Number)` with `parts[i] || 0`, which cannot see a * prerelease: `"1.290.6-beta.719.1"` splits to `["1","290","6-beta","719","1"]`, * `Number("6-beta")` is NaN, and `NaN || 0` is 0 — so the build compared as * 1.290.0 and read as OLDER than 1.290.5. `checkAndAutoUpdate` acts on exactly * that answer, so it installed the older release over the newer prerelease and * re-execed. Measured on a live fleet: every machine on a prerelease reverted * itself to the `latest` dist-tag on its next invocation, silently. * * So an unreadable version now answers 0, not -1. A comparison that cannot parse * its input must never be the thing that says "downgrade" — the honest answer to * "is this older?" when you cannot tell is "no", because the caller's action on * a yes is destructive and its action on a no is nothing. * * Precedence, per semver: numeric core field by field, a version WITH a * prerelease below the same core without one, prerelease identifiers compared * numerically when both are numeric (numeric below alphanumeric otherwise), and * a shorter run of identifiers below a longer one that shares its prefix. */ export function compareVersions(v1: string, v2: string): number { const a = parseVersion(v1); const b = parseVersion(v2); if (!a || !b) return 0; for (let i = 0; i < Math.max(a.core.length, b.core.length); i++) { const cmp = compareNumericStrings(a.core[i] ?? "0", b.core[i] ?? "0"); if (cmp !== 0) return cmp; } if (a.pre.length === 0 && b.pre.length === 0) return 0; // A release outranks any prerelease of the same core: 1.2.3 > 1.2.3-beta. if (a.pre.length === 0) return 1; if (b.pre.length === 0) return -1; for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { const x = a.pre[i]; const y = b.pre[i]; if (x === undefined) return -1; // shorter prefix is lower if (y === undefined) return 1; const xNum = /^\d+$/.test(x); const yNum = /^\d+$/.test(y); if (xNum && yNum) { const cmp = compareNumericStrings(x, y); if (cmp !== 0) return cmp; } else if (xNum !== yNum) { return xNum ? -1 : 1; // numeric identifiers rank below alphanumeric ones } else if (x !== y) { return x > y ? 1 : -1; } } return 0; } /** * Detect how agent-yes was installed. * Returns a short label: "git", "bun", "npm", "npx", "source", or "unknown". * A bun-link of a git checkout reports "git" (it runs the working tree). */ export function detectInstallMethod(): string { try { // fileURLToPath handles Windows drive letters correctly; new URL().pathname // yields "/C:/…", which breaks existsSync and the substring checks below. const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const norm = scriptDir.replace(/\\/g, "/"); const hasGit = (dir: string) => existsSync(path.join(dir, ".git")); if (!norm.includes("node_modules")) { // Running directly from a checkout (git clone, or a resolved bun-link). return hasGit(path.resolve(scriptDir, "..")) ? "git" : "source"; } // Inside node_modules: a bun-link symlink points back at the local repo. const nodeModulesEntry = scriptDir.replace(/[\\/]dist$/, ""); try { if (lstatSync(nodeModulesEntry).isSymbolicLink()) { const resolved = path.resolve( path.dirname(nodeModulesEntry), readlinkSync(nodeModulesEntry), ); return hasGit(resolved) ? "git" : "bun"; } } catch { // not a symlink — fall through to package-manager detection } // A real package install — figure out the manager. if (norm.includes("/.bun/")) return "bun"; if (norm.includes("/.npm/")) return "npx"; if (process.env.npm_config_user_agent?.startsWith("bun")) return "bun"; if (process.env.npm_execpath?.includes("bun")) return "bun"; if (process.env.npm_config_user_agent?.startsWith("npm")) return "npm"; return "npm"; } catch { return "unknown"; } } /** * Format version string with install method */ export function versionString(): string { return `agent-yes v${getInstalledPackage().version} (${detectInstallMethod()})`; } /** * Display version information with async latest version check */ export async function displayVersion(): Promise { console.log(versionString()); const latestVersion = await fetchLatestVersion(); if (latestVersion) { const comparison = compareVersions(getInstalledPackage().version, latestVersion); if (comparison < 0) { console.log(`\x1b[33m${latestVersion} (update available)\x1b[0m`); } else if (comparison > 0) { console.log(`${latestVersion} (latest published)`); } else { console.log(`${latestVersion} (latest)`); } } else { console.log("(unable to check for updates)"); } }