fix updates for zip-origin installs
This commit is contained in:
parent
79f4dfe37b
commit
1c329bd551
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@ taskfile.txt
|
|||||||
codex-guidelines
|
codex-guidelines
|
||||||
Twitch.png
|
Twitch.png
|
||||||
twitch-credentials-lumi.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 path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { spawnSync } = require("child_process");
|
|
||||||
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
|
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
|
||||||
const {
|
const {
|
||||||
createSnapshot,
|
createSnapshot,
|
||||||
finalizeSnapshot,
|
finalizeSnapshot,
|
||||||
discardSnapshot,
|
discardSnapshot,
|
||||||
restoreSnapshot
|
restoreSnapshot,
|
||||||
|
applyCoreFilesFromDirectory,
|
||||||
|
applyPluginFiles
|
||||||
} = require("./update-manager");
|
} = require("./update-manager");
|
||||||
const { getUpdateStatus } = require("./update-index");
|
const { getUpdateStatus } = require("./update-index");
|
||||||
|
const {
|
||||||
|
ensureManagedRepo,
|
||||||
|
writeUpdateState
|
||||||
|
} = require("./update-repository");
|
||||||
const {
|
const {
|
||||||
createRecoveryMarker,
|
createRecoveryMarker,
|
||||||
updateRecoveryMarker,
|
updateRecoveryMarker,
|
||||||
@ -19,17 +24,6 @@ const {
|
|||||||
const repoRoot = path.join(__dirname, "..", "..");
|
const repoRoot = path.join(__dirname, "..", "..");
|
||||||
const activeOperations = new Set();
|
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) {
|
function withOperation(key, fn) {
|
||||||
if (activeOperations.has(key)) {
|
if (activeOperations.has(key)) {
|
||||||
throw new Error("An update is already running for this target.");
|
throw new Error("An update is already running for this target.");
|
||||||
@ -62,9 +56,19 @@ function verifyPluginFiles(pluginId) {
|
|||||||
JSON.parse(fs.readFileSync(manifest, "utf8"));
|
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 () => {
|
return withOperation("core", async () => {
|
||||||
const status = getUpdateStatus({ source });
|
const status = getUpdateStatus({ source, remote });
|
||||||
const target = status.core;
|
const target = status.core;
|
||||||
if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked.");
|
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.");
|
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
|
major_crossing: target.major_crossing
|
||||||
});
|
});
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
|
let snapshotRecord = null;
|
||||||
try {
|
try {
|
||||||
emitProgress(publish, "update:queued", { target: "core" });
|
emitProgress(publish, "update:queued", { target: "core" });
|
||||||
emitProgress(publish, "update:checking", { target: "core" });
|
emitProgress(publish, "update:checking", { target: "core" });
|
||||||
@ -100,32 +105,64 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
|
|||||||
danger_notes: target.dangers
|
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:recovery_marker", { target: "core", marker_id: marker.id });
|
||||||
emitProgress(publish, "update:download", { target: "core", branch: target.source_branch });
|
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" });
|
emitProgress(publish, "update:apply", { target: "core" });
|
||||||
runGit(["pull", "--ff-only", status.remote, target.source_branch]);
|
applyCoreFilesFromDirectory(managed.path);
|
||||||
updateRecoveryMarker({ status: "verifying" });
|
updateRecoveryMarker({ status: "verifying" });
|
||||||
emitProgress(publish, "update:verify", { target: "core" });
|
emitProgress(publish, "update:verify", { target: "core" });
|
||||||
verifyCoreFiles();
|
verifyCoreFiles();
|
||||||
const record = finalizeSnapshot(snapshot);
|
const record = snapshotRecord;
|
||||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
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:restart_required", { target: "core" });
|
||||||
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
|
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
|
||||||
return { status: "complete", restart_required: true, snapshot: record, target };
|
return { status: "complete", restart_required: true, snapshot: record, target };
|
||||||
} catch (error) {
|
} 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);
|
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 });
|
emitProgress(publish, "update:failed", { target: "core", error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) {
|
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, publish } = {}) {
|
||||||
return withOperation(`plugin:${pluginId}`, async () => {
|
return withOperation(`plugin:${pluginId}`, async () => {
|
||||||
const status = getUpdateStatus({ source });
|
const status = getUpdateStatus({ source, remote });
|
||||||
const target = status.plugins.find((plugin) => plugin.id === pluginId);
|
const target = status.plugins.find((plugin) => plugin.id === pluginId);
|
||||||
if (!target) throw new Error("Plugin is not installed.");
|
if (!target) throw new Error("Plugin is not installed.");
|
||||||
if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
|
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,
|
from_version: target.current_version,
|
||||||
to_version: target.safe_target_version,
|
to_version: target.safe_target_version,
|
||||||
source_branch: target.source_branch,
|
source_branch: target.source_branch,
|
||||||
update_method: "git",
|
update_method: target.installed === false ? "git_install" : "git",
|
||||||
rollback_safe: target.rollback_safe,
|
rollback_safe: target.rollback_safe,
|
||||||
major_crossing: target.major_crossing
|
major_crossing: target.major_crossing
|
||||||
});
|
});
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
|
let snapshotRecord = null;
|
||||||
try {
|
try {
|
||||||
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
|
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
|
||||||
emitProgress(publish, "update:metadata", target);
|
emitProgress(publish, "update:metadata", target);
|
||||||
@ -154,7 +192,7 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
|
|||||||
from_version: target.current_version,
|
from_version: target.current_version,
|
||||||
to_version: target.safe_target_version,
|
to_version: target.safe_target_version,
|
||||||
source_branch: target.source_branch,
|
source_branch: target.source_branch,
|
||||||
update_method: "git",
|
update_method: target.installed === false ? "git_install" : "git",
|
||||||
rollback_safe: target.rollback_safe,
|
rollback_safe: target.rollback_safe,
|
||||||
recovery_marker_id: marker.id,
|
recovery_marker_id: marker.id,
|
||||||
major_crossing: target.major_crossing,
|
major_crossing: target.major_crossing,
|
||||||
@ -162,22 +200,56 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
|
|||||||
danger_notes: target.dangers
|
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 });
|
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 });
|
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" });
|
updateRecoveryMarker({ status: "verifying" });
|
||||||
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
|
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
|
||||||
verifyPluginFiles(pluginId);
|
verifyPluginFiles(pluginId);
|
||||||
syncPluginRegistry();
|
syncPluginRegistry();
|
||||||
const record = finalizeSnapshot(snapshot);
|
const record = snapshotRecord;
|
||||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
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 });
|
emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
|
||||||
return { status: "complete", restart_required: false, snapshot: record, target };
|
return { status: "complete", restart_required: false, snapshot: record, target };
|
||||||
} catch (error) {
|
} 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);
|
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 });
|
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { spawnSync } = require("child_process");
|
|
||||||
const { getSetting } = require("./settings");
|
const { getSetting } = require("./settings");
|
||||||
const { scanPluginDirectories, getPlugins } = require("./plugins");
|
const { scanPluginDirectories, getPlugins } = require("./plugins");
|
||||||
const {
|
const {
|
||||||
@ -12,58 +11,21 @@ const {
|
|||||||
} = require("./versioning");
|
} = require("./versioning");
|
||||||
const { listSnapshots } = require("./update-manager");
|
const { listSnapshots } = require("./update-manager");
|
||||||
const { safeModeStatus } = require("./recovery-mode");
|
const { safeModeStatus } = require("./recovery-mode");
|
||||||
|
const {
|
||||||
|
createMetadataReader,
|
||||||
|
normalizeRepositoryTarget,
|
||||||
|
resolveSourceBranch
|
||||||
|
} = require("./update-repository");
|
||||||
|
|
||||||
const repoRoot = path.join(__dirname, "..", "..");
|
const repoRoot = path.join(__dirname, "..", "..");
|
||||||
|
const LUMI_AI_TOOL_ID = /^lumi_ai_[a-z0-9_-]+$/i;
|
||||||
|
|
||||||
function runGit(args, options = {}) {
|
function isLumiAiToolId(id) {
|
||||||
const result = spawnSync("git", args, {
|
return LUMI_AI_TOOL_ID.test(String(id || "")) && String(id || "") !== "lumi_ai";
|
||||||
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 tryGit(args, fallback = "") {
|
function readJsonFromReader(reader, filePath) {
|
||||||
try {
|
const raw = reader.readFile(filePath);
|
||||||
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);
|
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
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) {
|
function parseMarkdownChangelog(raw) {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
const entries = [];
|
const entries = [];
|
||||||
@ -122,11 +60,11 @@ function parseMarkdownChangelog(raw) {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changelogEntries(ref, basePath = "") {
|
function changelogEntries(reader, basePath = "") {
|
||||||
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json"));
|
const json = readJsonFromReader(reader, path.posix.join(basePath, "changelog.json"));
|
||||||
if (Array.isArray(json)) return json;
|
if (Array.isArray(json)) return json;
|
||||||
if (Array.isArray(json?.versions)) return json.versions;
|
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);
|
return parseMarkdownChangelog(md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,10 +80,10 @@ function manifestRawVersion(manifest, fallbackVersion) {
|
|||||||
return String(manifest?.version || fallbackVersion || "").trim();
|
return String(manifest?.version || fallbackVersion || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function coreManifest(ref) {
|
function coreManifest(reader) {
|
||||||
return readJsonGitFile(ref, "update-manifest.json") ||
|
return readJsonFromReader(reader, "update-manifest.json") ||
|
||||||
readJsonGitFile(ref, "lumi.manifest.json") ||
|
readJsonFromReader(reader, "lumi.manifest.json") ||
|
||||||
readJsonGitFile(ref, "package.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 versions = manifestVersions(manifest, manifest?.version);
|
||||||
const rawVersion = manifestRawVersion(manifest);
|
const rawVersion = manifestRawVersion(manifest);
|
||||||
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
|
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
|
||||||
@ -206,6 +253,7 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
|
|||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
id: id || kind,
|
id: id || kind,
|
||||||
|
installed,
|
||||||
name: name || manifest?.name || id || "Lumi core",
|
name: name || manifest?.name || id || "Lumi core",
|
||||||
current_version: currentVersion,
|
current_version: currentVersion,
|
||||||
latest_available_version: latest?.version || rawVersion || 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 = {}) {
|
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 requestedSource = options.source || "stable";
|
||||||
const sourceBranch = resolveSourceBranch(remote, requestedSource);
|
const sourceBranch = resolveSourceBranch(configuredRemote, requestedSource);
|
||||||
fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null);
|
const reader = createMetadataReader(configuredRemote, sourceBranch);
|
||||||
const ref = remoteRef(remote, sourceBranch);
|
const remote = reader.repository || configuredRemote;
|
||||||
const core = buildStatus({
|
try {
|
||||||
kind: "core",
|
const core = buildStatus({
|
||||||
currentVersion: localCoreVersion(),
|
kind: "core",
|
||||||
manifest: coreManifest(ref),
|
currentVersion: localCoreVersion(),
|
||||||
changelog: changelogEntries(ref),
|
manifest: coreManifest(reader),
|
||||||
sourceBranch,
|
changelog: changelogEntries(reader),
|
||||||
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,
|
|
||||||
sourceBranch,
|
sourceBranch,
|
||||||
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
||||||
});
|
});
|
||||||
});
|
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
|
||||||
return {
|
const remoteDirs = new Set(reader.listPluginDirs());
|
||||||
generated_at: new Date().toISOString(),
|
const remotePluginDirs = new Set();
|
||||||
source_branch: sourceBranch,
|
const remoteTools = new Map();
|
||||||
requested_source: requestedSource,
|
const candidates = localPluginCandidates(registry);
|
||||||
remote,
|
for (const pluginId of remoteDirs) {
|
||||||
core,
|
if (isLumiAiToolId(pluginId)) {
|
||||||
plugins,
|
const toolMetadata = readJsonFromReader(reader, `plugins/${pluginId}/tool_info.json`);
|
||||||
plugins_summary: {
|
if (toolMetadata) {
|
||||||
installed_plugins: installed.length,
|
remoteTools.set(pluginId, {
|
||||||
total_plugins: remoteDirs.size || installed.length,
|
...toolMetadata,
|
||||||
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
|
repository_path: toolMetadata.repository_path || `plugins/${pluginId}`
|
||||||
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
|
});
|
||||||
},
|
continue;
|
||||||
recovery: safeModeStatus()
|
}
|
||||||
};
|
}
|
||||||
|
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 = {
|
module.exports = {
|
||||||
getUpdateStatus,
|
getUpdateStatus,
|
||||||
resolveSourceBranch,
|
resolveSourceBranch
|
||||||
fetchRemote,
|
|
||||||
runGit,
|
|
||||||
readGitFile
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,6 +21,36 @@ const dataDir = path.join(repoRoot, "data");
|
|||||||
const snapshotsDir = path.join(dataDir, "snapshots");
|
const snapshotsDir = path.join(dataDir, "snapshots");
|
||||||
const indexPath = path.join(snapshotsDir, "index.json");
|
const indexPath = path.join(snapshotsDir, "index.json");
|
||||||
const maxSnapshots = 20;
|
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() {
|
function ensureSnapshotsDir() {
|
||||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||||
@ -66,9 +96,12 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
|
|||||||
|
|
||||||
let pluginExisted = false;
|
let pluginExisted = false;
|
||||||
let pluginZip = null;
|
let pluginZip = null;
|
||||||
|
let fullZip = null;
|
||||||
if (type === "bot") {
|
if (type === "bot") {
|
||||||
const coreZip = path.join(snapshotPath, "core.zip");
|
const coreZip = path.join(snapshotPath, "core.zip");
|
||||||
zipCore(coreZip);
|
zipCore(coreZip);
|
||||||
|
fullZip = path.join(snapshotPath, "full.zip");
|
||||||
|
zipFullInstall(fullZip);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "plugin" && pluginId) {
|
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) {
|
function finalizeSnapshot(snapshot) {
|
||||||
@ -187,15 +220,9 @@ function resolveZipRoot(extractedDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePatchRoot(extractedDir) {
|
function resolvePatchRoot(extractedDir) {
|
||||||
const entries = fs.readdirSync(extractedDir, { withFileTypes: true });
|
// Patch archives are copied relative to the repository root. Do not infer a
|
||||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
// nested root from a single top-level directory such as src/, or patches will
|
||||||
const files = entries.filter((entry) => entry.isFile());
|
// be applied to services/ and web/ instead of src/services/ and src/web/.
|
||||||
if (files.length > 0) {
|
|
||||||
return extractedDir;
|
|
||||||
}
|
|
||||||
if (dirs.length === 1) {
|
|
||||||
return path.join(extractedDir, dirs[0].name);
|
|
||||||
}
|
|
||||||
return extractedDir;
|
return extractedDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +289,15 @@ function zipCore(destination) {
|
|||||||
zip.writeZip(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) {
|
function zipFolder(source, destination, options) {
|
||||||
if (!AdmZip) {
|
if (!AdmZip) {
|
||||||
throw new Error("adm-zip is not installed. Run npm install.");
|
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 });
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = path.join(folderPath, entry.name);
|
const fullPath = path.join(folderPath, entry.name);
|
||||||
const relPath = path.relative(basePath, fullPath);
|
const relPath = normalizeRelative(path.relative(basePath, fullPath));
|
||||||
const topLevel = relPath.split(path.sep)[0];
|
const topLevel = relPath.split("/")[0];
|
||||||
if (ignore.has(topLevel)) {
|
if (ignore.has(relPath) || ignore.has(topLevel)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
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 });
|
const entries = fs.readdirSync(source, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const srcPath = path.join(source, entry.name);
|
|
||||||
const destPath = path.join(target, entry.name);
|
const destPath = path.join(target, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
fs.mkdirSync(destPath, { recursive: true });
|
fs.mkdirSync(destPath, { recursive: true });
|
||||||
copyDirectory(srcPath, destPath, ignore);
|
copyDirectory(srcPath, destPath, ignore, { base });
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
fs.copyFileSync(srcPath, destPath);
|
fs.copyFileSync(srcPath, destPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCoreUpdate(rootPath) {
|
function removeGeneratedPaths() {
|
||||||
resetCoreFiles();
|
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(
|
copyDirectory(
|
||||||
rootPath,
|
rootPath,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
new Set([".git", "node_modules", "data", "plugins"])
|
PRESERVE_RELATIVE_PATHS,
|
||||||
|
{ base: rootPath }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,7 +398,8 @@ function applyCorePatch(rootPath) {
|
|||||||
copyDirectory(
|
copyDirectory(
|
||||||
rootPath,
|
rootPath,
|
||||||
repoRoot,
|
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") {
|
if (mode === "patch") {
|
||||||
applyCorePatch(rootPath);
|
applyCorePatch(rootPath);
|
||||||
} else {
|
} else {
|
||||||
applyCoreUpdate(rootPath);
|
applyCoreFilesFromDirectory(rootPath);
|
||||||
}
|
}
|
||||||
const record = finalizeSnapshot(snapshot);
|
const record = finalizeSnapshot(snapshot);
|
||||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||||
@ -525,14 +593,16 @@ function restoreSnapshot(id, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "bot") {
|
if (entry.type === "bot") {
|
||||||
|
const fullZip = path.join(entry.path, "full.zip");
|
||||||
const coreZip = path.join(entry.path, "core.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.");
|
throw new Error("Snapshot core archive missing.");
|
||||||
}
|
}
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
|
||||||
extractZip(coreZip, tempDir);
|
extractZip(restoreZip, tempDir);
|
||||||
const rootPath = resolveZipRoot(tempDir);
|
const rootPath = resolveZipRoot(tempDir);
|
||||||
applyCoreUpdate(rootPath);
|
applyCoreFilesFromDirectory(rootPath);
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,6 +635,7 @@ module.exports = {
|
|||||||
createSnapshot,
|
createSnapshot,
|
||||||
finalizeSnapshot,
|
finalizeSnapshot,
|
||||||
discardSnapshot,
|
discardSnapshot,
|
||||||
|
applyCoreFilesFromDirectory,
|
||||||
applyPluginFiles,
|
applyPluginFiles,
|
||||||
resetPluginCode,
|
resetPluginCode,
|
||||||
replacePluginDirectory,
|
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 { getUpdateStatus } = require("./update-index");
|
||||||
const fs = require("fs");
|
const { applyCoreUpdate } = require("./repo-update");
|
||||||
const os = require("os");
|
const {
|
||||||
const { spawnSync } = require("child_process");
|
normalizeRepositoryTarget,
|
||||||
let AdmZip = null;
|
resolveSourceBranch
|
||||||
try {
|
} = require("./update-repository");
|
||||||
AdmZip = require("adm-zip");
|
|
||||||
} catch {
|
|
||||||
AdmZip = null;
|
|
||||||
}
|
|
||||||
const { applyBotUpdate } = require("./update-manager");
|
|
||||||
|
|
||||||
const repoRoot = path.join(__dirname, "..", "..");
|
function normalizeUpdateSource(branch) {
|
||||||
|
const requested = String(branch || "main").trim();
|
||||||
function runGit(args, options = {}) {
|
if (requested === "main" || requested === "stable") return "stable";
|
||||||
const result = spawnSync("git", args, {
|
if (requested === "experimental") return "experimental";
|
||||||
cwd: options.cwd || repoRoot,
|
return requested || "stable";
|
||||||
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 checkForUpdates(remote, branch) {
|
function checkForUpdates(remote, branch) {
|
||||||
const target = normalizeRepositoryTarget(remote);
|
const target = normalizeRepositoryTarget(remote);
|
||||||
if (!isGitRepository()) {
|
const source = normalizeUpdateSource(branch);
|
||||||
runGit(["ls-remote", "--heads", target, branch]);
|
const status = getUpdateStatus({ remote: target, source });
|
||||||
return true;
|
return Boolean(status.core?.update_available);
|
||||||
}
|
|
||||||
runGit(["fetch", target, branch]);
|
|
||||||
const count = runGit([
|
|
||||||
"rev-list",
|
|
||||||
`HEAD..${remoteRef(target, branch)}`,
|
|
||||||
"--count"
|
|
||||||
]);
|
|
||||||
return Number(count) > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pullUpdates(remote, branch) {
|
async function pullUpdates(remote, branch) {
|
||||||
const target = normalizeRepositoryTarget(remote);
|
const target = normalizeRepositoryTarget(remote);
|
||||||
if (isGitRepository()) {
|
const source = normalizeUpdateSource(branch);
|
||||||
return runGit(["pull", target, branch]);
|
const result = await applyCoreUpdate({ remote: target, source });
|
||||||
}
|
return `Applied core update from ${result.target.source_branch}.`;
|
||||||
return applyRepositorySnapshot(target, branch);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyRepositorySnapshot(remote, branch) {
|
function resolveUpdateBranch(remote, branch) {
|
||||||
if (!AdmZip) {
|
return resolveSourceBranch(normalizeRepositoryTarget(remote), normalizeUpdateSource(branch));
|
||||||
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 requestRestart() {
|
function requestRestart() {
|
||||||
@ -116,5 +38,6 @@ module.exports = {
|
|||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
pullUpdates,
|
pullUpdates,
|
||||||
normalizeRepositoryTarget,
|
normalizeRepositoryTarget,
|
||||||
|
resolveUpdateBranch,
|
||||||
requestRestart
|
requestRestart
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4926,9 +4926,10 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
|
|
||||||
app.get("/admin/plugins", requireRole("admin"), (req, res) => {
|
app.get("/admin/plugins", requireRole("admin"), (req, res) => {
|
||||||
syncPluginRegistry();
|
syncPluginRegistry();
|
||||||
|
const plugins = getPlugins().filter((plugin) => !/^lumi_ai_[a-z0-9_-]+$/i.test(plugin.id || ""));
|
||||||
res.render("admin-plugins", {
|
res.render("admin-plugins", {
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
plugins: getPlugins()
|
plugins
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -5099,17 +5100,33 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
app.get("/admin/updates", requireRole("admin"), (req, res) => {
|
app.get("/admin/updates", requireRole("admin"), (req, res) => {
|
||||||
let updateStatus = null;
|
let updateStatus = null;
|
||||||
let updateStatusError = null;
|
let updateStatusError = null;
|
||||||
|
let snapshots = [];
|
||||||
|
let recoveryStatus = {};
|
||||||
try {
|
try {
|
||||||
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateStatusError = error.message;
|
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", {
|
res.render("admin-updates", {
|
||||||
title: "Updates",
|
title: "Updates",
|
||||||
snapshots: listSnapshots(),
|
snapshots,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
updateStatusError,
|
updateStatusError,
|
||||||
recoveryStatus: safeModeStatus()
|
recoveryStatus
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -37,8 +37,13 @@
|
|||||||
<p class="hint">Use a remote alias such as <code>origin</code> or a repository URL. The <code>.git</code> suffix is optional.</p>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Git branch</label>
|
<label>Update branch</label>
|
||||||
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
|
<% 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>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
const selectedSource = status.requested_source || "stable";
|
const selectedSource = status.requested_source || "stable";
|
||||||
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||||
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
|
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 changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
|
||||||
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
|
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
|
||||||
const applyConfirmText = (item, label) => {
|
const applyConfirmText = (item, label) => {
|
||||||
@ -32,8 +34,8 @@
|
|||||||
<label class="field compact">
|
<label class="field compact">
|
||||||
<span>Update source</span>
|
<span>Update source</span>
|
||||||
<select name="source">
|
<select name="source">
|
||||||
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
|
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable</option>
|
||||||
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
|
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Experimental</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="button subtle" type="submit">Reload metadata</button>
|
<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>
|
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
|
||||||
</section>
|
</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) { %>
|
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||||
<section class="card update-recovery-banner">
|
<section class="card update-recovery-banner">
|
||||||
<h2>Safe Mode / Recovery</h2>
|
<h2>Safe Mode / Recovery</h2>
|
||||||
@ -162,7 +193,7 @@
|
|||||||
<summary>
|
<summary>
|
||||||
<span>
|
<span>
|
||||||
<strong><%= plugin.name %></strong>
|
<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>
|
||||||
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
|
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
|
||||||
</summary>
|
</summary>
|
||||||
@ -174,6 +205,52 @@
|
|||||||
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
|
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p><%= plugin.version_description %></p>
|
<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.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.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><% } %>
|
<% 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>
|
||||||
<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) %>">
|
<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 %>" />
|
<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>
|
</form>
|
||||||
<% if (plugin.snapshot.available) { %>
|
<% 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">
|
<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>
|
<button class="button danger" type="submit">Revert previous</button>
|
||||||
</form>
|
</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">
|
<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>
|
<button class="button danger" type="submit">Disable for recovery</button>
|
||||||
</form>
|
</form>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<details class="inline-details">
|
<details class="inline-details">
|
||||||
<summary>Show advanced plugin ZIP options</summary>
|
<summary>Show advanced plugin ZIP options</summary>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user