feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user