From 76b586ed853640edd90bd1969e5d8c4905f0e03c Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Mon, 10 Aug 2026 01:12:21 +0200 Subject: [PATCH] update --- .env.example | 28 +++++ scripts/setup.mjs | 250 +++++++++++++++++++++++++++++++------- scripts/wait-postgres.mjs | 82 +++++++++---- 3 files changed, 293 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index ff110c1..d57ccbf 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,34 @@ TOKEN_SIGNING_SECRET= # openssl rand -hex 32 APP_ENCRYPTION_KEY= +# --- Production host (uncomment / set on the server; setup --production helps) --- +# APP_ENV=production +# SESSION_SECURE=true +# WEB_ORIGIN=https://descrybe.io +# PUBLIC_API_URL=https://api.descrybe.io +# HTTP_ADDR=127.0.0.1:28471 +# TRUSTED_PROXIES=127.0.0.1 +# DATABASE_URL=postgres://descrybe:CHANGE_ME@127.0.0.1:5432/descrybe?sslmode=disable +# TOKEN_SIGNING_SECRET and APP_ENCRYPTION_KEY: leave empty and run +# npm run setup +# to generate (or: openssl rand -hex 32). + +# Platform invite / forgot-password mail (optional env; prefer /admin/settings). +# EMAIL_DRY_RUN defaults true when unset (safe). Live send needs false + SMTP. +# EMAIL_DRY_RUN=true +# SMTP_ENABLED=false +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM=noreply@descrybe.io + +# Cutover / ops toggles (optional) +# HYPERCARE_MODE=false +# STRIPE_MOCK=false +# MAINTENANCE_MODE=false +# READ_ONLY_MODE=false + # --- Optional bootstrap (safe defaults in code; uncomment to override) --- # TRUSTED_PROXIES - hop-1 reverse-proxy / LB peers only (CIDR or IP, comma-separated). # When set, TrustedRealIP rewrites RemoteAddr from X-Forwarded-For / X-Real-IP only if diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 4512bd3..f89e8fc 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -1,14 +1,17 @@ #!/usr/bin/env node /** - * One-shot local bootstrap (any OS): + * One-shot bootstrap: * 1. Copy .env.example → .env if missing - * 2. Fill empty TOKEN_SIGNING_SECRET / APP_ENCRYPTION_KEY (local only) - * 3. docker compose up -d (Postgres) - * 4. wait for healthy DB - * 5. goose migrate + sqlc + * 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: node scripts/setup.mjs - * Then: npm install && npm run dev + * 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"; @@ -22,6 +25,28 @@ import { 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", @@ -44,8 +69,8 @@ function runNodeScript(rel) { if (r.status !== 0) process.exit(r.status ?? 1); } -function runDockerCompose(args) { - const r = spawnSync("docker", ["compose", ...args], { +function runDockerCompose(composeArgs) { + const r = spawnSync("docker", ["compose", ...composeArgs], { cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32", @@ -68,41 +93,187 @@ function ensureEnvFile() { console.log(`[setup] created ${path.relative(repoRoot, dest)} from .env.example`); } -function fillEmptySecrets() { - const text = readRootEnvFile(); - if (text == null) return; - const parsed = parseEnvFile(text); - const need = []; - for (const key of ["TOKEN_SIGNING_SECRET", "APP_ENCRYPTION_KEY"]) { - if (!parsed[key] || !String(parsed[key]).trim()) need.push(key); - } - if (need.length === 0) return; +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; - let next = text; - for (const key of need) { - const secret = crypto.randomBytes(32).toString("hex"); - const lineRe = new RegExp(`^(\\s*${key}\\s*=\\s*).*$`, "m"); - if (lineRe.test(next)) { - next = next.replace(lineRe, `$1${secret}`); - } else { - next = `${next.trimEnd()}\n${key}=${secret}\n`; - } - console.log(`[setup] generated local ${key} (not committed)`); + 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); } -console.log("==> Descrybe local setup"); +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/"); -// Go uses `go version`, not `go --version`. requireCmd("go", "Install Go 1.25+ from https://go.dev/dl/", ["version"]); -requireCmd("docker", "Install Docker Desktop / Docker Engine with Compose v2"); ensureEnvFile(); -fillEmptySecrets(); +mergeMissingKeysFromExample(); +fillBootstrapEnv(); +reviewEnv(); -console.log("==> docker compose up -d (Postgres on host :5433)"); -runDockerCompose(["up", "-d"]); +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"); @@ -111,12 +282,5 @@ console.log("==> migrate"); runNodeScript("scripts/migrate.mjs"); console.log(""); -console.log("Setup complete."); -console.log("Next:"); -console.log(" npm install"); -console.log(" npm run dev # API :28471 + web :28472 + worker"); -console.log(" npm run seed # optional demo login"); -console.log(" npm run health # /healthz + /readyz (needs worker)"); -console.log(""); -console.log("Web: http://localhost:28472"); -console.log("API: http://localhost:28471"); +console.log("Setup complete. Next: npm install && npm run dev"); +console.log("Web http://localhost:28472 · API http://localhost:28471"); diff --git a/scripts/wait-postgres.mjs b/scripts/wait-postgres.mjs index a5f2c30..4329153 100644 --- a/scripts/wait-postgres.mjs +++ b/scripts/wait-postgres.mjs @@ -1,36 +1,68 @@ #!/usr/bin/env node /** - * Wait until docker-compose Postgres is accepting connections. + * Wait until DATABASE_URL Postgres accepts TCP connections. + * Works with native Postgres or Docker-mapped ports (no docker CLI required). + * * Usage: node scripts/wait-postgres.mjs */ -import { spawnSync } from "node:child_process"; +import net from "node:net"; import { setTimeout as delay } from "node:timers/promises"; -import { repoRoot } from "./root-env.mjs"; +import { loadRootEnv } from "./root-env.mjs"; const maxAttempts = Number(process.env.DESCRYBE_DB_WAIT_ATTEMPTS || 60); const sleepMs = Number(process.env.DESCRYBE_DB_WAIT_MS || 1000); -for (let i = 1; i <= maxAttempts; i++) { - const r = spawnSync( - "docker", - [ - "compose", - "exec", - "-T", - "postgres", - "pg_isready", - "-U", - "descrybe", - "-d", - "descrybe", - ], - { - cwd: repoRoot, - encoding: "utf8", - shell: process.platform === "win32", - }, +const env = loadRootEnv(process.env); +const databaseUrl = env.DATABASE_URL; +if (!databaseUrl || !String(databaseUrl).trim()) { + console.error( + "[db] DATABASE_URL is required (root .env or shell; see .env.example)", ); - if (r.status === 0) { + process.exit(1); +} + +function parsePostgresTarget(urlStr) { + let u; + try { + u = new URL(urlStr); + } catch { + console.error("[db] DATABASE_URL is not a valid URL"); + process.exit(1); + } + const protocol = (u.protocol || "").replace(/:$/, ""); + if (protocol !== "postgres" && protocol !== "postgresql") { + console.error(`[db] DATABASE_URL must be postgres(ql)://… (got ${protocol})`); + process.exit(1); + } + const host = u.hostname || "127.0.0.1"; + const port = Number(u.port || 5432); + if (!Number.isFinite(port) || port <= 0) { + console.error("[db] DATABASE_URL has an invalid port"); + process.exit(1); + } + return { host, port }; +} + +function canConnect(host, port) { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + const done = (ok) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(2000); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + }); +} + +const { host, port } = parsePostgresTarget(databaseUrl); +console.log(`[db] waiting for Postgres at ${host}:${port}`); + +for (let i = 1; i <= maxAttempts; i++) { + if (await canConnect(host, port)) { console.log(`[db] postgres ready (attempt ${i})`); process.exit(0); } @@ -39,5 +71,7 @@ for (let i = 1; i <= maxAttempts; i++) { } console.error("\n[db] postgres did not become ready in time"); -console.error("Check: docker compose ps && docker compose logs postgres"); +console.error(`Check: DATABASE_URL host ${host}:${port} is up and listening`); +console.error("Native: sudo systemctl status postgresql"); +console.error("Docker (optional local): docker compose ps && docker compose logs postgres"); process.exit(1);