Files
descrybe/scripts/setup.mjs
T

287 lines
8.3 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
2026-08-10 01:12:21 +02:00
* One-shot bootstrap:
* 1. Copy .env.example → .env if missing
2026-08-10 01:12:21 +02:00
* 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
*
2026-08-10 01:12:21 +02:00
* 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";
2026-08-10 01:12:21 +02:00
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);
}
2026-08-10 01:12:21 +02:00
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`);
}
2026-08-10 01:12:21 +02:00
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);
2026-08-10 01:12:21 +02:00
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}`);
}
2026-08-10 01:12:21 +02:00
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;
}
}
2026-08-10 01:12:21 +02:00
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}`);
}
}
2026-08-10 01:12:21 +02:00
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();
2026-08-10 01:12:21 +02:00
mergeMissingKeysFromExample();
fillBootstrapEnv();
reviewEnv();
2026-08-10 01:12:21 +02:00
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("");
2026-08-10 01:12:21 +02:00
console.log("Setup complete. Next: npm install && npm run dev");
console.log("Web http://localhost:28472 · API http://localhost:28471");