Merge PR #2280: feat(cli): CLI v4 — Commander.js, 50+ commands, TUI, i18n, plugins (Phases 0-9)

Complete rewrite of the OmniRoute CLI:
- Commander.js-based modular architecture (50+ command files)
- Full i18n support (en + pt-BR, 1222 keys each)
- TUI interactive interface (OAuthFlow, EvalWatch, ProvidersTestAll)
- Plugin system (omniroute-cmd-*)
- OpenAPI codegen (omniroute api <tag> <op>)
- Commands: serve, combo, compression, keys, tunnel, backup, test-provider,
  health, memory, MCP, A2A, oauth, skills, webhooks, usage, cost, eval,
  context-eng, dashboard, doctor, env, files, logs, models, nodes, oneproxy,
  open, openapi, plugin, policy, pricing, providers, quota, registry, repl,
  reset-encrypted-columns, resilience, restart, runtime, sessions, setup,
  simulate, status, stop, stream, sync, tags, telemetry, translator, tray, update
- Code review fixes: C1-C3, I1-I5, M1-M4 applied

# Conflicts:
#	bin/cli/commands/config.mjs
#	bin/omniroute.mjs
#	package-lock.json
#	package.json
This commit is contained in:
diegosouzapw
2026-05-15 10:50:54 -03:00
229 changed files with 30264 additions and 4992 deletions

View File

@@ -61,7 +61,6 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"LICENSE",
"README.md",
"bin/cli-commands.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
@@ -97,8 +96,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"app/server.js",
"app/server-ws.mjs",
"app/responses-ws-proxy.mjs",
"bin/cli-commands.mjs",
"bin/cli/index.mjs",
"bin/cli/program.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Validates that:
* 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json.
* 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections).
* 3. No raw string literals are passed to .description() in commands without going
* through t() — only warns, does not fail hard (many descriptions use || fallback).
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands");
const LOCALES_DIR = join(ROOT, "bin", "cli", "locales");
// Paths that look like t() keys but are actually import paths — skip them.
const IGNORE_AS_KEY = new Set([".", ".."]);
const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/;
function walk(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walk(full));
} else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
results.push(full);
}
}
return results;
}
function flattenKeys(obj, prefix = "") {
const keys = new Set();
for (const [k, v] of Object.entries(obj)) {
const full = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
for (const sub of flattenKeys(v, full)) keys.add(sub);
} else {
keys.add(full);
}
}
return keys;
}
function collectTKeys(files) {
const used = new Set();
const re = /\bt\(\s*["']([^"']+)["']/g;
for (const file of files) {
const src = readFileSync(file, "utf8");
let m;
re.lastIndex = 0;
while ((m = re.exec(src)) !== null) {
const key = m[1];
if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue;
used.add(key);
}
}
return used;
}
function loadJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
const files = walk(COMMANDS_DIR);
const usedKeys = collectTKeys(files);
const en = loadJson(join(LOCALES_DIR, "en.json"));
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
const enKeys = flattenKeys(en);
let errors = 0;
// Check 1: all used keys exist in en.json
const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k));
if (missingInEn.length > 0) {
console.error("[cli-i18n] Keys used in commands but missing in en.json:");
for (const k of missingInEn) console.error(`${k}`);
errors += missingInEn.length;
} else {
console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`);
}
// Check 2: pt-BR.json has the same top-level sections as en.json
const enTopLevel = Object.keys(en);
const ptTopLevel = new Set(Object.keys(ptBR));
const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k));
if (missingTopLevel.length > 0) {
console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:");
for (const k of missingTopLevel) console.error(`${k}`);
errors += missingTopLevel.length;
} else {
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
}
if (errors > 0) {
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
process.exit(1);
} else {
console.log("[cli-i18n] PASS — CLI i18n is consistent");
}

View File

@@ -244,6 +244,18 @@ try {
checkI18nMirrorFile("llm.txt", llmPath);
// CHANGELOG.md mirrors are translations — check version sections and size, not exact content
checkI18nChangelogFile(changelogPath);
// Anti-regression: legacy duplicate docs that have been superseded must not return.
// Use docs/reference/* as the source of truth.
const supersededDocs = [{ legacy: "docs/CLI-TOOLS.md", current: "docs/reference/CLI-TOOLS.md" }];
for (const { legacy, current } of supersededDocs) {
const legacyAbs = path.resolve(cwd, legacy);
if (fs.existsSync(legacyAbs)) {
fail(
`legacy duplicate ${legacy} reappeared — use ${current} instead (single source of truth)`
);
}
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}

View File

@@ -45,6 +45,7 @@ const IGNORE_FROM_CODE = new Set([
"TZ",
"LANG",
"LC_ALL",
"LC_MESSAGES",
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",
@@ -64,6 +65,25 @@ const IGNORE_FROM_CODE = new Set([
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// CLI machine-id token opt-out (server-side flag; not user-configurable via .env).
"OMNIROUTE_DISABLE_CLI_TOKEN",
// update-notifier opt-out for the CLI binary.
"OMNIROUTE_NO_UPDATE_NOTIFIER",
// Platform / OS detection vars read by CLI environment helper (bin/cli/utils/environment.mjs).
// These are external signals set by the host OS or cloud provider — not OmniRoute config.
"CODESPACES",
"GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN",
"GITPOD_WORKSPACE_ID",
"NO_COLOR",
"REPL_ID",
"REPL_SLUG",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
"OPENAPI_SPEC",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Generates bin/cli/api-commands/<tag>.mjs from the OpenAPI spec.
* Run: npm run build:cli-api
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import yaml from "js-yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/reference/openapi.yaml");
const OUT_DIR = join(ROOT, "bin/cli/api-commands");
// Operations already covered by hand-crafted commands — skip in generated output.
const IGNORED_OP_IDS = new Set([
"createChatCompletion",
"streamChatCompletion",
"listModels",
"getModel",
"createEmbedding",
"createImage",
"createImageEdit",
"createImageVariation",
"createTranscription",
"createSpeech",
"createModeration",
]);
function kebab(s) {
return s
.replace(/([A-Z])/g, (m) => "-" + m.toLowerCase())
.replace(/^-/, "")
.replace(/_/g, "-")
.replace(/--+/g, "-");
}
function camelCase(s) {
return s.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
}
function escapeStr(s) {
return String(s || "")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.slice(0, 150);
}
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const spec = yaml.load(readFileSync(SPEC_PATH, "utf8"));
/** @type {Record<string, Array<{path: string, method: string, opId: string, op: object}>>} */
const byTag = {};
for (const [path, methods] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(methods)) {
if (["parameters", "summary", "description", "servers"].includes(method)) continue;
if (typeof op !== "object" || op === null) continue;
if (IGNORED_OP_IDS.has(op.operationId)) continue;
const rawTag = op.tags?.[0] || "uncategorized";
const tag = rawTag
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const opId = op.operationId || `${method}-${path.replace(/[^a-z0-9]/gi, "-")}`;
byTag[tag] = byTag[tag] || [];
byTag[tag].push({ path, method, opId, op });
}
}
const generatedTags = [];
for (const [tag, ops] of Object.entries(byTag)) {
const fnName = `register_${tag.replace(/-/g, "_")}`;
const lines = [
`// AUTO-GENERATED from ${SPEC_PATH.replace(ROOT + "/", "")}. Do not edit.`,
`import { apiFetch } from "../api.mjs";`,
`import { emit } from "../output.mjs";`,
`import { readFileSync } from "node:fs";`,
``,
`export function ${fnName}(parent) {`,
` const tag = parent.command("${tag}").description("${escapeStr(ops[0]?.op?.tags?.[0] || tag)} endpoints");`,
];
for (const { path, method, opId, op } of ops) {
const cmdName = kebab(opId);
const params = op.parameters || [];
const pathParams = params.filter((p) => p.in === "path");
const queryParams = params.filter((p) => p.in === "query");
const hasBody = !!op.requestBody;
const summary = escapeStr(op.summary || op.description || cmdName);
lines.push(` tag.command("${cmdName}")`);
lines.push(` .description("${summary}")`);
for (const p of pathParams) {
lines.push(
` .requiredOption("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`
);
}
for (const p of queryParams) {
const flag = p.required ? "requiredOption" : "option";
lines.push(` .${flag}("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`);
}
if (hasBody) {
lines.push(` .option("--body <jsonOrPath>", "JSON body or @path/to/file.json")`);
}
lines.push(` .action(async (opts, cmd) => {`);
lines.push(` const gOpts = cmd.optsWithGlobals();`);
// Build URL with path param substitution
lines.push(` let url = "${path}";`);
for (const p of pathParams) {
lines.push(
` url = url.replace("{${p.name}}", encodeURIComponent(opts.${camelCase(kebab(p.name))} ?? ""));`
);
}
// Build query string from query params
if (queryParams.length > 0) {
lines.push(` const qs = new URLSearchParams();`);
for (const p of queryParams) {
const optName = camelCase(kebab(p.name));
lines.push(
` if (opts.${optName} != null) qs.set("${p.name}", String(opts.${optName}));`
);
}
lines.push(` if (qs.toString()) url += "?" + qs.toString();`);
}
// Body handling
if (hasBody) {
lines.push(` let body;`);
lines.push(` if (opts.body) {`);
lines.push(` body = opts.body.startsWith("@")`);
lines.push(` ? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))`);
lines.push(` : JSON.parse(opts.body);`);
lines.push(` }`);
}
const bodyArg = hasBody ? ", body" : "";
lines.push(
` const res = await apiFetch(url, { method: "${method.toUpperCase()}"${hasBody ? ", body" : ""}, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });`
);
lines.push(` const data = res.ok ? await res.json() : await res.text();`);
lines.push(` emit(data, gOpts);`);
lines.push(` });`);
}
lines.push(`}`);
const content = lines.join("\n") + "\n";
writeFileSync(join(OUT_DIR, `${tag}.mjs`), content);
generatedTags.push(tag);
console.log(`[generate] ${tag}.mjs — ${ops.length} operations`);
}
// Generate registry
const registryLines = [
`// AUTO-GENERATED. Do not edit.`,
...generatedTags.map((t) => `import { register_${t.replace(/-/g, "_")} } from "./${t}.mjs";`),
``,
`export const API_TAGS = ${JSON.stringify(generatedTags)};`,
``,
`export function registerApiCommands(program) {`,
` const api = program`,
` .command("api")`,
` .description("Direct REST API access (generated from OpenAPI spec)");`,
` api`,
` .command("tags")`,
` .description("List available API tag groups")`,
` .action(() => { API_TAGS.forEach((t) => console.log(t)); });`,
...generatedTags.map((t) => ` register_${t.replace(/-/g, "_")}(api);`),
`}`,
];
writeFileSync(join(OUT_DIR, "registry.mjs"), registryLines.join("\n") + "\n");
console.log(`[generate] registry.mjs — ${generatedTags.length} tags`);
console.log("[generate] Done.");