feat: enhance NodeCG runtime management and packaging

- Update .gitignore and .prettierignore to exclude additional cache and configuration files.
- Revise README.md for clarity on build processes and runtime behavior.
- Improve architecture documentation to reflect changes in startup flow and module responsibilities.
- Modify troubleshooting guide to address common runtime issues and installation steps.
- Enhance ESLint configuration to ignore more directories.
- Update package.json scripts for better build and distribution processes.
- Introduce build-scoreko-bundle.mjs for building the Scoreko bundle.
- Implement prepare-nodecg-runtime.mjs for managing NodeCG runtime installation and updates.
- Add runtime-provisioner.ts to handle user-specific NodeCG runtime provisioning.
- Create tests for runtime provisioning to ensure correct behavior.
- Refactor process-manager.ts and main.ts to integrate new runtime management logic.
This commit is contained in:
2026-05-09 17:45:36 +02:00
parent b10b8adb98
commit 41e4e91c4b
16 changed files with 737 additions and 100 deletions
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const electronRoot = process.cwd();
const bundleRoot = path.resolve(electronRoot, "..");
const packageJsonPath = path.join(bundleRoot, "package.json");
const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml");
const nodeModulesPath = path.join(bundleRoot, "node_modules");
if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) {
console.error(`Scoreko bundle root was not found at: ${bundleRoot}`);
process.exit(1);
}
if (!existsSync(nodeModulesPath)) {
console.error(
[
"The Scoreko bundle dependencies are not installed.",
`Run this once from ${bundleRoot}:`,
" pnpm install",
].join("\n"),
);
process.exit(1);
}
const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
const childEnv = {
...process.env,
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
PATH: `${path.join(bundleRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
};
function removeGeneratedOutput(relativePath) {
const targetPath = path.resolve(bundleRoot, relativePath);
if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) {
throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`);
}
rmSync(targetPath, { recursive: true, force: true });
}
function runCommand(command, args) {
const result = spawnSync(command, args, {
cwd: bundleRoot,
stdio: "inherit",
shell: process.platform === "win32",
env: childEnv,
});
if (result.error) {
console.error(`Could not run '${command} ${args.join(" ")}': ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runLocalBin(commandName, args) {
const extension = process.platform === "win32" ? ".CMD" : "";
runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args);
}
for (const entry of generatedBundleEntries) {
removeGeneratedOutput(entry);
}
for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
}
runLocalBin("vite", ["build", "--configLoader", "runner"]);
runLocalBin("tsc", ["-b", "tsconfig.extension.json"]);
+9 -5
View File
@@ -37,16 +37,20 @@ function parseIntInRange(name, fallback, min, max) {
function checkNodecgInstall() {
const indexPath = path.join(nodecgRootPath, "index.js");
const bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json");
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath);
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath);
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath);
addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath);
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
try {
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
addCheck(true, "lib/nodecg permissions", "Read/write OK");
addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
} catch {
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
}
@@ -82,7 +86,7 @@ async function main() {
}
for (const check of checks) {
const icon = check.ok ? "" : "";
const icon = check.ok ? "OK" : "FAIL";
console.log(`${icon} ${check.title}: ${check.details}`);
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const electronRoot = process.cwd();
const bundleRoot = path.resolve(electronRoot, "..");
const runtimeRoot = path.join(electronRoot, "lib", "nodecg");
const runtimeNodeModules = path.join(runtimeRoot, "node_modules");
const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName);
const bundleEntries = [
"assets",
"dashboard",
"extension",
"graphics",
"schemas",
"shared",
"configschema.json",
"LICENSE",
"package.json",
"README.md",
];
const requiredBundleEntries = ["dashboard", "extension", "graphics", "schemas", "shared", "package.json"];
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function copyIfExists(source, destination) {
if (!existsSync(source)) {
return false;
}
cpSync(source, destination, {
recursive: true,
force: true,
dereference: true,
filter: (sourcePath) => !sourcePath.split(path.sep).includes("node_modules"),
});
return true;
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
stdio: "inherit",
shell: process.platform === "win32",
env: {
...process.env,
npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"),
},
});
if (result.error) {
throw new Error(`${command} ${args.join(" ")} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}`);
}
}
function getInstalledNodecgVersion() {
const nodecgPackagePath = path.join(bundleRoot, "node_modules", "nodecg", "package.json");
if (!existsSync(nodecgPackagePath)) {
throw new Error(
[
"NodeCG is not installed in the parent project.",
`Expected: ${nodecgPackagePath}`,
`Run 'pnpm install' from ${bundleRoot} before packaging.`,
].join("\n"),
);
}
return readJson(nodecgPackagePath).version;
}
function assertBundleBuildExists() {
for (const entry of requiredBundleEntries) {
const source = path.join(bundleRoot, entry);
if (!existsSync(source)) {
throw new Error(
[
`The built Scoreko bundle is missing '${entry}'.`,
`Expected: ${source}`,
`Run 'pnpm build' from ${bundleRoot} before packaging.`,
].join("\n"),
);
}
}
}
function createRuntimePackageJson() {
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
const dependencies = {
nodecg: getInstalledNodecgVersion(),
...(bundlePackageJson.dependencies ?? {}),
};
writeFileSync(
path.join(runtimeRoot, "package.json"),
`${JSON.stringify(
{
private: true,
name: "scoreko-nodecg-runtime",
version: bundlePackageJson.version ?? "0.0.0",
description: "Packaged NodeCG runtime for Scoreko Desktop.",
type: "commonjs",
scripts: {
start: "node index.js",
},
dependencies,
},
null,
2,
)}\n`,
);
writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n');
}
function copyBundle() {
mkdirSync(runtimeBundleRoot, { recursive: true });
for (const entry of bundleEntries) {
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
}
}
function writeManifest() {
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
const runtimePackageJson = readJson(path.join(runtimeRoot, "package.json"));
writeFileSync(
path.join(runtimeRoot, ".scoreko-runtime.json"),
`${JSON.stringify(
{
bundleName,
bundleVersion: bundlePackageJson.version ?? "0.0.0",
nodecgVersion: runtimePackageJson.dependencies.nodecg,
generatedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
}
function installRuntimeDependencies() {
if (process.env.SCOREKO_SKIP_RUNTIME_NPM_INSTALL === "1") {
console.log("[prepare-runtime] Skipping runtime npm install by environment request.");
return;
}
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot);
}
function main() {
assertBundleBuildExists();
rmSync(runtimeRoot, { recursive: true, force: true });
mkdirSync(runtimeNodeModules, { recursive: true });
mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true });
createRuntimePackageJson();
copyBundle();
installRuntimeDependencies();
writeManifest();
console.log(`[prepare-runtime] NodeCG runtime ready at ${runtimeRoot}`);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
+21 -20
View File
@@ -1,15 +1,20 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
const root = process.cwd();
const nodecgDir = path.join(root, "lib", "nodecg");
const sqliteLegacyDir = path.join(nodecgDir, "workspaces", "database-adapter-sqlite-legacy");
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const moduleDirs = [nodecgDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json")));
if (!electronVersion) {
console.error("Could not determine Electron version from package.json.");
process.exit(1);
}
if (moduleDirs.length === 0) {
console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces.");
if (!existsSync(path.join(nodecgDir, "package.json"))) {
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
process.exit(1);
}
@@ -23,8 +28,10 @@ function run(command, args, cwd) {
env: {
...process.env,
npm_config_runtime: "electron",
npm_config_target: "39.5.1",
npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers",
npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"),
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"),
},
});
@@ -38,19 +45,13 @@ function run(command, args, cwd) {
});
}
for (const dir of moduleDirs) {
if (dir === sqliteLegacyDir) {
console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`);
await run("npm", ["install"], dir);
await run("npm", ["install", "bindings", "--no-save"], dir);
}
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`);
await run(
"npm",
["rebuild", "better-sqlite3", "--runtime=electron", "--target=39.5.1", "--dist-url=https://electronjs.org/headers"],
dir,
);
}
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`);
await run(npmCommand, [
"rebuild",
"better-sqlite3",
"--runtime=electron",
`--target=${electronVersion}`,
"--dist-url=https://electronjs.org/headers",
], nodecgDir);
console.log("\n[rebuild-native] Done.");