Compare commits

..

4 Commits

Author SHA1 Message Date
diegosouzapw af46f87eed feat(bootstrap): zero-config auto-generated secrets on first run
Build Electron Desktop App / Validate version (push) Failing after 33s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Resolves root cause of #252 (Electron black screen) and #249 (OAuth fail)
for users running with zero configuration (no .env needed).

New: scripts/bootstrap-env.mjs
- Auto-generates JWT_SECRET (64 bytes), STORAGE_ENCRYPTION_KEY (32 bytes),
  API_KEY_SECRET (32 bytes) if missing or empty
- Persists to {DATA_DIR}/server.env — survives restarts, Docker volume
  remounts, and upgrades without changing secrets
- Reads .env from CWD (user overrides), then merges process.env (highest prio)
- Logs friendly warnings for missing optional OAuth secrets

Updated: run-standalone.mjs + run-next.mjs
- Call bootstrapEnv() before spawning server — covers npm + Docker paths

Updated: electron/main.js (synchronous inline — CJS cannot await import ESM)
- Reads userData/server.env, generates missing secrets with crypto.randomBytes()
- Persists back to server.env, sets OMNIROUTE_BOOTSTRAPPED=true

New: BootstrapBanner.tsx + page.tsx update
- Dismissable amber banner on dashboard home when running in zero-config mode
- Shows where server.env is located and how to customize secrets
2026-03-10 15:15:07 -03:00
diegosouzapw fd749d1e0b fix(electron): auto-generate JWT_SECRET and STORAGE_ENCRYPTION_KEY if missing
In packaged Electron on macOS/Windows/Linux, there is no .env file.
The Next.js server needs JWT_SECRET and STORAGE_ENCRYPTION_KEY to start —
without them it crashes silently, causing ERR_CONNECTION_REFUSED
and a black screen in the Electron window.

Fix: Generate cryptographically random values with crypto.randomBytes()
on first launch, persist them in userData/electron-env.json, and pass
them to the spawned server.js process via the env option.

Root cause: macOS users reported 'app black screen' (#252) and
ERR_CONNECTION_REFUSED — this was the Next.js server crashing at startup
because these env vars don't exist in the desktop OS environment.
2026-03-10 15:06:57 -03:00
diegosouzapw 5046f90dfa docs(workflow): make openapi.yaml sync mandatory in generate-release
- Step 4 now marked ⚠️ MANDATORY with CI will fail warning
- Command is now auto-extracting version from package.json (no manual substitution)
- Step 4 has // turbo annotation for auto-execution
- Added 'Known CI Pitfalls' table: docs-sync failures, Electron fpm, Docker 502
2026-03-10 15:02:08 -03:00
diegosouzapw cf13e95610 fix(ci): bump openapi.yaml version to 2.2.4
check:docs-sync fails when openapi.yaml version != package.json version.
Updating to match after v2.2.4 release.

Systematic fix: openapi.yaml version must always be updated alongside
package.json during releases (see generate-release workflow step 4).
2026-03-10 14:43:17 -03:00
11 changed files with 348 additions and 10 deletions
+15 -2
View File
@@ -53,10 +53,14 @@ Keep an empty `## [Unreleased]` section above it.
## [2.x.y] — YYYY-MM-DD
```
### 4. Update openapi.yaml version
### 4. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
sed -i 's/version: OLD/version: NEW/' docs/openapi.yaml
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
```
### 5. Stage, commit, and tag
@@ -95,3 +99,12 @@ ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
- After npm publish, verify with `npm info omniroute version`
- Lock file sync errors are caused by skipping `npm install` after version bump
## Known CI Pitfalls
| CI failure | Cause | Fix |
| ------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 4 — `docs/openapi.yaml` version not updated | Run step 4 (`sed -i ...`) and commit |
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
+25
View File
@@ -11,6 +11,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [2.2.5] — 2026-03-10
> ### 🔧 Zero-Config Bootstrap · 🐛 Electron Black Screen Fix
### Features
- **Zero-config bootstrap (#252, #249)** — OmniRoute now auto-generates required secrets on first run across all deployment modes (npm, Docker, Electron Desktop App):
- `JWT_SECRET` (64-byte hex) — required for auth/sessions
- `STORAGE_ENCRYPTION_KEY` (32-byte hex) — required for SQLite encryption
- `API_KEY_SECRET` (32-byte hex) — required for API key signing
- Secrets are persisted to `{DATA_DIR}/server.env` and survive restarts, Docker volume remounts, and upgrades
- Friendly startup warnings if OAuth secrets (Antigravity, iFlow, Gemini) are not configured
- New **`scripts/bootstrap-env.mjs`** module — single source of truth for zero-config initialization
### Bug Fixes
- **Electron black screen on macOS/Windows/Linux** — The Next.js server was crashing silently because `JWT_SECRET` and `STORAGE_ENCRYPTION_KEY` are never present in desktop OS environments. Fixed by calling `bootstrapEnv()` before spawning `server.js`, with secrets persisted to Electron's `userData` directory.
- **Dashboard bootstrap banner** — Added dismissable amber warning banner on the dashboard home when running in zero-config mode, showing where `server.env` is stored and how to customize secrets.
### Note for Docker users
Previously, `--env-file .env` was required to pass secrets to the container. Now OmniRoute will generate and persist them automatically in the mounted volume. Existing `DATA_DIR` secrets are always respected.
---
## [2.2.4] — 2026-03-10
> ### 🔧 CI Fixes
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.2.3
version: 2.2.5
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+64 -1
View File
@@ -383,6 +383,69 @@ function startNextServer() {
return;
}
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
// This mirrors bootstrap-env.mjs logic synchronously:
// 1. Read persisted secrets from userData/server.env
// 2. Generate missing secrets with crypto.randomBytes()
// 3. Persist back to userData/server.env for future restarts
const crypto = require("crypto");
const userDataDir = app.getPath("userData");
const serverEnvPath = path.join(userDataDir, "server.env");
// Parse a simple KEY=VALUE file
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const env = {};
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const eq = t.indexOf("=");
if (eq < 1) continue;
env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
}
return env;
}
const persisted = parseEnvFile(serverEnvPath);
const serverEnv = { ...process.env, ...persisted };
let changed = false;
if (!serverEnv.JWT_SECRET) {
serverEnv.JWT_SECRET = persisted.JWT_SECRET = crypto.randomBytes(64).toString("hex");
changed = true;
console.log("[Electron] ✨ JWT_SECRET auto-generated");
}
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
.randomBytes(32)
.toString("hex");
serverEnv.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
changed = true;
console.log("[Electron] ✨ STORAGE_ENCRYPTION_KEY auto-generated");
}
if (!serverEnv.API_KEY_SECRET) {
serverEnv.API_KEY_SECRET = persisted.API_KEY_SECRET = crypto.randomBytes(32).toString("hex");
changed = true;
console.log("[Electron] ✨ API_KEY_SECRET auto-generated");
}
if (changed) {
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
try {
fs.mkdirSync(userDataDir, { recursive: true });
const lines = [
"# Auto-generated by OmniRoute bootstrap",
"",
...Object.entries(persisted).map(([k, v]) => `${k}=${v}`),
"",
];
fs.writeFileSync(serverEnvPath, lines.join("\n"), "utf8");
console.log("[Electron] 📁 Secrets persisted to:", serverEnvPath);
} catch (e) {
console.warn("[Electron] Could not persist secrets:", e.message);
}
}
console.log("[Electron] Starting Next.js server on port", serverPort);
sendToRenderer("server-status", { status: "starting", port: serverPort });
@@ -390,7 +453,7 @@ function startNextServer() {
nextServer = spawn("node", [serverScript], {
cwd: NEXT_SERVER_PATH,
env: {
...process.env,
...serverEnv,
PORT: String(serverPort),
NODE_ENV: "production",
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.2.4",
"version": "2.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.2.4",
"version": "2.2.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.2.4",
"version": "2.2.5",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
+174
View File
@@ -0,0 +1,174 @@
#!/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 / CLI: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in userData
*
* Priority (lowest highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. .env in CWD (user overrides)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
// ── 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) ───────────────────────────
function resolveDataDir(overridePath) {
if (overridePath) return resolve(overridePath);
const configured = process.env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = process.env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
// ── 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 dataDir = resolveDataDir(dataDirOverride);
const serverEnvPath = join(dataDir, "server.env");
const dotEnvPath = join(process.cwd(), ".env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
const dotEnv = parseEnvFile(dotEnvPath);
// ── Merge: persisted < .env < process.env ─────────────────────────────────
const merged = { ...persisted, ...dotEnv, ...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()) {
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`
);
}
+5 -1
View File
@@ -5,12 +5,16 @@ import {
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
const runtimePorts = resolveRuntimePorts();
const { dashboardPort } = runtimePorts;
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
if (mode === "dev") {
args.splice(2, 0, "--webpack");
@@ -18,5 +22,5 @@ if (mode === "dev") {
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
env: withRuntimePortEnv(env, runtimePorts),
});
+5 -1
View File
@@ -5,10 +5,14 @@ import {
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const runtimePorts = resolveRuntimePorts();
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
spawnWithForwardedSignals("node", ["server.js"], {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
env: withRuntimePortEnv(env, runtimePorts),
});
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
/**
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
* The banner is dismissable and persists only for the current session.
*/
export default function BootstrapBanner() {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
// Determine default data dir hint based on platform hint from user-agent
const dataDir =
typeof navigator !== "undefined" && navigator.platform?.startsWith("Win")
? "%APPDATA%\\omniroute\\server.env"
: "~/.omniroute/server.env";
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200 mb-4"
>
<span className="text-amber-400 text-base shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-300">Running in zero-config mode</p>
<p className="mt-0.5 text-amber-200/80">
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">{dataDir}</code>. No
action is required your data is encrypted and safe. To use custom keys, add{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">JWT_SECRET</code> and{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">
STORAGE_ENCRYPTION_KEY
</code>{" "}
to that file.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-400/60 hover:text-amber-300 transition-colors ml-1"
aria-label="Dismiss"
>
</button>
</div>
);
}
+8 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import HomePageClient from "./HomePageClient";
import BootstrapBanner from "./BootstrapBanner";
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
export const dynamic = "force-dynamic";
@@ -12,5 +13,11 @@ export default async function DashboardPage() {
redirect("/dashboard/onboarding");
}
const machineId = await getMachineId();
return <HomePageClient machineId={machineId} />;
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>
{isBootstrapped && <BootstrapBanner />}
<HomePageClient machineId={machineId} />
</>
);
}