213 lines
7.9 KiB
JavaScript
213 lines
7.9 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.");
|
|
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
|
|
};
|