fix updates for zip-origin installs
This commit is contained in:
parent
79f4dfe37b
commit
1c329bd551
21
.gitignore
vendored
21
.gitignore
vendored
@ -5,17 +5,18 @@ plugins/*/data/**
|
||||
!plugins/*/data/**/
|
||||
!plugins/*/data/**/.gitkeep
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.bot details.md
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
npm-debug.log
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
.env.*
|
||||
!.env.example
|
||||
.bot details.md
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
npm-debug.log
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
taskfile.txt
|
||||
codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
.secrets
|
||||
|
||||
48
TODO.md
Normal file
48
TODO.md
Normal file
@ -0,0 +1,48 @@
|
||||
# TODO
|
||||
|
||||
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
|
||||
|
||||
## OKF Knowledge System
|
||||
|
||||
- Add `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` directories with documented ownership rules.
|
||||
- Implement OKF Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Generate fixed core/plugin OKF from routes, commands, schemas, plugin metadata, README docs, and defaults.
|
||||
- Add admin-editable community OKF for community names, owner/admin names, bot name, currency, items, roles, lore, links, moderation rules, command rules, and local terminology.
|
||||
- Add corrections OKF files created only through admin review.
|
||||
- Implement safe placeholder resolution for core/plugin OKF references such as `{{community.currency.primary_name}}`.
|
||||
- Preserve source metadata for every retrieved OKF chunk: path, id, heading, score, and excerpt.
|
||||
|
||||
## OKF Retrieval and Indexing
|
||||
|
||||
- Implement or adapt an OKF indexer that chunks Markdown by heading.
|
||||
- Re-index only changed OKF files when possible.
|
||||
- Enforce retrieval priority: active corrections, community OKF, plugin OKF, core OKF.
|
||||
- Add retrieval boosts for corrections.
|
||||
- Ensure missing placeholders never crash context injection.
|
||||
|
||||
## Feedback Loop
|
||||
|
||||
- Add thumbs up/down controls to Lumi chat answers.
|
||||
- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status.
|
||||
- Show an optional comment field for thumbs down and keep thumbs up lightweight.
|
||||
- Ensure feedback never directly modifies OKF.
|
||||
|
||||
## Admin Feedback Review
|
||||
|
||||
- Add an admin feedback review page or section.
|
||||
- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item.
|
||||
- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete.
|
||||
- Add correction creation UI with title, tags, affected topic/file, and Markdown body.
|
||||
- Save approved corrections as OKF files and re-index after save.
|
||||
- Add links from feedback rows to generated correction files.
|
||||
|
||||
## Storage and Preservation
|
||||
|
||||
- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage.
|
||||
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
|
||||
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-06-17: Added permanent repo-update architecture for ZIP-origin installs using `data/update-cache/repo`, non-live-git metadata checks, snapshot-copy apply, update-state recording, and preservation of user-owned paths.
|
||||
- 2026-06-17: Separated Lumi AI `tool_info.json` tools from normal plugin update rows and rendered tools under the `lumi_ai` plugin row.
|
||||
@ -1,14 +1,19 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
|
||||
const {
|
||||
createSnapshot,
|
||||
finalizeSnapshot,
|
||||
discardSnapshot,
|
||||
restoreSnapshot
|
||||
restoreSnapshot,
|
||||
applyCoreFilesFromDirectory,
|
||||
applyPluginFiles
|
||||
} = require("./update-manager");
|
||||
const { getUpdateStatus } = require("./update-index");
|
||||
const {
|
||||
ensureManagedRepo,
|
||||
writeUpdateState
|
||||
} = require("./update-repository");
|
||||
const {
|
||||
createRecoveryMarker,
|
||||
updateRecoveryMarker,
|
||||
@ -19,17 +24,6 @@ const {
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const activeOperations = new Set();
|
||||
|
||||
function runGit(args) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function withOperation(key, fn) {
|
||||
if (activeOperations.has(key)) {
|
||||
throw new Error("An update is already running for this target.");
|
||||
@ -62,9 +56,19 @@ function verifyPluginFiles(pluginId) {
|
||||
JSON.parse(fs.readFileSync(manifest, "utf8"));
|
||||
}
|
||||
|
||||
async function applyCoreUpdate({ source = "stable", publish } = {}) {
|
||||
function applyPluginFromRepositorySnapshot(remote, branch, pluginId) {
|
||||
const managed = ensureManagedRepo(remote, branch);
|
||||
const pluginRoot = path.join(managed.path, "plugins", pluginId);
|
||||
if (!fs.existsSync(path.join(pluginRoot, "plugin.json"))) {
|
||||
throw new Error(`Plugin ${pluginId} was not found in ${branch}.`);
|
||||
}
|
||||
applyPluginFiles(pluginRoot, pluginId, { preserveData: true });
|
||||
return managed;
|
||||
}
|
||||
|
||||
async function applyCoreUpdate({ source = "stable", remote = null, publish } = {}) {
|
||||
return withOperation("core", async () => {
|
||||
const status = getUpdateStatus({ source });
|
||||
const status = getUpdateStatus({ source, remote });
|
||||
const target = status.core;
|
||||
if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked.");
|
||||
if (!target.update_available) throw new Error("No safe core update target is available.");
|
||||
@ -79,6 +83,7 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
|
||||
major_crossing: target.major_crossing
|
||||
});
|
||||
let snapshot = null;
|
||||
let snapshotRecord = null;
|
||||
try {
|
||||
emitProgress(publish, "update:queued", { target: "core" });
|
||||
emitProgress(publish, "update:checking", { target: "core" });
|
||||
@ -100,32 +105,64 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
|
||||
danger_notes: target.dangers
|
||||
}
|
||||
});
|
||||
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id });
|
||||
snapshotRecord = finalizeSnapshot(snapshot);
|
||||
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshotRecord.id });
|
||||
emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id });
|
||||
emitProgress(publish, "update:download", { target: "core", branch: target.source_branch });
|
||||
runGit(["fetch", status.remote, target.source_branch]);
|
||||
const managed = ensureManagedRepo(status.remote, target.source_branch);
|
||||
emitProgress(publish, "update:apply", { target: "core" });
|
||||
runGit(["pull", "--ff-only", status.remote, target.source_branch]);
|
||||
applyCoreFilesFromDirectory(managed.path);
|
||||
updateRecoveryMarker({ status: "verifying" });
|
||||
emitProgress(publish, "update:verify", { target: "core" });
|
||||
verifyCoreFiles();
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
const record = snapshotRecord;
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
writeUpdateState({
|
||||
remote: managed.repository,
|
||||
branch: managed.branch,
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "complete",
|
||||
last_snapshot_id: record.id,
|
||||
last_target_kind: "core",
|
||||
last_target_version: target.safe_target_version
|
||||
});
|
||||
emitProgress(publish, "update:restart_required", { target: "core" });
|
||||
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
|
||||
return { status: "complete", restart_required: true, snapshot: record, target };
|
||||
} catch (error) {
|
||||
if (snapshot) discardSnapshot(snapshot);
|
||||
if (snapshotRecord) {
|
||||
try {
|
||||
restoreSnapshot(snapshotRecord.id, {
|
||||
expectedType: "bot",
|
||||
currentVersion: target.safe_target_version,
|
||||
allowUnsafeMajorRollback: true
|
||||
});
|
||||
} catch (restoreError) {
|
||||
markRecoveryMarkerFailed(restoreError);
|
||||
}
|
||||
} else if (snapshot) {
|
||||
try {
|
||||
discardSnapshot(snapshot);
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
markRecoveryMarkerFailed(error);
|
||||
writeUpdateState({
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "failed",
|
||||
last_target_kind: "core",
|
||||
last_error: error.message
|
||||
});
|
||||
emitProgress(publish, "update:failed", { target: "core", error: error.message });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) {
|
||||
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, publish } = {}) {
|
||||
return withOperation(`plugin:${pluginId}`, async () => {
|
||||
const status = getUpdateStatus({ source });
|
||||
const status = getUpdateStatus({ source, remote });
|
||||
const target = status.plugins.find((plugin) => plugin.id === pluginId);
|
||||
if (!target) throw new Error("Plugin is not installed.");
|
||||
if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
|
||||
@ -136,11 +173,12 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
update_method: target.installed === false ? "git_install" : "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
major_crossing: target.major_crossing
|
||||
});
|
||||
let snapshot = null;
|
||||
let snapshotRecord = null;
|
||||
try {
|
||||
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
|
||||
emitProgress(publish, "update:metadata", target);
|
||||
@ -154,7 +192,7 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
update_method: target.installed === false ? "git_install" : "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
recovery_marker_id: marker.id,
|
||||
major_crossing: target.major_crossing,
|
||||
@ -162,22 +200,56 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
|
||||
danger_notes: target.dangers
|
||||
}
|
||||
});
|
||||
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id });
|
||||
snapshotRecord = finalizeSnapshot(snapshot);
|
||||
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotRecord.id });
|
||||
emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch });
|
||||
runGit(["fetch", status.remote, target.source_branch]);
|
||||
emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId });
|
||||
runGit(["checkout", `${status.remote}/${target.source_branch}`, "--", `plugins/${pluginId}`]);
|
||||
const managed = applyPluginFromRepositorySnapshot(status.remote, target.source_branch, pluginId);
|
||||
updateRecoveryMarker({ status: "verifying" });
|
||||
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
|
||||
verifyPluginFiles(pluginId);
|
||||
syncPluginRegistry();
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
const record = snapshotRecord;
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
writeUpdateState({
|
||||
remote: managed.repository,
|
||||
branch: managed.branch,
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "complete",
|
||||
last_snapshot_id: record.id,
|
||||
last_target_kind: "plugin",
|
||||
last_target_id: pluginId,
|
||||
last_target_version: target.safe_target_version
|
||||
});
|
||||
emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
|
||||
return { status: "complete", restart_required: false, snapshot: record, target };
|
||||
} catch (error) {
|
||||
if (snapshot) discardSnapshot(snapshot);
|
||||
if (snapshotRecord) {
|
||||
try {
|
||||
restoreSnapshot(snapshotRecord.id, {
|
||||
expectedType: "plugin",
|
||||
expectedPluginId: pluginId,
|
||||
currentVersion: target.safe_target_version,
|
||||
allowUnsafeMajorRollback: true
|
||||
});
|
||||
} catch (restoreError) {
|
||||
markRecoveryMarkerFailed(restoreError);
|
||||
}
|
||||
} else if (snapshot) {
|
||||
try {
|
||||
discardSnapshot(snapshot);
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
markRecoveryMarkerFailed(error);
|
||||
writeUpdateState({
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "failed",
|
||||
last_target_kind: "plugin",
|
||||
last_target_id: pluginId,
|
||||
last_error: error.message
|
||||
});
|
||||
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { getSetting } = require("./settings");
|
||||
const { scanPluginDirectories, getPlugins } = require("./plugins");
|
||||
const {
|
||||
@ -12,58 +11,21 @@ const {
|
||||
} = require("./versioning");
|
||||
const { listSnapshots } = require("./update-manager");
|
||||
const { safeModeStatus } = require("./recovery-mode");
|
||||
const {
|
||||
createMetadataReader,
|
||||
normalizeRepositoryTarget,
|
||||
resolveSourceBranch
|
||||
} = require("./update-repository");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const LUMI_AI_TOOL_ID = /^lumi_ai_[a-z0-9_-]+$/i;
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
timeout: options.timeout || 20000
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
|
||||
}
|
||||
return result.stdout.trim();
|
||||
function isLumiAiToolId(id) {
|
||||
return LUMI_AI_TOOL_ID.test(String(id || "")) && String(id || "") !== "lumi_ai";
|
||||
}
|
||||
|
||||
function tryGit(args, fallback = "") {
|
||||
try {
|
||||
return runGit(args);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchRemote(remote, branch = null) {
|
||||
const args = ["fetch", "--prune", remote];
|
||||
if (branch) args.push(branch);
|
||||
runGit(args);
|
||||
}
|
||||
|
||||
function normalizeRepositoryTarget(value) {
|
||||
const target = String(value || "origin").trim() || "origin";
|
||||
if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) {
|
||||
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function isRepositoryUrl(value) {
|
||||
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(String(value || ""));
|
||||
}
|
||||
|
||||
function remoteRef(remote, branch) {
|
||||
return isRepositoryUrl(remote) ? "FETCH_HEAD" : `${remote}/${branch}`;
|
||||
}
|
||||
|
||||
function readGitFile(ref, filePath) {
|
||||
const output = tryGit(["show", `${ref}:${filePath}`], "");
|
||||
return output || null;
|
||||
}
|
||||
|
||||
function readJsonGitFile(ref, filePath) {
|
||||
const raw = readGitFile(ref, filePath);
|
||||
function readJsonFromReader(reader, filePath) {
|
||||
const raw = reader.readFile(filePath);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@ -80,30 +42,6 @@ function readLocalJson(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceBranch(remote, requested = "stable") {
|
||||
if (requested === "experimental") {
|
||||
const refs = tryGit([
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)|%(committerdate:iso8601)",
|
||||
`refs/remotes/${remote}/experimental-*`
|
||||
], "");
|
||||
const branches = refs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [ref, date] = line.split("|");
|
||||
return { branch: ref.replace(`${remote}/`, ""), date };
|
||||
})
|
||||
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
if (branches[0]) return branches[0].branch;
|
||||
}
|
||||
if (requested && requested !== "stable" && requested !== "main") {
|
||||
return String(requested).replace(/^origin\//, "");
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
|
||||
function parseMarkdownChangelog(raw) {
|
||||
if (!raw) return [];
|
||||
const entries = [];
|
||||
@ -122,11 +60,11 @@ function parseMarkdownChangelog(raw) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function changelogEntries(ref, basePath = "") {
|
||||
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json"));
|
||||
function changelogEntries(reader, basePath = "") {
|
||||
const json = readJsonFromReader(reader, path.posix.join(basePath, "changelog.json"));
|
||||
if (Array.isArray(json)) return json;
|
||||
if (Array.isArray(json?.versions)) return json.versions;
|
||||
const md = readGitFile(ref, path.posix.join(basePath, "CHANGELOG.md"));
|
||||
const md = reader.readFile(path.posix.join(basePath, "CHANGELOG.md"));
|
||||
return parseMarkdownChangelog(md);
|
||||
}
|
||||
|
||||
@ -142,10 +80,10 @@ function manifestRawVersion(manifest, fallbackVersion) {
|
||||
return String(manifest?.version || fallbackVersion || "").trim();
|
||||
}
|
||||
|
||||
function coreManifest(ref) {
|
||||
return readJsonGitFile(ref, "update-manifest.json") ||
|
||||
readJsonGitFile(ref, "lumi.manifest.json") ||
|
||||
readJsonGitFile(ref, "package.json") ||
|
||||
function coreManifest(reader) {
|
||||
return readJsonFromReader(reader, "update-manifest.json") ||
|
||||
readJsonFromReader(reader, "lumi.manifest.json") ||
|
||||
readJsonFromReader(reader, "package.json") ||
|
||||
{};
|
||||
}
|
||||
|
||||
@ -171,7 +109,116 @@ function snapshotAvailability(kind, id = null) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) {
|
||||
function localPluginCandidates(registry) {
|
||||
const candidates = new Map();
|
||||
for (const plugin of scanPluginDirectories()) {
|
||||
candidates.set(plugin.id, { ...plugin, installed: true });
|
||||
}
|
||||
for (const plugin of registry.values()) {
|
||||
if (isLumiAiToolId(plugin.id)) {
|
||||
continue;
|
||||
}
|
||||
if (candidates.has(plugin.id)) {
|
||||
continue;
|
||||
}
|
||||
const pluginPath = plugin.path || "";
|
||||
const installed = Boolean(pluginPath && fs.existsSync(path.join(pluginPath, "plugin.json")));
|
||||
candidates.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.name || plugin.id,
|
||||
version: plugin.version || "0.0.0",
|
||||
description: "",
|
||||
main: "index.js",
|
||||
dir: pluginPath,
|
||||
installed
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function localToolCandidates() {
|
||||
const tools = new Map();
|
||||
const pluginsPath = path.join(repoRoot, "plugins");
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(pluginsPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return tools;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !isLumiAiToolId(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const metadataPath = path.join(pluginsPath, entry.name, "tool_info.json");
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
||||
tools.set(entry.name, {
|
||||
id: metadata.tool_id || entry.name,
|
||||
dir: path.join(pluginsPath, entry.name),
|
||||
metadata,
|
||||
installed: true
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
function toolDisplayName(metadata, id) {
|
||||
return metadata?.display_name || metadata?.name || id;
|
||||
}
|
||||
|
||||
function buildToolStatus({ id, localMetadata, remoteMetadata, sourceBranch, installed = true }) {
|
||||
const metadata = remoteMetadata || localMetadata || {};
|
||||
const currentVersion = installed
|
||||
? manifestRawVersion(localMetadata, localMetadata?.version) || "0.0.0"
|
||||
: "0.0.0";
|
||||
const targetVersion = manifestRawVersion(remoteMetadata, remoteMetadata?.version) || currentVersion;
|
||||
const currentIsVersioned = Boolean(parseSemver(currentVersion));
|
||||
const targetIsVersioned = Boolean(parseSemver(targetVersion));
|
||||
const updateAvailable = installed === false
|
||||
? Boolean(remoteMetadata)
|
||||
: Boolean(remoteMetadata) && (
|
||||
currentIsVersioned && targetIsVersioned
|
||||
? compareSemver(targetVersion, currentVersion) > 0
|
||||
: targetVersion !== currentVersion
|
||||
);
|
||||
const warnings = [];
|
||||
if (!remoteMetadata) warnings.push("Remote tool metadata is missing.");
|
||||
if (remoteMetadata?.remote_invalid) warnings.push(remoteMetadata.remote_error || "Remote tool metadata is invalid.");
|
||||
return {
|
||||
kind: "tool",
|
||||
id,
|
||||
tool_id: metadata.tool_id || id,
|
||||
name: toolDisplayName(metadata, id),
|
||||
installed,
|
||||
current_version: currentVersion,
|
||||
latest_available_version: targetVersion,
|
||||
safe_target_version: targetVersion || null,
|
||||
update_available: updateAvailable,
|
||||
blocked: Boolean(remoteMetadata?.remote_invalid),
|
||||
blocked_reason: remoteMetadata?.remote_error || null,
|
||||
source_branch: sourceBranch,
|
||||
channel: "lumi_ai",
|
||||
version_description: installed === false
|
||||
? `Available to install at ${targetVersion || "unknown"}`
|
||||
: `${currentVersion} -> ${targetVersion || currentVersion}`,
|
||||
description: metadata.description || "",
|
||||
capabilities: Array.isArray(metadata.capabilities) ? metadata.capabilities : [],
|
||||
limitations: Array.isArray(metadata.limitations) ? metadata.limitations : [],
|
||||
minimum_lumi_ai_version: metadata.minimum_lumi_ai_version || "",
|
||||
repository_path: metadata.repository_path || `plugins/${id}`,
|
||||
warnings,
|
||||
dangers: remoteMetadata?.remote_invalid ? [remoteMetadata.remote_error || "Remote tool metadata is invalid."] : [],
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel, installed = true }) {
|
||||
const versions = manifestVersions(manifest, manifest?.version);
|
||||
const rawVersion = manifestRawVersion(manifest);
|
||||
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
|
||||
@ -206,6 +253,7 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
|
||||
return {
|
||||
kind,
|
||||
id: id || kind,
|
||||
installed,
|
||||
name: name || manifest?.name || id || "Lumi core",
|
||||
current_version: currentVersion,
|
||||
latest_available_version: latest?.version || rawVersion || currentVersion,
|
||||
@ -236,69 +284,132 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
|
||||
};
|
||||
}
|
||||
|
||||
function remotePluginDirs(ref) {
|
||||
const output = tryGit(["ls-tree", "-d", "--name-only", `${ref}:plugins`], "");
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => item.replace(/^plugins\//, ""));
|
||||
}
|
||||
|
||||
function getUpdateStatus(options = {}) {
|
||||
const remote = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin"));
|
||||
const configuredRemote = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin"));
|
||||
const requestedSource = options.source || "stable";
|
||||
const sourceBranch = resolveSourceBranch(remote, requestedSource);
|
||||
fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null);
|
||||
const ref = remoteRef(remote, sourceBranch);
|
||||
const core = buildStatus({
|
||||
kind: "core",
|
||||
currentVersion: localCoreVersion(),
|
||||
manifest: coreManifest(ref),
|
||||
changelog: changelogEntries(ref),
|
||||
sourceBranch,
|
||||
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
||||
});
|
||||
const installed = scanPluginDirectories();
|
||||
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
|
||||
const remoteDirs = new Set(remotePluginDirs(ref));
|
||||
const plugins = installed.map((plugin) => {
|
||||
const basePath = `plugins/${plugin.id}`;
|
||||
const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {};
|
||||
const changelog = changelogEntries(ref, basePath);
|
||||
const dbPlugin = registry.get(plugin.id);
|
||||
return buildStatus({
|
||||
kind: "plugin",
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
currentVersion: dbPlugin?.version || plugin.version || "0.0.0",
|
||||
manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
|
||||
changelog,
|
||||
const sourceBranch = resolveSourceBranch(configuredRemote, requestedSource);
|
||||
const reader = createMetadataReader(configuredRemote, sourceBranch);
|
||||
const remote = reader.repository || configuredRemote;
|
||||
try {
|
||||
const core = buildStatus({
|
||||
kind: "core",
|
||||
currentVersion: localCoreVersion(),
|
||||
manifest: coreManifest(reader),
|
||||
changelog: changelogEntries(reader),
|
||||
sourceBranch,
|
||||
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
||||
});
|
||||
});
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
source_branch: sourceBranch,
|
||||
requested_source: requestedSource,
|
||||
remote,
|
||||
core,
|
||||
plugins,
|
||||
plugins_summary: {
|
||||
installed_plugins: installed.length,
|
||||
total_plugins: remoteDirs.size || installed.length,
|
||||
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
|
||||
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
|
||||
},
|
||||
recovery: safeModeStatus()
|
||||
};
|
||||
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
|
||||
const remoteDirs = new Set(reader.listPluginDirs());
|
||||
const remotePluginDirs = new Set();
|
||||
const remoteTools = new Map();
|
||||
const candidates = localPluginCandidates(registry);
|
||||
for (const pluginId of remoteDirs) {
|
||||
if (isLumiAiToolId(pluginId)) {
|
||||
const toolMetadata = readJsonFromReader(reader, `plugins/${pluginId}/tool_info.json`);
|
||||
if (toolMetadata) {
|
||||
remoteTools.set(pluginId, {
|
||||
...toolMetadata,
|
||||
repository_path: toolMetadata.repository_path || `plugins/${pluginId}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const manifest = readJsonFromReader(reader, `plugins/${pluginId}/plugin.json`);
|
||||
if (!manifest) {
|
||||
continue;
|
||||
}
|
||||
remotePluginDirs.add(pluginId);
|
||||
if (candidates.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
candidates.set(pluginId, {
|
||||
id: pluginId,
|
||||
name: manifest.name || pluginId,
|
||||
version: "0.0.0",
|
||||
description: manifest.description || "",
|
||||
main: manifest.main || "index.js",
|
||||
dir: "",
|
||||
installed: false
|
||||
});
|
||||
}
|
||||
const installed = Array.from(candidates.values());
|
||||
const plugins = installed.map((plugin) => {
|
||||
const basePath = `plugins/${plugin.id}`;
|
||||
const manifest = readJsonFromReader(reader, `${basePath}/plugin.json`) || {};
|
||||
const changelog = changelogEntries(reader, basePath);
|
||||
const dbPlugin = registry.get(plugin.id);
|
||||
return buildStatus({
|
||||
kind: "plugin",
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
currentVersion: dbPlugin?.version || plugin.version || "0.0.0",
|
||||
manifest: remotePluginDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
|
||||
changelog,
|
||||
sourceBranch,
|
||||
channel: requestedSource === "experimental" ? "experimental" : "stable",
|
||||
installed: plugin.installed !== false
|
||||
});
|
||||
});
|
||||
const localTools = localToolCandidates();
|
||||
for (const [toolId, tool] of localTools.entries()) {
|
||||
if (!remoteTools.has(toolId)) {
|
||||
remoteTools.set(toolId, null);
|
||||
}
|
||||
}
|
||||
const aiTools = Array.from(new Set([
|
||||
...Array.from(localTools.keys()),
|
||||
...Array.from(remoteTools.keys())
|
||||
])).sort((a, b) => a.localeCompare(b)).map((toolId) => {
|
||||
const localTool = localTools.get(toolId);
|
||||
return buildToolStatus({
|
||||
id: toolId,
|
||||
localMetadata: localTool?.metadata || null,
|
||||
remoteMetadata: remoteTools.get(toolId) || null,
|
||||
sourceBranch,
|
||||
installed: localTool?.installed === true
|
||||
});
|
||||
});
|
||||
const lumiAiPlugin = plugins.find((plugin) => plugin.id === "lumi_ai");
|
||||
if (lumiAiPlugin) {
|
||||
lumiAiPlugin.tools = aiTools;
|
||||
lumiAiPlugin.tools_summary = {
|
||||
installed_tools: aiTools.filter((tool) => tool.installed !== false).length,
|
||||
total_tools: aiTools.length,
|
||||
updatable_tools: aiTools.filter((tool) => tool.update_available && tool.installed !== false).length,
|
||||
available_tools: aiTools.filter((tool) => tool.installed === false).length,
|
||||
blocked_tools: aiTools.filter((tool) => tool.blocked).length
|
||||
};
|
||||
}
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
source_branch: sourceBranch,
|
||||
requested_source: requestedSource,
|
||||
remote,
|
||||
core,
|
||||
plugins,
|
||||
ai_tools: aiTools,
|
||||
tools_summary: {
|
||||
installed_tools: aiTools.filter((tool) => tool.installed !== false).length,
|
||||
total_tools: aiTools.length,
|
||||
updatable_tools: aiTools.filter((tool) => tool.update_available && tool.installed !== false).length,
|
||||
available_tools: aiTools.filter((tool) => tool.installed === false).length,
|
||||
blocked_tools: aiTools.filter((tool) => tool.blocked).length
|
||||
},
|
||||
plugins_summary: {
|
||||
installed_plugins: installed.filter((plugin) => plugin.installed !== false).length,
|
||||
total_plugins: remotePluginDirs.size || installed.length,
|
||||
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
|
||||
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
|
||||
},
|
||||
recovery: safeModeStatus()
|
||||
};
|
||||
} finally {
|
||||
reader.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUpdateStatus,
|
||||
resolveSourceBranch,
|
||||
fetchRemote,
|
||||
runGit,
|
||||
readGitFile
|
||||
resolveSourceBranch
|
||||
};
|
||||
|
||||
@ -21,6 +21,36 @@ const dataDir = path.join(repoRoot, "data");
|
||||
const snapshotsDir = path.join(dataDir, "snapshots");
|
||||
const indexPath = path.join(snapshotsDir, "index.json");
|
||||
const maxSnapshots = 20;
|
||||
const PRESERVE_RELATIVE_PATHS = new Set([
|
||||
".git",
|
||||
"node_modules",
|
||||
"data",
|
||||
"config",
|
||||
"storage",
|
||||
"uploads",
|
||||
"logs",
|
||||
"database",
|
||||
"databases",
|
||||
"plugins",
|
||||
"knowledge/community",
|
||||
"knowledge/corrections",
|
||||
".env",
|
||||
".env.local",
|
||||
".env.production",
|
||||
".secrets",
|
||||
"codex-guidelines"
|
||||
]);
|
||||
const GENERATED_RELATIVE_PATHS = new Set([
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
".cache",
|
||||
".parcel-cache",
|
||||
".turbo",
|
||||
"tmp",
|
||||
"temp",
|
||||
"data/update-cache"
|
||||
]);
|
||||
|
||||
function ensureSnapshotsDir() {
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
@ -66,9 +96,12 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
|
||||
|
||||
let pluginExisted = false;
|
||||
let pluginZip = null;
|
||||
let fullZip = null;
|
||||
if (type === "bot") {
|
||||
const coreZip = path.join(snapshotPath, "core.zip");
|
||||
zipCore(coreZip);
|
||||
fullZip = path.join(snapshotPath, "full.zip");
|
||||
zipFullInstall(fullZip);
|
||||
}
|
||||
|
||||
if (type === "plugin" && pluginId) {
|
||||
@ -83,7 +116,7 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata };
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, fullZip, snapshotPath, metadata };
|
||||
}
|
||||
|
||||
function finalizeSnapshot(snapshot) {
|
||||
@ -187,15 +220,9 @@ function resolveZipRoot(extractedDir) {
|
||||
}
|
||||
|
||||
function resolvePatchRoot(extractedDir) {
|
||||
const entries = fs.readdirSync(extractedDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||
const files = entries.filter((entry) => entry.isFile());
|
||||
if (files.length > 0) {
|
||||
return extractedDir;
|
||||
}
|
||||
if (dirs.length === 1) {
|
||||
return path.join(extractedDir, dirs[0].name);
|
||||
}
|
||||
// Patch archives are copied relative to the repository root. Do not infer a
|
||||
// nested root from a single top-level directory such as src/, or patches will
|
||||
// be applied to services/ and web/ instead of src/services/ and src/web/.
|
||||
return extractedDir;
|
||||
}
|
||||
|
||||
@ -262,6 +289,15 @@ function zipCore(destination) {
|
||||
zip.writeZip(destination);
|
||||
}
|
||||
|
||||
function zipFullInstall(destination) {
|
||||
if (!AdmZip) {
|
||||
throw new Error("adm-zip is not installed. Run npm install.");
|
||||
}
|
||||
const zip = new AdmZip();
|
||||
addFolder(zip, repoRoot, repoRoot, new Set([".git", "node_modules", "data/update-cache", "data/snapshots"]));
|
||||
zip.writeZip(destination);
|
||||
}
|
||||
|
||||
function zipFolder(source, destination, options) {
|
||||
if (!AdmZip) {
|
||||
throw new Error("adm-zip is not installed. Run npm install.");
|
||||
@ -276,9 +312,9 @@ function addFolder(zip, folderPath, basePath, ignore) {
|
||||
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(folderPath, entry.name);
|
||||
const relPath = path.relative(basePath, fullPath);
|
||||
const topLevel = relPath.split(path.sep)[0];
|
||||
if (ignore.has(topLevel)) {
|
||||
const relPath = normalizeRelative(path.relative(basePath, fullPath));
|
||||
const topLevel = relPath.split("/")[0];
|
||||
if (ignore.has(relPath) || ignore.has(topLevel)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
@ -301,29 +337,60 @@ function resetCoreFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectory(source, target, ignore) {
|
||||
function normalizeRelative(value) {
|
||||
return String(value || "").split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isRelativePathIgnored(relativePath, ignore) {
|
||||
const rel = normalizeRelative(relativePath);
|
||||
if (!rel) return false;
|
||||
if (ignore.has(rel)) return true;
|
||||
const parts = rel.split("/");
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
if (ignore.has(parts.slice(0, index).join("/"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function copyDirectory(source, target, ignore, options = {}) {
|
||||
const base = options.base || source;
|
||||
const entries = fs.readdirSync(source, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (ignore.has(entry.name)) {
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const relPath = normalizeRelative(path.relative(base, srcPath));
|
||||
if (isRelativePathIgnored(relPath, ignore)) {
|
||||
continue;
|
||||
}
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const destPath = path.join(target, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fs.mkdirSync(destPath, { recursive: true });
|
||||
copyDirectory(srcPath, destPath, ignore);
|
||||
copyDirectory(srcPath, destPath, ignore, { base });
|
||||
} else if (entry.isFile()) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyCoreUpdate(rootPath) {
|
||||
resetCoreFiles();
|
||||
function removeGeneratedPaths() {
|
||||
for (const relativePath of GENERATED_RELATIVE_PATHS) {
|
||||
const target = path.join(repoRoot, relativePath);
|
||||
if (target.startsWith(path.join(repoRoot, "data", "snapshots"))) {
|
||||
continue;
|
||||
}
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function applyCoreFilesFromDirectory(rootPath) {
|
||||
removeGeneratedPaths();
|
||||
copyDirectory(
|
||||
rootPath,
|
||||
repoRoot,
|
||||
new Set([".git", "node_modules", "data", "plugins"])
|
||||
PRESERVE_RELATIVE_PATHS,
|
||||
{ base: rootPath }
|
||||
);
|
||||
}
|
||||
|
||||
@ -331,7 +398,8 @@ function applyCorePatch(rootPath) {
|
||||
copyDirectory(
|
||||
rootPath,
|
||||
repoRoot,
|
||||
new Set([".git", "node_modules", "data", "plugins"])
|
||||
PRESERVE_RELATIVE_PATHS,
|
||||
{ base: rootPath }
|
||||
);
|
||||
}
|
||||
|
||||
@ -425,7 +493,7 @@ async function applyBotUpdate(zipPath, options = {}) {
|
||||
if (mode === "patch") {
|
||||
applyCorePatch(rootPath);
|
||||
} else {
|
||||
applyCoreUpdate(rootPath);
|
||||
applyCoreFilesFromDirectory(rootPath);
|
||||
}
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
@ -525,14 +593,16 @@ function restoreSnapshot(id, options = {}) {
|
||||
}
|
||||
|
||||
if (entry.type === "bot") {
|
||||
const fullZip = path.join(entry.path, "full.zip");
|
||||
const coreZip = path.join(entry.path, "core.zip");
|
||||
if (!fs.existsSync(coreZip)) {
|
||||
const restoreZip = fs.existsSync(fullZip) ? fullZip : coreZip;
|
||||
if (!fs.existsSync(restoreZip)) {
|
||||
throw new Error("Snapshot core archive missing.");
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
|
||||
extractZip(coreZip, tempDir);
|
||||
extractZip(restoreZip, tempDir);
|
||||
const rootPath = resolveZipRoot(tempDir);
|
||||
applyCoreUpdate(rootPath);
|
||||
applyCoreFilesFromDirectory(rootPath);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@ -565,6 +635,7 @@ module.exports = {
|
||||
createSnapshot,
|
||||
finalizeSnapshot,
|
||||
discardSnapshot,
|
||||
applyCoreFilesFromDirectory,
|
||||
applyPluginFiles,
|
||||
resetPluginCode,
|
||||
replacePluginDirectory,
|
||||
|
||||
258
src/services/update-repository.js
Normal file
258
src/services/update-repository.js
Normal file
@ -0,0 +1,258 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const dataDir = path.join(repoRoot, "data");
|
||||
const updateCacheDir = path.join(dataDir, "update-cache");
|
||||
const managedRepoDir = path.join(updateCacheDir, "repo");
|
||||
const updateStatePath = path.join(dataDir, "update-state.json");
|
||||
const DEFAULT_REPOSITORY = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
|
||||
const PRESERVED_PATHS = [
|
||||
"data",
|
||||
"config",
|
||||
"storage",
|
||||
"uploads",
|
||||
"logs",
|
||||
"database",
|
||||
"databases",
|
||||
"plugins",
|
||||
"knowledge/community",
|
||||
"knowledge/corrections",
|
||||
".env",
|
||||
".env.local",
|
||||
".env.production",
|
||||
".secrets",
|
||||
"codex-guidelines"
|
||||
];
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
encoding: "utf8",
|
||||
timeout: options.timeout || 120000
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function tryGit(args, options = {}, fallback = "") {
|
||||
try {
|
||||
return runGit(args, options);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isRepositoryUrl(value) {
|
||||
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(String(value || ""));
|
||||
}
|
||||
|
||||
function normalizeRepositoryTarget(value) {
|
||||
const target = String(value || DEFAULT_REPOSITORY).trim() || DEFAULT_REPOSITORY;
|
||||
if (isRepositoryUrl(target)) {
|
||||
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function isGitRepository(directory = repoRoot) {
|
||||
return tryGit(["rev-parse", "--is-inside-work-tree"], { cwd: directory }, "") === "true";
|
||||
}
|
||||
|
||||
function readUpdateState() {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(updateStatePath, "utf8"));
|
||||
return state && typeof state === "object" ? state : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeUpdateState(values = {}) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const current = readUpdateState();
|
||||
const next = {
|
||||
schema_version: 1,
|
||||
preserve_paths: PRESERVED_PATHS,
|
||||
managed_repo_path: managedRepoDir,
|
||||
updated_at: new Date().toISOString(),
|
||||
...current,
|
||||
...values
|
||||
};
|
||||
fs.writeFileSync(updateStatePath, JSON.stringify(next, null, 2), "utf8");
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveRepositoryRemote(value) {
|
||||
const target = normalizeRepositoryTarget(value);
|
||||
if (isRepositoryUrl(target)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const state = readUpdateState();
|
||||
if (isRepositoryUrl(state.remote)) {
|
||||
return normalizeRepositoryTarget(state.remote);
|
||||
}
|
||||
|
||||
if (isGitRepository(managedRepoDir)) {
|
||||
const cached = tryGit(["remote", "get-url", target], { cwd: managedRepoDir }, "");
|
||||
if (cached) return normalizeRepositoryTarget(cached);
|
||||
const origin = tryGit(["remote", "get-url", "origin"], { cwd: managedRepoDir }, "");
|
||||
if (origin) return normalizeRepositoryTarget(origin);
|
||||
}
|
||||
|
||||
if (isGitRepository(repoRoot)) {
|
||||
const live = tryGit(["remote", "get-url", target], { cwd: repoRoot }, "");
|
||||
if (live) return normalizeRepositoryTarget(live);
|
||||
}
|
||||
|
||||
if (target === "origin") {
|
||||
return DEFAULT_REPOSITORY;
|
||||
}
|
||||
|
||||
throw new Error("Configure Git remote / repository target as a repository URL for ZIP-origin installs.");
|
||||
}
|
||||
|
||||
function resolveSourceBranch(remote, requested = "stable") {
|
||||
const repository = resolveRepositoryRemote(remote);
|
||||
if (requested === "experimental") {
|
||||
const refs = runGit(["ls-remote", "--heads", repository, "experimental-*"], { timeout: 60000 });
|
||||
const branches = refs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().split(/\s+/)[1] || "")
|
||||
.map((ref) => ref.replace(/^refs\/heads\//, ""))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
if (branches[0]) return branches[0];
|
||||
return "experimental";
|
||||
}
|
||||
if (requested && requested !== "stable" && requested !== "main") {
|
||||
return String(requested).replace(/^origin\//, "");
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
|
||||
function migrateZipEraLayout(remote) {
|
||||
fs.mkdirSync(updateCacheDir, { recursive: true });
|
||||
const state = readUpdateState();
|
||||
if (state.migrated_zip_layout_at) {
|
||||
return state;
|
||||
}
|
||||
return writeUpdateState({
|
||||
migrated_zip_layout_at: new Date().toISOString(),
|
||||
migration: "zip-era-layout",
|
||||
remote: resolveRepositoryRemote(remote),
|
||||
live_install_git_repository: isGitRepository(repoRoot)
|
||||
});
|
||||
}
|
||||
|
||||
function resetManagedRepo(repository) {
|
||||
fs.rmSync(managedRepoDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(updateCacheDir, { recursive: true });
|
||||
runGit(["clone", repository, managedRepoDir], {
|
||||
cwd: updateCacheDir,
|
||||
timeout: 300000
|
||||
});
|
||||
}
|
||||
|
||||
function ensureManagedRepo(remote, branch) {
|
||||
const repository = resolveRepositoryRemote(remote);
|
||||
migrateZipEraLayout(repository);
|
||||
fs.mkdirSync(updateCacheDir, { recursive: true });
|
||||
|
||||
if (!isGitRepository(managedRepoDir)) {
|
||||
resetManagedRepo(repository);
|
||||
} else {
|
||||
const currentRemote = normalizeRepositoryTarget(
|
||||
tryGit(["remote", "get-url", "origin"], { cwd: managedRepoDir }, "")
|
||||
);
|
||||
if (currentRemote && currentRemote !== repository) {
|
||||
runGit(["remote", "set-url", "origin", repository], { cwd: managedRepoDir });
|
||||
} else if (!currentRemote) {
|
||||
resetManagedRepo(repository);
|
||||
}
|
||||
}
|
||||
|
||||
runGit(["fetch", "--prune", "origin"], {
|
||||
cwd: managedRepoDir,
|
||||
timeout: 300000
|
||||
});
|
||||
runGit(["checkout", "-B", branch, `origin/${branch}`], {
|
||||
cwd: managedRepoDir,
|
||||
timeout: 120000
|
||||
});
|
||||
runGit(["reset", "--hard", `origin/${branch}`], {
|
||||
cwd: managedRepoDir,
|
||||
timeout: 120000
|
||||
});
|
||||
runGit(["clean", "-fdx"], {
|
||||
cwd: managedRepoDir,
|
||||
timeout: 120000
|
||||
});
|
||||
|
||||
writeUpdateState({
|
||||
remote: repository,
|
||||
branch,
|
||||
managed_repo_path: managedRepoDir,
|
||||
last_fetch_at: new Date().toISOString(),
|
||||
live_install_git_repository: isGitRepository(repoRoot)
|
||||
});
|
||||
return {
|
||||
repository,
|
||||
branch,
|
||||
path: managedRepoDir
|
||||
};
|
||||
}
|
||||
|
||||
function createMetadataReader(remote, branch) {
|
||||
const managed = ensureManagedRepo(remote, branch);
|
||||
return {
|
||||
repository: managed.repository,
|
||||
branch: managed.branch,
|
||||
root: managed.path,
|
||||
readFile(filePath) {
|
||||
const target = path.resolve(managed.path, filePath);
|
||||
if (!target.startsWith(managed.path + path.sep) && target !== managed.path) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.readFileSync(target, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
listPluginDirs() {
|
||||
const pluginsPath = path.join(managed.path, "plugins");
|
||||
try {
|
||||
return fs.readdirSync(pluginsPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
cleanup() {}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REPOSITORY,
|
||||
PRESERVED_PATHS,
|
||||
updateCacheDir,
|
||||
managedRepoDir,
|
||||
updateStatePath,
|
||||
runGit,
|
||||
isRepositoryUrl,
|
||||
isGitRepository,
|
||||
normalizeRepositoryTarget,
|
||||
resolveRepositoryRemote,
|
||||
resolveSourceBranch,
|
||||
migrateZipEraLayout,
|
||||
ensureManagedRepo,
|
||||
createMetadataReader,
|
||||
readUpdateState,
|
||||
writeUpdateState
|
||||
};
|
||||
@ -1,111 +1,33 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
let AdmZip = null;
|
||||
try {
|
||||
AdmZip = require("adm-zip");
|
||||
} catch {
|
||||
AdmZip = null;
|
||||
}
|
||||
const { applyBotUpdate } = require("./update-manager");
|
||||
const { getUpdateStatus } = require("./update-index");
|
||||
const { applyCoreUpdate } = require("./repo-update");
|
||||
const {
|
||||
normalizeRepositoryTarget,
|
||||
resolveSourceBranch
|
||||
} = require("./update-repository");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "Git command failed.");
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function isGitRepository() {
|
||||
try {
|
||||
return runGit(["rev-parse", "--is-inside-work-tree"]) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepositoryTarget(value) {
|
||||
const target = String(value || "origin").trim() || "origin";
|
||||
if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) {
|
||||
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function remoteRef(remote, branch) {
|
||||
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(remote)
|
||||
? "FETCH_HEAD"
|
||||
: `${remote}/${branch}`;
|
||||
function normalizeUpdateSource(branch) {
|
||||
const requested = String(branch || "main").trim();
|
||||
if (requested === "main" || requested === "stable") return "stable";
|
||||
if (requested === "experimental") return "experimental";
|
||||
return requested || "stable";
|
||||
}
|
||||
|
||||
function checkForUpdates(remote, branch) {
|
||||
const target = normalizeRepositoryTarget(remote);
|
||||
if (!isGitRepository()) {
|
||||
runGit(["ls-remote", "--heads", target, branch]);
|
||||
return true;
|
||||
}
|
||||
runGit(["fetch", target, branch]);
|
||||
const count = runGit([
|
||||
"rev-list",
|
||||
`HEAD..${remoteRef(target, branch)}`,
|
||||
"--count"
|
||||
]);
|
||||
return Number(count) > 0;
|
||||
const source = normalizeUpdateSource(branch);
|
||||
const status = getUpdateStatus({ remote: target, source });
|
||||
return Boolean(status.core?.update_available);
|
||||
}
|
||||
|
||||
async function pullUpdates(remote, branch) {
|
||||
const target = normalizeRepositoryTarget(remote);
|
||||
if (isGitRepository()) {
|
||||
return runGit(["pull", target, branch]);
|
||||
}
|
||||
return applyRepositorySnapshot(target, branch);
|
||||
const source = normalizeUpdateSource(branch);
|
||||
const result = await applyCoreUpdate({ remote: target, source });
|
||||
return `Applied core update from ${result.target.source_branch}.`;
|
||||
}
|
||||
|
||||
async function applyRepositorySnapshot(remote, branch) {
|
||||
if (!AdmZip) {
|
||||
throw new Error("adm-zip is not installed. Run npm install before applying repository updates from a ZIP install.");
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repo-update-"));
|
||||
const cloneDir = path.join(tempDir, "repo");
|
||||
const zipPath = path.join(tempDir, "core.zip");
|
||||
try {
|
||||
runGit(["clone", "--depth", "1", "--branch", branch, remote, cloneDir], { cwd: tempDir });
|
||||
const zip = new AdmZip();
|
||||
addFolderToZip(zip, cloneDir, cloneDir, new Set([".git", "node_modules", "data", "plugins", "updates"]));
|
||||
zip.writeZip(zipPath);
|
||||
await applyBotUpdate(zipPath, {
|
||||
mode: "full",
|
||||
metadata: {
|
||||
source_branch: branch,
|
||||
update_method: "repo_clone",
|
||||
rollback_safe: false
|
||||
}
|
||||
});
|
||||
return `Applied core update from ${remote} (${branch}).`;
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function addFolderToZip(zip, folderPath, basePath, ignore) {
|
||||
for (const entry of fs.readdirSync(folderPath, { withFileTypes: true })) {
|
||||
const fullPath = path.join(folderPath, entry.name);
|
||||
const relPath = path.relative(basePath, fullPath);
|
||||
const topLevel = relPath.split(path.sep)[0];
|
||||
if (ignore.has(topLevel)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
addFolderToZip(zip, fullPath, basePath, ignore);
|
||||
} else if (entry.isFile()) {
|
||||
zip.addLocalFile(fullPath, path.dirname(relPath) === "." ? "" : path.dirname(relPath));
|
||||
}
|
||||
}
|
||||
function resolveUpdateBranch(remote, branch) {
|
||||
return resolveSourceBranch(normalizeRepositoryTarget(remote), normalizeUpdateSource(branch));
|
||||
}
|
||||
|
||||
function requestRestart() {
|
||||
@ -116,5 +38,6 @@ module.exports = {
|
||||
checkForUpdates,
|
||||
pullUpdates,
|
||||
normalizeRepositoryTarget,
|
||||
resolveUpdateBranch,
|
||||
requestRestart
|
||||
};
|
||||
|
||||
@ -4926,9 +4926,10 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
|
||||
app.get("/admin/plugins", requireRole("admin"), (req, res) => {
|
||||
syncPluginRegistry();
|
||||
const plugins = getPlugins().filter((plugin) => !/^lumi_ai_[a-z0-9_-]+$/i.test(plugin.id || ""));
|
||||
res.render("admin-plugins", {
|
||||
title: "Plugins",
|
||||
plugins: getPlugins()
|
||||
plugins
|
||||
});
|
||||
});
|
||||
|
||||
@ -5099,17 +5100,33 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
app.get("/admin/updates", requireRole("admin"), (req, res) => {
|
||||
let updateStatus = null;
|
||||
let updateStatusError = null;
|
||||
let snapshots = [];
|
||||
let recoveryStatus = {};
|
||||
try {
|
||||
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
} catch (error) {
|
||||
updateStatusError = error.message;
|
||||
}
|
||||
try {
|
||||
snapshots = listSnapshots();
|
||||
} catch (error) {
|
||||
updateStatusError = [updateStatusError, `Snapshots could not be loaded: ${error.message}`]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
try {
|
||||
recoveryStatus = safeModeStatus();
|
||||
} catch (error) {
|
||||
updateStatusError = [updateStatusError, `Recovery status could not be loaded: ${error.message}`]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
res.render("admin-updates", {
|
||||
title: "Updates",
|
||||
snapshots: listSnapshots(),
|
||||
snapshots,
|
||||
updateStatus,
|
||||
updateStatusError,
|
||||
recoveryStatus: safeModeStatus()
|
||||
recoveryStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -36,10 +36,15 @@
|
||||
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" placeholder="origin or https://git.example/owner/repo(.git)" />
|
||||
<p class="hint">Use a remote alias such as <code>origin</code> or a repository URL. The <code>.git</code> suffix is optional.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Git branch</label>
|
||||
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Update branch</label>
|
||||
<% const selectedUpdateBranch = settings.git_branch === 'experimental' ? 'experimental' : 'main'; %>
|
||||
<select name="git_branch">
|
||||
<option value="main" <%= selectedUpdateBranch === 'main' ? 'selected' : '' %>>Stable</option>
|
||||
<option value="experimental" <%= selectedUpdateBranch === 'experimental' ? 'selected' : '' %>>Experimental</option>
|
||||
</select>
|
||||
<p class="hint">Stable uses <code>main</code>. Experimental uses the newest <code>experimental-*</code> branch.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<div class="inline-actions">
|
||||
<%- include("partials/state-button", {
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
const selectedSource = status.requested_source || "stable";
|
||||
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
|
||||
const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success";
|
||||
const toolBadgeText = (item) => item?.blocked ? "Blocked" : item?.installed === false ? "Available" : item?.update_available ? "Update available" : "Current";
|
||||
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
|
||||
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
|
||||
const applyConfirmText = (item, label) => {
|
||||
@ -32,8 +34,8 @@
|
||||
<label class="field compact">
|
||||
<span>Update source</span>
|
||||
<select name="source">
|
||||
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
|
||||
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
|
||||
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable</option>
|
||||
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Experimental</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button subtle" type="submit">Reload metadata</button>
|
||||
@ -41,6 +43,35 @@
|
||||
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Manual ZIP updates</h2>
|
||||
<p class="hint">These upload paths stay available even when repository metadata cannot be loaded.</p>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Core ZIP</h3>
|
||||
<p class="hint">Use a full core ZIP or a patch ZIP containing files relative to the repo root.</p>
|
||||
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="update_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload core ZIP</button>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
|
||||
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Plugin ZIP</h3>
|
||||
<p class="hint">Use this fallback when plugin metadata is unavailable. Plugin ZIPs must include a valid <code>plugin.json</code>.</p>
|
||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload plugin ZIP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||
<section class="card update-recovery-banner">
|
||||
<h2>Safe Mode / Recovery</h2>
|
||||
@ -162,7 +193,7 @@
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= plugin.name %></strong>
|
||||
<span class="hint"><%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
|
||||
<span class="hint"><%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
|
||||
</span>
|
||||
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
|
||||
</summary>
|
||||
@ -174,6 +205,52 @@
|
||||
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
|
||||
</div>
|
||||
<p><%= plugin.version_description %></p>
|
||||
<% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %>
|
||||
<% if (plugin.id === "lumi_ai" && tools.length) { %>
|
||||
<% const toolSummary = plugin.tools_summary || {}; %>
|
||||
<details class="lumi-expandable-settings plugin-tool-list" <%= tools.some((tool) => tool.blocked || tool.update_available || tool.installed === false) ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Lumi AI tools</strong>
|
||||
<span class="hint"><%= toolSummary.installed_tools || 0 %> installed · <%= toolSummary.total_tools || tools.length %> known · <%= toolSummary.updatable_tools || 0 %> updatable · <%= toolSummary.available_tools || 0 %> available</span>
|
||||
</span>
|
||||
<span class="badge <%= toolSummary.blocked_tools ? "danger" : (toolSummary.updatable_tools || toolSummary.available_tools) ? "warning" : "success" %>"><%= toolSummary.blocked_tools ? `${toolSummary.blocked_tools} blocked` : `${(toolSummary.updatable_tools || 0) + (toolSummary.available_tools || 0)} actions` %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body plugin-update-list">
|
||||
<% tools.forEach((tool) => { %>
|
||||
<details class="lumi-expandable-settings plugin-update-row" <%= tool.blocked || tool.update_available || tool.installed === false ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= tool.name %></strong>
|
||||
<span class="hint"><%= tool.installed === false ? "Not installed" : tool.current_version %> -> <%= tool.safe_target_version || "none" %> · <%= tool.repository_path || `plugins/${tool.id}` %></span>
|
||||
</span>
|
||||
<span class="badge <%= toolBadgeClass(tool) %>"><%= toolBadgeText(tool) %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body update-detail-grid">
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Current</span><strong><%= tool.installed === false ? "Not installed" : tool.current_version %></strong></div>
|
||||
<div><span>Target</span><strong><%= tool.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Minimum Lumi AI</span><strong><%= tool.minimum_lumi_ai_version || "Not specified" %></strong></div>
|
||||
<div><span>Source branch</span><strong><%= tool.source_branch %></strong></div>
|
||||
</div>
|
||||
<% if (tool.description) { %><p><%= tool.description %></p><% } %>
|
||||
<% if (tool.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% tool.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (tool.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% tool.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (tool.capabilities?.length) { %>
|
||||
<div>
|
||||
<h3>Capabilities</h3>
|
||||
<ul class="update-changelog">
|
||||
<% tool.capabilities.forEach((item) => { %><li><span><%= item %></span></li><% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
<% } %>
|
||||
<p class="hint">Lumi AI tools are installed and managed by the Lumi AI tool manager. They are not normal plugin registry entries.</p>
|
||||
</div>
|
||||
</details>
|
||||
<% }) %>
|
||||
</div>
|
||||
</details>
|
||||
<% } %>
|
||||
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
|
||||
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
@ -195,7 +272,7 @@
|
||||
</form>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= applyLabel(plugin) %></button>
|
||||
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= plugin.installed === false ? "Install from repo" : applyLabel(plugin) %></button>
|
||||
</form>
|
||||
<% if (plugin.snapshot.available) { %>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin">
|
||||
@ -204,9 +281,11 @@
|
||||
<button class="button danger" type="submit">Revert previous</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (plugin.installed !== false) { %>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
|
||||
<button class="button danger" type="submit">Disable for recovery</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
<details class="inline-details">
|
||||
<summary>Show advanced plugin ZIP options</summary>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user