159 lines
5.1 KiB
JavaScript
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,
|
|
};
|