diff --git a/bin/cli/commands/a2a.mjs b/bin/cli/commands/a2a.mjs new file mode 100644 index 0000000000..aca37451eb --- /dev/null +++ b/bin/cli/commands/a2a.mjs @@ -0,0 +1,94 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerA2a(program) { + const a2a = program.command("a2a").description("Agent-to-Agent (A2A) server"); + + a2a + .command("status") + .description("Show A2A server status") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runA2aStatusCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + a2a + .command("card") + .description("Print the Agent Card JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runA2aCardCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runA2aStatusCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/api/a2a/status", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.log("A2A status not available."); + return 0; + } + + const status = await res.json(); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(status, null, 2)); + return 0; + } + + const running = status.running ? "\x1b[32mrunning\x1b[0m" : "\x1b[31mstopped\x1b[0m"; + console.log(` Status: ${running}`); + console.log(` Protocol: ${status.protocol || "JSON-RPC 2.0"}`); + console.log(` Tasks: ${status.activeTasks || 0} active`); + + if (status.skills?.length) { + console.log("\n Skills:"); + for (const skill of status.skills) { + console.log(`\x1b[2m - ${skill.name}: ${skill.description || "N/A"}\x1b[0m`); + } + } + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runA2aCardCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/.well-known/agent.json", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (res.ok) { + const card = await res.json(); + console.log(JSON.stringify(card, null, 2)); + return 0; + } + console.log("Agent card not available."); + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/backup.mjs b/bin/cli/commands/backup.mjs new file mode 100644 index 0000000000..24863c7a0e --- /dev/null +++ b/bin/cli/commands/backup.mjs @@ -0,0 +1,190 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveDataDir } from "../data-dir.mjs"; +import { t } from "../i18n.mjs"; + +function getBackupDir() { + return join(resolveDataDir(), "backups"); +} + +const FILES_TO_BACKUP = [ + { name: "storage.sqlite" }, + { name: "settings.json" }, + { name: "combos.json" }, + { name: "providers.json" }, +]; + +export function registerBackup(program) { + program + .command("backup") + .description(t("backup.description")) + .option("--name ", "Custom backup name") + .action(async (opts) => { + const exitCode = await runBackupCommand(opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export function registerRestore(program) { + program + .command("restore [backupId]") + .description(t("backup.restoreDescription")) + .option("--list", "List available backups") + .option("--yes", "Skip confirmation") + .action(async (backupId, opts) => { + const exitCode = await runRestoreCommand(backupId, opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runBackupCommand(opts = {}) { + const dataDir = resolveDataDir(); + const backupDir = getBackupDir(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const backupName = opts.name ? `omniroute-backup-${opts.name}` : `omniroute-backup-${timestamp}`; + const backupPath = join(backupDir, backupName); + + console.log(t("backup.creating")); + + try { + if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true }); + + let Database; + try { + Database = (await import("better-sqlite3")).default; + } catch { + Database = null; + } + + let backedUp = 0; + let skipped = 0; + + for (const file of FILES_TO_BACKUP) { + const sourcePath = join(dataDir, file.name); + if (existsSync(sourcePath)) { + const destPath = join(backupPath, file.name); + mkdirSync(dirname(destPath), { recursive: true }); + if (file.name.endsWith(".sqlite") && Database) { + const db = new Database(sourcePath, { readonly: true }); + await db.backup(destPath); + db.close(); + } else { + copyFileSync(sourcePath, destPath); + } + backedUp++; + } else { + skipped++; + } + } + + if (backedUp > 0) { + const info = { + timestamp: new Date().toISOString(), + version: "omniroute-cli-v1", + files: FILES_TO_BACKUP.filter((f) => existsSync(join(dataDir, f.name))).map((f) => f.name), + }; + writeFileSync(join(backupPath, "backup-info.json"), JSON.stringify(info, null, 2), "utf8"); + console.log(t("backup.done", { path: backupPath })); + console.log(`\x1b[2m ${backedUp} backed up, ${skipped} skipped\x1b[0m`); + return 0; + } + + console.log(t("backup.noFiles")); + return 0; + } catch (err) { + console.error(t("backup.failed", { error: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runRestoreCommand(backupId, opts = {}) { + const backupDir = getBackupDir(); + + if (opts.list || !backupId) { + console.log(`\n\x1b[1m\x1b[36m${t("backup.listTitle")}\x1b[0m\n`); + if (!existsSync(backupDir)) { + console.log(t("backup.noBackups")); + return 0; + } + + try { + const dirs = readdirSync(backupDir) + .filter((f) => f.startsWith("omniroute-backup-")) + .sort() + .reverse(); + + if (dirs.length === 0) { + console.log(t("backup.noBackups")); + return 0; + } + + for (const dir of dirs) { + const infoPath = join(backupDir, dir, "backup-info.json"); + if (existsSync(infoPath)) { + const info = JSON.parse(readFileSync(infoPath, "utf8")); + const id = dir.replace("omniroute-backup-", ""); + const dateStr = new Date(info.timestamp).toLocaleString(); + console.log(` ${id}`); + console.log(`\x1b[2m ${dateStr} — ${info.files?.length || 0} files\x1b[0m`); + } else { + console.log(`\x1b[2m ${dir.replace("omniroute-backup-", "")}\x1b[0m`); + } + } + } catch (err) { + console.error( + t("common.error", { message: err instanceof Error ? err.message : String(err) }) + ); + return 1; + } + + if (!backupId) console.log("\nUsage: omniroute restore "); + return 0; + } + + const backupPath = join(backupDir, `omniroute-backup-${backupId}`); + if (!existsSync(backupPath)) { + console.error(t("backup.notFound", { name: backupId })); + return 1; + } + + const infoPath = join(backupPath, "backup-info.json"); + const ts = existsSync(infoPath) ? JSON.parse(readFileSync(infoPath, "utf8")).timestamp : backupId; + + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(t("backup.confirmRestore", { ts }) + " [y/N] ", resolve) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + console.log(t("backup.restoring", { path: backupPath })); + + const dataDir = resolveDataDir(); + try { + for (const file of FILES_TO_BACKUP) { + const sourcePath = join(backupPath, file.name); + if (existsSync(sourcePath)) { + copyFileSync(sourcePath, join(dataDir, file.name)); + console.log(`\x1b[2m Restored: ${file.name}\x1b[0m`); + } + } + console.log(t("backup.restored")); + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/cache.mjs b/bin/cli/commands/cache.mjs new file mode 100644 index 0000000000..ee43e6e508 --- /dev/null +++ b/bin/cli/commands/cache.mjs @@ -0,0 +1,102 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerCache(program) { + const cache = program.command("cache").description(t("cache.description")); + + cache + .command("status") + .alias("stats") + .description("Show cache statistics") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runCacheStatusCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + cache + .command("clear") + .description("Clear all cached responses") + .option("--yes", "Skip confirmation") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runCacheClearCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runCacheStatusCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("cache.noServer")); + return 1; + } + + try { + const res = await apiFetch("/api/cache/stats", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.log("Cache stats not available."); + return 0; + } + + const stats = await res.json(); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(stats, null, 2)); + return 0; + } + + console.log(`\n\x1b[1m\x1b[36mCache Status\x1b[0m\n`); + console.log(` Semantic hits: ${stats.semanticHits || 0}`); + console.log(` Signature hits: ${stats.signatureHits || 0}`); + if (stats.size !== undefined) console.log(` Size: ${stats.size}`); + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runCacheClearCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("cache.noServer")); + return 1; + } + + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question("Clear all cached responses? [y/N] ", resolve) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + try { + const res = await apiFetch("/api/cache/clear", { + method: "POST", + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (res.ok) { + console.log(t("cache.cleared")); + return 0; + } + console.error(t("cache.clearFailed")); + return 1; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/completion.mjs b/bin/cli/commands/completion.mjs new file mode 100644 index 0000000000..d9c268b4a7 --- /dev/null +++ b/bin/cli/commands/completion.mjs @@ -0,0 +1,150 @@ +import { Argument } from "commander"; +import { t } from "../i18n.mjs"; + +const VALID_SHELLS = ["bash", "zsh", "fish"]; + +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); + }); +} + +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 generateBashCompletion() { + return `#!/bin/bash +# OmniRoute CLI Bash Completion + +_omniroute() { + local cur prev opts 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" + + 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 ;; + esac +} + +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 +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' +`; +} diff --git a/bin/cli/commands/env.mjs b/bin/cli/commands/env.mjs new file mode 100644 index 0000000000..77f16b1155 --- /dev/null +++ b/bin/cli/commands/env.mjs @@ -0,0 +1,100 @@ +import { t } from "../i18n.mjs"; + +const OMNIROUTE_ENV_VARS = [ + "PORT", + "API_PORT", + "DASHBOARD_PORT", + "DATA_DIR", + "REQUIRE_API_KEY", + "LOG_LEVEL", + "NODE_ENV", + "REQUEST_TIMEOUT_MS", + "ENABLE_SOCKS5_PROXY", + "OMNIROUTE_API_KEY", + "OMNIROUTE_BASE_URL", + "OMNIROUTE_HTTP_TIMEOUT_MS", +]; + +const ENV_DEFAULTS = { + PORT: "20128", + DASHBOARD_PORT: "20128", + DATA_DIR: "~/.omniroute", + NODE_ENV: "production", +}; + +export function registerEnv(program) { + const env = program.command("env").description("Show and manage environment variables"); + + env + .command("show") + .alias("list") + .description("Show current environment variables") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + await runEnvShowCommand({ ...opts, output: globalOpts.output }); + }); + + env + .command("get ") + .description("Get a single environment variable") + .action(async (key) => { + await runEnvGetCommand(key); + }); + + env + .command("set ") + .description("Set an environment variable (current session only)") + .action(async (key, value) => { + await runEnvSetCommand(key, value); + }); +} + +export async function runEnvShowCommand(opts = {}) { + const current = {}; + for (const key of OMNIROUTE_ENV_VARS) { + if (process.env[key] !== undefined) current[key] = process.env[key]; + } + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify({ current, defaults: ENV_DEFAULTS }, null, 2)); + return 0; + } + + console.log("\n\x1b[1m\x1b[36mEnvironment Variables\x1b[0m\n"); + console.log(" Current:"); + if (Object.keys(current).length === 0) { + console.log("\x1b[2m (none set)\x1b[0m"); + } else { + for (const [key, value] of Object.entries(current)) { + const display = key.includes("KEY") || key.includes("SECRET") ? "***" : value; + console.log(`\x1b[2m ${key.padEnd(28)} ${display}\x1b[0m`); + } + } + + console.log("\n Defaults:"); + for (const [key, value] of Object.entries(ENV_DEFAULTS)) { + console.log(` ${key.padEnd(28)} ${value}`); + } + + return 0; +} + +export async function runEnvGetCommand(key) { + if (!key) { + console.error("Key is required. Usage: omniroute env get "); + return 1; + } + console.log(process.env[key] || ""); + return 0; +} + +export async function runEnvSetCommand(key, value) { + if (!key || value === undefined) { + console.error("Usage: omniroute env set "); + return 1; + } + process.env[key] = String(value); + console.log(`\x1b[33m ${key}=${value} (temporary — current session only)\x1b[0m`); + return 0; +} diff --git a/bin/cli/commands/health.mjs b/bin/cli/commands/health.mjs new file mode 100644 index 0000000000..34013e6649 --- /dev/null +++ b/bin/cli/commands/health.mjs @@ -0,0 +1,73 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerHealth(program) { + program + .command("health") + .description(t("health.description")) + .option("-v, --verbose", "Show extended info (memory, breakers)") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runHealthCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runHealthCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("health.noServer")); + return 1; + } + + try { + const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + + const health = await res.json(); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(health, null, 2)); + return 0; + } + + console.log(`\n\x1b[1m\x1b[36m${t("health.title")}\x1b[0m\n`); + console.log(t("health.status", { status: "\x1b[32mhealthy\x1b[0m" })); + if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime })); + if (health.version) console.log(` Version: ${health.version}`); + + if (health.requests !== undefined) { + console.log(t("health.requests", { count: health.requests })); + } + + if (health.breakers && opts.verbose) { + console.log("\n \x1b[1mCircuit Breakers\x1b[0m"); + for (const [name, status] of Object.entries(health.breakers)) { + const state = + status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m"; + console.log(` ${name.padEnd(20)} ${state}`); + } + } + + if (health.cache && opts.verbose) { + console.log("\n \x1b[1mCache\x1b[0m"); + console.log(` Semantic hits: ${health.cache.semanticHits || 0}`); + console.log(` Signature hits: ${health.cache.signatureHits || 0}`); + } + + if (opts.verbose && health.memory) { + console.log("\n \x1b[1mMemory\x1b[0m"); + console.log(` RSS: ${health.memory.rss || "N/A"}`); + console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`); + } + + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/mcp.mjs b/bin/cli/commands/mcp.mjs new file mode 100644 index 0000000000..29d1693a01 --- /dev/null +++ b/bin/cli/commands/mcp.mjs @@ -0,0 +1,90 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerMcp(program) { + const mcp = program.command("mcp").description(t("mcp.title")); + + mcp + .command("status") + .description("Show MCP server status") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runMcpStatusCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + mcp + .command("restart") + .description("Restart the MCP server") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runMcpRestartCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runMcpStatusCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/api/mcp/status", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.log(t("mcp.stopped")); + return 0; + } + + const status = await res.json(); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(status, null, 2)); + return 0; + } + + const transport = status.transport || "stdio"; + console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped")); + if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`); + if (status.scopes?.length) { + console.log(" Scopes:"); + for (const scope of status.scopes) console.log(` - ${scope}`); + } + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runMcpRestartCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/api/mcp/restart", { + method: "POST", + retry: false, + timeout: 10000, + acceptNotOk: true, + }); + if (res.ok) { + console.log(t("mcp.restarted")); + return 0; + } + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/quota.mjs b/bin/cli/commands/quota.mjs new file mode 100644 index 0000000000..a657845e51 --- /dev/null +++ b/bin/cli/commands/quota.mjs @@ -0,0 +1,100 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerQuota(program) { + program + .command("quota") + .description(t("quota.description")) + .option("--provider ", "Filter by provider") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runQuotaCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("quota.noServer")); + return 1; + } + + let quotaData = null; + + try { + const res = await apiFetch("/api/quota", { retry: false, timeout: 5000, acceptNotOk: true }); + if (res.ok) quotaData = await res.json(); + } catch {} + + if (!quotaData) { + try { + const res = await apiFetch("/api/v1/providers", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (res.ok) { + const providers = await res.json(); + quotaData = { + providers: providers.map((p) => ({ + provider: p.name || p.id, + quota: p.quota || p.remaining || "N/A", + used: p.used || 0, + reset: p.resetAt || "N/A", + })), + }; + } + } catch {} + } + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(quotaData || { error: "No quota data" }, null, 2)); + return 0; + } + + if (!quotaData?.providers) { + console.log(t("quota.noData")); + return 0; + } + + let providers = quotaData.providers; + if (opts.provider) { + const filter = opts.provider.toLowerCase(); + providers = providers.filter((p) => p.provider.toLowerCase().includes(filter)); + } + + console.log(`\n\x1b[1m\x1b[36mProvider Quota Usage\x1b[0m\n`); + console.log( + "\x1b[36m" + + " Provider".padEnd(25) + + "Used".padEnd(15) + + "Remaining".padEnd(20) + + "Reset\x1b[0m" + ); + console.log( + "\x1b[2m " + + "─".repeat(24) + + " " + + "─".repeat(14) + + " " + + "─".repeat(19) + + " " + + "─".repeat(15) + + "\x1b[0m" + ); + + for (const p of providers) { + const provider = (p.provider || "unknown").slice(0, 23).padEnd(25); + const used = String(p.used || 0).padEnd(15); + const remaining = String(p.quota || p.remaining || "N/A") + .slice(0, 18) + .padEnd(20); + const reset = p.reset || "N/A"; + console.log(` ${provider}${used}${remaining}${reset}`); + } + + console.log(`\n \x1b[32mTotal: ${providers.length} providers\x1b[0m`); + return 0; +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 8794f1fdae..0d999c3008 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -13,6 +13,16 @@ import { registerCombo } from "./combo.mjs"; import { registerStatus } from "./status.mjs"; import { registerLogs } from "./logs.mjs"; import { registerUpdate } from "./update.mjs"; +import { registerBackup, registerRestore } from "./backup.mjs"; +import { registerHealth } from "./health.mjs"; +import { registerQuota } from "./quota.mjs"; +import { registerCache } from "./cache.mjs"; +import { registerMcp } from "./mcp.mjs"; +import { registerA2a } from "./a2a.mjs"; +import { registerTunnel } from "./tunnel.mjs"; +import { registerEnv } from "./env.mjs"; +import { registerTestProvider } from "./test-provider.mjs"; +import { registerCompletion } from "./completion.mjs"; export function registerCommands(program) { registerServe(program); @@ -30,4 +40,15 @@ export function registerCommands(program) { registerStatus(program); registerLogs(program); registerUpdate(program); + registerBackup(program); + registerRestore(program); + registerHealth(program); + registerQuota(program); + registerCache(program); + registerMcp(program); + registerA2a(program); + registerTunnel(program); + registerEnv(program); + registerTestProvider(program); + registerCompletion(program); } diff --git a/bin/cli/commands/test-provider.mjs b/bin/cli/commands/test-provider.mjs new file mode 100644 index 0000000000..d398fa6a23 --- /dev/null +++ b/bin/cli/commands/test-provider.mjs @@ -0,0 +1,62 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerTestProvider(program) { + program + .command("test [provider] [model]") + .description(t("test.description")) + .option("--all-providers", "Test all configured providers") + .option("--json", "Output as JSON") + .action(async (provider, model, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runTestProviderCommand(provider, model, { + ...opts, + output: globalOpts.output, + }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runTestProviderCommand(provider, model, opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("test.noServer")); + return 1; + } + + const targetProvider = provider || "anthropic"; + const targetModel = model || "claude-haiku-4-5-20251001"; + + console.log(t("test.testing", { provider: targetProvider, model: targetModel })); + + try { + const res = await apiFetch("/api/v1/providers/test", { + method: "POST", + body: { provider: targetProvider, model: targetModel }, + retry: false, + timeout: 30000, + acceptNotOk: true, + }); + + const result = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(result, null, 2)); + return result.success ? 0 : 1; + } + + if (result.success) { + console.log(`\x1b[32m✔ ${t("test.passed")}\x1b[0m`); + if (result.response) console.log(`\x1b[2m Response: ${result.response}\x1b[0m`); + return 0; + } + + console.error( + `\x1b[31m✖ ${t("test.failed", { error: result.error || "Unknown error" })}\x1b[0m` + ); + return 1; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/commands/tunnel.mjs b/bin/cli/commands/tunnel.mjs new file mode 100644 index 0000000000..442c03cf27 --- /dev/null +++ b/bin/cli/commands/tunnel.mjs @@ -0,0 +1,151 @@ +import { Argument } from "commander"; +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +const VALID_TUNNEL_TYPES = ["cloudflare", "tailscale", "ngrok"]; + +export function registerTunnel(program) { + const tunnel = program.command("tunnel").description(t("tunnel.title")); + + tunnel + .command("list") + .description("List active tunnels") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelListCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + tunnel + .command("create [type]") + .description("Create a tunnel") + .addArgument( + new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare") + ) + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelCreateCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + tunnel + .command("stop ") + .description("Stop a tunnel") + .option("--yes", "Skip confirmation") + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelStopCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runTunnelListCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/api/tunnels", { retry: false, timeout: 5000, acceptNotOk: true }); + if (!res.ok) { + console.log("Tunnel info not available."); + return 0; + } + + const tunnels = await res.json(); + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(tunnels, null, 2)); + return 0; + } + + console.log(`\n\x1b[1m\x1b[36m${t("tunnel.title")}\x1b[0m\n`); + if (!Array.isArray(tunnels) || tunnels.length === 0) { + console.log(" No active tunnels."); + return 0; + } + + for (const tunnel of tunnels) { + const status = tunnel.active ? "\x1b[32m● active\x1b[0m" : "\x1b[2m○ inactive\x1b[0m"; + console.log(` ${(tunnel.type || "unknown").padEnd(12)} ${tunnel.url || "N/A"} ${status}`); + } + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runTunnelCreateCommand(type = "cloudflare", opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch("/api/tunnels", { + method: "POST", + body: { type }, + retry: false, + timeout: 15000, + acceptNotOk: true, + }); + if (res.ok) { + const result = await res.json(); + console.log(t("tunnel.created", { url: result.url })); + return 0; + } + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runTunnelStopCommand(type, opts = {}) { + if (!type) { + console.error("Tunnel type required. Valid: " + VALID_TUNNEL_TYPES.join(", ")); + return 1; + } + + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(t("tunnel.confirmStop", { id: type }) + " [y/N] ", resolve) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + + try { + const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}`, { + method: "DELETE", + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (res.ok) { + console.log(t("tunnel.stopped")); + return 0; + } + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index fc01366116..cc5219f2f2 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -75,12 +75,46 @@ }, "backup": { "title": "Backup", + "description": "Create a backup of OmniRoute data", "creating": "Creating backup...", "done": "Backup saved to {path}", + "noFiles": "No files to backup (database not initialized)", + "failed": "Backup failed: {error}", "restoring": "Restoring from {path}...", "restored": "Restore complete.", + "restoreDescription": "Restore from a backup", + "listTitle": "Available Backups", + "noBackups": "No backups found.", + "notFound": "Backup not found: {name}", "confirmRestore": "Overwrite current data with backup from {ts}?" }, + "health": { + "title": "Health", + "description": "Show server health status", + "status": "Status: {status}", + "uptime": "Uptime: {uptime}", + "requests": "Requests (24h): {count}", + "cost": "Cost (24h): ${cost}", + "noServer": "Server not running. Start with: omniroute serve" + }, + "quota": { + "description": "Show provider quota usage", + "noServer": "Server not running. Start with: omniroute serve", + "noData": "No quota information available." + }, + "cache": { + "description": "Manage response cache", + "noServer": "Server not running. Start with: omniroute serve", + "cleared": "Cache cleared.", + "clearFailed": "Failed to clear cache." + }, + "test": { + "description": "Test a provider connection", + "noServer": "Server not running. Start with: omniroute serve", + "testing": "Testing {provider} / {model}...", + "passed": "Connection successful!", + "failed": "Connection failed: {error}" + }, "update": { "checking": "Checking for updates...", "upToDate": "Already up to date ({version}).", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 2ed620308a..48643f54fa 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -75,12 +75,46 @@ }, "backup": { "title": "Backup", + "description": "Criar backup dos dados do OmniRoute", "creating": "Criando backup...", "done": "Backup salvo em {path}", + "noFiles": "Nenhum arquivo para backup (banco não inicializado)", + "failed": "Backup falhou: {error}", "restoring": "Restaurando de {path}...", "restored": "Restauração concluída.", + "restoreDescription": "Restaurar a partir de um backup", + "listTitle": "Backups Disponíveis", + "noBackups": "Nenhum backup encontrado.", + "notFound": "Backup não encontrado: {name}", "confirmRestore": "Substituir dados atuais pelo backup de {ts}?" }, + "health": { + "title": "Saúde", + "description": "Exibir status de saúde do servidor", + "status": "Status: {status}", + "uptime": "Uptime: {uptime}", + "requests": "Requisições (24h): {count}", + "cost": "Custo (24h): ${cost}", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve" + }, + "quota": { + "description": "Exibir uso de cota dos provedores", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve", + "noData": "Nenhuma informação de cota disponível." + }, + "cache": { + "description": "Gerenciar cache de respostas", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve", + "cleared": "Cache limpo.", + "clearFailed": "Falha ao limpar cache." + }, + "test": { + "description": "Testar conexão com um provedor", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve", + "testing": "Testando {provider} / {model}...", + "passed": "Conexão bem-sucedida!", + "failed": "Conexão falhou: {error}" + }, "update": { "checking": "Verificando atualizações...", "upToDate": "Já está atualizado ({version}).", diff --git a/tests/unit/cli-backup-command.test.ts b/tests/unit/cli-backup-command.test.ts new file mode 100644 index 0000000000..9298909bc8 --- /dev/null +++ b/tests/unit/cli-backup-command.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +function createTempDataDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-backup-")); +} + +async function withBackupEnv(fn: (dataDir: string) => Promise) { + const dataDir = createTempDataDir(); + process.env.DATA_DIR = dataDir; + + const originalLog = console.log; + console.log = () => {}; + + try { + await fn(dataDir); + } finally { + console.log = originalLog; + fs.rmSync(dataDir, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +} + +test("backup returns 0 with no files (empty data dir)", async () => { + await withBackupEnv(async () => { + const { runBackupCommand } = await import("../../bin/cli/commands/backup.mjs"); + const result = await runBackupCommand({}); + assert.equal(result, 0); + }); +}); + +test("backup creates backup-info.json when storage.sqlite exists", async () => { + await withBackupEnv(async (dataDir) => { + const dbPath = path.join(dataDir, "storage.sqlite"); + const Database = (await import("better-sqlite3")).default; + new Database(dbPath).close(); + + const { runBackupCommand } = await import("../../bin/cli/commands/backup.mjs"); + const result = await runBackupCommand({}); + assert.equal(result, 0); + + const backupDir = path.join(dataDir, "backups"); + assert.ok(fs.existsSync(backupDir)); + const entries = fs.readdirSync(backupDir).filter((d) => d.startsWith("omniroute-backup-")); + assert.ok(entries.length > 0); + const infoPath = path.join(backupDir, entries[0], "backup-info.json"); + assert.ok(fs.existsSync(infoPath)); + const info = JSON.parse(fs.readFileSync(infoPath, "utf8")); + assert.ok(info.timestamp); + assert.ok(Array.isArray(info.files)); + }); +}); + +test("restore --list returns 0 with no backups", async () => { + await withBackupEnv(async () => { + const { runRestoreCommand } = await import("../../bin/cli/commands/backup.mjs"); + const result = await runRestoreCommand(undefined, { list: true }); + assert.equal(result, 0); + }); +}); + +test("restore returns 1 when backup id not found", async () => { + await withBackupEnv(async () => { + const { runRestoreCommand } = await import("../../bin/cli/commands/backup.mjs"); + const originalError = console.error; + console.error = () => {}; + const result = await runRestoreCommand("nonexistent-id", { yes: true }); + console.error = originalError; + assert.equal(result, 1); + }); +}); diff --git a/tests/unit/cli-server-commands.test.ts b/tests/unit/cli-server-commands.test.ts new file mode 100644 index 0000000000..522ffed4e2 --- /dev/null +++ b/tests/unit/cli-server-commands.test.ts @@ -0,0 +1,156 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const ORIGINAL_FETCH = globalThis.fetch; + +function makeResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": "application/json" }), + json: async () => data, + text: async () => JSON.stringify(data), + } as unknown as Response; +} + +async function withServerFetch(mockFetch: typeof fetch, fn: () => Promise) { + globalThis.fetch = mockFetch; + try { + await fn(); + } finally { + globalThis.fetch = ORIGINAL_FETCH; + } +} + +// ── health ──────────────────────────────────────────────────────────────────── + +test("health returns 1 when server is offline", async () => { + await withServerFetch( + (async () => { + throw new Error("offline"); + }) as typeof fetch, + async () => { + const { runHealthCommand } = await import("../../bin/cli/commands/health.mjs"); + const originalError = console.error; + console.error = () => {}; + const result = await runHealthCommand({}); + console.error = originalError; + assert.equal(result, 1); + } + ); +}); + +test("health --json returns 0 when server responds", async () => { + const mockData = { status: "ok", uptime: "1h", version: "3.8.0" }; + const mockFetch = (async (url: string) => { + return makeResponse(String(url).includes("health") ? mockData : { status: "ok" }); + }) as typeof fetch; + + await withServerFetch(mockFetch, async () => { + const { runHealthCommand } = await import("../../bin/cli/commands/health.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runHealthCommand({ json: true }); + console.log = originalLog; + assert.equal(result, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.equal(parsed.status, "ok"); + }); +}); + +// ── quota ───────────────────────────────────────────────────────────────────── + +test("quota returns 1 when server is offline", async () => { + await withServerFetch( + (async () => { + throw new Error("offline"); + }) as typeof fetch, + async () => { + const { runQuotaCommand } = await import("../../bin/cli/commands/quota.mjs"); + const originalError = console.error; + console.error = () => {}; + const result = await runQuotaCommand({}); + console.error = originalError; + assert.equal(result, 1); + } + ); +}); + +// ── mcp ─────────────────────────────────────────────────────────────────────── + +test("mcp status --json returns 0 when server responds", async () => { + const mcpStatus = { running: true, toolsCount: 37, transport: "stdio" }; + const mockFetch = (async (url: string) => makeResponse(mcpStatus)) as typeof fetch; + + await withServerFetch(mockFetch, async () => { + const { runMcpStatusCommand } = await import("../../bin/cli/commands/mcp.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runMcpStatusCommand({ json: true }); + console.log = originalLog; + assert.equal(result, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.equal(parsed.running, true); + }); +}); + +// ── completion ──────────────────────────────────────────────────────────────── + +test("completion bash outputs bash script", async () => { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runCompletionCommand("bash"); + console.log = originalLog; + assert.equal(result, 0); + assert.ok(lines.join("").includes("_omniroute")); +}); + +test("completion zsh outputs zsh script", async () => { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runCompletionCommand("zsh"); + console.log = originalLog; + assert.equal(result, 0); + assert.ok(lines.join("").includes("#compdef omniroute")); +}); + +test("completion fish outputs fish script", async () => { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runCompletionCommand("fish"); + console.log = originalLog; + assert.equal(result, 0); + assert.ok(lines.join("").includes("complete -c omniroute")); +}); + +// ── env ─────────────────────────────────────────────────────────────────────── + +test("env show returns 0", async () => { + const { runEnvShowCommand } = await import("../../bin/cli/commands/env.mjs"); + const originalLog = console.log; + console.log = () => {}; + const result = await runEnvShowCommand({}); + console.log = originalLog; + assert.equal(result, 0); +}); + +test("env get returns 0 and prints env value", async () => { + process.env.__OMNIROUTE_TEST_KEY__ = "hello"; + const { runEnvGetCommand } = await import("../../bin/cli/commands/env.mjs"); + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runEnvGetCommand("__OMNIROUTE_TEST_KEY__"); + console.log = originalLog; + delete process.env.__OMNIROUTE_TEST_KEY__; + assert.equal(result, 0); + assert.ok(lines.join("").includes("hello")); +});