/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/config.mjs * Purpose : Environment loading + zod-validated configuration * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { readFileSync, mkdirSync, existsSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { z } from 'zod'; export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); /** The sole owner of this platform. Hard-coded on purpose — see CLAUDE.md §0. */ export const OWNER = Object.freeze({ name: 'Simon-Pierre Boucher', email: 'contact@spboucher.ai', username: 'spb', site: 'https://spboucher.ai', tagline: 'Builder of models, clusters, and the tools that run them.', }); const schema = z.object({ SPBGIT_PORT: z.coerce.number().int().min(1).max(65535).default(7420), SPBGIT_HOST: z.string().min(1).default('127.0.0.1'), SPBGIT_PUBLIC_URL: z.string().url().default('https://git.spboucher.ai'), SPBGIT_GIT_ROOT: z.string().min(1).default('/srv/git'), SPBGIT_DATA_DIR: z.string().min(1).default('/srv/spbgit/data'), SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'), SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'), }); /** * Parse a `.env`-style file into key/value pairs. Tiny on purpose — no dep. * @param {string} path * @returns {Record} */ function parseEnvFile(path) { const out = {}; let raw; try { raw = readFileSync(path, 'utf8'); } catch { return out; } for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } out[key] = value; } return out; } /** * Load, validate and freeze the runtime configuration. * Values in the real environment win over `.env` file values. * @param {Record} [overrides] test-only overrides (highest precedence) * @returns {Readonly} resolved config */ export function loadConfig(overrides = {}) { const fileEnv = parseEnvFile(join(PROJECT_ROOT, '.env')); const merged = { ...fileEnv, ...process.env, ...overrides }; const parsed = schema.safeParse(merged); if (!parsed.success) { const issues = parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); throw new Error(`Invalid configuration:\n${issues}`); } const env = parsed.data; const abs = (p) => resolve(PROJECT_ROOT, p); const config = Object.freeze({ env: env.SPBGIT_ENV, isDev: env.SPBGIT_ENV === 'development', port: env.SPBGIT_PORT, host: env.SPBGIT_HOST, publicUrl: env.SPBGIT_PUBLIC_URL.replace(/\/+$/, ''), logLevel: env.SPBGIT_LOG_LEVEL, gitRoot: abs(env.SPBGIT_GIT_ROOT), trashDir: join(abs(env.SPBGIT_GIT_ROOT), '.trash'), dataDir: abs(env.SPBGIT_DATA_DIR), cacheDir: abs(env.SPBGIT_CACHE_DIR), owner: OWNER, }); return config; } /** * Create every directory the platform needs. Idempotent, fails loudly. * @param {ReturnType} config */ export function ensureDirs(config) { for (const dir of [config.gitRoot, config.trashDir, config.dataDir, config.cacheDir]) { if (!existsSync(dir)) { try { mkdirSync(dir, { recursive: true }); } catch (err) { throw new Error(`Cannot create required directory ${dir}: ${err.message}`); } } } }