const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { resolveData } = require("./paths"); const { assertToolId } = require("./tool_repo_client"); const REQUIRED_FIELDS = Object.freeze([ "tool_id", "display_name", "version", "description", "scope", "permissions", "capabilities", "limitations" ]); class ToolInstaller { constructor(options = {}) { this.repoClient = options.repoClient; this.pluginsDir = options.pluginsDir || path.resolve(__dirname, "..", ".."); this.stagingRoot = options.stagingRoot || resolveData("tools", "staging"); } scanLocal() { if (!fs.existsSync(this.pluginsDir)) return []; return fs.readdirSync(this.pluginsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && /^lumi_ai_[a-z0-9_-]+$/i.test(entry.name)) .map((entry) => { const dir = path.join(this.pluginsDir, entry.name); try { return { tool_id: entry.name, dir, metadata: validateToolDirectory(dir, entry.name), valid: true }; } catch (error) { return { tool_id: entry.name, dir, metadata: fallbackMetadata(entry.name), valid: false, error: error.message }; } }); } local(toolId) { assertToolId(toolId); return this.scanLocal().find((entry) => entry.tool_id === toolId) || null; } async install(toolId) { if (this.local(toolId)) throw new Error("AI tool plugin is already installed."); return this.stageAndSwap(toolId, false); } async update(toolId) { if (!this.local(toolId)) throw new Error("AI tool plugin is not installed."); return this.stageAndSwap(toolId, true); } async stageAndSwap(toolId, updating) { assertToolId(toolId); const id = crypto.randomUUID(); const stageBase = path.join(this.stagingRoot, id); const stageDir = path.join(stageBase, toolId); const target = path.join(this.pluginsDir, toolId); const backup = path.join(this.pluginsDir, `.${toolId}.backup-${id}`); fs.mkdirSync(stageDir, { recursive: true }); try { await this.repoClient.downloadTool(toolId, stageDir); const remoteMetadata = validateToolDirectory(stageDir, toolId); if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata); let movedCurrent = false; try { if (updating) { fs.renameSync(target, backup); movedCurrent = true; } fs.renameSync(stageDir, target); } catch (error) { fs.rmSync(target, { recursive: true, force: true }); if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target); throw error; } fs.rmSync(backup, { recursive: true, force: true }); return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating }; } finally { fs.rmSync(stageBase, { recursive: true, force: true }); if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target); else fs.rmSync(backup, { recursive: true, force: true }); } } delete(toolId) { assertToolId(toolId); const target = path.resolve(this.pluginsDir, toolId); if (path.dirname(target) !== path.resolve(this.pluginsDir)) throw new Error("Tool deletion path is unsafe."); if (!fs.existsSync(target)) return false; fs.rmSync(target, { recursive: true, force: true }); return true; } } function validateToolDirectory(directory, folderName = path.basename(directory)) { const metadataFile = path.join(directory, "tool_info.json"); const readmeFile = path.join(directory, "readme.md"); if (!fs.existsSync(metadataFile)) throw new Error("tool_info.json is required."); if (!fs.existsSync(readmeFile)) throw new Error("readme.md is required."); let metadata; try { metadata = JSON.parse(fs.readFileSync(metadataFile, "utf8")); } catch { throw new Error("tool_info.json is invalid JSON."); } for (const field of REQUIRED_FIELDS) { if (metadata[field] == null || metadata[field] === "") throw new Error(`tool_info.json is missing ${field}.`); } assertToolId(metadata.tool_id); if (metadata.tool_id !== folderName) throw new Error("tool_id must match the plugin folder name."); if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(metadata.version))) { throw new Error("tool_info.json version must be semantic version text."); } for (const field of ["capabilities", "limitations"]) { if (!Array.isArray(metadata[field])) throw new Error(`${field} must be an array.`); } if (!["string", "object"].includes(typeof metadata.permissions)) throw new Error("permissions must be a string, array, or object."); if (metadata.settings_schema != null && (!metadata.settings_schema || typeof metadata.settings_schema !== "object" || Array.isArray(metadata.settings_schema))) { throw new Error("settings_schema must be an object."); } validateRelativeEntrypoints(metadata.entrypoints); return normalizeMetadata(metadata); } function normalizeMetadata(metadata) { return { ...metadata, tool_type: String(metadata.tool_type || "general"), owning_plugin: String(metadata.owning_plugin || metadata.tool_id), capabilities: metadata.capabilities.map(String), limitations: metadata.limitations.map(String), required_plugins: arrayOfStrings(metadata.required_plugins), required_platforms: arrayOfStrings(metadata.required_platforms), dependencies: arrayOfStrings(metadata.dependencies), data_paths: arrayOfStrings(metadata.data_paths), risk_level: String(metadata.risk_level || "sensitive"), confirmation_required: metadata.confirmation_required !== false }; } function preserveConfiguredPaths(source, destination, remoteMetadata) { if (!fs.existsSync(source) || remoteMetadata.preserve_on_update === false) return; let paths = ["data", "config"]; try { const localMetadata = validateToolDirectory(source, path.basename(source)); paths.push(...localMetadata.data_paths); if (Array.isArray(localMetadata.preserve_on_update)) paths.push(...localMetadata.preserve_on_update); } catch {} if (Array.isArray(remoteMetadata.preserve_on_update)) paths.push(...remoteMetadata.preserve_on_update); for (const relative of [...new Set(paths.map(safeRelativePath).filter(Boolean))]) { const current = path.join(source, relative); const staged = path.join(destination, relative); if (!fs.existsSync(current)) continue; fs.rmSync(staged, { recursive: true, force: true }); copyPath(current, staged); } } function copyPath(source, destination) { const stat = fs.statSync(source); if (stat.isDirectory()) { fs.mkdirSync(destination, { recursive: true }); for (const entry of fs.readdirSync(source)) copyPath(path.join(source, entry), path.join(destination, entry)); } else if (stat.isFile()) { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(source, destination); } } function validateRelativeEntrypoints(entrypoints) { if (!entrypoints) return; const values = typeof entrypoints === "string" ? [entrypoints] : Object.values(entrypoints).flat(); for (const value of values) { if (typeof value !== "string" || !safeRelativePath(value)) throw new Error("Entrypoints must be safe relative paths."); } } function safeRelativePath(value) { const normalized = String(value || "").replaceAll("\\", "/").replace(/^\/+/, ""); if (!normalized || normalized.split("/").includes("..") || path.isAbsolute(normalized)) return null; return normalized; } function arrayOfStrings(value) { return Array.isArray(value) ? value.map(String).map((entry) => entry.trim()).filter(Boolean) : []; } function fallbackMetadata(toolId) { return { tool_id: toolId, display_name: toolId, version: "0.0.0", description: "", scope: "unavailable", permissions: [], capabilities: [], limitations: [] }; } module.exports = { REQUIRED_FIELDS, ToolInstaller, arrayOfStrings, fallbackMetadata, normalizeMetadata, preserveConfiguredPaths, safeRelativePath, validateToolDirectory };