mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
env config
This commit is contained in:
@@ -7,3 +7,4 @@ lib
|
||||
.localappdata
|
||||
.npm-cache
|
||||
.npm-runtime-cache
|
||||
.env
|
||||
+33
-8
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user