2026-03-10 15:15:07 -03:00
|
|
|
|
#!/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:
|
2026-03-14 21:14:19 +02:00
|
|
|
|
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
2026-03-10 15:15:07 -03:00
|
|
|
|
* - Docker: same, secrets persisted in mounted volume
|
2026-03-14 21:14:19 +02:00
|
|
|
|
* - Electron: called from main.js startup, persisted in DATA_DIR
|
2026-03-10 15:15:07 -03:00
|
|
|
|
*
|
|
|
|
|
|
* Priority (lowest → highest):
|
|
|
|
|
|
* 1. Auto-generated defaults
|
|
|
|
|
|
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
2026-03-14 21:14:19 +02:00
|
|
|
|
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
2026-03-10 15:15:07 -03:00
|
|
|
|
* 4. process.env (shell / Docker -e flags, highest priority)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
import { randomBytes } from "node:crypto";
|
2026-03-10 15:15:07 -03:00
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
2026-03-14 21:14:19 +02:00
|
|
|
|
import { createRequire } from "node:module";
|
2026-03-10 15:15:07 -03:00
|
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
|
import { join, resolve } from "node:path";
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
|
|
|
2026-03-10 15:15:07 -03:00
|
|
|
|
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
|
|
|
|
|
const OPTIONAL_OAUTH_SECRETS = [
|
|
|
|
|
|
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
|
|
|
|
|
{ key: "IFLOW_OAUTH_CLIENT_SECRET", label: "iFlow OAuth" },
|
|
|
|
|
|
{ key: "GEMINI_OAUTH_CLIENT_SECRET", label: "Gemini OAuth" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
2026-03-14 21:14:19 +02:00
|
|
|
|
function resolveDataDir(overridePath, env = process.env) {
|
2026-03-14 21:29:34 +02:00
|
|
|
|
if (overridePath?.trim()) return resolve(overridePath);
|
2026-03-10 15:15:07 -03:00
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
const configured = env.DATA_DIR?.trim();
|
2026-03-10 15:15:07 -03:00
|
|
|
|
if (configured) return resolve(configured);
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform === "win32") {
|
2026-03-14 21:14:19 +02:00
|
|
|
|
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
2026-03-10 15:15:07 -03:00
|
|
|
|
return join(appData, "omniroute");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
const xdg = env.XDG_CONFIG_HOME?.trim();
|
2026-03-10 15:15:07 -03:00
|
|
|
|
if (xdg) return join(resolve(xdg), "omniroute");
|
|
|
|
|
|
|
|
|
|
|
|
return join(homedir(), ".omniroute");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
function getPreferredEnvFilePath(env = process.env) {
|
|
|
|
|
|
const candidates = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (env.DATA_DIR?.trim()) {
|
|
|
|
|
|
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 21:33:34 +02:00
|
|
|
|
candidates.push(join(resolveDataDir(null, env), ".env"));
|
2026-03-14 21:14:19 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-03-14 21:23:07 +02:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
|
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
2026-03-14 21:14:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 15:15:07 -03:00
|
|
|
|
// ── 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`);
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
|
|
|
|
|
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
|
|
|
|
|
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
2026-03-10 15:15:07 -03:00
|
|
|
|
const serverEnvPath = join(dataDir, "server.env");
|
|
|
|
|
|
|
|
|
|
|
|
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
|
|
|
|
|
let persisted = parseEnvFile(serverEnvPath);
|
|
|
|
|
|
|
2026-03-14 21:14:19 +02:00
|
|
|
|
// ── 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 };
|
2026-03-10 15:15:07 -03:00
|
|
|
|
|
|
|
|
|
|
// ── 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()) {
|
2026-03-14 21:14:19 +02:00
|
|
|
|
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.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-10 15:15:07 -03:00
|
|
|
|
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`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|