| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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); |
|
|
| |
| 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" }, |
| ]; |
|
|
| |
| 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}`); |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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"); |
| } |
|
|
| |
| |
| |
| |
| |
| 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"); |
|
|
| |
| let persisted = parseEnvFile(serverEnvPath); |
|
|
| |
| |
| const merged = { ...persisted, ...preferredEnv, ...process.env }; |
|
|
| |
| 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)"); |
| } |
|
|
| |
| if (needsPersist) { |
| try { |
| mkdirSync(dataDir, { recursive: true }); |
| |
| writeEnvFile(serverEnvPath, persisted); |
| log(`π Secrets persisted to: ${serverEnvPath}`); |
| } catch (e) { |
| log(`β οΈ Could not persist secrets to ${serverEnvPath}: ${e.message}`); |
| } |
| } |
|
|
| |
| if (needsPersist) { |
| merged.OMNIROUTE_BOOTSTRAPPED = "true"; |
| } |
|
|
| |
| 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."); |
| } |
|
|
| |
| if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) { |
| log("β οΈ INITIAL_PASSWORD is not set β using default 'CHANGEME'. Change it in Settings!"); |
| } |
|
|
| return merged; |
| } |
|
|
| |
| 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` |
| ); |
| } |
|
|