Files
AgentHub/server/lib/ai-tools-sync.js
T

159 lines
5.1 KiB
JavaScript

/**
* @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,
};