209 lines
8.3 KiB
JavaScript
209 lines
8.3 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const { resolveData } = require("./paths");
|
|
const { registerManagedTool } = require("./tool_registry");
|
|
|
|
class ToolLoader {
|
|
constructor(options = {}) {
|
|
this.registry = options.registry;
|
|
this.installer = options.installer;
|
|
this.settings = options.settings;
|
|
this.lumiAiVersion = options.lumiAiVersion || "0.0.0";
|
|
this.lumiVersion = options.lumiVersion || "0.0.0";
|
|
this.stateFile = options.stateFile || resolveData("tools", "enabled.json");
|
|
this.loaded = new Map();
|
|
this.statuses = new Map();
|
|
}
|
|
|
|
async loadEnabled() {
|
|
const state = this.readState();
|
|
for (const local of this.installer.scanLocal()) {
|
|
if (state.enabled[local.tool_id] === true) {
|
|
try { await this.enable(local.tool_id, { persist: false }); }
|
|
catch (error) { this.setStatus(local.tool_id, "unavailable", error.message); }
|
|
}
|
|
}
|
|
}
|
|
|
|
async enable(toolId, options = {}) {
|
|
const local = this.installer.local(toolId);
|
|
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
|
|
const dependencies = this.inspectDependencies(local.metadata, local.dir);
|
|
if (dependencies.blocking.length) {
|
|
const message = `Unavailable: ${dependencies.blocking.join("; ")}`;
|
|
this.setStatus(toolId, "unavailable", message, dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message, dependencies };
|
|
}
|
|
await this.disable(toolId, { persist: false });
|
|
const backend = backendEntrypoint(local.metadata, local.dir);
|
|
const registered = [];
|
|
let cleanup = null;
|
|
if (backend) {
|
|
clearRequireCache(local.dir);
|
|
try {
|
|
const module = require(backend);
|
|
const register = module?.register || module?.init;
|
|
if (typeof register !== "function") throw new Error("Backend entrypoint must export register() or init().");
|
|
const result = await register({
|
|
metadata: Object.freeze({ ...local.metadata }),
|
|
registerTool: (definition) => {
|
|
const unregister = registerManagedTool(this.registry, local.metadata, definition);
|
|
registered.push({ id: definition.tool_id, unregister });
|
|
return unregister;
|
|
},
|
|
paths: Object.freeze({
|
|
root: local.dir,
|
|
data: path.join(local.dir, "data"),
|
|
config: path.join(local.dir, "config")
|
|
}),
|
|
assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}`
|
|
});
|
|
cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null;
|
|
} catch (error) {
|
|
this.registry.unregisterOwner(toolId);
|
|
this.setStatus(toolId, "unavailable", error.message, dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message: error.message, dependencies };
|
|
}
|
|
}
|
|
this.loaded.set(toolId, { cleanup, registered, metadata: local.metadata, dir: local.dir });
|
|
this.setStatus(toolId, "enabled", dependencies.optional.length ? `Enabled with limitations: ${dependencies.optional.join("; ")}` : "", dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: true, registered: registered.map((entry) => entry.id), dependencies };
|
|
}
|
|
|
|
async disable(toolId, options = {}) {
|
|
const loaded = this.loaded.get(toolId);
|
|
if (loaded) {
|
|
try { await loaded.cleanup?.(); } catch {}
|
|
this.registry.unregisterOwner(toolId);
|
|
this.loaded.delete(toolId);
|
|
} else {
|
|
this.registry.unregisterOwner(toolId);
|
|
}
|
|
if (options.persist !== false) this.setEnabled(toolId, false);
|
|
this.setStatus(toolId, "disabled", "");
|
|
return { disabled: true };
|
|
}
|
|
|
|
async stopAll() {
|
|
for (const toolId of [...this.loaded.keys()]) await this.disable(toolId, { persist: false });
|
|
}
|
|
|
|
isEnabled(toolId) {
|
|
return this.readState().enabled[toolId] === true;
|
|
}
|
|
|
|
status(toolId) {
|
|
return this.statuses.get(toolId) || { state: this.isEnabled(toolId) ? "pending" : "disabled", message: "" };
|
|
}
|
|
|
|
inspectDependencies(metadata, toolDir) {
|
|
const blocking = [];
|
|
const optional = [];
|
|
for (const pluginId of metadata.required_plugins || []) {
|
|
if (/^lumi_ai_.+/i.test(pluginId)) {
|
|
blocking.push(`AI tool plugins cannot depend on AI tool plugin ${pluginId}`);
|
|
} else if (!["core", "lumi_ai"].includes(pluginId) && !fs.existsSync(path.resolve(toolDir, "..", pluginId))) {
|
|
blocking.push(`required plugin ${pluginId} is missing`);
|
|
}
|
|
}
|
|
for (const platform of metadata.required_platforms || []) {
|
|
if (this.settings?.getSetting?.(`platform_${platform}_enabled`, false) !== true) {
|
|
blocking.push(`required platform ${platform} is disabled`);
|
|
}
|
|
}
|
|
for (const dependency of metadata.dependencies || []) {
|
|
try { require.resolve(dependency, { paths: [toolDir] }); }
|
|
catch { optional.push(`optional dependency ${dependency} is missing`); }
|
|
}
|
|
if (metadata.minimum_lumi_ai_version && compareVersions(this.lumiAiVersion, metadata.minimum_lumi_ai_version) < 0) {
|
|
blocking.push(`requires Lumi AI ${metadata.minimum_lumi_ai_version}`);
|
|
}
|
|
if (metadata.minimum_lumi_version && compareVersions(this.lumiVersion, metadata.minimum_lumi_version) < 0) {
|
|
blocking.push(`requires Lumi ${metadata.minimum_lumi_version}`);
|
|
}
|
|
return { blocking, optional };
|
|
}
|
|
|
|
resolveAsset(toolId, relative) {
|
|
const loaded = this.loaded.get(toolId);
|
|
if (!loaded || !this.isEnabled(toolId)) return null;
|
|
const roots = assetRoots(loaded.metadata, loaded.dir);
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, String(relative || ""));
|
|
if ((candidate === root || candidate.startsWith(`${root}${path.sep}`)) && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setStatus(toolId, state, message, dependencies = null) {
|
|
this.statuses.set(toolId, { state, message: String(message || ""), dependencies });
|
|
}
|
|
|
|
readState() {
|
|
try {
|
|
const value = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
|
|
return { enabled: value.enabled && typeof value.enabled === "object" ? value.enabled : {} };
|
|
} catch {
|
|
return { enabled: {} };
|
|
}
|
|
}
|
|
|
|
setEnabled(toolId, enabled) {
|
|
const state = this.readState();
|
|
state.enabled[toolId] = Boolean(enabled);
|
|
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
|
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
|
|
fs.renameSync(temporary, this.stateFile);
|
|
}
|
|
}
|
|
|
|
function backendEntrypoint(metadata, toolDir) {
|
|
const configured = typeof metadata.entrypoints === "string"
|
|
? metadata.entrypoints
|
|
: metadata.entrypoints?.backend;
|
|
const relative = configured || (fs.existsSync(path.join(toolDir, "index.js")) ? "index.js" : "");
|
|
if (!relative) return null;
|
|
const target = path.resolve(toolDir, relative);
|
|
if (!target.startsWith(`${path.resolve(toolDir)}${path.sep}`) || !fs.existsSync(target)) {
|
|
throw new Error("Backend entrypoint is missing or unsafe.");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function assetRoots(metadata, toolDir) {
|
|
const entries = Array.isArray(metadata.frontend_assets)
|
|
? metadata.frontend_assets
|
|
: metadata.frontend_assets ? [metadata.frontend_assets] : ["public"];
|
|
return entries.map((entry) => path.resolve(toolDir, entry))
|
|
.filter((entry) => entry.startsWith(`${path.resolve(toolDir)}${path.sep}`) && fs.existsSync(entry));
|
|
}
|
|
|
|
function clearRequireCache(directory) {
|
|
const prefix = `${path.resolve(directory)}${path.sep}`;
|
|
for (const key of Object.keys(require.cache)) if (key.startsWith(prefix)) delete require.cache[key];
|
|
}
|
|
|
|
function compareVersions(left, right) {
|
|
const parse = (value) => String(value || "0.0.0").split(/[+-]/)[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
const a = parse(left);
|
|
const b = parse(right);
|
|
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
|
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) > (b[index] || 0) ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
module.exports = {
|
|
ToolLoader,
|
|
assetRoots,
|
|
backendEntrypoint,
|
|
clearRequireCache,
|
|
compareVersions
|
|
};
|