diff --git a/.gitignore b/.gitignore index 250ab4a..f16c7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ lib .localappdata .npm-cache .npm-runtime-cache +.env \ No newline at end of file diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 017d48b..c9e77e1 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -7,12 +7,31 @@ import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs"; const checks = []; +function loadEnv() { + if (!fs.existsSync(".env")) { + console.error("FAIL Configuración: Archivo .env obligatorio no encontrado."); + console.error("Por favor, crea un archivo .env basado en .env.example en la raíz del proyecto."); + process.exit(1); + } + try { + process.loadEnvFile(".env"); + console.log("OK Configuración: Archivo .env cargado correctamente.\n"); + } catch (error) { + console.error(`FAIL Configuración: Error al leer el archivo .env: ${error.message}`); + process.exit(1); + } +} + function addCheck(ok, title, details) { checks.push({ ok, title, details }); } -function parsePort(name, fallback) { - const raw = process.env[name] ?? fallback; +function parsePort(name) { + const raw = process.env[name]; + if (!raw) { + addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`); + return null; + } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`); @@ -23,8 +42,12 @@ function parsePort(name, fallback) { return parsed; } -function parseIntInRange(name, fallback, min, max) { - const raw = process.env[name] ?? String(fallback); +function parseIntInRange(name, min, max) { + const raw = process.env[name]; + if (!raw) { + addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`); + return; + } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < min || parsed > max) { addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`); @@ -73,10 +96,12 @@ function checkPortAvailability(port) { } async function main() { - const port = parsePort("NODECG_PORT", "9090"); - parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000); - parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 120000, 1000, 600000); - parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000); + loadEnv(); + + const port = parsePort("NODECG_PORT"); + parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000); + parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000); + parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000); checkNodecgInstall(); if (port) { diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts index 5d2b22b..9d1fb40 100644 --- a/src/main/app/bootstrap.ts +++ b/src/main/app/bootstrap.ts @@ -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); diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index 37468fd..8e976bc 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -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); diff --git a/src/tests/runtime-config.test.ts b/src/tests/runtime-config.test.ts index a283f96..853580d 100644 --- a/src/tests/runtime-config.test.ts +++ b/src/tests/runtime-config.test.ts @@ -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, run: () => void): void { + const previousValues: Record = {}; + 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); + }, + ); +});