const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { spawn } = require("child_process"); const DEFAULT_EXCLUDED_DIRECTORIES = new Set([ ".git", ".vs", ".vscode", "bin", "obj", "node_modules", "data", "logs", "dist", "build", "coverage", "output", "publish", "package", "tmp", "temp", "tests", "docs" ]); const DEFAULT_EXCLUDED_FILES = new Set([ ".ds_store", "thumbs.db", "readme.md", "changelog.md" ]); class DevelopmentUpdateService { constructor(options = {}) { this.repoRoot = path.resolve(options.repoRoot || path.join(__dirname, "..", "..")); this.cacheRoot = path.resolve(options.cacheRoot || path.join(this.repoRoot, "data", "development-updates")); this.runtime = options.runtime || global.lumiRuntime || null; this.spawn = options.spawn || spawn; this.platform = options.platform || process.platform; this.activeBuilds = new Map(); } enabled() { return Boolean(this.runtime?.isDevelopment); } inventory() { const companionRoot = path.join(this.repoRoot, "companion"); const components = []; const relatedServerPluginIds = new Set(); const corePaths = [ path.join(companionRoot, "src"), path.join(companionRoot, "legal"), path.join(companionRoot, "Lumi.Companion.sln"), path.join(companionRoot, "scripts", "publish-dev-update.ps1") ].filter((target) => fs.existsSync(target)); if (corePaths.length) { components.push(componentRecord("companion:core", "companion-core", corePaths, this.repoRoot)); } const companionPluginsRoot = path.join(companionRoot, "plugins"); for (const name of listDirectories(companionPluginsRoot)) { const pluginRoot = path.join(companionPluginsRoot, name); components.push(componentRecord(`companion-plugin:${name}`, "companion-plugin", [pluginRoot], this.repoRoot)); const declaredId = readPluginId(path.join(pluginRoot, "plugin.json")); if (declaredId) relatedServerPluginIds.add(declaredId); } const serverPluginsRoot = path.join(this.repoRoot, "plugins"); for (const name of listDirectories(serverPluginsRoot)) { const pluginRoot = path.join(serverPluginsRoot, name); const related = relatedServerPluginIds.has(name) || fs.existsSync(path.join(pluginRoot, "companion_manifest.json")); components.push({ ...componentRecord(`lumi-plugin:${name}`, "lumi-plugin", [pluginRoot], this.repoRoot), companion_related: related }); } components.sort((a, b) => a.id.localeCompare(b.id)); const packageComponents = components.filter((entry) => entry.kind === "companion-core" || entry.kind === "companion-plugin"); const synchronizedServerComponents = components.filter((entry) => entry.kind === "lumi-plugin" && entry.companion_related); const trackedComponents = [...packageComponents, ...synchronizedServerComponents].sort((a, b) => a.id.localeCompare(b.id)); const aggregateChecksum = aggregateComponents(trackedComponents); return { generated_at: new Date().toISOString(), aggregate_checksum: aggregateChecksum, components, package_components: packageComponents, synchronized_server_components: synchronizedServerComponents, tracked_components: trackedComponents, updatable_components: trackedComponents }; } compare(current = {}) { const inventory = this.inventory(); const currentAggregate = normalizeChecksum(current.aggregate_checksum || current.build_checksum); const currentComponents = normalizeComponentMap(current.components || current.component_checksums); const changed = inventory.updatable_components.filter((entry) => currentComponents[entry.id] !== entry.checksum); return { ...inventory, current_aggregate_checksum: currentAggregate || null, update_available: currentAggregate !== inventory.aggregate_checksum, changed_components: changed.map((entry) => ({ id: entry.id, kind: entry.kind, checksum: entry.checksum })), server_components: inventory.synchronized_server_components.map((entry) => ({ id: entry.id, checksum: entry.checksum })) }; } async buildCompanionArtifact(state = this.inventory()) { if (!this.enabled()) throw new Error("Local development updates are disabled in production mode."); const buildId = normalizeChecksum(state.aggregate_checksum); if (!buildId) throw new Error("The local Companion source checksum is invalid."); const cached = this.readArtifact(buildId); if (cached) return cached; if (this.activeBuilds.has(buildId)) return this.activeBuilds.get(buildId); const build = this.buildCompanionArtifactInternal(state).finally(() => this.activeBuilds.delete(buildId)); this.activeBuilds.set(buildId, build); return build; } readArtifact(buildId) { const safeId = normalizeChecksum(buildId); if (!safeId) return null; const archivePath = path.join(this.cacheRoot, "companion", safeId, "Lumi.Companion-win-x64.zip"); const metadataPath = path.join(this.cacheRoot, "companion", safeId, "artifact.json"); if (!fs.existsSync(archivePath) || !fs.existsSync(metadataPath)) return null; try { const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8")); const stat = fs.statSync(archivePath); const sha256 = sha256File(archivePath); if (stat.size !== metadata.bytes || sha256 !== metadata.sha256) return null; return { ...metadata, path: archivePath }; } catch { return null; } } async buildCompanionArtifactInternal(state) { const buildId = state.aggregate_checksum; const buildRoot = path.join(this.cacheRoot, "companion", buildId); const archivePath = path.join(buildRoot, "Lumi.Companion-win-x64.zip"); const manifestPath = path.join(buildRoot, ".lumi-dev-build.json"); fs.mkdirSync(buildRoot, { recursive: true }); const manifest = { schema_version: 1, mode: "localhost-development", build_checksum: buildId, generated_at: new Date().toISOString(), components: Object.fromEntries(state.updatable_components.map((entry) => [entry.id, entry.checksum])) }; fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); const script = path.join(this.repoRoot, "companion", "scripts", "publish-dev-update.ps1"); if (!fs.existsSync(script)) throw new Error("The local Companion development publish script is missing."); const shell = findPowerShell(this.platform); await runProcess(this.spawn, shell, [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-OutputArchive", archivePath, "-ManifestPath", manifestPath ], { cwd: this.repoRoot, timeoutMs: 15 * 60 * 1000 }); if (!fs.existsSync(archivePath)) throw new Error("The local Companion build completed without producing an update archive."); const afterBuild = this.inventory(); if (afterBuild.aggregate_checksum !== buildId) { try { fs.rmSync(buildRoot, { recursive: true, force: true }); } catch { } return this.buildCompanionArtifact(afterBuild); } const metadata = { build_id: buildId, sha256: sha256File(archivePath), bytes: fs.statSync(archivePath).size, entrypoint: "Lumi.Companion.App.exe", generated_at: new Date().toISOString(), changed_components: state.updatable_components.map((entry) => entry.id) }; fs.writeFileSync(path.join(buildRoot, "artifact.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); this.cleanupArtifacts(buildId); return { ...metadata, path: archivePath }; } cleanupArtifacts(keepBuildId, maximum = 5) { const root = path.join(this.cacheRoot, "companion"); if (!fs.existsSync(root)) return; const entries = fs.readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name !== keepBuildId) .map((entry) => ({ name: entry.name, path: path.join(root, entry.name), mtime: fs.statSync(path.join(root, entry.name)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); for (const entry of entries.slice(Math.max(0, maximum - 1))) { try { fs.rmSync(entry.path, { recursive: true, force: true }); } catch { } } } } function componentRecord(id, kind, targets, root) { const checksum = hashTargets(targets, root); return { id, kind, checksum, paths: targets.map((target) => path.relative(root, target).replace(/\\/g, "/")) }; } function hashTargets(targets, root) { const files = []; for (const target of targets) collectFiles(target, files); files.sort((a, b) => a.localeCompare(b)); const hash = crypto.createHash("sha256"); for (const file of files) { const relative = path.relative(root, file).replace(/\\/g, "/"); const stat = fs.statSync(file); hash.update(relative, "utf8"); hash.update("\0"); hash.update(String(stat.size), "utf8"); hash.update("\0"); const fd = fs.openSync(file, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } hash.update("\0"); } return hash.digest("hex"); } function collectFiles(target, output) { if (!fs.existsSync(target)) return; const stat = fs.lstatSync(target); if (stat.isSymbolicLink()) return; if (stat.isFile()) { if (!DEFAULT_EXCLUDED_FILES.has(path.basename(target).toLowerCase())) output.push(target); return; } if (!stat.isDirectory()) return; for (const entry of fs.readdirSync(target, { withFileTypes: true })) { if (entry.isDirectory() && DEFAULT_EXCLUDED_DIRECTORIES.has(entry.name)) continue; if (entry.isFile() && DEFAULT_EXCLUDED_FILES.has(entry.name.toLowerCase())) continue; collectFiles(path.join(target, entry.name), output); } } function aggregateComponents(components) { const hash = crypto.createHash("sha256"); for (const entry of components.slice().sort((a, b) => a.id.localeCompare(b.id))) { hash.update(entry.id, "utf8"); hash.update("\0"); hash.update(entry.checksum, "utf8"); hash.update("\0"); } return hash.digest("hex"); } function normalizeChecksum(value) { const normalized = String(value || "").trim().toLowerCase(); return /^[a-f0-9]{64}$/.test(normalized) ? normalized : ""; } function normalizeComponentMap(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const output = {}; for (const [key, checksum] of Object.entries(value)) { const safe = normalizeChecksum(checksum); if (safe && /^[-a-z0-9_.:]{1,160}$/i.test(key)) output[key] = safe; } return output; } function readPluginId(manifestPath) { if (!fs.existsSync(manifestPath)) return ""; try { const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"))?.id; return /^[-a-z0-9_.]{1,100}$/i.test(String(value || "")) ? String(value) : ""; } catch { return ""; } } function listDirectories(root) { if (!fs.existsSync(root)) return []; return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); } function sha256File(target) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(target, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } return hash.digest("hex"); } function findPowerShell(platform = process.platform) { if (platform !== "win32") return "pwsh"; return "powershell.exe"; } function runProcess(spawnImpl, command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawnImpl(command, args, { cwd: options.cwd, env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1" }, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; const limit = 256 * 1024; child.stdout?.on("data", (chunk) => { stdout = `${stdout}${chunk}`.slice(-limit); }); child.stderr?.on("data", (chunk) => { stderr = `${stderr}${chunk}`.slice(-limit); }); const timer = setTimeout(() => { child.kill(); reject(new Error("The local Companion build timed out.")); }, options.timeoutMs || 15 * 60 * 1000); child.once("error", (error) => { clearTimeout(timer); reject(error); }); child.once("exit", (code) => { clearTimeout(timer); if (code === 0) return resolve({ stdout, stderr }); const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); reject(new Error(`The local Companion build failed with exit code ${code}. ${detail}`.trim())); }); }); } let singleton = null; function getDevelopmentUpdateService(options = {}) { if (!singleton || options.reset) singleton = new DevelopmentUpdateService(options); return singleton; } module.exports = { DevelopmentUpdateService, aggregateComponents, getDevelopmentUpdateService, hashTargets, normalizeComponentMap, normalizeChecksum, sha256File };