272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
const { resolveData } = require("./paths");
|
|
|
|
const DEFAULT_REPOSITORY = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
|
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
class ToolRepoClient {
|
|
constructor(options = {}) {
|
|
this.settings = options.settings;
|
|
this.fetch = options.fetch || global.fetch;
|
|
this.repoRoot = options.repoRoot || path.resolve(__dirname, "..", "..", "..");
|
|
this.cacheFile = options.cacheFile || resolveData("tools", "remote-metadata.json");
|
|
this.now = options.now || Date.now;
|
|
this.runGit = options.runGit || ((args) => spawnSync("git", args, {
|
|
cwd: this.repoRoot,
|
|
encoding: "utf8",
|
|
timeout: 10000
|
|
}));
|
|
}
|
|
|
|
source() {
|
|
const configured = String(this.settings?.getSetting?.("git_remote", "origin") || "origin").trim();
|
|
const branch = String(this.settings?.getSetting?.("git_branch", "main") || "main").trim() || "main";
|
|
let repository = looksLikeRepositoryUrl(configured) ? configured : "";
|
|
if (!repository) {
|
|
const result = this.runGit(["remote", "get-url", configured]);
|
|
if (result?.status === 0) repository = String(result.stdout || "").trim();
|
|
}
|
|
try {
|
|
repository = normalizeRepositoryUrl(repository || DEFAULT_REPOSITORY);
|
|
} catch {
|
|
repository = normalizeRepositoryUrl(DEFAULT_REPOSITORY);
|
|
}
|
|
return { repository, branch, ...parseRepositoryUrl(repository) };
|
|
}
|
|
|
|
async discover({ force = false } = {}) {
|
|
const source = this.source();
|
|
const cached = this.readCache();
|
|
const cacheMatches = cached?.repository === source.repository && cached?.branch === source.branch;
|
|
if (!force && cacheMatches && this.now() - Number(cached.checked_at_ms) < CACHE_TTL_MS) {
|
|
return { ...cached, cached: true, stale: false };
|
|
}
|
|
try {
|
|
const rows = await this.requestJson(this.contentsUrl(source, "plugins"));
|
|
const directories = (Array.isArray(rows) ? rows : [])
|
|
.filter((row) => row.type === "dir" && /^lumi_ai_[a-z0-9_-]+$/i.test(row.name));
|
|
const tools = await Promise.all(directories.map(async (row) => {
|
|
try {
|
|
const metadata = await this.readRemoteJson(row.name, "tool_info.json", source);
|
|
validateRemoteMetadata(metadata, row.name);
|
|
return { ...metadata, repository_path: metadata.repository_path || `plugins/${row.name}` };
|
|
} catch (error) {
|
|
return {
|
|
tool_id: row.name,
|
|
display_name: row.name,
|
|
version: "0.0.0",
|
|
description: "",
|
|
scope: "unavailable",
|
|
permissions: [],
|
|
capabilities: [],
|
|
limitations: [],
|
|
remote_invalid: true,
|
|
remote_error: error.message,
|
|
repository_path: `plugins/${row.name}`
|
|
};
|
|
}
|
|
}));
|
|
const payload = {
|
|
repository: source.repository,
|
|
branch: source.branch,
|
|
checked_at: new Date(this.now()).toISOString(),
|
|
checked_at_ms: this.now(),
|
|
tools
|
|
};
|
|
this.writeCache(payload);
|
|
return { ...payload, cached: false, stale: false };
|
|
} catch (error) {
|
|
if (cacheMatches) {
|
|
return { ...cached, cached: true, stale: true, error: error.message };
|
|
}
|
|
return {
|
|
repository: source.repository,
|
|
branch: source.branch,
|
|
checked_at: null,
|
|
checked_at_ms: 0,
|
|
tools: [],
|
|
cached: false,
|
|
stale: true,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async readReadme(toolId) {
|
|
return this.readRemoteText(toolId, "readme.md", this.source());
|
|
}
|
|
|
|
async downloadTool(toolId, destination) {
|
|
assertToolId(toolId);
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
await this.downloadDirectory(`plugins/${toolId}`, destination, this.source());
|
|
return destination;
|
|
}
|
|
|
|
async downloadDirectory(repositoryPath, destination, source) {
|
|
const entries = await this.requestJson(this.contentsUrl(source, repositoryPath));
|
|
if (!Array.isArray(entries)) throw new Error(`Remote path ${repositoryPath} is not a directory.`);
|
|
for (const entry of entries) {
|
|
const target = safeChild(destination, entry.name);
|
|
if (entry.type === "dir") {
|
|
fs.mkdirSync(target, { recursive: true });
|
|
await this.downloadDirectory(entry.path, target, source);
|
|
} else if (entry.type === "file") {
|
|
const file = await this.requestJson(this.contentsUrl(source, entry.path));
|
|
const content = await this.readFileContent(file, source);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, content);
|
|
}
|
|
}
|
|
}
|
|
|
|
async readRemoteJson(toolId, filename, source = this.source()) {
|
|
return JSON.parse(await this.readRemoteText(toolId, filename, source));
|
|
}
|
|
|
|
async readRemoteText(toolId, filename, source = this.source()) {
|
|
assertToolId(toolId);
|
|
const file = await this.requestJson(this.contentsUrl(source, `plugins/${toolId}/${filename}`));
|
|
return (await this.readFileContent(file, source)).toString("utf8");
|
|
}
|
|
|
|
async readFileContent(file, source) {
|
|
if (typeof file?.content === "string") return decodeContent(file);
|
|
if (!file?.download_url) throw new Error("Remote file content is unavailable.");
|
|
const download = new URL(file.download_url);
|
|
if (download.origin !== new URL(source.repository).origin) {
|
|
throw new Error("Remote file download URL is outside the configured repository host.");
|
|
}
|
|
return this.requestBuffer(download.href);
|
|
}
|
|
|
|
contentsUrl(source, repositoryPath) {
|
|
const encodedPath = repositoryPath.split("/").map(encodeURIComponent).join("/");
|
|
return `${source.api_base}/contents/${encodedPath}?ref=${encodeURIComponent(source.branch)}`;
|
|
}
|
|
|
|
async requestJson(url) {
|
|
if (typeof this.fetch !== "function") throw new Error("Remote repository access is unavailable.");
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 10000);
|
|
timer.unref?.();
|
|
try {
|
|
const response = await this.fetch(url, {
|
|
headers: { Accept: "application/json", "User-Agent": "Lumi-AI-Tool-Manager" },
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) throw new Error(`Repository request failed (${response.status}).`);
|
|
return response.json();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async requestBuffer(url) {
|
|
if (typeof this.fetch !== "function") throw new Error("Remote repository access is unavailable.");
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 10000);
|
|
timer.unref?.();
|
|
try {
|
|
const response = await this.fetch(url, {
|
|
headers: { Accept: "application/octet-stream", "User-Agent": "Lumi-AI-Tool-Manager" },
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) throw new Error(`Repository download failed (${response.status}).`);
|
|
return Buffer.from(await response.arrayBuffer());
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
readCache() {
|
|
try {
|
|
const value = JSON.parse(fs.readFileSync(this.cacheFile, "utf8"));
|
|
return Array.isArray(value.tools) ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
writeCache(value) {
|
|
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
|
|
const temporary = `${this.cacheFile}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
fs.renameSync(temporary, this.cacheFile);
|
|
}
|
|
}
|
|
|
|
function normalizeRepositoryUrl(value) {
|
|
let url = String(value || "").trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
const ssh = url.match(/^git@([^:]+):(.+)$/);
|
|
if (ssh) url = `https://${ssh[1]}/${ssh[2]}`;
|
|
if (url.startsWith("ssh://git@")) {
|
|
const parsed = new URL(url);
|
|
url = `https://${parsed.hostname}${parsed.pathname}`;
|
|
}
|
|
const parsed = new URL(url);
|
|
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Configured Git repository must use HTTP(S) or a convertible SSH URL.");
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
return parsed.href.replace(/\/$/, "").replace(/\.git$/i, "");
|
|
}
|
|
|
|
function parseRepositoryUrl(repository) {
|
|
const parsed = new URL(repository);
|
|
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
if (parts.length < 2) throw new Error("Configured repository URL must include owner and repository.");
|
|
const owner = parts.at(-2);
|
|
const repo = parts.at(-1);
|
|
return {
|
|
owner,
|
|
repo,
|
|
api_base: `${parsed.origin}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
|
|
};
|
|
}
|
|
|
|
function looksLikeRepositoryUrl(value) {
|
|
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(value);
|
|
}
|
|
|
|
function decodeContent(file) {
|
|
if (!file || file.type !== "file" || typeof file.content !== "string") {
|
|
throw new Error("Remote file content is unavailable.");
|
|
}
|
|
if (file.encoding && file.encoding !== "base64") throw new Error(`Unsupported repository encoding: ${file.encoding}.`);
|
|
return Buffer.from(file.content.replace(/\s+/g, ""), "base64");
|
|
}
|
|
|
|
function assertToolId(toolId) {
|
|
if (!/^lumi_ai_[a-z0-9_-]+$/i.test(String(toolId || ""))) throw new Error("Invalid Lumi AI tool plugin ID.");
|
|
}
|
|
|
|
function safeChild(parent, name) {
|
|
if (!name || name !== path.basename(name)) throw new Error("Remote tool path is unsafe.");
|
|
const target = path.resolve(parent, name);
|
|
if (!target.startsWith(`${path.resolve(parent)}${path.sep}`)) throw new Error("Remote tool path escapes staging.");
|
|
return target;
|
|
}
|
|
|
|
function validateRemoteMetadata(metadata, folderName) {
|
|
for (const field of [
|
|
"tool_id", "display_name", "version", "description", "scope", "permissions", "capabilities", "limitations"
|
|
]) {
|
|
if (metadata?.[field] == null || metadata[field] === "") throw new Error(`tool_info.json is missing ${field}.`);
|
|
}
|
|
if (metadata.tool_id !== folderName) throw new Error("tool_id must match the remote folder name.");
|
|
}
|
|
|
|
module.exports = {
|
|
CACHE_TTL_MS,
|
|
DEFAULT_REPOSITORY,
|
|
ToolRepoClient,
|
|
assertToolId,
|
|
decodeContent,
|
|
looksLikeRepositoryUrl,
|
|
normalizeRepositoryUrl,
|
|
parseRepositoryUrl,
|
|
validateRemoteMetadata
|
|
};
|