322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
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
|
|
};
|