env config

This commit is contained in:
2026-05-31 18:35:59 +02:00
parent 92e2da1758
commit ce59c5db89
5 changed files with 283 additions and 27 deletions
+21 -5
View File
@@ -1,24 +1,40 @@
import { app, BrowserWindow } from "electron";
import path from "node:path";
import { getRuntimeConfig } from "../config/runtime-config";
import { getRuntimeConfig, loadEnvFile, AppRuntimeConfig } from "../config/runtime-config";
import { showFatalError, log } from "../errors/error-presenter";
import { createNodecgProcessManager } from "../nodecg/process-manager";
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
import { scheduleUpdateCheck } from "../updates/update-service";
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
import { createApplicationController } from "./application-controller";
import { getApplicationPaths } from "./paths";
import { getApplicationPaths, getRootPath } from "./paths";
export function bootstrap(): void {
const appConfig = getRuntimeConfig();
const isDev = !app.isPackaged;
const compiledMainDir = path.resolve(__dirname, "..");
const resourcesPath = process.resourcesPath;
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
const envFilePath = path.join(rootPath, ".env");
let appConfig: AppRuntimeConfig;
try {
loadEnvFile(envFilePath);
appConfig = getRuntimeConfig();
} catch (error: unknown) {
app.on("ready", () => {
showFatalError("No se pudo cargar la configuración de la aplicación.", error);
app.exit(1);
});
return;
}
const paths = getApplicationPaths({
appConfig,
appDataPath: app.getPath("appData"),
compiledMainDir: path.resolve(__dirname, ".."),
compiledMainDir,
isDev,
resourcesPath: process.resourcesPath,
resourcesPath,
});
app.setName(appConfig.title);
+74 -14
View File
@@ -1,3 +1,5 @@
import fs from "node:fs";
export type AppRuntimeConfig = {
title: string;
userModelId: string;
@@ -21,29 +23,51 @@ export type AppRuntimeConfig = {
const MIN_TCP_PORT = 1;
const MAX_TCP_PORT = 65535;
export function loadEnvFile(envFilePath: string): void {
if (!fs.existsSync(envFilePath)) {
throw new Error(
`Archivo de configuración obligatorio no encontrado: ${envFilePath}\n\nPor favor, crea un archivo .env basado en .env.example en la raíz de la aplicación.`,
);
}
try {
process.loadEnvFile(envFilePath);
} catch (error) {
throw new Error(
`Error al leer el archivo de configuración .env: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export function getRuntimeConfig(): AppRuntimeConfig {
// Centralized defaults keep local development and packaged builds consistent.
return {
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"),
title: getRequiredEnv("SCOREKO_APP_TITLE"),
userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 120000, 1000, 600000),
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
loadingDashboardRoute: getRequiredEnv("SCOREKO_LOADING_ROUTE"),
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"),
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
updateCheckDelayMs: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000),
};
}
export function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value || value.length === 0) {
throw new Error(`La variable de entorno requerida '${name}' no está definida en el archivo .env.`);
}
return value;
}
export function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim();
return value && value.length > 0 ? value : undefined;
@@ -53,8 +77,18 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback;
}
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
const rawValue = getRequiredEnv(name);
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
}
return parsedValue;
}
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
@@ -70,6 +104,19 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
return parsedValue;
}
export function parseRequiredEnvBool(name: string): boolean {
const rawValue = getRequiredEnv(name).toLowerCase();
if (["1", "true", "yes", "on"].includes(rawValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(rawValue)) {
return false;
}
throw new Error(`The ${name} variable must be a boolean. Received value: '${rawValue}'.`);
}
export function parseEnvBool(name: string, fallback: boolean): boolean {
const rawValue = process.env[name]?.trim().toLowerCase();
if (!rawValue) {
@@ -105,6 +152,19 @@ export function parseOptionalHttpUrl(name: string): string | undefined {
}
}
export function parseRequiredEnvPort(name: string): string {
const rawValue = getRequiredEnv(name);
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
throw new Error(
`The ${name} variable must be a valid TCP port (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Received value: '${rawValue}'.`,
);
}
return String(parsedValue);
}
export function parseEnvPort(name: string, fallback: string): string {
const rawValue = getEnv(name, fallback);
const parsedValue = Number.parseInt(rawValue, 10);
+154
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import {
getEnv,
@@ -8,6 +9,12 @@ import {
parseEnvIntInRange,
parseEnvPort,
parseOptionalHttpUrl,
loadEnvFile,
getRuntimeConfig,
getRequiredEnv,
parseRequiredEnvIntInRange,
parseRequiredEnvBool,
parseRequiredEnvPort,
} from "../main/config/runtime-config";
function withEnv(name: string, value: string | undefined, run: () => void): void {
@@ -31,6 +38,30 @@ function withEnv(name: string, value: string | undefined, run: () => void): void
}
}
function withEnvs(envs: Record<string, string | undefined>, run: () => void): void {
const previousValues: Record<string, string | undefined> = {};
for (const name of Object.keys(envs)) {
previousValues[name] = process.env[name];
if (envs[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = envs[name];
}
}
try {
run();
} finally {
for (const name of Object.keys(envs)) {
if (previousValues[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = previousValues[name];
}
}
}
}
test("getOptionalEnv returns undefined for missing variable", () => {
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
@@ -106,3 +137,126 @@ test("parseOptionalHttpUrl rejects unsupported protocols", () => {
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
});
});
test("loadEnvFile throws on non-existent file", () => {
const missingPath = path.join(__dirname, "does-not-exist-.env");
assert.throws(() => loadEnvFile(missingPath), /Archivo de configuración obligatorio no encontrado/);
});
test("getRequiredEnv throws on missing or empty variable", () => {
withEnv("TEST_REQUIRED_ENV", undefined, () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
withEnv("TEST_REQUIRED_ENV", " ", () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
});
test("getRequiredEnv returns trimmed value when present", () => {
withEnv("TEST_REQUIRED_ENV", " scoreko-app ", () => {
assert.equal(getRequiredEnv("TEST_REQUIRED_ENV"), "scoreko-app");
});
});
test("parseRequiredEnvIntInRange validates required integer and throws if missing", () => {
withEnv("TEST_REQ_INT", undefined, () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /no está definida/);
});
withEnv("TEST_REQ_INT", "150", () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /must be an integer/);
});
withEnv("TEST_REQ_INT", "42", () => {
assert.equal(parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), 42);
});
});
test("parseRequiredEnvBool validates required boolean and throws if missing", () => {
withEnv("TEST_REQ_BOOL", undefined, () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /no está definida/);
});
withEnv("TEST_REQ_BOOL", "maybe", () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /must be a boolean/);
});
withEnv("TEST_REQ_BOOL", "true", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), true);
});
withEnv("TEST_REQ_BOOL", "off", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), false);
});
});
test("parseRequiredEnvPort validates required port and throws if missing", () => {
withEnv("TEST_REQ_PORT", undefined, () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /no está definida/);
});
withEnv("TEST_REQ_PORT", "70000", () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /valid TCP port/);
});
withEnv("TEST_REQ_PORT", "9090", () => {
assert.equal(parseRequiredEnvPort("TEST_REQ_PORT"), "9090");
});
});
test("getRuntimeConfig throws if required variables are missing", () => {
withEnvs(
{
SCOREKO_APP_TITLE: undefined,
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.desktop",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko",
NODECG_PORT: "9090",
NODECG_BUNDLE_NAME: "scoreko-dev",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/main.html?standalone=true",
SCOREKO_LOADING_ROUTE: "dashboard/loading/main.html?standalone=true",
ELECTRON_LOAD_DELAY_MS: "10000",
NODECG_STARTUP_TIMEOUT_MS: "120000",
NODECG_KILL_TIMEOUT_MS: "2500",
SCOREKO_UPDATES_ENABLED: "true",
SCOREKO_UPDATE_CHECK_DELAY_MS: "5000",
},
() => {
assert.throws(() => getRuntimeConfig(), /SCOREKO_APP_TITLE/);
},
);
});
test("getRuntimeConfig parses successfully when all required variables are set", () => {
withEnvs(
{
SCOREKO_APP_TITLE: "Scoreko Test App",
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.test",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko-test",
NODECG_PORT: "9191",
NODECG_BUNDLE_NAME: "scoreko-dev-test",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/test.html",
SCOREKO_LOADING_ROUTE: "dashboard/loading/test.html",
ELECTRON_LOAD_DELAY_MS: "5000",
NODECG_STARTUP_TIMEOUT_MS: "60000",
NODECG_KILL_TIMEOUT_MS: "1500",
SCOREKO_UPDATES_ENABLED: "false",
SCOREKO_UPDATE_CHECK_DELAY_MS: "3000",
},
() => {
const config = getRuntimeConfig();
assert.equal(config.title, "Scoreko Test App");
assert.equal(config.userModelId, "com.scoreko.test");
assert.equal(config.userDataDirectoryName, "scoreko-test");
assert.equal(config.nodecgPort, "9191");
assert.equal(config.bundleName, "scoreko-dev-test");
assert.equal(config.mainDashboardRoute, "dashboard/scoreko-dev/test.html");
assert.equal(config.loadingDashboardRoute, "dashboard/loading/test.html");
assert.equal(config.loadDelayMs, 5000);
assert.equal(config.startupTimeoutMs, 60000);
assert.equal(config.nodecgKillTimeoutMs, 1500);
assert.equal(config.updatesEnabled, false);
assert.equal(config.updateCheckDelayMs, 3000);
},
);
});