#!/usr/bin/env node /** * One-shot bootstrap: * 1. Copy .env.example → .env if missing * 2. Merge any new keys from .env.example into .env (never overwrite values) * 3. Generate empty TOKEN_SIGNING_SECRET / APP_ENCRYPTION_KEY * 4. Fill empty bootstrap defaults (APP_ENV, HTTP_ADDR, WEB_ORIGIN, …) * 5. Wait for DATABASE_URL Postgres + goose migrate + sqlc * 6. Print env review * * Usage: npm run setup * * Optional: npm run setup -- --docker (start docker-compose Postgres) * Optional: npm run setup -- --production (APP_ENV=production + SESSION_SECURE=true) */ import { spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { dotenvPath, parseEnvFile, readRootEnvFile, repoRoot, writeRootEnvFile, } from "./root-env.mjs"; const args = new Set(process.argv.slice(2)); const useDocker = args.has("--docker") || process.env.DESCRYBE_USE_DOCKER === "1" || /^true|yes|on$/i.test(String(process.env.DESCRYBE_USE_DOCKER || "")); const productionFlag = args.has("--production") || process.env.DESCRYBE_SETUP_PRODUCTION === "1" || /^true|yes|on$/i.test(String(process.env.DESCRYBE_SETUP_PRODUCTION || "")); const GENERATED_SECRETS = ["TOKEN_SIGNING_SECRET", "APP_ENCRYPTION_KEY"]; const BOOTSTRAP_DEFAULTS = { APP_ENV: "development", HTTP_ADDR: ":28471", WEB_ORIGIN: "http://localhost:28472", PUBLIC_API_URL: "http://localhost:28471", SESSION_SECURE: "false", DATABASE_URL: "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable", }; function requireCmd(bin, hint, versionArgs = ["--version"]) { const r = spawnSync(bin, versionArgs, { encoding: "utf8", shell: process.platform === "win32", }); if (r.error || r.status !== 0) { console.error(`[setup] missing prerequisite: ${bin}`); if (hint) console.error(` ${hint}`); process.exit(1); } } function runNodeScript(rel) { const script = path.join(repoRoot, rel); const r = spawnSync(process.execPath, [script], { cwd: repoRoot, stdio: "inherit", env: process.env, }); if (r.status !== 0) process.exit(r.status ?? 1); } function runDockerCompose(composeArgs) { const r = spawnSync("docker", ["compose", ...composeArgs], { cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32", }); if (r.status !== 0) process.exit(r.status ?? 1); } function ensureEnvFile() { const dest = dotenvPath(); if (fs.existsSync(dest)) { console.log(`[setup] using existing ${path.relative(repoRoot, dest)}`); return; } const example = path.join(repoRoot, ".env.example"); if (!fs.existsSync(example)) { console.error("[setup] .env.example missing"); process.exit(1); } fs.copyFileSync(example, dest); console.log(`[setup] created ${path.relative(repoRoot, dest)} from .env.example`); } function mergeMissingKeysFromExample() { const examplePath = path.join(repoRoot, ".env.example"); if (!fs.existsSync(examplePath)) return; const exampleText = fs.readFileSync(examplePath, "utf8"); const envText = readRootEnvFile(); if (envText == null) return; const have = parseEnvFile(envText); const exampleParsed = parseEnvFile(exampleText); const missing = Object.keys(exampleParsed).filter((k) => !(k in have)); if (missing.length === 0) return; let next = envText.trimEnd(); next += "\n\n# --- added by setup from .env.example ---\n"; for (const key of missing) { next += `${key}=${exampleParsed[key] ?? ""}\n`; console.log(`[setup] added missing key ${key} from .env.example`); } writeRootEnvFile(next); } function setEnvKey(text, key, value) { const lineRe = new RegExp(`^(\\s*${key}\\s*=\\s*).*$`, "m"); if (lineRe.test(text)) { return text.replace(lineRe, `$1${value}`); } return `${text.trimEnd()}\n${key}=${value}\n`; } function isEmpty(parsed, key) { return !parsed[key] || !String(parsed[key]).trim(); } function fillBootstrapEnv() { let text = readRootEnvFile(); if (text == null) return; let parsed = parseEnvFile(text); let changed = false; if (productionFlag) { if (isEmpty(parsed, "APP_ENV") || parsed.APP_ENV === "development") { text = setEnvKey(text, "APP_ENV", "production"); changed = true; console.log("[setup] set APP_ENV=production (--production)"); } parsed = parseEnvFile(text); if (isEmpty(parsed, "SESSION_SECURE") || parsed.SESSION_SECURE === "false") { text = setEnvKey(text, "SESSION_SECURE", "true"); changed = true; console.log("[setup] set SESSION_SECURE=true (--production)"); } parsed = parseEnvFile(text); } for (const key of GENERATED_SECRETS) { parsed = parseEnvFile(text); if (!isEmpty(parsed, key)) continue; if ( key === "APP_ENCRYPTION_KEY" && parsed.CREDENTIALS_ENCRYPTION_KEY && String(parsed.CREDENTIALS_ENCRYPTION_KEY).trim() ) { text = setEnvKey( text, "APP_ENCRYPTION_KEY", String(parsed.CREDENTIALS_ENCRYPTION_KEY).trim(), ); changed = true; console.log( "[setup] copied CREDENTIALS_ENCRYPTION_KEY → APP_ENCRYPTION_KEY", ); continue; } text = setEnvKey(text, key, crypto.randomBytes(32).toString("hex")); changed = true; console.log(`[setup] generated ${key}`); } for (const [key, def] of Object.entries(BOOTSTRAP_DEFAULTS)) { parsed = parseEnvFile(text); if (!isEmpty(parsed, key)) continue; if ( productionFlag && (key === "DATABASE_URL" || key === "WEB_ORIGIN" || key === "PUBLIC_API_URL" || key === "SESSION_SECURE" || key === "APP_ENV") ) { continue; } text = setEnvKey(text, key, def); changed = true; console.log(`[setup] set default ${key}=${def}`); } if (changed) writeRootEnvFile(text); } function isProductionEnv(appEnv) { const e = String(appEnv || "") .trim() .toLowerCase(); return e === "production" || e === "prod"; } function reviewEnv() { const text = readRootEnvFile(); if (text == null) return; const parsed = parseEnvFile(text); const appEnv = parsed.APP_ENV || "development"; const prod = isProductionEnv(appEnv); console.log(""); console.log("==> env review"); console.log(`APP_ENV=${appEnv || "(empty)"}`); const required = [ "DATABASE_URL", "APP_ENV", "HTTP_ADDR", "WEB_ORIGIN", "PUBLIC_API_URL", "SESSION_SECURE", "TOKEN_SIGNING_SECRET", "APP_ENCRYPTION_KEY", ]; let ok = true; for (const key of required) { const empty = isEmpty(parsed, key); if (empty) ok = false; const show = key === "TOKEN_SIGNING_SECRET" || key === "APP_ENCRYPTION_KEY" ? empty ? "(empty)" : "(set)" : empty ? "(empty)" : String(parsed[key]).slice(0, 64); console.log(` [${empty ? "MISSING" : "ok"}] ${key}=${show}`); } if (prod) { if (String(parsed.SESSION_SECURE).toLowerCase() !== "true") { console.log(" [WARN] SESSION_SECURE must be true in production"); ok = false; } const origin = String(parsed.WEB_ORIGIN || "").toLowerCase(); if (!origin.startsWith("https://")) { console.log(" [WARN] WEB_ORIGIN must be https://… in production"); ok = false; } if (/localhost|127\.0\.0\.1|::1/.test(origin)) { console.log(" [WARN] WEB_ORIGIN must not be loopback in production"); ok = false; } } if (!ok) { console.log("[setup] fix MISSING/WARN items in root .env before production boot"); } else { console.log(`[setup] bootstrap env ok for APP_ENV=${appEnv}`); } } console.log("==> Descrybe setup"); requireCmd("node", "Install Node.js 20+ from https://nodejs.org/"); requireCmd("go", "Install Go 1.25+ from https://go.dev/dl/", ["version"]); ensureEnvFile(); mergeMissingKeysFromExample(); fillBootstrapEnv(); reviewEnv(); if (useDocker) { console.log("==> docker compose up -d (Postgres on host :5433)"); runDockerCompose(["up", "-d"]); } else { console.log("==> using DATABASE_URL Postgres (no Docker)"); } console.log("==> wait for Postgres"); runNodeScript("scripts/wait-postgres.mjs"); console.log("==> migrate"); runNodeScript("scripts/migrate.mjs"); console.log(""); console.log("Setup complete. Next: npm install && npm run build"); console.log("Web http://localhost:28472 · API http://localhost:28471"); console.log( "Note: build/migrate auto-detect OS/RAM/CPU; sqlc needs ≥6GiB (npm run migrate -- --sqlc).", );