mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
* fix(ci): clear base-reds on release/v3.8.50 (round 4) Drains the HARD failures reported by Release-Green run 31693210948 on issue #9985 (ESLint errors: 2) plus the merge-integrity red every open PR is inheriting. - ESLint error 1: @omniroute/opencode-plugin/src/index.ts had a stray extra '});' (introduced by #9316) that broke parsing with 'unexpected file in NFT list' on the build path. - ESLint error 2: cli-env-inline-comment-10100.test.ts used new Function to extract parseEnvValue from the bin entrypoint (no-new-func, Hard Rule #3). Extracted the helper to bin/cli/utils/parseEnvValue.mjs and import it from both the entrypoint and the test (same behavior, no eval). - open-sse-typecheck (Fast Quality Gates): open-sse/utils/stream.ts imported sseCommentsEnabled twice (#9378) causing TS2300 Duplicate identifier; removed the duplicate import. - Merge integrity (changelog + generated skills): skills/omni-settings/SKILL.md was edited manually by #10169 without updating the generator source, so check:agent-skills-sync failed on every PR (Generated: 1). Moved the curated thinking-budget content into a <!-- skill:custom-start --> block (the documented preservation mechanism), which the generator now keeps in sync. Refs #9985 * fix(tests): align wave1-a poolside test with #10216 probed catalog #10216 published Poolside's two authenticated-probe models (poolside/laguna-xs-2.1, poolside/laguna-s-2.1) as static seeds, but the wave1-a free-tier test still asserted 'no invented static model ids' (entry.models === []), failing every open PR. Separate poolside from the empty-models assertion and pin its probed catalog explicitly so a future catalog change is a deliberate update, not a silent drift. * fix(pack): register parseEnvValue.mjs in PACK_ARTIFACT_REQUIRED_PATHS The extract of parseEnvValue to bin/cli/utils/parseEnvValue.mjs added a new direct import to bin/omniroute.mjs, which pack-artifact-entrypoint-closures enforces against PACK_ARTIFACT_REQUIRED_PATHS. Register the module so a future tarball omission fails loudly. * fix(combo): restore default same-model retry semantics after #10217 #10217 wired config.failoverBeforeRetry into the same-model retry guard in both the priority/auto and round-robin loops, but DEFAULT_COMBO_CONFIG defaulted the flag to true — flipping same-model retry off for every combo that never touched the setting, not just the opt-in case. Round-4 bisect (06f41cda63vsd2fd88dfbc) reproduced this against tests/unit/combo-499-abort.test.ts, tests/unit/combo-quota-exhaustion-only-fallback.test.ts and tests/unit/combo-stream-readiness-fallback.test.ts. Flip the default to false so the historical retry-before-failover behavior returns for combos that never set the flag, while explicit opt-in (the two new tests #10217 added to combo-routing-engine.test.ts) still works. * fix(quality): register visionBridge-responses-9597 in stryker tap.testFiles check-mutation-test-coverage.mjs flagged tests/unit/guardrails/visionBridge-responses-9597.test.ts as covering open-sse/services/combo/comboStructure.ts without being listed in stryker.conf.json's tap.testFiles array. Add it so mutation coverage attribution stays accurate. * test(pack): expect parseEnvValue.mjs in the missing-artifact-paths fixture The prior commit on this branch registered bin/cli/utils/parseEnvValue.mjs in PACK_ARTIFACT_REQUIRED_PATHS but the "findMissingArtifactPaths flags missing root runtime files in the tarball" test still hardcoded the old expected list, so it never accounted for the new required path being absent from the simulated tarball. Add it in its alphabetical slot. * chore(lint): prune stale no-explicit-any suppression for call-log-file-rotation --prune-suppressions found tests/unit/call-log-file-rotation.test.ts no longer produces the 5 suppressed @typescript-eslint/no-explicit-any warnings recorded in config/quality/eslint-suppressions.json. Remove the dead entry so a regression would be caught again. Full-tree run with --max-warnings 0 is clean: 0 errors, 0 warnings. * fix(combo): decouple failoverBeforeRetry same-model guard from the skipUpstreamRetry default Audit found that DEFAULT_COMBO_CONFIG.failoverBeforeRetry has defaulted to true since before #10217 (predates #2417), and that value also feeds the independent skipUpstreamRetry mechanism (src/sse/handlers/chat.ts:859,1126). The previous commit on this branch flipped that default to false to fix the #10217 same-model retry guard, which silently disabled skipUpstreamRetry's own default-on behavior for every combo without an opt-in — a regression in the opposite direction (executor-level retries before the loop's own failover, changing latency/failure behavior). Revert the default back to true and decouple the two mechanisms instead: resolveComboConfig/resolveComboSetupConfig now also compute failoverBeforeRetryExplicit, true only when a cascade layer (combo/provider/ global) literally sets failoverBeforeRetry to true — not merely inherited from the default. The #10217 same-model retry guards in combo.ts (priority/ auto and round-robin loops) now read failoverBeforeRetryExplicit instead of config.failoverBeforeRetry, restoring opt-in-only behavior for that guard while the skipUpstreamRetry pass-through (config.failoverBeforeRetry at combo.ts:1297,2865) is untouched and keeps its historical default-on. * fix(combo,i18n): align getDefaultComboConfig with 10217 explicit flag; pt denoRelay entities Two round-4 follow-ups exposed by the combinated base-red PR run: 1. comboConfig.ts: #10217 round-4 fix (104afeda4e) added failoverBeforeRetryExplicit to resolveComboConfig/resolveComboSetupConfig but getDefaultComboConfig() returned only DEFAULT_COMBO_CONFIG, so the combo-config.test.ts deepEqual (resolveComboConfig(null) === getDefaultComboConfig()) failed on the extra field. Mirror the opt-in flag as false in the default. 2. pt.json: denoRelayOrgDomainHint still carried raw <app-name>/<org-slug> (the UNCLOSED_TAG RSC regression) — encode as <...> like the other 42 locales, greening i18n-deno-relay-unclosed-tag.test.ts. * chore(lint): disable @next/next/no-location-assign-relative-destination pending per-case review (#10292) The eslint-config-next bump in #10043 shipped this new rule, flagging 6 pre-existing window.location.href navigations — several are deliberate full-page reloads (login/logout state reset). Off with tracking issue rather than a blanket router.push rewrite. * fix(i18n): fill 439 missing UI keys (thinkingMode ×39 locales + pt catch-up) to restore 100% coverage The #10169 Thinking Budget keys existed only in en/pt-BR/vi and the pt (PT-PT) catalog from #10250 lagged 88 recent keys, dropping i18nUiCoverage to 99.3% vs the frozen 100% ratchet baseline. Translated via the i18n:sync-ui marker pipeline; glossary + ICU placeholder post-pass clean. * fix(i18n): zh-TW glossary — replace retired 默認 with canonical 預設 in new thinkingMode keys * chore(quality): rebase dead-code baseline 248 -> 409 for knip 6.32 bump (#10043) dependabot #10043 upgraded knip 6.27 -> 6.32, which detects 162 MORE genuinely-unused exports (331 vs 169) that 6.27 missed; DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip266e39d3with a fresh 6.32 node_modules, so every PR is born red until the tool change is absorbed. Owner authorized rebaseline (2026-08-13 via PR #10260). Structural cleanup of the newly-surfaced dead exports remains separate debt. --------- Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com> Co-authored-by: backryun <bakryun0718@proton.me>
298 lines
13 KiB
JavaScript
Executable File
298 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* OmniRoute CLI entry point.
|
|
*
|
|
* Special bypasses (handled before Commander):
|
|
* --version / -V (alone) Fast-path: print the version and exit, skipping the
|
|
* tsx/esm + polyfill imports, env-file loading, and
|
|
* Commander's ~70-command registration entirely.
|
|
* --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, writeFileSync } 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";
|
|
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
|
|
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const ROOT = join(__dirname, "..");
|
|
|
|
// Fast-path a bare `--version`/`-V` query BEFORE the tsx/esm registration, the
|
|
// polyfill import, env-file loading, or Commander's command registration (~70
|
|
// modules — DB, providers, OAuth, etc.) run. None of that work is needed to answer
|
|
// "what version is this" — mirrors upstream 9router PR #2414 (fast-path help/version
|
|
// ahead of expensive self-heal hooks), adapted to OmniRoute's Commander CLI where the
|
|
// equivalent expensive work is eager command registration rather than npm-install-based
|
|
// runtime self-healing. `--help` is intentionally NOT fast-pathed here: its output is
|
|
// generated dynamically from every registered subcommand, so skipping registration
|
|
// would truncate the help text instead of just speeding it up.
|
|
if (isVersionFastPath(process.argv)) {
|
|
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
console.log(pkg.version);
|
|
process.exit(0);
|
|
}
|
|
|
|
// MCP stdio transport uses stdout exclusively for JSON-RPC messages. Redirect
|
|
// console.log/warn to stderr before anything else runs — including the tsx/esm and
|
|
// polyfill imports below, since those (and their transitive module graphs, e.g. DB
|
|
// init) can themselves log during evaluation. Redirecting after those imports let
|
|
// early output leak straight into the JSON-RPC stream and corrupt it client-side
|
|
// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON").
|
|
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);
|
|
}
|
|
|
|
// 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");
|
|
|
|
// #7791: tsx's tsconfig-path resolution does not apply when OmniRoute is
|
|
// installed globally (files live under node_modules/omniroute/), so bare
|
|
// `@/...` specifiers (declared in tsconfig.json paths as `@/* → ./src/*`)
|
|
// fail with ERR_MODULE_NOT_FOUND. Register an ESM resolve hook that maps
|
|
// `@/...` to absolute file URLs under <ROOT>/src/. Safe no-op in dev checkout
|
|
// (paths already resolve via tsconfig) and when ROOT has no `src/` dir.
|
|
const { registerAliasResolver } = await import("./aliasResolver.mjs");
|
|
await registerAliasResolver(ROOT);
|
|
|
|
// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to
|
|
// `<DATA_DIR>/server.env` (electron/main.js), never `.env`. Migrating an existing
|
|
// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable —
|
|
// the CLI only ever looked for `.env`, so the STORAGE_ENCRYPTION_KEY needed to decrypt
|
|
// the migrated database was silently dropped (#7302). One-time, one-directory migration:
|
|
// if `<dataDir>/.env` is absent but `<dataDir>/server.env` is present, copy it to `.env`
|
|
// so it flows through the normal env-loading path below. Never overwrites an existing
|
|
// `.env` — an explicit `.env` always wins over a legacy `server.env`.
|
|
function migrateElectronServerEnv(dataDir) {
|
|
try {
|
|
const envPath = join(dataDir, ".env");
|
|
const serverEnvPath = join(dataDir, "server.env");
|
|
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
|
|
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
|
|
console.log(
|
|
` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`
|
|
);
|
|
} catch {
|
|
// Ignore errors migrating server.env — fall back to normal env loading below.
|
|
}
|
|
}
|
|
|
|
function loadEnvFile() {
|
|
const envPaths = [];
|
|
const loadedEnvPaths = [];
|
|
const seenEnvPaths = new Set();
|
|
const addEnvPath = (envPath) => {
|
|
if (seenEnvPaths.has(envPath)) return;
|
|
seenEnvPaths.add(envPath);
|
|
envPaths.push(envPath);
|
|
};
|
|
|
|
migrateElectronServerEnv(process.env.DATA_DIR || getDefaultDataDir());
|
|
|
|
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();
|
|
if (process.env[key] === undefined) {
|
|
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
|
|
// Next.js has no android branch in getCacheDirectory(): if ~/.cache (and tmp)
|
|
// do not already exist it aborts the instrumentation hook, and every request
|
|
// then returns a silent HTTP 500 even though the CLI still looks "running".
|
|
// Create the cache dir (and set XDG_CACHE_HOME when unset) before serve/Next.
|
|
{
|
|
const { ensureAndroidCacheDir } = await import("./cli/utils/ensureAndroidCacheDir.mjs");
|
|
ensureAndroidCacheDir();
|
|
}
|
|
|
|
// 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);
|
|
}
|