mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(cli): migrar backup/restore/health/quota/cache/mcp/a2a/tunnel/env/test/completion (Fase 1.4)
This commit is contained in:
94
bin/cli/commands/a2a.mjs
Normal file
94
bin/cli/commands/a2a.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
190
bin/cli/commands/backup.mjs
Normal file
190
bin/cli/commands/backup.mjs
Normal file
@@ -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 <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 <backup-id>");
|
||||
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;
|
||||
}
|
||||
}
|
||||
102
bin/cli/commands/cache.mjs
Normal file
102
bin/cli/commands/cache.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
150
bin/cli/commands/completion.mjs
Normal file
150
bin/cli/commands/completion.mjs
Normal file
@@ -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>", "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'
|
||||
`;
|
||||
}
|
||||
100
bin/cli/commands/env.mjs
Normal file
100
bin/cli/commands/env.mjs
Normal file
@@ -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 <key>")
|
||||
.description("Get a single environment variable")
|
||||
.action(async (key) => {
|
||||
await runEnvGetCommand(key);
|
||||
});
|
||||
|
||||
env
|
||||
.command("set <key> <value>")
|
||||
.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 <key>");
|
||||
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 <key> <value>");
|
||||
return 1;
|
||||
}
|
||||
process.env[key] = String(value);
|
||||
console.log(`\x1b[33m ${key}=${value} (temporary — current session only)\x1b[0m`);
|
||||
return 0;
|
||||
}
|
||||
73
bin/cli/commands/health.mjs
Normal file
73
bin/cli/commands/health.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
90
bin/cli/commands/mcp.mjs
Normal file
90
bin/cli/commands/mcp.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
100
bin/cli/commands/quota.mjs
Normal file
100
bin/cli/commands/quota.mjs
Normal file
@@ -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 <id>", "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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
62
bin/cli/commands/test-provider.mjs
Normal file
62
bin/cli/commands/test-provider.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
151
bin/cli/commands/tunnel.mjs
Normal file
151
bin/cli/commands/tunnel.mjs
Normal file
@@ -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 <type>")
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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}).",
|
||||
|
||||
@@ -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}).",
|
||||
|
||||
77
tests/unit/cli-backup-command.test.ts
Normal file
77
tests/unit/cli-backup-command.test.ts
Normal file
@@ -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<void>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
156
tests/unit/cli-server-commands.test.ts
Normal file
156
tests/unit/cli-server-commands.test.ts
Normal file
@@ -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<void>) {
|
||||
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"));
|
||||
});
|
||||
Reference in New Issue
Block a user