Lumi/plugins/lumi_ai/backend/tool_repo_client.js
2026-06-13 20:28:06 +02:00

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
};