Files
OmniRoute/bin/omniroute.mjs
Diego Rodrigues de Sa e Souza 92715c8f2c Release v3.8.46
Release v3.8.46. Full changelog: CHANGELOG.md → [3.8.46]. Contributor attribution in the CHANGELOG entries.
2026-07-07 13:14:06 -03:00

234 lines
9.3 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* OmniRoute CLI entry point.
*
* Special bypasses (handled before Commander):
* --mcp Start MCP server over stdio
* reset-encrypted-columns Recovery tool for broken encrypted credentials
* reset-password Reset the admin/management password
*
* All other commands are routed through Commander (bin/cli/program.mjs).
*/
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import updateNotifier from "update-notifier";
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
// Register tsx so dynamic imports of .ts source files (referenced as .js per
// TypeScript conventions) resolve correctly. The build never emits .js for
// src/lib/cli-helper/, so tsx handles the .ts → .js resolution at runtime.
await import("tsx/esm");
await import("../open-sse/utils/setupPolyfill.ts");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// MCP stdio transport uses stdout exclusively for JSON-RPC messages.
// Redirect console.log/warn to stderr early (before loadEnvFile and DB init)
// so no startup output corrupts the protocol.
if (process.argv.includes("--mcp")) {
const { Console } = await import("node:console");
const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr });
console.log = stderrConsole.log.bind(stderrConsole);
console.warn = stderrConsole.warn.bind(stderrConsole);
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
const seenEnvPaths = new Set();
const addEnvPath = (envPath) => {
if (seenEnvPaths.has(envPath)) return;
seenEnvPaths.add(envPath);
envPaths.push(envPath);
};
if (process.env.DATA_DIR) {
addEnvPath(join(process.env.DATA_DIR, ".env"));
}
addEnvPath(join(getDefaultDataDir(), ".env"));
addEnvPath(join(process.cwd(), ".env"));
// Skip the repo-checkout .env when explicitly requested (used by isolation tests
// that need a deterministic environment without the development repo's defaults).
if (process.env.OMNIROUTE_CLI_SKIP_REPO_ENV !== "1") {
addEnvPath(join(ROOT, ".env"));
}
for (const envPath of envPaths) {
try {
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (process.env[key] === undefined) {
process.env[key] = value.replace(/^["']|["']$/g, "");
}
}
}
loadedEnvPaths.push(envPath);
}
} catch {
// Ignore errors reading env files.
}
}
for (const envPath of loadedEnvPaths) {
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
}
}
loadEnvFile();
// Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env)
// This ensures the key survives across upgrades and is not regenerated on each install.
// See: https://github.com/diegosouzapw/OmniRoute/issues/1622
//
// Only provision for commands that actually touch encrypted storage. Purely
// informational invocations (`--version`, `--help`, `help`) must not create a
// key or write ~/.omniroute/.env — running a read-only command should never
// mutate the data dir.
if (shouldProvisionStorageKey(process.argv)) {
const { randomBytes } = await import("node:crypto");
const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { homedir } = await import("node:os");
if (!process.env.STORAGE_ENCRYPTION_KEY) {
// Persist the key into DATA_DIR when set — that's the directory mounted as a volume in
// Docker (where storage.sqlite lives), so the key survives `docker down` / `docker pull`.
// Writing only to ~/.omniroute (the container home, not a volume) silently lost the key on
// container recreation, leaving the persisted encrypted DB undecryptable (regression of #1622).
const dataDir = process.env.DATA_DIR || join(homedir(), ".omniroute");
const envPath = join(dataDir, ".env");
const dbPath = join(dataDir, "storage.sqlite");
// Safety guard: never auto-generate a fresh key when a database already exists in
// DATA_DIR. A new key cannot decrypt previously-encrypted credentials and would lock the
// user out (then the encryption layer aborts on every read). Mirrors bootstrapEnv's
// hasEncryptedCredentials guard. Restoring the previous key in DATA_DIR/.env recovers it.
// (#1622 follow-up — reported by Daniel Nach; original persistence by @Chewji9875)
if (existsSync(dbPath)) {
console.warn(
` \x1b[33m⚠ STORAGE_ENCRYPTION_KEY is not set but a database already exists at\x1b[0m\n` +
` \x1b[33m ${dbPath}\x1b[0m\n` +
` \x1b[33m Not auto-generating a new key — it could not decrypt existing data. Restore your\x1b[0m\n` +
` \x1b[33m previous key in ${envPath}, or move/remove the database to start fresh.\x1b[0m`
);
} else {
// First run (no database yet) — generate and persist a fresh key.
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const key = randomBytes(32).toString("hex");
// Read existing .env content or start fresh
let content = "";
if (existsSync(envPath)) {
content = readFileSync(envPath, "utf-8");
}
// Append key if not already present
if (!content.includes("STORAGE_ENCRYPTION_KEY=")) {
const separator = content.trim() ? "\n" : "";
const newContent = content.trimEnd() + separator + `STORAGE_ENCRYPTION_KEY=${key}`;
writeFileSync(envPath, newContent + "\n", "utf-8");
console.log(` \x1b[2m✨ Generated STORAGE_ENCRYPTION_KEY in ${envPath}\x1b[0m`);
}
// Set in process.env for immediate use
process.env.STORAGE_ENCRYPTION_KEY = key;
}
}
}
// Apply --lang before Commander parses (program descriptions call t() during setup)
{
const langIdx = process.argv.findIndex((a) => a === "--lang");
const langArg = langIdx >= 0 ? process.argv[langIdx + 1] : null;
const langEnv = process.env.OMNIROUTE_LANG;
const chosen = langArg || langEnv;
if (chosen) {
const { setLocale } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href
);
setLocale(chosen);
}
}
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
process.on("exit", () => {
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;
const outputIdx = process.argv.indexOf("--output");
const outputVal = outputIdx >= 0 ? process.argv[outputIdx + 1] : null;
if (outputVal === "json" || outputVal === "jsonl" || outputVal === "csv") return;
if (process.argv.some((a) => a.startsWith("--output=json") || a.startsWith("--output=jsonl") || a.startsWith("--output=csv"))) return;
if (_notifier.update) {
_notifier.notify({
defer: false,
isGlobal: true,
message:
`Update available: ${_notifier.update.current}${_notifier.update.latest}\n` +
"Run `npm install -g omniroute` or `omniroute update --apply`",
});
}
});
if (process.argv.includes("--mcp")) {
try {
const { startMcpCli } = await import(pathToFileURL(join(ROOT, "bin", "mcp-server.mjs")).href);
await startMcpCli(ROOT);
} catch (err) {
console.error("\x1b[31m✖ Failed to start MCP server:\x1b[0m", err.message || err);
process.exit(1);
}
process.exit(0);
}
if (process.argv.includes("reset-encrypted-columns")) {
const { runResetEncryptedColumns } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "commands", "reset-encrypted-columns.mjs")).href
);
const exitCode = await runResetEncryptedColumns(process.argv.slice(2));
process.exit(exitCode ?? 0);
}
if (process.argv.includes("reset-password")) {
// bin/reset-password.mjs self-executes its `main()` on import and calls
// process.exit() on completion/error. Await a never-resolving promise so
// control never falls through to Commander (which would then reject
// `reset-password` as an unknown command). See #6261.
await import(pathToFileURL(join(ROOT, "bin", "reset-password.mjs")).href);
await new Promise(() => {});
}
try {
const { createProgram } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "program.mjs")).href
);
const program = createProgram();
await program.parseAsync(process.argv);
} catch (err) {
if (err.exitCode !== undefined) process.exit(err.exitCode);
console.error("\x1b[31m✖", err.message, "\x1b[0m");
process.exit(1);
}