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

210 lines
7.7 KiB
JavaScript

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.");
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
};