161 lines
6.0 KiB
JavaScript
161 lines
6.0 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
class ToolSettings {
|
|
constructor(options = {}) {
|
|
this.installer = options.installer;
|
|
}
|
|
|
|
describe(toolId) {
|
|
const local = this.installer.local(toolId);
|
|
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
|
|
const schema = normalizeSchema(local.metadata.settings_schema);
|
|
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
|
|
const values = this.readValues(local.dir, schema);
|
|
return {
|
|
tool_id: toolId,
|
|
display_name: local.metadata.display_name,
|
|
schema,
|
|
values: redactSecrets(values, schema),
|
|
configured_secrets: Object.fromEntries(
|
|
Object.entries(schema)
|
|
.filter(([, field]) => field.secret === true)
|
|
.map(([key]) => [key, Boolean(values[key])])
|
|
)
|
|
};
|
|
}
|
|
|
|
save(toolId, input) {
|
|
const local = this.installer.local(toolId);
|
|
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
|
|
const schema = normalizeSchema(local.metadata.settings_schema);
|
|
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
|
|
const current = this.readValues(local.dir, schema);
|
|
const next = {};
|
|
for (const [key, field] of Object.entries(schema)) {
|
|
const incoming = input?.[key];
|
|
if (field.secret === true && (incoming == null || String(incoming) === "")) {
|
|
next[key] = current[key] ?? field.default;
|
|
} else {
|
|
next[key] = normalizeValue(incoming, field, key);
|
|
}
|
|
}
|
|
const file = settingsFile(local.dir);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
try { fs.chmodSync(temporary, 0o600); } catch {}
|
|
fs.renameSync(temporary, file);
|
|
return this.describe(toolId);
|
|
}
|
|
|
|
readRaw(toolId) {
|
|
const local = this.installer.local(toolId);
|
|
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
|
|
const schema = normalizeSchema(local.metadata.settings_schema);
|
|
return this.readValues(local.dir, schema);
|
|
}
|
|
|
|
readValues(toolDir, schema) {
|
|
let stored = {};
|
|
try { stored = JSON.parse(fs.readFileSync(settingsFile(toolDir), "utf8")); }
|
|
catch {}
|
|
return Object.fromEntries(
|
|
Object.entries(schema).map(([key, field]) => {
|
|
try {
|
|
return [key, normalizeValue(stored[key] ?? field.default, field, key)];
|
|
} catch {
|
|
return [key, normalizeValue(field.default, field, key)];
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeSchema(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
return Object.fromEntries(Object.entries(value).map(([key, field]) => {
|
|
if (!/^[a-z][a-z0-9_]*$/i.test(key) || !field || typeof field !== "object" || Array.isArray(field)) {
|
|
throw new Error("settings_schema contains an invalid field.");
|
|
}
|
|
const type = String(field.type || "string");
|
|
if (!["string", "integer", "number", "boolean", "enum", "string_list", "multi_select"].includes(type)) {
|
|
throw new Error(`Unsupported settings type for ${key}.`);
|
|
}
|
|
const options = Array.isArray(field.options) ? field.options.map(String) : [];
|
|
if (["enum", "multi_select"].includes(type) && !options.length) {
|
|
throw new Error(`${key} must define options.`);
|
|
}
|
|
return [key, {
|
|
type,
|
|
label: String(field.label || key.replaceAll("_", " ")),
|
|
description: String(field.description || ""),
|
|
default: field.default ?? defaultValue(type, options),
|
|
options,
|
|
minimum: finiteOrNull(field.minimum),
|
|
maximum: finiteOrNull(field.maximum),
|
|
secret: field.secret === true,
|
|
required: field.required === true,
|
|
rows: Math.max(2, Math.min(12, Number.parseInt(field.rows, 10) || 3))
|
|
}];
|
|
}));
|
|
}
|
|
|
|
function normalizeValue(value, field, key = "setting") {
|
|
if (field.type === "boolean") return value === true || value === "true" || value === "1" || value === "on";
|
|
if (field.type === "integer" || field.type === "number") {
|
|
const number = field.type === "integer" ? Number.parseInt(value, 10) : Number(value);
|
|
if (!Number.isFinite(number)) throw new Error(`${field.label || key} must be a number.`);
|
|
return clamp(number, field.minimum, field.maximum);
|
|
}
|
|
if (field.type === "string_list") {
|
|
const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/);
|
|
return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 200);
|
|
}
|
|
if (field.type === "multi_select") {
|
|
const selected = Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)];
|
|
return [...new Set(selected)].filter((entry) => field.options.includes(entry));
|
|
}
|
|
const text = String(value ?? "").trim();
|
|
if (field.required && !text) throw new Error(`${field.label || key} is required.`);
|
|
if (field.type === "enum" && !field.options.includes(text)) throw new Error(`${field.label || key} is invalid.`);
|
|
return text;
|
|
}
|
|
|
|
function redactSecrets(values, schema) {
|
|
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
|
|
key,
|
|
schema[key]?.secret === true ? "" : value
|
|
]));
|
|
}
|
|
|
|
function settingsFile(toolDir) {
|
|
return path.join(toolDir, "data", "settings.json");
|
|
}
|
|
|
|
function defaultValue(type, options) {
|
|
if (type === "boolean") return false;
|
|
if (type === "integer" || type === "number") return 0;
|
|
if (type === "string_list" || type === "multi_select") return [];
|
|
if (type === "enum") return options[0] || "";
|
|
return "";
|
|
}
|
|
|
|
function finiteOrNull(value) {
|
|
if (value == null || value === "") return null;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
|
|
function clamp(value, minimum, maximum) {
|
|
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
|
|
}
|
|
|
|
module.exports = {
|
|
ToolSettings,
|
|
normalizeSchema,
|
|
normalizeValue,
|
|
redactSecrets,
|
|
settingsFile
|
|
};
|