feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file Server-side favicon cache for the AI Tools list, so the browser never
|
||||
* calls a third-party site. One `https://<domain>/favicon.ico` fetch per tool
|
||||
* — the domain is the catalog's `faviconDomain` or the homepage host, a dotted
|
||||
* hostname only (no IP literal, no port) — kept in `<data dir>/runtime/
|
||||
* ai-tool-icons/`. Raster types only, recognised by their MAGIC BYTES — the
|
||||
* header is ignored, since a site may label anything image/png (or send no
|
||||
* type at all) — because an SVG or HTML page served from the dashboard's own
|
||||
* origin would run script if its URL were opened directly. A miss is
|
||||
* remembered for a day.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const aiTools = require("./ai-tools");
|
||||
|
||||
// Multi-resolution .ico files are routinely 100 KB+ (cursor.com's is 115 KB).
|
||||
const MAX_BYTES = 256 * 1024;
|
||||
const MISS_MS = 24 * 60 * 60 * 1000;
|
||||
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||
/** The raster type of `body` by its signature, or null (SVG, HTML, anything else). */
|
||||
function sniff(body) {
|
||||
const hex = body.subarray(0, 4).toString("hex");
|
||||
if (hex === "89504e47") return "image/png";
|
||||
if (hex === "00000100") return "image/x-icon";
|
||||
if (hex === "47494638") return "image/gif";
|
||||
if (hex.startsWith("ffd8ff")) return "image/jpeg";
|
||||
if (hex === "52494646" && body.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconDir = () => path.join(getDataDir(), "runtime", "ai-tool-icons");
|
||||
|
||||
/** The domain to ask, or null when the tool names none we may fetch. */
|
||||
function iconDomain(tool) {
|
||||
let d = tool?.faviconDomain;
|
||||
if (!d && tool?.homepageUrl) {
|
||||
try {
|
||||
const u = new URL(tool.homepageUrl);
|
||||
d = u.port ? null : u.hostname;
|
||||
} catch {
|
||||
d = null;
|
||||
}
|
||||
}
|
||||
d = typeof d === "string" ? d.toLowerCase() : null;
|
||||
return d && DOMAIN_RE.test(d) ? d : null;
|
||||
}
|
||||
|
||||
function readCached(id) {
|
||||
const dir = iconDir();
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
|
||||
if (meta.miss) return Date.now() - meta.at < MISS_MS ? { miss: true } : null;
|
||||
return { type: meta.type, body: fs.readFileSync(path.join(dir, `${id}.img`)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCached(id, hit) {
|
||||
const dir = iconDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (hit) fs.writeFileSync(path.join(dir, `${id}.img`), hit.body);
|
||||
const meta = hit ? { type: hit.type, at: Date.now() } : { miss: true, at: Date.now() };
|
||||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(meta));
|
||||
}
|
||||
|
||||
// ponytail: only the root /favicon.ico — ~22 of the 38 catalog sites serve a
|
||||
// raster one there; the rest keep their letter. Parse the homepage's
|
||||
// <link rel="icon"> if more real icons are wanted (many of those are SVG).
|
||||
async function fetchIcon(domain) {
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/favicon.ico`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: "follow",
|
||||
});
|
||||
const final = new URL(res.url || `https://${domain}/`);
|
||||
const sameSite = aiTools.siteOf(final.hostname) === aiTools.siteOf(domain);
|
||||
if (!res.ok || final.protocol !== "https:" || !sameSite) return null;
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length > MAX_BYTES) return null;
|
||||
const type = sniff(body);
|
||||
return type ? { type, body } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `{type, body}` for tool `id`, or null (letter avatar). Never throws. */
|
||||
async function iconFor(id) {
|
||||
const tool = aiTools.get(id);
|
||||
const domain = iconDomain(tool);
|
||||
if (!domain) return null;
|
||||
const cached = readCached(id);
|
||||
if (cached) return cached.miss ? null : cached;
|
||||
const hit = await fetchIcon(domain);
|
||||
try {
|
||||
writeCached(id, hit);
|
||||
} catch {
|
||||
/* a read-only data dir only costs the cache */
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
module.exports = { iconFor, iconDomain };
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file The one-at-a-time install/update runner behind Settings → AI Tools.
|
||||
* Every command comes from a catalog file (never a request) and runs as an
|
||||
* argv with stdin closed and the login shell's PATH first: `npm install -g
|
||||
* <pkg>@latest`, a tool's own updater, or a vendor script the server
|
||||
* downloads (https, same site as the tool's homepage, ≤ 1 MB) and runs with
|
||||
* `bash <file>`. The job lives in memory — a child of the server, so it dies
|
||||
* with it — and it reports its end only AFTER the PATH is re-read and the tool
|
||||
* re-detected, so the client's first look at a finished job is already true.
|
||||
* Each command runs in its own process group: a timeout kills the whole group
|
||||
* (SIGTERM, then SIGKILL), and a job whose child has exited ends even when a
|
||||
* leftover grandchild still holds its output pipes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const aiTools = require("./ai-tools");
|
||||
const versions = require("./ai-tool-versions");
|
||||
const { hydrateShellPath } = require("./shell-path");
|
||||
|
||||
const LOG_CAP = 256 * 1024;
|
||||
let jobTimeoutMs = 10 * 60 * 1000;
|
||||
const SCRIPT_MAX = 1024 * 1024;
|
||||
const EACCES_HINT =
|
||||
"\n[agenthub] npm could not write its global directory (EACCES): this Node is installed " +
|
||||
"system-wide. Use a user-level Node (nvm, fnm) or install the tool by hand.\n";
|
||||
|
||||
const EXIT_GRACE_MS = 500; // after 'exit', how long to wait for the pipes to close
|
||||
const KILL_GRACE_MS = 5000; // SIGTERM → SIGKILL
|
||||
|
||||
let spawnImpl = spawn;
|
||||
let hydrate = hydrateShellPath;
|
||||
let job = null;
|
||||
let starting = false;
|
||||
let seq = 0;
|
||||
|
||||
function makeErr(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
const npmArgv = (pkg) => {
|
||||
const npm = versions.npmBin();
|
||||
if (!npm) throw makeErr("ENONPM", "npm was not found on the login shell's PATH");
|
||||
return [npm, "install", "-g", `${pkg}@latest`];
|
||||
};
|
||||
|
||||
/** The steps one action on one tool would run — `{argv}` or `{script: url}`. */
|
||||
async function stepsFor(id, action) {
|
||||
const tool = aiTools.get(id);
|
||||
if (!tool) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
|
||||
const d = aiTools.detect(id);
|
||||
if (action === "install") {
|
||||
if (!tool.install) throw makeErr("ENOINSTALL", `${tool.label} has no install command`);
|
||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||
return tool.install.kind === "npm"
|
||||
? [{ argv: npmArgv(tool.install.package) }]
|
||||
: [{ script: tool.install.url }];
|
||||
}
|
||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
||||
if (!(await versions.npmManaged(id))) {
|
||||
throw makeErr(
|
||||
"ENOTNPM",
|
||||
`${tool.label} was installed outside npm; update it the way it was installed`
|
||||
);
|
||||
}
|
||||
return [{ argv: npmArgv(tool.install.package) }];
|
||||
}
|
||||
|
||||
function append(j, text) {
|
||||
j.log += text;
|
||||
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
|
||||
}
|
||||
|
||||
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
|
||||
async function download(url) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
|
||||
const final = new URL(res.url || url);
|
||||
const site = aiTools.siteOf(new URL(url).hostname);
|
||||
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
|
||||
throw new Error(`script redirected off ${site}: ${final.href}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length > SCRIPT_MAX) throw new Error("script is larger than 1 MB");
|
||||
const file = path.join(
|
||||
os.tmpdir(),
|
||||
`agenthub-install-${crypto.randomBytes(6).toString("hex")}.sh`
|
||||
);
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Signal the child's whole process group; the child alone when it has none. */
|
||||
function killTree(child, sig) {
|
||||
try {
|
||||
if (child.pid) return void process.kill(-child.pid, sig);
|
||||
} catch {
|
||||
/* group already gone — try the child itself */
|
||||
}
|
||||
try {
|
||||
child.kill?.(sig);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
|
||||
function runArgv(j, argv) {
|
||||
return new Promise((resolve) => {
|
||||
append(j, `$ ${argv.join(" ")}\n`);
|
||||
let child;
|
||||
let done = false;
|
||||
const end = (code) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
j.child = null;
|
||||
resolve(code == null ? 1 : code);
|
||||
};
|
||||
try {
|
||||
child = spawnImpl(argv[0], argv.slice(1), {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, PATH: versions.jobPath() },
|
||||
detached: true, // its own process group, so a timeout reaches grandchildren
|
||||
});
|
||||
} catch (err) {
|
||||
append(j, `${err.message}\n`);
|
||||
return resolve(127);
|
||||
}
|
||||
j.child = child;
|
||||
child.stdout?.on("data", (b) => append(j, String(b)));
|
||||
child.stderr?.on("data", (b) => append(j, String(b)));
|
||||
child.on("error", (err) => append(j, `${err.message}\n`));
|
||||
child.on("close", (code) => end(code));
|
||||
// 'close' waits for every holder of the pipes; a daemon an installer left
|
||||
// behind would hold them forever and keep the job — and EBUSY — alive.
|
||||
child.on("exit", (code) => setTimeout(() => end(code), EXIT_GRACE_MS).unref?.());
|
||||
});
|
||||
}
|
||||
|
||||
async function runSteps(j, steps, onStepOk) {
|
||||
const timer = setTimeout(() => {
|
||||
j.timedOut = true;
|
||||
append(j, "\n[agenthub] ETIMEDOUT: the job ran past 10 minutes and was stopped.\n");
|
||||
const child = j.child;
|
||||
if (!child) return;
|
||||
killTree(child, "SIGTERM");
|
||||
setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref?.();
|
||||
}, jobTimeoutMs);
|
||||
timer.unref?.();
|
||||
let code = 0;
|
||||
try {
|
||||
for (const step of steps) {
|
||||
let argv = step.argv;
|
||||
let tmp = null;
|
||||
if (step.script) {
|
||||
append(j, `$ download ${step.script}\n`);
|
||||
tmp = await download(step.script);
|
||||
argv = ["bash", tmp];
|
||||
}
|
||||
try {
|
||||
code = await runArgv(j, argv);
|
||||
} finally {
|
||||
if (tmp) fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
if (j.timedOut) code = code || 124;
|
||||
if (code !== 0) {
|
||||
if (/EACCES/.test(j.log) && /npm/.test(path.basename(argv[0]))) append(j, EACCES_HINT);
|
||||
break;
|
||||
}
|
||||
onStepOk?.(step);
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `${err.message}\n`);
|
||||
code = code || 1;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function newJob(tool, action) {
|
||||
return {
|
||||
id: ++seq,
|
||||
tool,
|
||||
action,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
log: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-detect, THEN report the end: the client refetches the list once, on the
|
||||
* first poll that sees a non-running status. Never throws — a failure here
|
||||
* (a read-only data dir, a tool gone from the catalog mid-job) goes to the log.
|
||||
*/
|
||||
async function finish(j, code) {
|
||||
try {
|
||||
await hydrate().catch(() => {});
|
||||
aiTools.refresh();
|
||||
versions.invalidate();
|
||||
if (code === 0 && j.action === "install" && aiTools.detect(j.tool).installed) {
|
||||
aiTools.setEnabled(j.tool, true);
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `\n[agenthub] ${err.message}\n`);
|
||||
} finally {
|
||||
j.exitCode = code;
|
||||
j.endedAt = new Date().toISOString();
|
||||
j.status = code === 0 ? "ok" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
function claim() {
|
||||
if (starting || job?.status === "running")
|
||||
throw makeErr("EBUSY", "another install or update is running");
|
||||
starting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start `install` or `update` on one tool. Resolves once it has STARTED with
|
||||
* the public job; the job itself runs on and is read through current().
|
||||
*/
|
||||
async function start(id, action) {
|
||||
claim();
|
||||
let steps;
|
||||
try {
|
||||
steps = await stepsFor(id, action);
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
job = newJob(id, action);
|
||||
const j = job;
|
||||
void runSteps(j, steps)
|
||||
.then((code) => finish(j, code))
|
||||
.catch(() => finish(j, 1));
|
||||
return view(j);
|
||||
}
|
||||
|
||||
/** One job that updates every npm-managed tool with a newer version and every self-updater. */
|
||||
async function startUpdateAll() {
|
||||
claim();
|
||||
let steps = [];
|
||||
try {
|
||||
const v = await versions.versions();
|
||||
for (const t of aiTools.list()) {
|
||||
if (!aiTools.detect(t.id).installed || !t.update) continue;
|
||||
if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) {
|
||||
steps.push(...(await stepsFor(t.id, "update")));
|
||||
}
|
||||
}
|
||||
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
job = newJob(null, "update-all");
|
||||
const j = job;
|
||||
void runSteps(j, steps)
|
||||
.then((code) => finish(j, code))
|
||||
.catch(() => finish(j, 1));
|
||||
return view(j);
|
||||
}
|
||||
|
||||
function view(j) {
|
||||
if (!j) return null;
|
||||
const { child: _c, timedOut: _t, ...rest } = j;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const current = () => view(job);
|
||||
|
||||
/** Test seams. */
|
||||
function __setSpawn(fn) {
|
||||
spawnImpl = fn || spawn;
|
||||
}
|
||||
function __setHydrate(fn) {
|
||||
hydrate = fn || hydrateShellPath;
|
||||
}
|
||||
function __reset({ timeoutMs = 10 * 60 * 1000 } = {}) {
|
||||
job = null;
|
||||
starting = false;
|
||||
jobTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
module.exports = { start, startUpdateAll, current, stepsFor, __setSpawn, __setHydrate, __reset };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Daily copy of the AI Tools catalog from the newest AgentHub release,
|
||||
* so a new tool or a fixed flag reaches a user without a plugin update. Only a
|
||||
* release NEWER than the running build is copied (a dev checkout ahead of the
|
||||
* newest release is never rolled back), each file is validated at release
|
||||
* trust by ai-tools.js, and the set is replaced whole or not at all. Stored
|
||||
* in `<data dir>/runtime/ai-tools-remote/`. Off with AGENTHUB_AI_TOOLS_SYNC=0.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const {
|
||||
compareVersions,
|
||||
releaseApiUrl,
|
||||
fetchReleaseFromUrl,
|
||||
repoUrlFor,
|
||||
DEFAULT_ROOT,
|
||||
} = require("./update-check");
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FIRST_MS = 60 * 1000;
|
||||
const MAX_FILES = 200;
|
||||
const MAX_BYTES = 64 * 1024;
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}\.json$/;
|
||||
|
||||
let versionOverride = null;
|
||||
|
||||
const disabled = () => /^(0|false|off)$/i.test(process.env.AGENTHUB_AI_TOOLS_SYNC || "");
|
||||
const remoteDir = () => path.join(getDataDir(), "runtime", "ai-tools-remote");
|
||||
|
||||
function runningVersion() {
|
||||
if (versionOverride) return versionOverride;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(DEFAULT_ROOT, "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMeta() {
|
||||
try {
|
||||
const m = JSON.parse(fs.readFileSync(path.join(remoteDir(), "meta.json"), "utf8"));
|
||||
return typeof m.tag === "string" ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The synced dir when it holds a release newer than this build; else null. */
|
||||
function activeRemoteDir() {
|
||||
if (disabled()) return null;
|
||||
const meta = readMeta();
|
||||
const running = runningVersion();
|
||||
if (!meta || !running || compareVersions(meta.tag, running) <= 0) return null;
|
||||
return remoteDir();
|
||||
}
|
||||
|
||||
function catalogMeta() {
|
||||
if (!activeRemoteDir()) return { source: "builtin", tag: null, fetchedAt: null };
|
||||
const { tag, fetchedAt } = readMeta();
|
||||
return { source: "remote", tag, fetchedAt: fetchedAt || null };
|
||||
}
|
||||
|
||||
async function getText(url) {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) throw new Error(`http_${res.status}`);
|
||||
// ponytail: reads the body before checking its size; stream with a byte cap
|
||||
// if a forge ever answers with something large enough to matter.
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/** One pass. Never throws; `{ok:false, reason}` on every miss. */
|
||||
async function syncOnce({ repoUrl, root = DEFAULT_ROOT } = {}) {
|
||||
if (disabled()) return { ok: false, reason: "disabled" };
|
||||
const repo = repoUrl || (await repoUrlFor(root));
|
||||
const api = repo && releaseApiUrl(repo);
|
||||
if (!api) return { ok: false, reason: "no_remote" };
|
||||
const rel = await fetchReleaseFromUrl(repo);
|
||||
if (!rel.ok) return { ok: false, reason: rel.reason };
|
||||
const running = runningVersion();
|
||||
if (!running || compareVersions(rel.tag, running) <= 0) return { ok: false, reason: "not_newer" };
|
||||
|
||||
const { validate, refresh } = require("./ai-tools");
|
||||
const origin = new URL(api).origin;
|
||||
const tmp = `${remoteDir()}.tmp-${process.pid}`;
|
||||
try {
|
||||
const listUrl = api.replace(
|
||||
/\/releases\/latest$/,
|
||||
`/contents/server/data/ai-tools?ref=${encodeURIComponent(rel.tag)}`
|
||||
);
|
||||
const entries = JSON.parse(await getText(listUrl));
|
||||
if (!Array.isArray(entries) || entries.length > MAX_FILES) {
|
||||
return { ok: false, reason: "bad_listing" };
|
||||
}
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
for (const e of entries) {
|
||||
if (e?.type !== "file" || !NAME_RE.test(e.name || "")) continue;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(e.download_url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.origin !== origin) continue; // only the forge the release came from
|
||||
const text = await getText(url.href); // a failed download aborts the pass
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) continue;
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (validate(doc, "remote")) fs.writeFileSync(path.join(tmp, e.name), text);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "meta.json"),
|
||||
JSON.stringify({ tag: rel.tag, fetchedAt: new Date().toISOString() }) + "\n"
|
||||
);
|
||||
const old = `${remoteDir()}.old-${process.pid}`;
|
||||
if (fs.existsSync(remoteDir())) fs.renameSync(remoteDir(), old);
|
||||
fs.renameSync(tmp, remoteDir());
|
||||
fs.rmSync(old, { recursive: true, force: true });
|
||||
refresh();
|
||||
return { ok: true, tag: rel.tag };
|
||||
} catch (err) {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
return { ok: false, reason: "unreachable", error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function startAiToolsSync() {
|
||||
if (disabled()) return;
|
||||
setTimeout(() => {
|
||||
void syncOnce();
|
||||
setInterval(() => void syncOnce(), DAY_MS).unref();
|
||||
}, FIRST_MS).unref();
|
||||
}
|
||||
|
||||
/** Test seam: pin the running version, or null for package.json. */
|
||||
function __setVersion(v) {
|
||||
versionOverride = v;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncOnce,
|
||||
startAiToolsSync,
|
||||
activeRemoteDir,
|
||||
catalogMeta,
|
||||
remoteDir,
|
||||
__setVersion,
|
||||
};
|
||||
+89
-11
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
|
||||
* (`server/data/ai-tools/*.json` plus overrides in `DASHBOARD_AI_TOOLS_DIR`),
|
||||
* (`server/data/ai-tools/*.json`, the newer release's copy synced by
|
||||
* ai-tools-sync.js, and overrides in `DASHBOARD_AI_TOOLS_DIR`, in rising
|
||||
* precedence), how each is installed/updated (built-in and release files only),
|
||||
* which of them are installed (a walk of the PATH directories — no process per
|
||||
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
|
||||
* A catalog file is data only: its args and env reach an argv in pty-run.js,
|
||||
* never a shell, and a custom file may not claim a hand-written `launcher`.
|
||||
* never a shell, and a custom file may not claim a hand-written `launcher` nor
|
||||
* an `install`/`update` — an Install click would run whatever it named.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,6 +22,10 @@ const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
// Files shipped in a release (built-in, or synced from the newest release) are
|
||||
// trusted to name a launcher and an install/update; a custom file is not.
|
||||
const TRUSTED = new Set(["builtin", "remote"]);
|
||||
|
||||
let dirsOverride = null;
|
||||
let catalog = null; // Map id -> tool, in catalog order
|
||||
@@ -33,6 +40,47 @@ function makeErr(code, message) {
|
||||
const isStrArray = (v, re) =>
|
||||
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
|
||||
|
||||
// ponytail: compares the last two host labels, not a public suffix list —
|
||||
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
||||
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
||||
|
||||
/** The script URL, when it is https on the homepage's site; else null. */
|
||||
function scriptUrl(url, homepageUrl) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const home = new URL(homepageUrl);
|
||||
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
||||
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `{install, update}` normalised, or null when either is malformed. */
|
||||
function installFields(doc) {
|
||||
let install = null;
|
||||
if (doc.install !== undefined) {
|
||||
const i = doc.install;
|
||||
if (i?.kind === "npm" && typeof i.package === "string" && PKG_RE.test(i.package)) {
|
||||
install = { kind: "npm", package: i.package };
|
||||
} else if (i?.kind === "script" && typeof i.url === "string") {
|
||||
const url = scriptUrl(i.url, doc.homepageUrl);
|
||||
if (!url) return null;
|
||||
install = { kind: "script", url };
|
||||
} else return null;
|
||||
}
|
||||
let update = null;
|
||||
if (doc.update !== undefined) {
|
||||
const u = doc.update;
|
||||
if ((u?.kind === "npm" || u?.kind === "script") && install?.kind === u.kind) {
|
||||
update = { kind: u.kind };
|
||||
} else if (u?.kind === "self" && isStrArray(u.args) && u.args.length) {
|
||||
update = { kind: "self", args: u.args };
|
||||
} else return null;
|
||||
}
|
||||
return { install, update };
|
||||
}
|
||||
|
||||
/** A normalised tool, or null when the file breaks any rule. */
|
||||
function validate(doc, source) {
|
||||
if (!doc || typeof doc !== "object") return null;
|
||||
@@ -50,10 +98,13 @@ function validate(doc, source) {
|
||||
}
|
||||
let launcher = null;
|
||||
if (doc.launcher !== undefined) {
|
||||
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
if (!TRUSTED.has(source) || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
return null;
|
||||
launcher = doc.launcher;
|
||||
}
|
||||
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
|
||||
const fields = installFields(doc);
|
||||
if (!fields) return null;
|
||||
let prompt = null;
|
||||
if (!launcher) {
|
||||
const p = doc.prompt;
|
||||
@@ -84,15 +135,33 @@ function validate(doc, source) {
|
||||
bypass: doc.bypass || null,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
install: fields.install,
|
||||
update: fields.update,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const RANK = { builtin: 0, remote: 1, custom: 2 };
|
||||
|
||||
/** `[dir, source]` pairs, lowest precedence first. */
|
||||
function catalogDirs() {
|
||||
if (dirsOverride) {
|
||||
const names =
|
||||
dirsOverride.length === 2 ? ["builtin", "custom"] : ["builtin", "remote", "custom"];
|
||||
return dirsOverride.map((d, i) => [d, names[i]]);
|
||||
}
|
||||
// Lazy: ai-tools-sync.js requires this module for validate().
|
||||
const remote = require("./ai-tools-sync").activeRemoteDir();
|
||||
return [
|
||||
[BUILTIN_DIR, "builtin"],
|
||||
[remote, "remote"],
|
||||
[process.env.DASHBOARD_AI_TOOLS_DIR, "custom"],
|
||||
].filter(([d]) => d);
|
||||
}
|
||||
|
||||
function load() {
|
||||
const dirs = dirsOverride || [BUILTIN_DIR, process.env.DASHBOARD_AI_TOOLS_DIR].filter(Boolean);
|
||||
const byId = new Map();
|
||||
dirs.forEach((dir, i) => {
|
||||
const source = i === 0 ? "builtin" : "custom";
|
||||
catalogDirs().forEach(([dir, source]) => {
|
||||
let files = [];
|
||||
try {
|
||||
files = fs
|
||||
@@ -113,8 +182,8 @@ function load() {
|
||||
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
|
||||
continue;
|
||||
}
|
||||
// Custom beats built-in; within one dir the first file wins.
|
||||
if (!byId.has(tool.id) || (source === "custom" && byId.get(tool.id).source === "builtin")) {
|
||||
// A later tier beats an earlier one; within one dir the first file wins.
|
||||
if (!byId.has(tool.id) || RANK[source] > RANK[byId.get(tool.id).source]) {
|
||||
byId.set(tool.id, tool);
|
||||
}
|
||||
}
|
||||
@@ -135,8 +204,9 @@ const list = () => [...cat().values()];
|
||||
const get = (id) => cat().get(id) || null;
|
||||
const has = (id) => typeof id === "string" && cat().has(id);
|
||||
|
||||
function which(name) {
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
/** First executable `name` on `pathStr` (default: the server's PATH). */
|
||||
function which(name, pathStr = process.env.PATH || "") {
|
||||
for (const dir of pathStr.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
@@ -248,13 +318,18 @@ function view() {
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
install: t.install,
|
||||
update: t.update,
|
||||
};
|
||||
}),
|
||||
default: effectiveDefault(),
|
||||
catalog: dirsOverride
|
||||
? { source: "builtin", tag: null, fetchedAt: null }
|
||||
: require("./ai-tools-sync").catalogMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
|
||||
/** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */
|
||||
function __setCatalogDirs(dirs) {
|
||||
dirsOverride = dirs;
|
||||
refresh();
|
||||
@@ -272,6 +347,9 @@ module.exports = {
|
||||
setDefault,
|
||||
effectiveDefault,
|
||||
view,
|
||||
validate,
|
||||
which,
|
||||
siteOf,
|
||||
ROUTER_CAPABLE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* invisible to AI Tools detection and to every lane pane. `$SHELL -ilc` runs
|
||||
* the rc files where those installers add to PATH; delimiters discard
|
||||
* whatever the rc files print, stdin is closed so a prompt gets EOF, and a
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. Adapted from Orca's
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. The shell's own
|
||||
* PATH is also kept in ITS order (`shellPath()`): the merge appends, so an
|
||||
* install job that must use the shell's npm puts this first. Adapted from Orca's
|
||||
* `hydrate-shell-path.ts` (MIT).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -15,6 +17,11 @@ const path = require("node:path");
|
||||
|
||||
const MARK = "__AGENTHUB_PATH__";
|
||||
|
||||
let lastShellPath = null;
|
||||
|
||||
/** The PATH the login shell last printed, in its order; null before/without one. */
|
||||
const shellPath = () => lastShellPath;
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} whether a shell PATH was read and merged.
|
||||
*/
|
||||
@@ -34,6 +41,7 @@ function hydrateShellPath({
|
||||
(err, stdout) => {
|
||||
const m = String(stdout || "").match(new RegExp(`${MARK}(.*?)${MARK}`, "s"));
|
||||
if (!m) return resolve(false); // timeout, spawn error, no output
|
||||
lastShellPath = m[1];
|
||||
const have = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
||||
const add = m[1].split(path.delimiter).filter((d) => d && !have.includes(d));
|
||||
process.env.PATH = [...have, ...new Set(add)].join(path.delimiter);
|
||||
@@ -48,4 +56,4 @@ function hydrateShellPath({
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { hydrateShellPath };
|
||||
module.exports = { hydrateShellPath, shellPath };
|
||||
|
||||
@@ -256,6 +256,24 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) {
|
||||
return fetchReleaseFromUrl(remoteUrl, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository URL releases are read from: the marketplace manifest for a
|
||||
* plugin-cache install (no git there), else the canonical git remote. Null
|
||||
* when neither answers. Never throws. Used by ai-tools-sync.js, which needs
|
||||
* the repo itself (not just the newest release) to read files at a tag.
|
||||
*/
|
||||
async function repoUrlFor(root) {
|
||||
const plugin = detectPluginInstall(root);
|
||||
if (plugin) return pluginMarketplaceUrl(plugin.marketplace, plugin.plugin);
|
||||
try {
|
||||
const remote = await pickCanonicalRemote(root);
|
||||
if (!remote) return null;
|
||||
return (await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 })) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP half of the release lookup, split out because a **plugin-cache
|
||||
* install has no git remote to derive the URL from** — it is unpacked from an
|
||||
@@ -821,5 +839,6 @@ module.exports = {
|
||||
pluginMarketplaceUrl,
|
||||
fetchReleaseFromUrl,
|
||||
fetchLatestRelease,
|
||||
repoUrlFor,
|
||||
isShallowClone,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user