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