spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/config.mjs8 * Purpose : Environment loading + zod-validated configuration9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { readFileSync, mkdirSync, existsSync } from 'node:fs';14import { resolve, join, dirname } from 'node:path';15import { fileURLToPath } from 'node:url';16import process from 'node:process';17import { z } from 'zod';1819export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');2021/** The sole owner of this platform. Hard-coded on purpose — see CLAUDE.md §0. */22export const OWNER = Object.freeze({23 name: 'Simon-Pierre Boucher',24 email: 'contact@spboucher.ai',25 username: 'spb',26 site: 'https://spboucher.ai',27 tagline: 'Builder of models, clusters, and the tools that run them.',28});2930const schema = z.object({31 SPBGIT_PORT: z.coerce.number().int().min(1).max(65535).default(7420),32 SPBGIT_HOST: z.string().min(1).default('127.0.0.1'),33 SPBGIT_PUBLIC_URL: z.string().url().default('https://git.spboucher.ai'),34 SPBGIT_GIT_ROOT: z.string().min(1).default('/srv/git'),35 SPBGIT_DATA_DIR: z.string().min(1).default('/srv/spbgit/data'),36 SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'),37 SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),38 SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'),39});4041/**42 * Parse a `.env`-style file into key/value pairs. Tiny on purpose — no dep.43 * @param {string} path44 * @returns {Record<string, string>}45 */46function parseEnvFile(path) {47 const out = {};48 let raw;49 try {50 raw = readFileSync(path, 'utf8');51 } catch {52 return out;53 }54 for (const line of raw.split('\n')) {55 const trimmed = line.trim();56 if (!trimmed || trimmed.startsWith('#')) continue;57 const eq = trimmed.indexOf('=');58 if (eq === -1) continue;59 const key = trimmed.slice(0, eq).trim();60 let value = trimmed.slice(eq + 1).trim();61 if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {62 value = value.slice(1, -1);63 }64 out[key] = value;65 }66 return out;67}6869/**70 * Load, validate and freeze the runtime configuration.71 * Values in the real environment win over `.env` file values.72 * @param {Record<string, string|undefined>} [overrides] test-only overrides (highest precedence)73 * @returns {Readonly<object>} resolved config74 */75export function loadConfig(overrides = {}) {76 const fileEnv = parseEnvFile(join(PROJECT_ROOT, '.env'));77 const merged = { ...fileEnv, ...process.env, ...overrides };78 const parsed = schema.safeParse(merged);79 if (!parsed.success) {80 const issues = parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');81 throw new Error(`Invalid configuration:\n${issues}`);82 }83 const env = parsed.data;84 const abs = (p) => resolve(PROJECT_ROOT, p);85 const config = Object.freeze({86 env: env.SPBGIT_ENV,87 isDev: env.SPBGIT_ENV === 'development',88 port: env.SPBGIT_PORT,89 host: env.SPBGIT_HOST,90 publicUrl: env.SPBGIT_PUBLIC_URL.replace(/\/+$/, ''),91 logLevel: env.SPBGIT_LOG_LEVEL,92 gitRoot: abs(env.SPBGIT_GIT_ROOT),93 trashDir: join(abs(env.SPBGIT_GIT_ROOT), '.trash'),94 dataDir: abs(env.SPBGIT_DATA_DIR),95 cacheDir: abs(env.SPBGIT_CACHE_DIR),96 owner: OWNER,97 });98 return config;99}100101/**102 * Create every directory the platform needs. Idempotent, fails loudly.103 * @param {ReturnType<typeof loadConfig>} config104 */105export function ensureDirs(config) {106 for (const dir of [config.gitRoot, config.trashDir, config.dataDir, config.cacheDir]) {107 if (!existsSync(dir)) {108 try {109 mkdirSync(dir, { recursive: true });110 } catch (err) {111 throw new Error(`Cannot create required directory ${dir}: ${err.message}`);112 }113 }114 }115}116