/** * @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. * A system-wide Node (`/usr/local`, root's) leaves npm's global prefix * unwritable to the dashboard's account, so there a global install goes to the * account's own `~/.local` (`--prefix`, bin in `~/.local/bin`) instead, and a * tool found under `~/.local/lib/node_modules` counts as npm-managed there. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); 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; let userPrefixImpl = () => process.platform === "win32" ? null : path.join(os.homedir(), ".local"); /** 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; } } /** The account's own npm prefix (`~/.local`), or null where npm has none (Windows). */ const userPrefix = () => userPrefixImpl(); /** Whether npm can write `dir` — or, when it is not there yet, its nearest existing parent. */ function writable(dir) { let d = dir; for (;;) { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch (err) { if (err.code !== "ENOENT") return false; } const up = path.dirname(d); if (up === d) return false; d = up; } } /** Whether a global install into `prefix` would succeed: its package and bin folders. */ const prefixWritable = (prefix) => process.platform === "win32" ? writable(prefix) : writable(path.join(prefix, "lib", "node_modules")) && writable(path.join(prefix, "bin")); /** * The `--prefix` a global install needs: none while npm's own global prefix is * writable, else the account's `~/.local` (a system-wide Node: EACCES otherwise). */ async function installPrefixArgs() { const npm = npmBin(); const prefix = npm ? await npmPrefix(npm) : null; const up = userPrefix(); if (!prefix || !up || prefixWritable(prefix)) return []; return ["--prefix", up]; } /** * Which npm prefix holds tool `id`: `"global"` (npm's own), `"user"` (the * account's `~/.local/lib/node_modules`), or null — not installed, or not by npm. */ async function managedBy(id, prefix) { const d = aiTools.detect(id); if (!d.installed) return null; if (prefix === undefined) { const npm = npmBin(); prefix = npm ? await npmPrefix(npm) : null; } if (prefix && under(prefix, d.path)) return "global"; const up = userPrefix(); if (up && under(path.join(up, "lib", "node_modules"), d.path)) return "user"; return null; } /** Whether tool `id` is installed AND lives under npm's global or the account's prefix. */ const npmManaged = async (id, prefix) => !!(await managedBy(id, prefix)); 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: the account's npm prefix (a path, or null for none); undefined restores it. */ function __setUserPrefix(p) { userPrefixImpl = p === undefined ? () => (process.platform === "win32" ? null : path.join(os.homedir(), ".local")) : () => p; } /** 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, managedBy, installPrefixArgs, userPrefix, npmBin, jobPath, parseVersion, __setExec, __setUserPrefix, };