update
This commit is contained in:
@@ -63,6 +63,34 @@ TOKEN_SIGNING_SECRET=
|
|||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
APP_ENCRYPTION_KEY=
|
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) ---
|
# --- Optional bootstrap (safe defaults in code; uncomment to override) ---
|
||||||
# TRUSTED_PROXIES - hop-1 reverse-proxy / LB peers only (CIDR or IP, comma-separated).
|
# 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
|
# When set, TrustedRealIP rewrites RemoteAddr from X-Forwarded-For / X-Real-IP only if
|
||||||
|
|||||||
+205
-41
@@ -1,14 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* One-shot local bootstrap (any OS):
|
* One-shot bootstrap:
|
||||||
* 1. Copy .env.example → .env if missing
|
* 1. Copy .env.example → .env if missing
|
||||||
* 2. Fill empty TOKEN_SIGNING_SECRET / APP_ENCRYPTION_KEY (local only)
|
* 2. Merge any new keys from .env.example into .env (never overwrite values)
|
||||||
* 3. docker compose up -d (Postgres)
|
* 3. Generate empty TOKEN_SIGNING_SECRET / APP_ENCRYPTION_KEY
|
||||||
* 4. wait for healthy DB
|
* 4. Fill empty bootstrap defaults (APP_ENV, HTTP_ADDR, WEB_ORIGIN, …)
|
||||||
* 5. goose migrate + sqlc
|
* 5. Wait for DATABASE_URL Postgres + goose migrate + sqlc
|
||||||
|
* 6. Print env review
|
||||||
*
|
*
|
||||||
* Usage: node scripts/setup.mjs
|
* Usage: npm run setup
|
||||||
* Then: npm install && npm run dev
|
*
|
||||||
|
* 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 { spawnSync } from "node:child_process";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
@@ -22,6 +25,28 @@ import {
|
|||||||
writeRootEnvFile,
|
writeRootEnvFile,
|
||||||
} from "./root-env.mjs";
|
} 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"]) {
|
function requireCmd(bin, hint, versionArgs = ["--version"]) {
|
||||||
const r = spawnSync(bin, versionArgs, {
|
const r = spawnSync(bin, versionArgs, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
@@ -44,8 +69,8 @@ function runNodeScript(rel) {
|
|||||||
if (r.status !== 0) process.exit(r.status ?? 1);
|
if (r.status !== 0) process.exit(r.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runDockerCompose(args) {
|
function runDockerCompose(composeArgs) {
|
||||||
const r = spawnSync("docker", ["compose", ...args], {
|
const r = spawnSync("docker", ["compose", ...composeArgs], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
@@ -68,41 +93,187 @@ function ensureEnvFile() {
|
|||||||
console.log(`[setup] created ${path.relative(repoRoot, dest)} from .env.example`);
|
console.log(`[setup] created ${path.relative(repoRoot, dest)} from .env.example`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillEmptySecrets() {
|
function mergeMissingKeysFromExample() {
|
||||||
const text = readRootEnvFile();
|
const examplePath = path.join(repoRoot, ".env.example");
|
||||||
if (text == null) return;
|
if (!fs.existsSync(examplePath)) return;
|
||||||
const parsed = parseEnvFile(text);
|
const exampleText = fs.readFileSync(examplePath, "utf8");
|
||||||
const need = [];
|
const envText = readRootEnvFile();
|
||||||
for (const key of ["TOKEN_SIGNING_SECRET", "APP_ENCRYPTION_KEY"]) {
|
if (envText == null) return;
|
||||||
if (!parsed[key] || !String(parsed[key]).trim()) need.push(key);
|
|
||||||
}
|
|
||||||
if (need.length === 0) return;
|
|
||||||
|
|
||||||
let next = text;
|
const have = parseEnvFile(envText);
|
||||||
for (const key of need) {
|
const exampleParsed = parseEnvFile(exampleText);
|
||||||
const secret = crypto.randomBytes(32).toString("hex");
|
const missing = Object.keys(exampleParsed).filter((k) => !(k in have));
|
||||||
const lineRe = new RegExp(`^(\\s*${key}\\s*=\\s*).*$`, "m");
|
if (missing.length === 0) return;
|
||||||
if (lineRe.test(next)) {
|
|
||||||
next = next.replace(lineRe, `$1${secret}`);
|
let next = envText.trimEnd();
|
||||||
} else {
|
next += "\n\n# --- added by setup from .env.example ---\n";
|
||||||
next = `${next.trimEnd()}\n${key}=${secret}\n`;
|
for (const key of missing) {
|
||||||
}
|
next += `${key}=${exampleParsed[key] ?? ""}\n`;
|
||||||
console.log(`[setup] generated local ${key} (not committed)`);
|
console.log(`[setup] added missing key ${key} from .env.example`);
|
||||||
}
|
}
|
||||||
writeRootEnvFile(next);
|
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/");
|
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("go", "Install Go 1.25+ from https://go.dev/dl/", ["version"]);
|
||||||
requireCmd("docker", "Install Docker Desktop / Docker Engine with Compose v2");
|
|
||||||
|
|
||||||
ensureEnvFile();
|
ensureEnvFile();
|
||||||
fillEmptySecrets();
|
mergeMissingKeysFromExample();
|
||||||
|
fillBootstrapEnv();
|
||||||
|
reviewEnv();
|
||||||
|
|
||||||
|
if (useDocker) {
|
||||||
console.log("==> docker compose up -d (Postgres on host :5433)");
|
console.log("==> docker compose up -d (Postgres on host :5433)");
|
||||||
runDockerCompose(["up", "-d"]);
|
runDockerCompose(["up", "-d"]);
|
||||||
|
} else {
|
||||||
|
console.log("==> using DATABASE_URL Postgres (no Docker)");
|
||||||
|
}
|
||||||
|
|
||||||
console.log("==> wait for Postgres");
|
console.log("==> wait for Postgres");
|
||||||
runNodeScript("scripts/wait-postgres.mjs");
|
runNodeScript("scripts/wait-postgres.mjs");
|
||||||
@@ -111,12 +282,5 @@ console.log("==> migrate");
|
|||||||
runNodeScript("scripts/migrate.mjs");
|
runNodeScript("scripts/migrate.mjs");
|
||||||
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log("Setup complete.");
|
console.log("Setup complete. Next: npm install && npm run dev");
|
||||||
console.log("Next:");
|
console.log("Web http://localhost:28472 · API http://localhost:28471");
|
||||||
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");
|
|
||||||
|
|||||||
+58
-24
@@ -1,36 +1,68 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* 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 { 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 maxAttempts = Number(process.env.DESCRYBE_DB_WAIT_ATTEMPTS || 60);
|
||||||
const sleepMs = Number(process.env.DESCRYBE_DB_WAIT_MS || 1000);
|
const sleepMs = Number(process.env.DESCRYBE_DB_WAIT_MS || 1000);
|
||||||
|
|
||||||
for (let i = 1; i <= maxAttempts; i++) {
|
const env = loadRootEnv(process.env);
|
||||||
const r = spawnSync(
|
const databaseUrl = env.DATABASE_URL;
|
||||||
"docker",
|
if (!databaseUrl || !String(databaseUrl).trim()) {
|
||||||
[
|
console.error(
|
||||||
"compose",
|
"[db] DATABASE_URL is required (root .env or shell; see .env.example)",
|
||||||
"exec",
|
|
||||||
"-T",
|
|
||||||
"postgres",
|
|
||||||
"pg_isready",
|
|
||||||
"-U",
|
|
||||||
"descrybe",
|
|
||||||
"-d",
|
|
||||||
"descrybe",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: repoRoot,
|
|
||||||
encoding: "utf8",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
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})`);
|
console.log(`[db] postgres ready (attempt ${i})`);
|
||||||
process.exit(0);
|
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("\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);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user