/** * @file Which npm-distributed AI tools are npm-MANAGED (their binary resolves * under `npm prefix -g`, so an npm update actually replaces what runs) and * whether a newer version is published. A tool installed another way — Codex's * standalone build, say — is never offered an npm update: it would install a * second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH * first), not the pinned Node's beside the server. Cached for a day. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { execFile } = require("node:child_process"); const aiTools = require("./ai-tools"); const { shellPath } = require("./shell-path"); const { compareVersions } = require("./update-check"); const CACHE_MS = 24 * 60 * 60 * 1000; const TIMEOUT_MS = 5000; let cache = null; // { at, data } const defaultExec = (file, args, opts) => new Promise((resolve, reject) => execFile(file, args, { ...opts, encoding: "utf8" }, (err, stdout) => err ? reject(err) : resolve({ stdout }) ) ); let exec = defaultExec; /** The PATH a job runs with: the shell's own order first, then the server's. */ const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter); /** The login shell's npm, else the server's; null when there is none. */ function npmBin() { const sp = shellPath(); return (sp && aiTools.which("npm", sp)) || aiTools.which("npm"); } const run = (bin, args) => { const { file, shell } = aiTools.spawnTarget(bin); return exec(file, args, { shell, timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } }); }; const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0]; async function npmPrefix(npm) { try { return (await run(npm, ["prefix", "-g"])).stdout.trim() || null; } catch { return null; } } function under(prefix, file) { try { const real = fs.realpathSync(file); const base = fs.realpathSync(prefix); return real.startsWith(base + path.sep); } catch { return false; } } /** Whether tool `id` is installed AND lives under npm's global prefix. */ async function npmManaged(id, prefix) { const d = aiTools.detect(id); if (!d.installed) return false; if (prefix === undefined) { const npm = npmBin(); prefix = npm ? await npmPrefix(npm) : null; } return !!prefix && under(prefix, d.path); } async function compute() { const npm = npmBin(); const prefix = npm ? await npmPrefix(npm) : null; const tools = aiTools .list() .filter((t) => t.update?.kind === "npm" && aiTools.detect(t.id).installed); const rows = await Promise.all( tools.map(async (t) => { const managed = await npmManaged(t.id, prefix); if (!managed) { return [t.id, { npmManaged: false, installed: null, latest: null, updateAvailable: false }]; } const [inst, latest] = await Promise.all([ run(aiTools.detect(t.id).path, ["--version"]).then( (r) => parseVersion(r.stdout), () => null ), run(npm, ["view", t.install.package, "version"]).then( (r) => parseVersion(r.stdout), () => null ), ]); const updateAvailable = !!(inst && latest && compareVersions(latest, inst) > 0); return [t.id, { npmManaged: true, installed: inst, latest, updateAvailable }]; }) ); return Object.fromEntries(rows); } /** `{[id]: {npmManaged, installed, latest, updateAvailable}}`, cached a day. */ async function versions({ fresh = false } = {}) { if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.data; const data = await compute(); cache = { at: Date.now(), data }; return data; } function invalidate() { cache = null; } /** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */ function __setExec(fn) { exec = fn || defaultExec; cache = null; } module.exports = { versions, invalidate, npmManaged, npmBin, jobPath, parseVersion, __setExec };