diff --git a/.env.example b/.env.example index 9b52204332..ecf7f3720d 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/bin/cli/CONVENTIONS.md b/bin/cli/CONVENTIONS.md new file mode 100644 index 0000000000..181f3b2f91 --- /dev/null +++ b/bin/cli/CONVENTIONS.md @@ -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 ` (default 3) — total attempts +- `--timeout ` (default 30000) — per attempt +- `--retry-on ` — 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 ` for extra-safe mutations. + +### Status → exit code map + +| Status | Exit | Retry? | +| --------------- | ---- | ------------------------------ | +| 200–299 | 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. diff --git a/bin/cli/README.md b/bin/cli/README.md new file mode 100644 index 0000000000..7342ae07a4 --- /dev/null +++ b/bin/cli/README.md @@ -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. diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs new file mode 100644 index 0000000000..3f96a9b364 --- /dev/null +++ b/bin/cli/api.mjs @@ -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; + } +} diff --git a/bin/cli/i18n.mjs b/bin/cli/i18n.mjs new file mode 100644 index 0000000000..c4fb7d0973 --- /dev/null +++ b/bin/cli/i18n.mjs @@ -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; +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json new file mode 100644 index 0000000000..4711dd4ca2 --- /dev/null +++ b/bin/cli/locales/en.json @@ -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}?" + } +} diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json new file mode 100644 index 0000000000..b2c0f2c07c --- /dev/null +++ b/bin/cli/locales/pt-BR.json @@ -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}?" + } +} diff --git a/bin/cli/output.mjs b/bin/cli/output.mjs new file mode 100644 index 0000000000..7be42b747d --- /dev/null +++ b/bin/cli/output.mjs @@ -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); +} diff --git a/bin/cli/runtime.mjs b/bin/cli/runtime.mjs new file mode 100644 index 0000000000..ba02d60ee5 --- /dev/null +++ b/bin/cli/runtime.mjs @@ -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?.(); + } +} diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b131d7505c..aed6d13811 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index a69d0f94ef..9f78c0f002 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -45,6 +45,7 @@ const IGNORE_FROM_CODE = new Set([ "TZ", "LANG", "LC_ALL", + "LC_MESSAGES", "CI", "GITHUB_ACTIONS", "RUNNER_OS", diff --git a/tests/unit/cli-exit-codes.test.ts b/tests/unit/cli-exit-codes.test.ts new file mode 100644 index 0000000000..5e467e7433 --- /dev/null +++ b/tests/unit/cli-exit-codes.test.ts @@ -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"); +});