import fs from "node:fs"; import path from "node:path"; import { getManagedNodecgRuntimePath } from "../app/paths"; type RuntimeProvisionerConfig = { sourceRuntimePath: string; userDataPath: string; appVersion: string; bundleName: string; log: (...args: unknown[]) => void; deps?: Partial; }; type RuntimeProvisionerDeps = { existsSync: (candidatePath: string) => boolean; mkdirSync: (candidatePath: string, options: { recursive: true }) => unknown; rmSync: (candidatePath: string, options: { recursive: true; force: true }) => unknown; cpSync: ( sourcePath: string, targetPath: string, options: { recursive: true; force: true; dereference: true; filter?: (sourcePath: string) => boolean; }, ) => unknown; readFileSync: (filePath: string) => string | Buffer; writeFileSync: (filePath: string, content: string) => unknown; statSync: (filePath: string) => { isDirectory: () => boolean }; symlinkSync: (target: string, path: string, type: "junction") => unknown; }; export type PreparedNodecgRuntime = { runtimePath: string; installed: boolean; }; type RuntimeManifest = { appVersion?: unknown; bundleName?: unknown; sourceRuntime?: RuntimeManifest | null; bundleVersion?: unknown; generatedAt?: unknown; nodecgVersion?: unknown; }; const MANAGED_RUNTIME_MARKER = ".scoreko-installed-runtime.json"; const WRITABLE_NODECG_DIRS = ["cfg", "db", "logs"] as const; const MANAGED_RUNTIME_ENTRIES = ["index.js", "package.json", "package-lock.json", "node_modules", "bundles"] as const; export function prepareUserNodecgRuntime({ sourceRuntimePath, userDataPath, appVersion, bundleName, log, deps, }: RuntimeProvisionerConfig): PreparedNodecgRuntime { const resolvedDeps = resolveDeps(deps); const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath); validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync); resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true }); const installed = shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps); if (installed) { log(`Installing managed NodeCG runtime into ${targetRuntimePath}`); installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps); } for (const writableDir of WRITABLE_NODECG_DIRS) { resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true }); } return { runtimePath: targetRuntimePath, installed }; } function resolveDeps(deps?: Partial): RuntimeProvisionerDeps { return { existsSync: deps?.existsSync ?? fs.existsSync, mkdirSync: deps?.mkdirSync ?? fs.mkdirSync, rmSync: deps?.rmSync ?? fs.rmSync, cpSync: deps?.cpSync ?? fs.cpSync, readFileSync: deps?.readFileSync ?? fs.readFileSync, writeFileSync: deps?.writeFileSync ?? fs.writeFileSync, statSync: deps?.statSync ?? fs.statSync, symlinkSync: deps?.symlinkSync ?? fs.symlinkSync, }; } function validateSourceRuntime( sourceRuntimePath: string, bundleName: string, existsSync: RuntimeProvisionerDeps["existsSync"], ): void { const requiredPaths = [ sourceRuntimePath, path.join(sourceRuntimePath, "index.js"), path.join(sourceRuntimePath, "package.json"), path.join(sourceRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), path.join(sourceRuntimePath, "bundles", bundleName, "package.json"), ]; const missingPaths = requiredPaths.filter((candidatePath) => !existsSync(candidatePath)); if (missingPaths.length > 0) { throw new Error( [ "The packaged NodeCG runtime is incomplete.", ...missingPaths.map((missingPath) => `Missing: ${missingPath}`), "Build the runtime with 'npm run prepare:runtime' before packaging or starting Electron.", ].join("\n"), ); } } function shouldInstallRuntime( sourceRuntimePath: string, targetRuntimePath: string, appVersion: string, bundleName: string, deps: RuntimeProvisionerDeps, ): boolean { const targetBootstrap = path.join(targetRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); const targetBundlePackage = path.join(targetRuntimePath, "bundles", bundleName, "package.json"); if (!deps.existsSync(targetBootstrap) || !deps.existsSync(targetBundlePackage)) { return true; } const targetMarker = readJson(path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), deps); const sourceMarker = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps); return ( targetMarker?.appVersion !== appVersion || targetMarker?.bundleName !== bundleName || targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion || targetMarker?.sourceRuntime?.generatedAt !== sourceMarker?.generatedAt || targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion ); } function installManagedRuntime( sourceRuntimePath: string, targetRuntimePath: string, appVersion: string, bundleName: string, deps: RuntimeProvisionerDeps, ): void { for (const entry of MANAGED_RUNTIME_ENTRIES) { deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true }); } for (const entry of MANAGED_RUNTIME_ENTRIES) { const sourcePath = path.join(sourceRuntimePath, entry); const targetPath = path.join(targetRuntimePath, entry); if (!deps.existsSync(sourcePath)) { continue; } if (deps.statSync(sourcePath).isDirectory()) { deps.symlinkSync(sourcePath, targetPath, "junction"); } else { deps.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true }); } } const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps); deps.writeFileSync( path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), `${JSON.stringify( { appVersion, bundleName, sourceRuntime, installedAt: new Date().toISOString(), }, null, 2, )}\n`, ); } function readJson( filePath: string, deps: Pick, ): RuntimeManifest | null { if (!deps.existsSync(filePath)) { return null; } try { return JSON.parse(String(deps.readFileSync(filePath))); } catch { return null; } }