OmniRoute/scripts/bootstrap-env.mjs
2026-03-28 23:35:59 -03:00

226 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* OmniRoute — Zero-Config Bootstrap
*
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in DATA_DIR
*
* Priority (lowest → highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const require = createRequire(import.meta.url);
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
{ key: "QODER_OAUTH_CLIENT_SECRET", label: "Qoder OAuth" },
{ key: "GEMINI_OAUTH_CLIENT_SECRET", label: "Gemini OAuth" },
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath, env = process.env) {
if (overridePath?.trim()) return resolve(overridePath);
const configured = env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(join(resolveDataDir(null, env), ".env"));
candidates.push(join(process.cwd(), ".env"));
return candidates.find((filePath) => existsSync(filePath)) ?? null;
}
function hasEncryptedCredentials(dataDir) {
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
const env = {};
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx < 1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
env[key] = val;
}
return env;
}
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
function writeEnvFile(filePath, env) {
const lines = [
"# Auto-generated by OmniRoute bootstrap — do not delete",
`# Created: ${new Date().toISOString()}`,
"",
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
"",
];
writeFileSync(filePath, lines.join("\n"), "utf8");
}
// ── Main bootstrap function ──────────────────────────────────────────────────
/**
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
* @returns {Record<string, string>} merged env to pass to child process
*/
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
const serverEnvPath = join(dataDir, "server.env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
const merged = { ...persisted, ...preferredEnv, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
if (!merged.JWT_SECRET?.trim()) {
persisted.JWT_SECRET = randomBytes(64).toString("hex");
merged.JWT_SECRET = persisted.JWT_SECRET;
needsPersist = true;
log("✨ JWT_SECRET auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
if (hasEncryptedCredentials(dataDir)) {
throw new Error(
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
}
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
needsPersist = true;
}
if (!merged.API_KEY_SECRET?.trim()) {
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
needsPersist = true;
log("✨ API_KEY_SECRET auto-generated (first run)");
}
// ── Persist new secrets ────────────────────────────────────────────────────
if (needsPersist) {
try {
mkdirSync(dataDir, { recursive: true });
// Only persist keys that we auto-generated (not .env or process.env vals)
writeEnvFile(serverEnvPath, persisted);
log(`📁 Secrets persisted to: ${serverEnvPath}`);
} catch (e) {
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
}
}
// ── Mark as bootstrapped ───────────────────────────────────────────────────
if (needsPersist) {
merged.OMNIROUTE_BOOTSTRAPPED = "true";
}
// ── Warn about missing optional OAuth secrets ──────────────────────────────
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(({ key }) => !merged[key]?.trim());
if (missingOauth.length > 0) {
log(" The following OAuth integrations are not configured:");
for (const { key, label } of missingOauth) {
log(`${label} (${key}) — set in .env or ${serverEnvPath}`);
}
log(" These providers will not work until configured.");
}
// ── Warn about default password ────────────────────────────────────────────
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
}
return merged;
}
// ── CLI usage: node scripts/bootstrap-env.mjs ──────────────────────────────
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
const env = bootstrapEnv();
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
process.stderr.write(
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
);
}