feat(cli): Fase 0.3 — helpers base + convenções (api, i18n, output, runtime)

- bin/cli/CONVENTIONS.md: fonte normativa de flags, exit codes, output,
  retry/backoff, i18n, secrets, auditoria de ações destrutivas
- bin/cli/api.mjs: apiFetch() com retry/backoff, Retry-After, ApiError,
  statusToExitCode, isServerUp; computeBackoff/shouldRetryStatus exportados
- bin/cli/runtime.mjs: withRuntime/withHttp/withDb — server-first / DB-fallback;
  ServerOfflineError com exitCode 3
- bin/cli/i18n.mjs: t() com Map achatado (sem bracket em prototype), interpolação
  {vars}, setLocale/detectLocale/resetForTests; hardened contra __proto__ traversal
- bin/cli/output.mjs: emit() (table/json/jsonl/csv), EXIT_CODES, maskSecret,
  printSuccess/printError/printWarning/exitWith; output → stdout, diagnóstico → stderr
- bin/cli/locales/en.json + pt-BR.json: strings base (setup/doctor/providers/
  keys/combo/serve/backup/update/health/mcp/tunnel)
- bin/cli/README.md: mapa da estrutura e guia de uso dos helpers
- tests/unit/cli-exit-codes.test.ts: 10 casos — EXIT_CODES, statusToExitCode,
  backoff exponencial, jitter ±25%, t() i18n com pt-BR e anti-__proto__
- .env.example + docs/reference/ENVIRONMENT.md: documentar 4 novas env vars CLI
  (OMNIROUTE_LANG, OMNIROUTE_CLI_TOKEN, OMNIROUTE_HTTP_TIMEOUT_MS, OMNIROUTE_VERBOSE)
- scripts/check/check-env-doc-sync.mjs: adicionar LC_MESSAGES ao allowlist de sistema
This commit is contained in:
diegosouzapw
2026-05-14 21:42:57 -03:00
parent cfc83e2fd8
commit 2e494f8f07
12 changed files with 1196 additions and 0 deletions

View File

@@ -780,6 +780,21 @@ APP_LOG_TO_FILE=true
# Default: 256 (Docker) | system default (npm)
# OMNIROUTE_MEMORY_MB=256
# ── CLI helpers (bin/cli/) ──
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR).
# Falls back to LC_ALL / LC_MESSAGES / LANG / en if unset.
# OMNIROUTE_LANG=en
# Bearer token injected as x-omniroute-cli-token header for machine-auth (task 8.12).
# Auto-generated on first run if machine-id is available; set manually to override.
# OMNIROUTE_CLI_TOKEN=
# Per-attempt HTTP timeout for CLI → server calls (milliseconds). Default: 30000.
# OMNIROUTE_HTTP_TIMEOUT_MS=30000
# Set to 1 to print retry/backoff details to stderr during CLI commands.
# OMNIROUTE_VERBOSE=0
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)

204
bin/cli/CONVENTIONS.md Normal file
View File

@@ -0,0 +1,204 @@
# OmniRoute CLI — Internal Conventions
> Status: normative. Source: `_tasks/features-v3.8.0/cli/fase-0-preparacao/0.3-definir-convencoes.md`.
> This file is the authoritative reference for every new or migrated CLI command.
> If reality diverges from this document, fix the code first; only edit this file
> after the discrepancy has been justified in a PR.
## 1. Subcommand style
**Standard**: `git`-style nested verbs.
```
omniroute keys add openai sk-xxx
omniroute combo switch fastest
omniroute memory search "react hooks"
```
**Not allowed**:
```
omniroute --add-key openai sk-xxx # ❌ flag-as-verb
omniroute add-key openai sk-xxx # ❌ hyphen at the top level
```
## 2. Flags
- Only `--long` and `-s` shorts (one-letter shorts reserved for very common
flags: `-h`, `-v`, `-o`, `-q`, `--no-open`).
- Format: `--api-key sk-xxx` (space). `=` accepted for parity but doc uses space.
- Naming: kebab-case (`--api-key`, `--non-interactive`, `--max-tokens`).
- Booleans: `--no-foo` (negative) and `--foo` (positive). Default `false` unless
documented.
- Multi-value: repeat the flag (`--header X-A=1 --header X-B=2`).
## 3. Output (`--output`)
| Value | Use case |
| ------- | -------------------------------------------- |
| `table` | default human-readable |
| `json` | single JSON object, pretty-printed |
| `jsonl` | streamed objects, one per line (logs, lists) |
| `csv` | spreadsheet ingestion |
Related flags:
- `--quiet` / `-q` — suppress headers/spinners (pipe-friendly).
- `--no-color` — force ANSI off (auto-detected if `!stdout.isTTY`).
Helper: `emit(rows, opts)` from `bin/cli/output.mjs` handles all four formats.
## 4. Exit codes
| Code | Meaning |
| ----- | --------------------------------- |
| `0` | success |
| `1` | generic error (uncaught, runtime) |
| `2` | invalid argument / misuse |
| `3` | server offline (when required) |
| `4` | auth / permission (401/403) |
| `5` | rate limit / quota (429) |
| `124` | timeout |
Helper: `exitWith(code, message?)` from `bin/cli/exit.mjs` (added under
`output.mjs` if needed) — always uses these constants. **Never** raw
`process.exit(N)` in command code.
## 5. HTTP errors + retry/backoff
All API calls go through `apiFetch(path, opts)` (`bin/cli/api.mjs`), which:
- Reads base URL from `OMNIROUTE_BASE_URL` env or `~/.omniroute/config.json`
(active profile).
- Injects `Authorization: Bearer ${OMNIROUTE_API_KEY}` when available.
- Injects `x-omniroute-cli-token` when applicable (see task 8.12).
- Applies a per-attempt timeout (`--timeout 30000`, default 30s).
- Maps status → exit code (401→4, 429→5, 5xx→1, etc.).
- Never exposes `err.stack` (CLAUDE.md hard rule #12).
- Applies exponential backoff with jitter on retryable statuses.
### Retry defaults
```js
export const RETRY_DEFAULTS = {
maxAttempts: 3, // 1 initial + 2 retries
baseMs: 500,
maxMs: 8000, // jitter can slightly exceed
jitter: true, // ±25%
retryableStatuses: [408, 425, 429, 502, 503, 504],
retryableErrorCodes: [
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"EAI_AGAIN",
"EPIPE",
],
};
```
### Global flags wired
- `--retry` (default on) / `--no-retry`
- `--retry-max <n>` (default 3) — total attempts
- `--timeout <ms>` (default 30000) — per attempt
- `--retry-on <csv>` — extra retryable statuses (e.g. `--retry-on 500`)
### Method semantics
- Mutations (`POST`/`PUT`/`DELETE`) retry **only** on idempotent-ish statuses
(`502`/`503`/`504`/`408`/network), never `409`/`422`. This avoids duplicate
side-effects.
- `GET` retries all `RETRY_DEFAULTS.retryableStatuses`.
- SSE / streaming does **not** auto-retry (operator decides).
- Optional `--idempotency-key <uuid>` for extra-safe mutations.
### Status → exit code map
| Status | Exit | Retry? |
| --------------- | ---- | ------------------------------ |
| 200299 | 0 | n/a |
| 400 | 2 | no |
| 401 | 4 | no |
| 403 | 4 | no |
| 404 | 2 | no |
| 408 | 124 | **yes** |
| 409 | 1 | no (mutations) |
| 422 | 2 | no |
| 425 | 1 | **yes** |
| 429 | 5 | **yes** (respects Retry-After) |
| 500 | 1 | configurable (default no) |
| 502 / 503 / 504 | 1 | **yes** |
| Network errors | 1 | **yes** |
| Timeout | 124 | **yes** |
## 6. Internationalization
- Every user-facing string goes through `t("module.key", vars)`.
- Catalogs live in `bin/cli/locales/{en,pt-BR}.json` (nested objects).
- Detection: `OMNIROUTE_LANG` overrides, otherwise `LC_ALL`, `LC_MESSAGES`,
`LANG`. Fallback: `en`.
- Missing keys return the key itself (no crash). PRs that add new strings
must update both `en` and `pt-BR` catalogs.
## 7. Logs / output channels
- `stdout` — useful output (parseable when `--output json|jsonl|csv`).
- `stderr` — progress, warnings, errors, spinners.
- `--verbose` / `-V` — extra detail on stderr.
- `--debug` — stack traces, request bodies (dev-mode only; redacts secrets).
## 8. Server-first / DB-fallback
Single helper:
```js
import { withRuntime } from "./runtime.mjs";
await withRuntime(async (ctx) => {
if (ctx.kind === "http") return ctx.api("/v1/providers");
return ctx.db.providers.list();
});
```
- `kind: "http"` when server is up (preferred).
- `kind: "db"` when offline (read-only operations).
- Mutations that require server **must** error with exit code `3` when the
server is down, never silently fall back.
- **Never** write raw SQL in commands — always go through `bin/cli/sqlite.mjs`
or the upstream `src/lib/db/` modules.
## 9. Audit of destructive actions
Commands that mutate state (delete, reset, `--force`) **must**:
- Ask for interactive confirmation (skipped with `--yes`).
- POST to `/api/compliance/audit-log` when the server is up.
- Support `--dry-run` (preview without effect).
## 10. Secrets
- **Never** log secrets. Mask as `sk-***-xxx` via `maskSecret()` from
`bin/cli/output.mjs`.
- **Never** accept a secret via positional without warning. Prefer:
- env (`OMNIROUTE_*_API_KEY`)
- stdin (`--api-key-stdin`)
- interactive `askSecret()` (echo off — already implemented in `io.mjs`)
- Secrets must not appear in `--verbose` / `--debug` output.
## 11. Testing baseline
- Every new command ships with at least one smoke test (happy path + one
error path).
- Use `tests/unit/cli-*.test.ts` naming. Prefer `node:test` for CLI suites
(no extra deps).
- Coverage target: ≥60% for `bin/cli/commands/`, ≥75% for `bin/cli/` overall
after Fase 8.
## 12. References
- CLAUDE.md hard rules — especially #11 (publicCreds), #12 (error
sanitization), #13 (shell injection).
- `docs/security/ERROR_SANITIZATION.md` — the only acceptable error shapes.
- `tests/unit/cli-tools-i18n.test.ts` — current i18n infrastructure (pre-`t()`).
- Commander.js docs — Options & subcommand patterns.

114
bin/cli/README.md Normal file
View File

@@ -0,0 +1,114 @@
# bin/cli — OmniRoute CLI internals
This directory contains the CLI runtime, helpers, and commands for the `omniroute` binary.
## Structure
```
bin/cli/
├── CONVENTIONS.md ← normative design rules (read this first)
├── README.md ← this file
├── index.mjs ← central command router (will migrate to Commander in 1.1)
├── args.mjs ← legacy arg parser (replaced by Commander in 1.1)
├── api.mjs ← apiFetch() — all HTTP calls + retry/backoff
├── runtime.mjs ← withRuntime() — server-first / DB-fallback
├── i18n.mjs ← t() — i18n helper + locale detection
├── output.mjs ← emit() — table/json/jsonl/csv + printSuccess/printError
├── io.mjs ← ask() / askSecret() — interactive prompts
├── data-dir.mjs ← resolveDataDir() / resolveStoragePath()
├── sqlite.mjs ← openOmniRouteDb() — DB bootstrap
├── encryption.mjs ← encrypt/decrypt credentials
├── provider-catalog.mjs ← static provider catalog
├── provider-store.mjs ← DB CRUD for provider_connections
├── provider-test.mjs ← testProviderApiKey()
├── settings-store.mjs ← DB CRUD for key_value settings
├── locales/
│ ├── en.json ← English strings
│ └── pt-BR.json ← Portuguese (Brazil) strings
└── commands/
├── setup.mjs
├── doctor.mjs
├── providers.mjs
├── config.mjs
├── status.mjs
├── logs.mjs
└── update.mjs
```
## Key helpers
### `apiFetch(path, opts)` — `api.mjs`
All HTTP calls to the OmniRoute server must go through this wrapper.
```js
import { apiFetch } from "./api.mjs";
const res = await apiFetch("/api/health");
if (!res.ok) await res.assertOk(); // throws ApiError with mapped exit code
const data = await res.json();
```
Options:
- `baseUrl` — override base URL (default: `OMNIROUTE_BASE_URL` env or `localhost:20128`)
- `apiKey` — override API key (default: `OMNIROUTE_API_KEY`)
- `method`, `body`, `headers` — standard fetch options
- `timeout` — per-attempt ms (default: `30000`)
- `retry``false` to disable (default: enabled)
- `retryMax` — total attempts (default: `3`)
- `verbose` — log retry attempts to stderr
### `withRuntime(fn, opts)` — `runtime.mjs`
Provides server-first / DB-fallback transparently.
```js
import { withRuntime } from "./runtime.mjs";
await withRuntime(async (ctx) => {
if (ctx.kind === "http") {
const res = await ctx.api("/v1/providers");
return res.json();
}
return ctx.db.prepare("SELECT * FROM provider_connections").all();
});
```
- `opts.requireServer = true` — throws `ServerOfflineError` (exit 3) if offline
- `opts.preferDb = true` — always use DB (skip server check)
### `t(key, vars)` — `i18n.mjs`
Internationalized strings. Catalog loaded from `locales/{locale}.json`.
```js
import { t } from "./i18n.mjs";
console.log(t("common.serverOffline"));
console.log(t("setup.testFailed", { error: err.message }));
```
Locale detection order: `OMNIROUTE_LANG``LC_ALL``LC_MESSAGES``LANG``en`.
### `emit(data, opts)` — `output.mjs`
Format-aware output. Reads `opts.output` to select table/json/jsonl/csv.
```js
import { emit, printError, EXIT_CODES } from "./output.mjs";
emit(providers, { output: opts.output ?? "table" });
printError("Something went wrong");
process.exit(EXIT_CODES.SERVER_OFFLINE);
```
## Adding a new command
1. Create `bin/cli/commands/your-command.mjs`
2. Export `runYourCommand(argv, context)` (pre-1.1) or `registerYourCommand(program)` (post-1.1)
3. Register in `bin/cli/index.mjs` (pre-1.1) or `bin/cli/program.mjs` (post-1.1)
4. Add strings to both `locales/en.json` and `locales/pt-BR.json`
5. Write test in `tests/unit/cli-your-command.test.ts`
See `CONVENTIONS.md` for exit codes, flag naming, output format, and destructive-action rules.

243
bin/cli/api.mjs Normal file
View File

@@ -0,0 +1,243 @@
import { setTimeout as sleep } from "node:timers/promises";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
baseMs: 500,
maxMs: 8000,
jitter: true,
retryableStatuses: [408, 425, 429, 502, 503, 504],
retryableErrorCodes: [
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"EAI_AGAIN",
"EPIPE",
],
});
const NON_RETRYABLE_ON_MUTATION = new Set([409, 422, 429]);
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export function getBaseUrl(opts = {}) {
if (opts.baseUrl) return stripTrailingSlash(opts.baseUrl);
const envUrl = process.env.OMNIROUTE_BASE_URL;
if (envUrl) return stripTrailingSlash(envUrl);
try {
const configPath = join(resolveDataDir(), "config.json");
if (existsSync(configPath)) {
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
}
} catch {
// Config read failures are not fatal — fall through to default.
}
const port = process.env.PORT || "20128";
return `http://localhost:${port}`;
}
function stripTrailingSlash(value) {
return String(value).replace(/\/+$/, "");
}
function resolveUrl(path, opts) {
if (/^https?:\/\//i.test(path)) return path;
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
}
function buildHeaders(opts) {
const headers = new Headers(opts.headers || {});
if (!headers.has("accept")) headers.set("accept", "application/json");
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
headers.set("content-type", "application/json");
}
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
if (apiKey && !headers.has("authorization")) {
headers.set("authorization", `Bearer ${apiKey}`);
}
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN;
if (cliToken && !headers.has("x-omniroute-cli-token")) {
headers.set("x-omniroute-cli-token", cliToken);
}
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
headers.set("idempotency-key", opts.idempotencyKey);
}
return headers;
}
function serializeBody(body, headers) {
if (body == null) return undefined;
if (typeof body === "string") return body;
if (body instanceof Buffer) return body;
if (body instanceof URLSearchParams) return body;
if (typeof body.pipe === "function") return body; // stream
if (headers.get("content-type")?.includes("application/json")) return JSON.stringify(body);
return JSON.stringify(body);
}
export function computeBackoff(attempt, retryAfterHeader, defaults = RETRY_DEFAULTS) {
if (retryAfterHeader != null) {
const secs = Number.parseFloat(String(retryAfterHeader));
if (Number.isFinite(secs) && secs >= 0) {
return Math.min(secs * 1000, defaults.maxMs);
}
}
const exp = Math.min(defaults.baseMs * 2 ** (attempt - 1), defaults.maxMs);
if (!defaults.jitter) return exp;
const jitter = exp * 0.25 * (Math.random() * 2 - 1);
return Math.max(0, exp + jitter);
}
export function shouldRetryStatus(status, method, opts = {}) {
if (opts.retry === false) return false;
const list = opts.retryableStatuses || RETRY_DEFAULTS.retryableStatuses;
if (!list.includes(status)) return false;
if (MUTATING_METHODS.has(method) && NON_RETRYABLE_ON_MUTATION.has(status)) {
return status === 429 ? Boolean(opts.retryMutationsOn429) : false;
}
return true;
}
export function shouldRetryError(err, opts = {}) {
if (opts.retry === false) return false;
const codes = opts.retryableErrorCodes || RETRY_DEFAULTS.retryableErrorCodes;
if (err?.code && codes.includes(err.code)) return true;
if (err?.name === "AbortError" || /timeout|abort/i.test(err?.message || "")) return true;
return false;
}
export function statusToExitCode(status) {
if (status >= 200 && status < 300) return 0;
if (status === 408) return 124;
if (status === 401 || status === 403) return 4;
if (status === 429) return 5;
if (status === 400 || status === 404 || status === 422) return 2;
if (status >= 500) return 1;
return 1;
}
export class ApiError extends Error {
constructor(message, { status, code, exitCode } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.exitCode = exitCode ?? (status != null ? statusToExitCode(status) : 1);
}
}
async function readResponseBody(res) {
const ct = res.headers.get("content-type") || "";
try {
if (ct.includes("application/json")) return await res.json();
return await res.text();
} catch {
return null;
}
}
function fetchOnce(url, init, timeoutMs) {
if (!timeoutMs) return fetch(url, init);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
const merged = { ...init, signal: ac.signal };
return fetch(url, merged).finally(() => clearTimeout(t));
}
export async function apiFetch(path, opts = {}) {
const method = String(opts.method || "GET").toUpperCase();
const url = resolveUrl(path, opts);
const headers = buildHeaders(opts);
const body = serializeBody(opts.body, headers);
const timeout =
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
const verbose = opts.verbose ?? process.env.OMNIROUTE_VERBOSE === "1";
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetchOnce(url, { method, headers, body }, timeout);
if (res.ok) return enrichResponse(res, opts);
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
if (verbose) {
process.stderr.write(
`[retry ${attempt}/${maxAttempts - 1}] ${method} ${url} → HTTP ${res.status}; wait ${Math.round(delay)}ms\n`
);
}
await sleep(delay);
continue;
}
return enrichResponse(res, opts);
} catch (err) {
lastErr = err;
if (attempt < maxAttempts && shouldRetryError(err, opts)) {
const delay = computeBackoff(attempt, null);
if (verbose) {
process.stderr.write(
`[retry ${attempt}/${maxAttempts - 1}] ${method} ${url}${err.code || err.message}; wait ${Math.round(delay)}ms\n`
);
}
await sleep(delay);
continue;
}
throw normalizeNetworkError(err);
}
}
throw normalizeNetworkError(lastErr);
}
function enrichResponse(res, opts) {
res.exitCode = statusToExitCode(res.status);
res.json = res.json.bind(res);
res.text = res.text.bind(res);
if (!res.ok && !opts.acceptNotOk) {
res.assertOk = async () => {
const payload = await readResponseBody(res);
const message = extractErrorMessage(payload, res.status);
throw new ApiError(message, { status: res.status });
};
} else {
res.assertOk = async () => res;
}
return res;
}
function extractErrorMessage(payload, status) {
if (payload && typeof payload === "object") {
if (typeof payload.error === "string") return payload.error;
if (payload.error?.message) return String(payload.error.message);
if (payload.message) return String(payload.message);
}
if (typeof payload === "string" && payload.length < 200) return payload;
return `HTTP ${status}`;
}
function normalizeNetworkError(err) {
if (err instanceof ApiError) return err;
const code = err?.code || (err?.name === "AbortError" ? "ETIMEDOUT" : undefined);
const exitCode = code === "ETIMEDOUT" ? 124 : 1;
return new ApiError(err?.message || "network error", { code, exitCode });
}
export async function isServerUp(opts = {}) {
try {
const res = await apiFetch("/api/health", {
...opts,
retry: false,
timeout: opts.timeout ?? 1500,
acceptNotOk: true,
});
return res.ok || res.status < 500;
} catch {
return false;
}
}

106
bin/cli/i18n.mjs Normal file
View File

@@ -0,0 +1,106 @@
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = join(__dirname, "locales");
const FALLBACK_LOCALE = "en";
const cache = new Map();
let activeLocale = null;
let fallbackCatalog = null;
export function detectLocale() {
const raw =
process.env.OMNIROUTE_LANG ||
process.env.LC_ALL ||
process.env.LC_MESSAGES ||
process.env.LANG ||
FALLBACK_LOCALE;
return normalize(raw);
}
function normalize(raw) {
const stripped = String(raw).split(".")[0].replace("_", "-");
if (!stripped) return FALLBACK_LOCALE;
if (hasCatalog(stripped)) return stripped;
const base = stripped.split("-")[0];
if (hasCatalog(base)) return base;
return FALLBACK_LOCALE;
}
function hasCatalog(locale) {
return existsSync(join(LOCALES_DIR, `${locale}.json`));
}
function flattenToMap(obj, prefix, result) {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
flattenToMap(value, fullKey, result);
} else if (typeof value === "string") {
result.set(fullKey, value);
}
}
}
function loadCatalog(locale) {
if (cache.has(locale)) return cache.get(locale);
const file = join(LOCALES_DIR, `${locale}.json`);
if (!existsSync(file)) {
cache.set(locale, null);
return null;
}
try {
const parsed = JSON.parse(readFileSync(file, "utf8"));
const flat = new Map();
flattenToMap(parsed, "", flat);
cache.set(locale, flat);
return flat;
} catch {
cache.set(locale, null);
return null;
}
}
export function setLocale(locale) {
activeLocale = normalize(locale);
loadCatalog(activeLocale);
return activeLocale;
}
export function getLocale() {
if (!activeLocale) activeLocale = detectLocale();
return activeLocale;
}
function interpolate(template, vars) {
if (!vars) return template;
const entries = Object.entries(vars);
if (entries.length === 0) return template;
const varMap = new Map(entries);
return template.replace(/\{(\w+)\}/g, (match, name) => {
const v = varMap.get(name);
return v !== undefined ? String(v) : match;
});
}
export function t(key, vars) {
if (!activeLocale) activeLocale = detectLocale();
const primary = loadCatalog(activeLocale);
const fromPrimary = primary?.get(key);
if (fromPrimary !== undefined) return interpolate(fromPrimary, vars);
if (activeLocale !== FALLBACK_LOCALE) {
if (!fallbackCatalog) fallbackCatalog = loadCatalog(FALLBACK_LOCALE);
const fromFallback = fallbackCatalog?.get(key);
if (fromFallback !== undefined) return interpolate(fromFallback, vars);
}
return key;
}
export function resetForTests() {
cache.clear();
activeLocale = null;
fallbackCatalog = null;
}

106
bin/cli/locales/en.json Normal file
View File

@@ -0,0 +1,106 @@
{
"common": {
"error": "Error: {message}",
"serverOffline": "OmniRoute server is offline. Start it with: omniroute serve",
"authRequired": "Authentication required. Set OMNIROUTE_API_KEY or run: omniroute setup",
"rateLimited": "Rate limit exceeded. Retry after {seconds}s.",
"timeout": "Request timed out after {ms}ms.",
"success": "Done.",
"yes": "yes",
"no": "no",
"confirm": "Are you sure? (yes/no)",
"dryRun": "[dry-run] would {action}",
"cancelled": "Cancelled."
},
"setup": {
"title": "OmniRoute Setup",
"passwordPrompt": "Admin password",
"providerPrompt": "Default provider (leave blank to skip)",
"done": "Setup complete",
"passwordSet": "Admin password configured",
"providerSet": "Provider configured: {name}",
"testingProvider": "Testing provider connection: {name}",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed: {error}",
"loginEnabled": "Login: enabled (password updated)",
"loginDisabled": "Login: disabled",
"providerInfo": "Provider: {info}"
},
"doctor": {
"title": "OmniRoute Doctor",
"dbOk": "Database: OK ({path})",
"dbMissing": "Database: not initialized — run `omniroute setup`",
"portOk": "Port {port}: available",
"portConflict": "Port {port}: in use by another process",
"encryptionOk": "Encryption key: configured",
"encryptionMissing": "Encryption key missing — run `omniroute setup`",
"allGood": "All checks passed.",
"warnings": "{count} warning(s) — see above."
},
"providers": {
"title": "Providers",
"noProviders": "No providers configured. Run: omniroute setup",
"testing": "Testing {name}...",
"available": "{count} provider(s) available",
"connected": "Connected",
"disconnected": "Disconnected",
"validationFailed": "Validation failed: {error}"
},
"keys": {
"title": "API Keys",
"added": "Key added for {provider}.",
"removed": "Key removed.",
"listed": "{count} key(s).",
"noKeys": "No keys configured.",
"confirmRemove": "Remove key {id}?"
},
"combo": {
"title": "Combos",
"switched": "Active combo: {name}",
"created": "Combo created: {name}",
"deleted": "Combo deleted: {name}",
"noCombos": "No combos configured.",
"confirmDelete": "Delete combo {name}?"
},
"serve": {
"starting": "Starting OmniRoute server on port {port}...",
"ready": "Ready at http://localhost:{port}",
"stopping": "Stopping server (PID {pid})...",
"stopped": "Server stopped.",
"notRunning": "Server is not running."
},
"backup": {
"title": "Backup",
"creating": "Creating backup...",
"done": "Backup saved to {path}",
"restoring": "Restoring from {path}...",
"restored": "Restore complete.",
"confirmRestore": "Overwrite current data with backup from {ts}?"
},
"update": {
"checking": "Checking for updates...",
"upToDate": "Already up to date ({version}).",
"available": "Update available: {current} → {latest}",
"installing": "Installing {latest}...",
"done": "Updated to {latest}. Restart the server to apply."
},
"health": {
"title": "Health",
"status": "Status: {status}",
"uptime": "Uptime: {uptime}",
"requests": "Requests (24h): {count}",
"cost": "Cost (24h): ${cost}"
},
"mcp": {
"title": "MCP Server",
"running": "MCP server running ({transport})",
"stopped": "MCP server stopped.",
"restarted": "MCP server restarted."
},
"tunnel": {
"title": "Tunnels",
"created": "Tunnel created: {url}",
"stopped": "Tunnel stopped.",
"confirmStop": "Stop tunnel {id}?"
}
}

106
bin/cli/locales/pt-BR.json Normal file
View File

@@ -0,0 +1,106 @@
{
"common": {
"error": "Erro: {message}",
"serverOffline": "Servidor OmniRoute offline. Inicie com: omniroute serve",
"authRequired": "Autenticação necessária. Configure OMNIROUTE_API_KEY ou execute: omniroute setup",
"rateLimited": "Limite de requisições atingido. Tente novamente em {seconds}s.",
"timeout": "Requisição expirou após {ms}ms.",
"success": "Concluído.",
"yes": "sim",
"no": "não",
"confirm": "Tem certeza? (sim/não)",
"dryRun": "[simulação] faria: {action}",
"cancelled": "Cancelado."
},
"setup": {
"title": "Configuração do OmniRoute",
"passwordPrompt": "Senha de administrador",
"providerPrompt": "Provedor padrão (deixe em branco para pular)",
"done": "Configuração concluída",
"passwordSet": "Senha de administrador configurada",
"providerSet": "Provedor configurado: {name}",
"testingProvider": "Testando conexão com provedor: {name}",
"testPassed": "Teste do provedor aprovado",
"testFailed": "Teste do provedor falhou: {error}",
"loginEnabled": "Login: habilitado (senha atualizada)",
"loginDisabled": "Login: desabilitado",
"providerInfo": "Provedor: {info}"
},
"doctor": {
"title": "OmniRoute Doctor",
"dbOk": "Banco de dados: OK ({path})",
"dbMissing": "Banco de dados: não inicializado — execute `omniroute setup`",
"portOk": "Porta {port}: disponível",
"portConflict": "Porta {port}: em uso por outro processo",
"encryptionOk": "Chave de criptografia: configurada",
"encryptionMissing": "Chave de criptografia ausente — execute `omniroute setup`",
"allGood": "Todas as verificações passaram.",
"warnings": "{count} aviso(s) — veja acima."
},
"providers": {
"title": "Provedores",
"noProviders": "Nenhum provedor configurado. Execute: omniroute setup",
"testing": "Testando {name}...",
"available": "{count} provedor(es) disponível(is)",
"connected": "Conectado",
"disconnected": "Desconectado",
"validationFailed": "Validação falhou: {error}"
},
"keys": {
"title": "Chaves de API",
"added": "Chave adicionada para {provider}.",
"removed": "Chave removida.",
"listed": "{count} chave(s).",
"noKeys": "Nenhuma chave configurada.",
"confirmRemove": "Remover chave {id}?"
},
"combo": {
"title": "Combos",
"switched": "Combo ativo: {name}",
"created": "Combo criado: {name}",
"deleted": "Combo excluído: {name}",
"noCombos": "Nenhum combo configurado.",
"confirmDelete": "Excluir combo {name}?"
},
"serve": {
"starting": "Iniciando servidor OmniRoute na porta {port}...",
"ready": "Pronto em http://localhost:{port}",
"stopping": "Parando servidor (PID {pid})...",
"stopped": "Servidor parado.",
"notRunning": "Servidor não está em execução."
},
"backup": {
"title": "Backup",
"creating": "Criando backup...",
"done": "Backup salvo em {path}",
"restoring": "Restaurando de {path}...",
"restored": "Restauração concluída.",
"confirmRestore": "Substituir dados atuais pelo backup de {ts}?"
},
"update": {
"checking": "Verificando atualizações...",
"upToDate": "Já está atualizado ({version}).",
"available": "Atualização disponível: {current} → {latest}",
"installing": "Instalando {latest}...",
"done": "Atualizado para {latest}. Reinicie o servidor para aplicar."
},
"health": {
"title": "Saúde",
"status": "Status: {status}",
"uptime": "Uptime: {uptime}",
"requests": "Requisições (24h): {count}",
"cost": "Custo (24h): ${cost}"
},
"mcp": {
"title": "Servidor MCP",
"running": "Servidor MCP em execução ({transport})",
"stopped": "Servidor MCP parado.",
"restarted": "Servidor MCP reiniciado."
},
"tunnel": {
"title": "Túneis",
"created": "Túnel criado: {url}",
"stopped": "Túnel parado.",
"confirmStop": "Parar túnel {id}?"
}
}

107
bin/cli/output.mjs Normal file
View File

@@ -0,0 +1,107 @@
const MASK_RE = /sk-[A-Za-z0-9]{4,}/g;
export const EXIT_CODES = Object.freeze({
SUCCESS: 0,
ERROR: 1,
INVALID_ARG: 2,
SERVER_OFFLINE: 3,
AUTH: 4,
RATE_LIMIT: 5,
TIMEOUT: 124,
});
export function maskSecret(value) {
if (typeof value !== "string") return value;
return value.replace(MASK_RE, (m) => `${m.slice(0, 5)}***${m.slice(-4)}`);
}
function toRows(data) {
if (Array.isArray(data)) return data;
if (data !== null && typeof data === "object") return [data];
return [{ value: data }];
}
function renderTable(rows) {
if (rows.length === 0) {
process.stdout.write("(empty)\n");
return;
}
const keys = Array.from(
rows.reduce((acc, row) => {
for (const k of Object.keys(row)) acc.add(k);
return acc;
}, new Set())
);
const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => String(r[k] ?? "").length)));
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
const header = keys.map((k, i) => k.padEnd(widths[i])).join(" | ");
process.stdout.write(`${header}\n${sep}\n`);
for (const row of rows) {
const line = keys.map((k, i) => String(row[k] ?? "").padEnd(widths[i])).join(" | ");
process.stdout.write(`${line}\n`);
}
}
function renderCsv(rows) {
if (rows.length === 0) return;
const keys = Object.keys(rows[0]);
process.stdout.write(keys.map(csvEscape).join(",") + "\n");
for (const row of rows) {
process.stdout.write(keys.map((k) => csvEscape(String(row[k] ?? ""))).join(",") + "\n");
}
}
function csvEscape(value) {
if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
export function emit(data, opts = {}) {
const format = opts.output || "table";
const rows = toRows(data);
switch (format) {
case "json":
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
break;
case "jsonl":
for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n");
break;
case "csv":
renderCsv(rows);
break;
default:
renderTable(rows);
}
}
export function printHeading(title, quiet = false) {
if (quiet) return;
process.stderr.write(`\n\x1b[1m\x1b[36m${title}\x1b[0m\n\n`);
}
export function printSuccess(message, quiet = false) {
if (quiet) return;
process.stderr.write(`\x1b[32m✔ ${message}\x1b[0m\n`);
}
export function printInfo(message, quiet = false) {
if (quiet) return;
process.stderr.write(`\x1b[2m${message}\x1b[0m\n`);
}
export function printWarning(message) {
process.stderr.write(`\x1b[33m⚠ ${message}\x1b[0m\n`);
}
export function printError(message) {
process.stderr.write(`\x1b[31m✖ ${message}\x1b[0m\n`);
}
export function exitWith(code, message) {
if (message) printError(message);
process.exit(code);
}

72
bin/cli/runtime.mjs Normal file
View File

@@ -0,0 +1,72 @@
import { apiFetch, isServerUp } from "./api.mjs";
import { openOmniRouteDb } from "./sqlite.mjs";
export class ServerOfflineError extends Error {
constructor(message = "Server is offline and operation requires HTTP runtime") {
super(message);
this.name = "ServerOfflineError";
this.exitCode = 3;
}
}
function makeHttpContext(opts) {
return {
kind: "http",
api: (path, fetchOpts = {}) => apiFetch(path, { ...opts, ...fetchOpts }),
baseUrl: opts.baseUrl,
};
}
async function makeDbContext() {
const { db, dataDir, dbPath } = await openOmniRouteDb();
return {
kind: "db",
db,
dataDir,
dbPath,
close: () => {
try {
db.close();
} catch {
// best-effort
}
},
};
}
export async function withRuntime(fn, opts = {}) {
const requireServer = opts.requireServer === true;
const preferDb = opts.preferDb === true;
if (!preferDb) {
const up = await isServerUp(opts);
if (up) {
return await fn(makeHttpContext(opts));
}
if (requireServer) {
throw new ServerOfflineError();
}
}
const ctx = await makeDbContext();
try {
return await fn(ctx);
} finally {
ctx.close?.();
}
}
export async function withHttp(fn, opts = {}) {
const up = await isServerUp(opts);
if (!up) throw new ServerOfflineError();
return fn(makeHttpContext(opts));
}
export async function withDb(fn) {
const ctx = await makeDbContext();
try {
return await fn(ctx);
} finally {
ctx.close?.();
}
}

View File

@@ -292,6 +292,18 @@ CLI_ALLOW_CONFIG_WRITES=true
CLI_CLAUDE_BIN=/host-cli/bin/claude
```
### CLI Binary (`omniroute`) helpers
These variables tune the `omniroute` CLI binary's own behavior (not the sidecar
detection above).
| Variable | Default | Source File | Description |
| --------------------------- | ---------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_LANG` | _(system)_ | `bin/cli/i18n.mjs` | Force CLI output language. BCP-47 locale (e.g. `en`, `pt-BR`). Overrides system locale env vars (LC_ALL, LC_MESSAGES). |
| `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. |
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
---
## 10. Internal Agent & MCP Integrations

View File

@@ -45,6 +45,7 @@ const IGNORE_FROM_CODE = new Set([
"TZ",
"LANG",
"LC_ALL",
"LC_MESSAGES",
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EXIT_CODES } from "../../bin/cli/output.mjs";
import { statusToExitCode, computeBackoff, RETRY_DEFAULTS } from "../../bin/cli/api.mjs";
import { t, resetForTests, setLocale } from "../../bin/cli/i18n.mjs";
// ─── exit code constants ──────────────────────────────────────────────────────
test("EXIT_CODES has expected values", () => {
assert.equal(EXIT_CODES.SUCCESS, 0);
assert.equal(EXIT_CODES.ERROR, 1);
assert.equal(EXIT_CODES.INVALID_ARG, 2);
assert.equal(EXIT_CODES.SERVER_OFFLINE, 3);
assert.equal(EXIT_CODES.AUTH, 4);
assert.equal(EXIT_CODES.RATE_LIMIT, 5);
assert.equal(EXIT_CODES.TIMEOUT, 124);
});
// ─── statusToExitCode mapping ────────────────────────────────────────────────
test("statusToExitCode maps HTTP statuses correctly", () => {
assert.equal(statusToExitCode(200), 0, "200 → 0");
assert.equal(statusToExitCode(201), 0, "201 → 0");
assert.equal(statusToExitCode(204), 0, "204 → 0");
assert.equal(statusToExitCode(400), 2, "400 → 2 (bad arg)");
assert.equal(statusToExitCode(401), 4, "401 → 4 (auth)");
assert.equal(statusToExitCode(403), 4, "403 → 4 (auth)");
assert.equal(statusToExitCode(404), 2, "404 → 2 (not found)");
assert.equal(statusToExitCode(408), 124, "408 → 124 (timeout)");
assert.equal(statusToExitCode(422), 2, "422 → 2 (validation)");
assert.equal(statusToExitCode(429), 5, "429 → 5 (rate limit)");
assert.equal(statusToExitCode(500), 1, "500 → 1 (server error)");
assert.equal(statusToExitCode(502), 1, "502 → 1 (gateway)");
assert.equal(statusToExitCode(503), 1, "503 → 1 (unavailable)");
assert.equal(statusToExitCode(504), 1, "504 → 1 (gateway timeout)");
});
// ─── retry backoff ────────────────────────────────────────────────────────────
test("computeBackoff respects Retry-After header", () => {
const delay = computeBackoff(1, "10");
assert.ok(delay <= RETRY_DEFAULTS.maxMs, "capped at maxMs");
assert.ok(delay <= 10_000, "respects 10s header");
assert.ok(delay > 0, "positive delay");
});
test("computeBackoff grows exponentially without header", () => {
const d1 = computeBackoff(1, null, { ...RETRY_DEFAULTS, jitter: false });
const d2 = computeBackoff(2, null, { ...RETRY_DEFAULTS, jitter: false });
const d3 = computeBackoff(3, null, { ...RETRY_DEFAULTS, jitter: false });
assert.ok(d2 > d1, "attempt 2 > attempt 1");
assert.ok(d3 >= d2, "attempt 3 >= attempt 2 (may cap)");
assert.ok(d3 <= RETRY_DEFAULTS.maxMs, "capped at maxMs");
});
test("computeBackoff with jitter stays within ±25% of base", () => {
const base = computeBackoff(1, null, { ...RETRY_DEFAULTS, jitter: false });
for (let i = 0; i < 20; i++) {
const jittered = computeBackoff(1, null, RETRY_DEFAULTS);
const tolerance = base * 0.25 + 1;
assert.ok(jittered >= base - tolerance, `jitter too low (${jittered} vs ${base})`);
assert.ok(jittered <= base + tolerance, `jitter too high (${jittered} vs ${base})`);
}
});
// ─── i18n ────────────────────────────────────────────────────────────────────
test("t() returns key for missing locale entry", () => {
resetForTests();
setLocale("en");
const result = t("nonexistent.key.that.does.not.exist");
assert.equal(result, "nonexistent.key.that.does.not.exist");
});
test("t() interpolates variables", () => {
resetForTests();
setLocale("en");
const result = t("common.error", { message: "disk full" });
assert.ok(result.includes("disk full"), `got: ${result}`);
});
test("t() falls back to en for unknown locale", () => {
resetForTests();
setLocale("xx-UNKNOWN");
const result = t("common.success");
assert.ok(result.length > 0 && result !== "common.success", `fallback failed: ${result}`);
});
test("t() supports pt-BR locale", () => {
resetForTests();
setLocale("pt-BR");
const en = (() => {
resetForTests();
setLocale("en");
return t("common.serverOffline");
})();
resetForTests();
setLocale("pt-BR");
const ptBR = t("common.serverOffline");
assert.notEqual(en, ptBR, "pt-BR should differ from en");
assert.ok(ptBR.length > 0 && ptBR !== "common.serverOffline");
});
test("t() does not expose __proto__ traversal", () => {
resetForTests();
setLocale("en");
const result = t("__proto__.polluted");
assert.equal(result, "__proto__.polluted", "should return key unchanged");
});