Lumi/src/services/update-repository.js
2026-06-17 12:08:35 +02:00

259 lines
7.0 KiB
JavaScript

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
};