OmniRoute/scripts/bootstrap-env.mjs
Diego Rodrigues de Sa e Souza 1442c47bbb
chore(release): v3.5.6 — email masking, model toggle, OpenRouter registries & bug fixes (#1080)
* fix(minimax): switch auth from x-api-key to Authorization Bearer (#1076)

Integrated into release/v3.5.6 — MiniMax auth fix with authHeader consistency normalization

* feat(CI,i18n): autogenerate language files + Add missing strings (#1071)

Integrated into release/v3.5.6 — i18n translations for memory, skills, and missing keys across 31 languages

* fix(ci): restore i18n continue-on-error, remove auto-commit race condition

* fix(husky): load nvm in hooks for VS Code compatibility

* fix(husky): gracefully skip hooks when npm is not in PATH

* fix: convert OpenAI function tool_choice to Claude tool format (#1072)

* fix: prevent EPIPE feedback loop filling logs at GB/s (#1006)

* fix: fallback to native fetch when undici dispatcher fails (#1054)

* fix: improve Qoder PAT validation with actionable error messages (#966)

- Add QODER_PERSONAL_ACCESS_TOKEN env var fallback for both validation and execution
- Pre-flight ping check to diagnose connectivity issues (Docker/proxy)
- Detect encrypted auth blobs from ~/.qoder/.auth/user and guide to website PAT
- Clear error messages for auth failures with link to integrations page
- Treat non-auth 4xx as auth-pass (request format issue, not token issue)
- Update tests to cover new validation paths (23 tests, all passing)

* feat: Improve the Chinese translation (#1079)

Integrated into release/v3.5.6

* chore(release): v3.5.6 — i18n updates and credential security fixes

* fix(ci): resolve e2e and docs-sync pipeline failures

* fix(security): bump next to 16.2.3 to resolve SNYK-JS-NEXT-15954202

* fix: guard Memory/Cache UI against null toLocaleString crash (#1083)

* fix: translate OpenAI tool_choice type 'function' to Claude 'tool' format (#1072)

* fix: pass custom baseUrl in provider API key validation (#1078)

* docs: update CHANGELOG with v3.5.6 bug fixes and security patches

* docs: rewrite implement-features workflow with 5-phase harvest-research-report-plan-execute pipeline

* docs: organize _ideia/ into viable/defer/notfit + add Phase 2.5 auto-response workflow

* docs: implementation plans for #1025, #750, #960, #1046 + close already-implemented #833, #973, #982

* feat: mask email addresses in dashboard for privacy (#1025)

* feat: add OpenRouter and GitHub to embedding/image provider registries (#960)

* feat: add model visibility toggle and search filter to provider page (#750)

* docs: move implemented features to notfit, update task plans status

* chore: untrack _ideia/ and _tasks/ from git — private/internal only

* chore(release): bump to v3.5.6 — changelog, docs, version sync & any-budget fix

* fix: remove explicit .ts extension in qoderCli import that caused 500 error in production build

---------

Co-authored-by: Jean Brito <jeanfbrito@gmail.com>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Ethan Hunt <136065060+only4copilot@users.noreply.github.com>
2026-04-09 15:55:59 -03:00

231 lines
9.3 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 = [
{ keys: ["ANTIGRAVITY_OAUTH_CLIENT_SECRET"], label: "Antigravity OAuth" },
{ keys: ["QODER_OAUTH_CLIENT_SECRET"], label: "Qoder OAuth" },
{
keys: ["GEMINI_CLI_OAUTH_CLIENT_SECRET", "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(
({ keys }) => !keys.some((key) => merged[key]?.trim())
);
if (missingOauth.length > 0) {
log(" The following OAuth integrations are not configured:");
for (const { keys, label } of missingOauth) {
log(`${label} (${keys.join(" or ")}) — 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`
);
}