feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 = (file, args) =>
|
||||
exec(file, args, { 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 };
|
||||
Reference in New Issue
Block a user