SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
4.2 KB · 116 lines javascript
Raw Blame History
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