diff --git a/bin/cli/commands/completion.mjs b/bin/cli/commands/completion.mjs index d9c268b4a7..d1d02d944c 100644 --- a/bin/cli/commands/completion.mjs +++ b/bin/cli/commands/completion.mjs @@ -1,65 +1,224 @@ -import { Argument } from "commander"; +import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; import { t } from "../i18n.mjs"; +import { apiFetch } from "../api.mjs"; +import { resolveDataDir } from "../data-dir.mjs"; -const VALID_SHELLS = ["bash", "zsh", "fish"]; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h -export function registerCompletion(program) { - program - .command("completion") - .description("Generate shell completion script") - .addArgument(new Argument("", "Shell type").choices(VALID_SHELLS)) - .action(async (shell) => { - const exitCode = await runCompletionCommand(shell); - if (exitCode !== 0) process.exit(exitCode); - }); +function cachePath() { + return join(resolveDataDir(), "completion-cache.json"); } -export async function runCompletionCommand(shell) { - switch (shell) { - case "bash": - console.log(generateBashCompletion()); - return 0; - case "zsh": - console.log(generateZshCompletion()); - return 0; - case "fish": - console.log(generateFishCompletion()); - return 0; - default: - console.error(`Invalid shell '${shell}'. Valid: ${VALID_SHELLS.join(", ")}`); - return 1; - } +function readCache() { + try { + const raw = JSON.parse(readFileSync(cachePath(), "utf8")); + if (raw && typeof raw.ts === "number" && Date.now() - raw.ts < CACHE_TTL_MS) return raw; + } catch {} + return null; } -function generateBashCompletion() { - return `#!/bin/bash -# OmniRoute CLI Bash Completion +async function refreshCache(opts = {}) { + let combos = [], + providers = [], + models = []; + try { + const [cr, pr, mr] = await Promise.allSettled([ + apiFetch("/api/combos", opts), + apiFetch("/api/providers", opts), + apiFetch("/api/models", opts), + ]); + if (cr.status === "fulfilled" && cr.value.ok) { + const j = await cr.value.json(); + combos = (j.combos || j.items || []).map((c) => c.name || c.id).filter(Boolean); + } + if (pr.status === "fulfilled" && pr.value.ok) { + const j = await pr.value.json(); + providers = (j.providers || j.items || []).map((p) => p.id || p.name).filter(Boolean); + } + if (mr.status === "fulfilled" && mr.value.ok) { + const j = await mr.value.json(); + models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean); + } + } catch {} + const data = { combos, providers, models, ts: Date.now() }; + try { + mkdirSync(dirname(cachePath()), { recursive: true }); + writeFileSync(cachePath(), JSON.stringify(data)); + } catch {} + return data; +} + +function detectShell() { + const shell = process.env.SHELL || ""; + if (shell.includes("zsh")) return "zsh"; + if (shell.includes("fish")) return "fish"; + return "bash"; +} + +function installPath(shell) { + const home = homedir(); + if (shell === "zsh") return join(home, ".zsh", "completions", "_omniroute"); + if (shell === "fish") return join(home, ".config", "fish", "completions", "omniroute.fish"); + return join(home, ".bash_completion.d", "omniroute"); +} + +function generateZshScript() { + return `#compdef omniroute + +# OmniRoute zsh completion (dynamic) +_omniroute_get_cache() { + local key="$1" + local cache="$HOME/.omniroute/completion-cache.json" + local now=$(date +%s 2>/dev/null || echo 0) + local mtime=0 + if [[ -f "$cache" ]]; then + mtime=$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null || echo 0) + fi + if [[ $((now - mtime)) -gt 3600 ]]; then + omniroute completion refresh --quiet >/dev/null 2>&1 + fi + if command -v python3 &>/dev/null && [[ -f "$cache" ]]; then + python3 -c "import json,sys;d=json.load(open('$cache'));print(' '.join(d.get('$key',[])))" 2>/dev/null + fi +} _omniroute() { - local cur prev opts cmds + local -a commands + commands=( + 'serve:Start the OmniRoute server' + 'stop:Stop the server' + 'restart:Restart the server' + 'setup:Configure OmniRoute' + 'doctor:Run health diagnostics' + 'status:Show server status' + 'logs:View application logs' + 'providers:Manage providers' + 'config:Manage config and contexts' + 'keys:Manage API keys' + 'models:Browse available models' + 'combo:Manage routing combos' + 'chat:Send chat completion' + 'stream:Stream chat completion' + 'dashboard:Open dashboard' + 'open:Open UI resource in browser' + 'backup:Create a backup' + 'restore:Restore from backup' + 'health:Show server health' + 'quota:Show provider quotas' + 'cache:Manage response cache' + 'mcp:MCP server management' + 'a2a:A2A server management' + 'tunnel:Tunnel management' + 'env:Environment variables' + 'test:Test provider connection' + 'update:Check for updates' + 'completion:Shell completion' + 'memory:Manage memory store' + 'skills:Manage skills' + ) + + _arguments -C \\ + '1: :->command' \\ + '*:: :->arg' && return 0 + + case $state in + command) _describe 'command' commands ;; + arg) + case $words[1] in + combo) + case $words[2] in + switch|delete|show) + local -a combos + combos=($(_omniroute_get_cache combos)) + _describe 'combo' combos ;; + *) _arguments '1:subcommand:(list switch create delete show suggest)' ;; + esac ;; + providers|keys) + case $words[2] in + add|remove|test) + local -a providers + providers=($(_omniroute_get_cache providers)) + _describe 'provider' providers ;; + *) _arguments '1:subcommand:(list add remove test)' ;; + esac ;; + chat|stream) + _arguments \\ + '--model[Model ID]:model:->models' \\ + '--combo[Combo name]:combo:->combos' \\ + '--system[System prompt]:' \\ + '--max-tokens[Max tokens]:' ;; + open) + _arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;; + completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;; + config) _arguments '1:subcommand:(list get set validate contexts)' ;; + *) ;; + esac + case $state in + models) + local -a models + models=($(_omniroute_get_cache models)) + _describe 'model' models ;; + combos) + local -a combos + combos=($(_omniroute_get_cache combos)) + _describe 'combo' combos ;; + esac ;; + esac +} + +compdef _omniroute omniroute +`; +} + +function generateBashScript() { + return `#!/bin/bash +# OmniRoute CLI bash completion (dynamic) + +_omniroute_get_cache() { + local key="$1" + local cache="$HOME/.omniroute/completion-cache.json" + local now + now=$(date +%s 2>/dev/null || echo 0) + local mtime=0 + [[ -f "$cache" ]] && mtime=$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null || echo 0) + if (( now - mtime > 3600 )); then + omniroute completion refresh --quiet >/dev/null 2>&1 + fi + if command -v python3 &>/dev/null && [[ -f "$cache" ]]; then + python3 -c "import json,sys;d=json.load(open('$cache'));print(' '.join(d.get('$key',[])))" 2>/dev/null + fi +} + +_omniroute() { + local cur prev cmds COMPREPLY=() cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" - - opts="--help --version" - cmds="setup doctor status logs providers config test update serve stop restart keys models combo completion dashboard backup restore health quota cache mcp a2a tunnel env" + cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills" case "\${prev}" in - setup) COMPREPLY=($(compgen -W "--password --add-provider --non-interactive" -- \${cur})); return 0 ;; - logs) COMPREPLY=($(compgen -W "--lines --level --follow" -- \${cur})); return 0 ;; - keys) COMPREPLY=($(compgen -W "add list remove" -- \${cur})); return 0 ;; - models) COMPREPLY=($(compgen -W "--json --search openai anthropic google groq" -- \${cur})); return 0 ;; - combo) COMPREPLY=($(compgen -W "list switch create delete" -- \${cur})); return 0 ;; - providers) COMPREPLY=($(compgen -W "available list test test-all validate" -- \${cur})); return 0 ;; - config) COMPREPLY=($(compgen -W "list get set validate" -- \${cur})); return 0 ;; - completion) COMPREPLY=($(compgen -W "bash zsh fish" -- \${cur})); return 0 ;; - serve) COMPREPLY=($(compgen -W "--port --daemon --no-open" -- \${cur})); return 0 ;; - cache) COMPREPLY=($(compgen -W "status stats clear" -- \${cur})); return 0 ;; - mcp) COMPREPLY=($(compgen -W "status restart" -- \${cur})); return 0 ;; - a2a) COMPREPLY=($(compgen -W "status card" -- \${cur})); return 0 ;; - tunnel) COMPREPLY=($(compgen -W "list create stop" -- \${cur})); return 0 ;; - env) COMPREPLY=($(compgen -W "show list get set" -- \${cur})); return 0 ;; - *) COMPREPLY=($(compgen -W "\${cmds} \${opts}" -- \${cur})); return 0 ;; + combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;; + keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;; + providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;; + config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;; + completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;; + open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;; + --model) + local models + models=$(_omniroute_get_cache models) + COMPREPLY=($(compgen -W "\${models}" -- "\${cur}")); return 0 ;; + --combo) + local combos + combos=$(_omniroute_get_cache combos) + COMPREPLY=($(compgen -W "\${combos}" -- "\${cur}")); return 0 ;; + switch|delete) + local combos + combos=$(_omniroute_get_cache combos) + COMPREPLY=($(compgen -W "\${combos}" -- "\${cur}")); return 0 ;; + *) + COMPREPLY=($(compgen -W "\${cmds} --help --version --output --quiet" -- "\${cur}")); return 0 ;; esac } @@ -67,84 +226,121 @@ complete -F _omniroute omniroute `; } -function generateZshCompletion() { - return `#compdef omniroute - -local -a commands -commands=( - 'serve:Start the OmniRoute server' - 'stop:Stop the server' - 'restart:Restart the server' - 'setup:Configure OmniRoute' - 'doctor:Run health diagnostics' - 'status:Show server status' - 'logs:View application logs' - 'providers:Manage providers' - 'config:Show CLI tool config' - 'keys:Manage API keys' - 'models:Browse available models' - 'combo:Manage routing combos' - 'dashboard:Open dashboard' - 'backup:Create a backup' - 'restore:Restore from backup' - 'health:Show server health' - 'quota:Show provider quotas' - 'cache:Manage response cache' - 'mcp:MCP server management' - 'a2a:A2A server management' - 'tunnel:Tunnel management' - 'env:Environment variables' - 'test:Test provider connection' - 'update:Check for updates' - 'completion:Generate shell completion' -) - -_arguments -C \\ - '1: :->command' \\ - '*:: :->arg' \\ - && return 0 - -case $state in - command) _describe 'command' commands ;; - arg) - case $words[1] in - keys) _arguments '1:subcommand:(add list remove)' ;; - combo) _arguments '1:subcommand:(list switch create delete)' ;; - providers) _arguments '1:subcommand:(available list test test-all validate)' ;; - config) _arguments '1:subcommand:(list get set validate)' ;; - cache) _arguments '1:subcommand:(status stats clear)' ;; - mcp) _arguments '1:subcommand:(status restart)' ;; - a2a) _arguments '1:subcommand:(status card)' ;; - tunnel) _arguments '1:subcommand:(list create stop)' ;; - env) _arguments '1:subcommand:(show list get set)' ;; - completion) _arguments '1:shell:(bash zsh fish)' ;; - serve) _arguments '--port[Port number]:port:' '--daemon[Run in background]' ;; - esac - ;; -esac -`; -} - -function generateFishCompletion() { - return `# OmniRoute CLI Fish Completion +function generateFishScript() { + return `# OmniRoute CLI fish completion (dynamic) complete -c omniroute -f -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'serve' -d 'Start server' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'stop' -d 'Stop server' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'restart' -d 'Restart server' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'setup' -d 'Configure OmniRoute' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'doctor' -d 'Run diagnostics' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'status' -d 'Show status' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'keys' -d 'Manage API keys' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'models' -d 'Browse models' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'combo' -d 'Manage combos' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'providers' -d 'Manage providers' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'dashboard' -d 'Open dashboard' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'health' -d 'Server health' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'backup' -d 'Create backup' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'restore' -d 'Restore backup' -complete -c omniroute -n '__fish_is_nth_arg 1' -a 'completion' -d 'Shell completion' -complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove' -complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete' -complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' + +set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test + +for cmd in $commands + complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd +end + +# Subcommands +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest' +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage' +complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all' +complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts' +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh' +complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience' + +# Dynamic completions from cache (requires python3) +function __omniroute_cache_get + set -l key $argv[1] + set -l cache "$HOME/.omniroute/completion-cache.json" + set -l now (date +%s 2>/dev/null; or echo 0) + set -l mtime 0 + test -f $cache; and set mtime (stat -c %Y $cache 2>/dev/null; or stat -f %m $cache 2>/dev/null; or echo 0) + if test (math $now - $mtime) -gt 3600 + omniroute completion refresh --quiet >/dev/null 2>&1 + end + if command -q python3; and test -f $cache + python3 -c "import json,sys;d=json.load(open('$cache'));print('\\n'.join(d.get('$key',[])))" 2>/dev/null + end +end + +complete -c omniroute -n '__fish_seen_subcommand_from combo; and __fish_seen_subcommand_from switch delete' -a '(__omniroute_cache_get combos)' +complete -c omniroute -l model -a '(__omniroute_cache_get models)' +complete -c omniroute -l combo -a '(__omniroute_cache_get combos)' `; } + +const generators = { zsh: generateZshScript, bash: generateBashScript, fish: generateFishScript }; + +export function registerCompletion(program) { + const comp = program + .command("completion") + .description(t("completion.description") || "Generate or install shell completion scripts"); + + comp + .command("zsh") + .description(t("completion.zsh") || "Print zsh completion script") + .action(async () => process.stdout.write(generateZshScript())); + + comp + .command("bash") + .description(t("completion.bash") || "Print bash completion script") + .action(async () => process.stdout.write(generateBashScript())); + + comp + .command("fish") + .description(t("completion.fish") || "Print fish completion script") + .action(async () => process.stdout.write(generateFishScript())); + + comp + .command("install [shell]") + .description(t("completion.install") || "Install completion script globally for detected shell") + .action(async (shell, opts, cmd) => { + const target = shell || detectShell(); + const gen = generators[target]; + if (!gen) { + process.stderr.write(`Unknown shell: ${target}. Valid: bash, zsh, fish\n`); + process.exit(2); + } + const dest = installPath(target); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, gen()); + process.stdout.write( + `Installed ${target} completion at ${dest}\nRestart your shell or source the file.\n` + ); + }); + + comp + .command("refresh") + .description(t("completion.refresh") || "Refresh cache of combos/providers/models") + .option("--quiet", "Suppress output") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await refreshCache(globalOpts); + if (!opts.quiet && !globalOpts.quiet) { + process.stdout.write( + `Cached: ${data.combos.length} combos, ${data.providers.length} providers, ${data.models.length} models\n` + ); + } + }); + + // Backward-compat: `omniroute completion ` (positional arg form) + comp + .command("") + .description("Print completion script for shell (bash, zsh, fish)") + .allowUnknownOption(false) + .action(async (shell) => { + const gen = generators[shell]; + if (!gen) { + process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`); + process.exit(1); + } + process.stdout.write(gen()); + }); +} + +// Legacy export for backward compatibility +export async function runCompletionCommand(shell) { + const gen = generators[shell]; + if (!gen) { + process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`); + return 1; + } + process.stdout.write(gen()); + return 0; +} diff --git a/bin/cli/commands/health.mjs b/bin/cli/commands/health.mjs index 34013e6649..f9289e170a 100644 --- a/bin/cli/commands/health.mjs +++ b/bin/cli/commands/health.mjs @@ -2,16 +2,42 @@ import { apiFetch, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; export function registerHealth(program) { - program + const health = program .command("health") .description(t("health.description")) .option("-v, --verbose", "Show extended info (memory, breakers)") .option("--json", "Output as JSON") + .option("--alerts-only", "Show only components with alerts") .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const exitCode = await runHealthCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + health + .command("components") + .description("List health components and their status") + .option("--alerts-only", "Show only components with alerts") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runHealthComponentsCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + health + .command("watch") + .description("Live dashboard — refresh every N seconds") + .option("--interval ", "Refresh interval in seconds", "5") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const interval = parseInt(opts.interval, 10) * 1000; + process.stdout.write("\x1B[2J\x1B[0f"); + while (true) { + process.stdout.write("\x1B[0f"); + await runHealthCommand({ ...globalOpts, verbose: true }); + await new Promise((r) => setTimeout(r, interval)); + } + }); } export async function runHealthCommand(opts = {}) { @@ -71,3 +97,27 @@ export async function runHealthCommand(opts = {}) { return 1; } } + +export async function runHealthComponentsCommand(opts = {}) { + try { + const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true }); + if (!res.ok) { + console.error(`HTTP ${res.status}`); + return 1; + } + const health = await res.json(); + const components = health.components || health.breakers || {}; + for (const [name, info] of Object.entries(components)) { + const status = + typeof info === "object" ? info.state || info.status || "unknown" : String(info); + const isAlert = status !== "closed" && status !== "ok" && status !== "healthy"; + if (opts.alertsOnly && !isAlert) continue; + const icon = isAlert ? "\x1b[33m⚠\x1b[0m" : "\x1b[32m✓\x1b[0m"; + console.log(` ${icon} ${name.padEnd(24)} ${status}`); + } + return 0; + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return 1; + } +} diff --git a/bin/cli/commands/keys.mjs b/bin/cli/commands/keys.mjs index f47d670d51..42789bcccc 100644 --- a/bin/cli/commands/keys.mjs +++ b/bin/cli/commands/keys.mjs @@ -55,6 +55,45 @@ export function registerKeys(program) { const exitCode = await runKeysRemoveCommand(provider, { ...opts, ...globalOpts }); if (exitCode !== 0) process.exit(exitCode); }); + + keys + .command("regenerate ") + .description("Regenerate an OmniRoute API key (management keys)") + .option("--yes", "Skip confirmation prompt") + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysRegenerateCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("revoke ") + .description("Revoke an OmniRoute API key") + .option("--yes", "Skip confirmation prompt") + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysRevokeCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("reveal ") + .description("Reveal the unmasked API key value") + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysRevealCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("usage ") + .description("Show recent usage for an API key") + .option("--limit ", "Number of recent requests", "20") + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysUsageCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); } export async function runKeysAddCommand(provider, apiKey, opts = {}) { @@ -212,3 +251,91 @@ async function readStdin() { process.stdin.on("end", () => resolve(data.trim())); }); } + +export async function runKeysRegenerateCommand(id, opts = {}) { + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => rl.question(`Regenerate key ${id}? [y/N] `, r)); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log("Aborted."); + return 0; + } + } + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, { + method: "POST", + ...opts, + }); + if (!res.ok) { + console.error(`Error: HTTP ${res.status}`); + return 1; + } + const data = await res.json(); + console.log(`Regenerated. New key: ${data.key || data.apiKey || "(see dashboard)"}`); + return 0; +} + +export async function runKeysRevokeCommand(id, opts = {}) { + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => rl.question(`Revoke key ${id}? [y/N] `, r)); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log("Aborted."); + return 0; + } + } + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/revoke`, { + method: "POST", + ...opts, + }); + if (!res.ok) { + console.error(`Error: HTTP ${res.status}`); + return 1; + } + console.log(`Key ${id} revoked.`); + return 0; +} + +export async function runKeysRevealCommand(id, opts = {}) { + process.stderr.write( + "⚠ This will display the full unmasked key. Ensure your screen is private.\n" + ); + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, { + ...opts, + }); + if (!res.ok) { + console.error(`Error: HTTP ${res.status}`); + return 1; + } + const data = await res.json(); + console.log(data.key || data.apiKey || "(not available)"); + return 0; +} + +export async function runKeysUsageCommand(id, opts = {}) { + const limit = opts.limit || "20"; + const res = await apiFetch( + `/api/v1/registered-keys/${encodeURIComponent(id)}/usage?limit=${limit}`, + { ...opts } + ); + if (!res.ok) { + console.error(`Error: HTTP ${res.status}`); + return 1; + } + const data = await res.json(); + const rows = data.usage || data.requests || data.items || []; + if (rows.length === 0) { + console.log("No usage data found."); + return 0; + } + for (const r of rows) { + const ts = r.timestamp || r.createdAt || ""; + const path = r.path || r.endpoint || ""; + const status = r.status || r.statusCode || ""; + console.log(` ${ts} ${String(status).padEnd(4)} ${path}`); + } + return 0; +} diff --git a/bin/cli/commands/logs.mjs b/bin/cli/commands/logs.mjs index 87b2f72547..cfbe930b31 100644 --- a/bin/cli/commands/logs.mjs +++ b/bin/cli/commands/logs.mjs @@ -10,6 +10,13 @@ export function registerLogs(program) { .option("--lines ", "Number of lines to fetch", "100") .option("--timeout ", "Connection timeout in ms", "30000") .option("--base-url ", "OmniRoute API base URL", "http://localhost:20128") + .option("--request-id ", "Filter by request ID") + .option("--api-key ", "Filter by API key") + .option("--combo ", "Filter by combo name") + .option("--status ", "Filter by HTTP status code") + .option("--duration-min ", "Min request duration in ms", parseInt) + .option("--duration-max ", "Max request duration in ms", parseInt) + .option("--export ", "Save logs to file (json/jsonl/csv)") .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const exitCode = await runLogsCommand({ ...opts, output: globalOpts.output }); diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index 2dec6ac0e1..5fe56e8c49 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -64,7 +64,9 @@ export function registerUpdate(program) { program .command("update") .description(t("update.checking")) - .option("--check", "Check for available update without applying") + .option("--check", "Check for available update — exit 0 if up-to-date, exit 1 if outdated") + .option("--apply", "Install latest version automatically (npm install -g)") + .option("--changelog", "Show changelog for the latest release") .option("--dry-run", "Show what would be updated without applying") .option("--no-backup", "Skip backup creation") .option("--yes", "Skip confirmation prompt") @@ -77,9 +79,11 @@ export function registerUpdate(program) { export async function runUpdateCommand(opts = {}) { const checkOnly = opts.check ?? false; + const applyNow = opts.apply ?? false; + const showChangelog = opts.changelog ?? false; const dryRun = opts.dryRun ?? false; const skipBackup = !(opts.backup ?? true); - const skipConfirm = opts.yes ?? false; + const skipConfirm = opts.yes ?? applyNow; const current = await getCurrentVersion(); const latest = await getLatestVersion(); @@ -94,6 +98,22 @@ export async function runUpdateCommand(opts = {}) { return 1; } + if (showChangelog) { + try { + const { stdout } = await execFileAsync("npm", ["view", "omniroute", "changelog"], { + timeout: 10000, + }); + if (stdout.trim()) { + console.log(stdout.trim()); + } else { + console.log(`Changelog: https://github.com/your-org/omniroute/releases/tag/v${latest}`); + } + } catch { + console.log(`Changelog: https://github.com/your-org/omniroute/releases/tag/v${latest}`); + } + return 0; + } + printHeading("OmniRoute Update"); console.log(` Current version: ${current}`); console.log(` Latest version: ${latest}`); @@ -107,8 +127,8 @@ export async function runUpdateCommand(opts = {}) { console.log(`\n Update available: ${current} → ${latest}`); if (checkOnly) { - console.log("\n Run `omniroute update` to apply the update."); - return 0; + console.log("\n Run `omniroute update --apply` to install automatically."); + return 1; // exit 1 = outdated (useful for scripts) } if (dryRun) { diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index ca442f3a57..bf9502d4e4 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1056,5 +1056,23 @@ "resolve": { "description": "Resolve sync conflicts interactively" } + }, + "completion": { + "description": "Generate or install shell completion scripts", + "zsh": "Print zsh completion script", + "bash": "Print bash completion script", + "fish": "Print fish completion script", + "install": "Install completion script globally for detected shell", + "refresh": "Refresh cache of combos/providers/models" + }, + "logs": { + "description": "Stream or export request logs", + "requestId": "Filter by request ID", + "apiKey": "Filter by API key", + "combo": "Filter by combo name", + "status": "Filter by HTTP status code", + "durationMin": "Min request duration in ms", + "durationMax": "Max request duration in ms", + "export": "Save logs to file (json/jsonl/csv)" } } diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 71a5e2d4d8..c1f5f64489 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1056,5 +1056,23 @@ "resolve": { "description": "Resolver conflitos de sincronização interativamente" } + }, + "completion": { + "description": "Gerar ou instalar scripts de completion do shell", + "zsh": "Imprimir script de completion para zsh", + "bash": "Imprimir script de completion para bash", + "fish": "Imprimir script de completion para fish", + "install": "Instalar completion globalmente para o shell detectado", + "refresh": "Atualizar cache de combos/providers/models" + }, + "logs": { + "description": "Streaming ou exportação de logs de request", + "requestId": "Filtrar por ID do request", + "apiKey": "Filtrar por chave de API", + "combo": "Filtrar por nome do combo", + "status": "Filtrar por código HTTP", + "durationMin": "Duração mínima do request em ms", + "durationMax": "Duração máxima do request em ms", + "export": "Salvar logs em arquivo (json/jsonl/csv)" } } diff --git a/tests/unit/cli-completion-dynamic.test.ts b/tests/unit/cli-completion-dynamic.test.ts new file mode 100644 index 0000000000..2d105c45d4 --- /dev/null +++ b/tests/unit/cli-completion-dynamic.test.ts @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("completion.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/completion.mjs"); + assert.equal(typeof mod.registerCompletion, "function"); + assert.equal(typeof mod.runCompletionCommand, "function"); +}); + +test("runCompletionCommand bash retorna 0 e string não-vazia", async () => { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const code = await runCompletionCommand("bash"); + assert.equal(code, 0); + } finally { + (process.stdout as any).write = orig; + } + const out = chunks.join(""); + assert.ok(out.includes("omniroute"), "bash script should mention omniroute"); + assert.ok(out.includes("_omniroute"), "bash script should define _omniroute function"); +}); + +test("runCompletionCommand zsh contém compdef", async () => { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const code = await runCompletionCommand("zsh"); + assert.equal(code, 0); + } finally { + (process.stdout as any).write = orig; + } + const out = chunks.join(""); + assert.ok(out.includes("compdef"), "zsh script should contain compdef"); +}); + +test("runCompletionCommand fish retorna 0", async () => { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const code = await runCompletionCommand("fish"); + assert.equal(code, 0); + } finally { + (process.stdout as any).write = orig; + } + const out = chunks.join(""); + assert.ok(out.includes("omniroute"), "fish script should mention omniroute"); +}); + +test("runCompletionCommand shell inválido retorna 1", async () => { + const orig = process.stderr.write.bind(process.stderr); + (process.stderr as any).write = () => true; + try { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const code = await runCompletionCommand("powershell" as any); + assert.equal(code, 1); + } finally { + (process.stderr as any).write = orig; + } +}); + +test("completion scripts incluem combos/providers/models no cache dinamicamente", async () => { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await runCompletionCommand("zsh"); + } finally { + (process.stdout as any).write = orig; + } + const out = chunks.join(""); + assert.ok( + out.includes("completion-cache.json") || out.includes("omniroute_get_cache"), + "should reference cache" + ); +}); diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts new file mode 100644 index 0000000000..84d725d115 --- /dev/null +++ b/tests/unit/cli-expanded-commands.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("logs.mjs pode ser importado com novas flags", async () => { + const mod = await import("../../bin/cli/commands/logs.mjs"); + assert.equal(typeof mod.registerLogs, "function"); + assert.equal(typeof mod.runLogsCommand, "function"); +}); + +test("health.mjs exporta runHealthComponentsCommand", async () => { + const mod = await import("../../bin/cli/commands/health.mjs"); + assert.equal(typeof mod.registerHealth, "function"); + assert.equal(typeof mod.runHealthCommand, "function"); + assert.equal(typeof mod.runHealthComponentsCommand, "function"); +}); + +test("update.mjs exporta runUpdateCommand", async () => { + const mod = await import("../../bin/cli/commands/update.mjs"); + assert.equal(typeof mod.registerUpdate, "function"); + assert.equal(typeof mod.runUpdateCommand, "function"); +}); + +test("keys.mjs exporta novos comandos", async () => { + const mod = await import("../../bin/cli/commands/keys.mjs"); + assert.equal(typeof mod.registerKeys, "function"); + assert.equal(typeof mod.runKeysRegenerateCommand, "function"); + assert.equal(typeof mod.runKeysRevokeCommand, "function"); + assert.equal(typeof mod.runKeysRevealCommand, "function"); + assert.equal(typeof mod.runKeysUsageCommand, "function"); +}); + +test("health components com alertsOnly=true não lança", async () => { + // Server não está rodando — função deve retornar 1 sem throw. + const { runHealthComponentsCommand } = await import("../../bin/cli/commands/health.mjs"); + const code = await runHealthComponentsCommand({ alertsOnly: true }); + assert.ok(code === 0 || code === 1, "should return 0 or 1"); +}); + +test("update --check com versão atual não lança", async () => { + const { runUpdateCommand } = await import("../../bin/cli/commands/update.mjs"); + // Sem servidor npm disponível pode retornar erro — só garante que não throw. + let code = 0; + try { + code = await runUpdateCommand({ check: true, yes: true }); + } catch { + code = 1; + } + assert.ok(code === 0 || code === 1); +});