mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
15
.env.example
15
.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)
|
||||
|
||||
204
bin/cli/CONVENTIONS.md
Normal file
204
bin/cli/CONVENTIONS.md
Normal 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? |
|
||||
| --------------- | ---- | ------------------------------ |
|
||||
| 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.
|
||||
114
bin/cli/README.md
Normal file
114
bin/cli/README.md
Normal 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
243
bin/cli/api.mjs
Normal 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
106
bin/cli/i18n.mjs
Normal 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
106
bin/cli/locales/en.json
Normal 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
106
bin/cli/locales/pt-BR.json
Normal 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
107
bin/cli/output.mjs
Normal 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
72
bin/cli/runtime.mjs
Normal 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?.();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,7 @@ const IGNORE_FROM_CODE = new Set([
|
||||
"TZ",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_MESSAGES",
|
||||
"CI",
|
||||
"GITHUB_ACTIONS",
|
||||
"RUNNER_OS",
|
||||
|
||||
110
tests/unit/cli-exit-codes.test.ts
Normal file
110
tests/unit/cli-exit-codes.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user