diff --git a/bin/cli/commands/tunnel.mjs b/bin/cli/commands/tunnel.mjs index 442c03cf27..98093442a8 100644 --- a/bin/cli/commands/tunnel.mjs +++ b/bin/cli/commands/tunnel.mjs @@ -31,13 +31,53 @@ export function registerTunnel(program) { tunnel .command("stop ") - .description("Stop a tunnel") - .option("--yes", "Skip confirmation") + .description(t("tunnel.stopDescription")) + .option("--yes", t("common.yesOpt")) .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); }); + + tunnel + .command("status ") + .description(t("tunnel.statusDescription")) + .option("--json", t("common.jsonOpt")) + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelStatusCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + tunnel + .command("logs ") + .description(t("tunnel.logsDescription")) + .option("--tail ", t("tunnel.tailOpt"), "50") + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelLogsCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + tunnel + .command("info ") + .description(t("tunnel.infoDescription")) + .option("--json", t("common.jsonOpt")) + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelInfoCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + tunnel + .command("rotate ") + .description(t("tunnel.rotateDescription")) + .option("--yes", t("common.yesOpt")) + .action(async (type, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runTunnelRotateCommand(type, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); } export async function runTunnelListCommand(opts = {}) { @@ -149,3 +189,159 @@ export async function runTunnelStopCommand(type, opts = {}) { return 1; } } + +export async function runTunnelStatusCommand(type, opts = {}) { + if (!type) { + console.error(t("tunnel.typeRequired")); + return 1; + } + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + try { + const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}/status`, { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + const uptime = data.uptime ? `${Math.floor(data.uptime / 60)}m` : "N/A"; + const statusLabel = data.active ? "\x1b[32m● active\x1b[0m" : "\x1b[31m○ inactive\x1b[0m"; + console.log(`\n\x1b[1m${type}\x1b[0m ${statusLabel}`); + console.log(` URL: ${data.url || "N/A"}`); + console.log(` Uptime: ${uptime}`); + console.log(` Requests: ${data.requests ?? data.totalRequests ?? "N/A"}`); + console.log(` Latency: ${data.avgLatencyMs != null ? `${data.avgLatencyMs}ms` : "N/A"}`); + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runTunnelLogsCommand(type, opts = {}) { + if (!type) { + console.error(t("tunnel.typeRequired")); + return 1; + } + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + const tail = Number(opts.tail || 50); + try { + const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}/logs?tail=${tail}`, { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + const lines = data.logs || data.lines || data; + if (!Array.isArray(lines) || lines.length === 0) { + console.log(t("tunnel.noLogs")); + return 0; + } + for (const line of lines) { + const ts = line.timestamp || line.ts || ""; + const msg = line.message || line.msg || String(line); + console.log(`\x1b[2m${ts}\x1b[0m ${msg}`); + } + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runTunnelInfoCommand(type, opts = {}) { + if (!type) { + console.error(t("tunnel.typeRequired")); + return 1; + } + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("common.serverOffline")); + return 1; + } + try { + const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}`, { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + console.log(`\n\x1b[1m\x1b[36m${t("tunnel.infoTitle", { type })}\x1b[0m\n`); + for (const [k, v] of Object.entries(data)) { + console.log(` ${String(k).padEnd(20)} ${JSON.stringify(v)}`); + } + return 0; + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; + } +} + +export async function runTunnelRotateCommand(type, opts = {}) { + if (!type) { + console.error(t("tunnel.typeRequired")); + 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.confirmRotate", { 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)}/rotate`, { + method: "POST", + retry: false, + timeout: 15000, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + console.log(t("tunnel.rotated", { url: data.url || "(see dashboard)" })); + return 0; + } 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 ed131361a1..233237daa9 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -405,7 +405,18 @@ "title": "Tunnels", "created": "Tunnel created: {url}", "stopped": "Tunnel stopped.", - "confirmStop": "Stop tunnel {id}?" + "confirmStop": "Stop tunnel {id}?", + "stopDescription": "Stop a tunnel", + "statusDescription": "Show detailed status of a tunnel", + "logsDescription": "Show logs for a tunnel", + "infoDescription": "Show configuration details of a tunnel", + "rotateDescription": "Generate a new tunnel URL", + "tailOpt": "Number of log lines to show", + "typeRequired": "Tunnel type is required.", + "noLogs": "No logs available.", + "infoTitle": "Tunnel info: {type}", + "rotated": "Tunnel URL rotated: {url}", + "confirmRotate": "Rotate tunnel {type}? (a new URL will be generated)" }, "stop": { "description": "Stop the OmniRoute server", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 8e697d6d09..113feb8c28 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -405,7 +405,18 @@ "title": "Túneis", "created": "Túnel criado: {url}", "stopped": "Túnel parado.", - "confirmStop": "Parar túnel {id}?" + "confirmStop": "Parar túnel {id}?", + "stopDescription": "Parar um túnel", + "statusDescription": "Exibir status detalhado de um túnel", + "logsDescription": "Exibir logs de um túnel", + "infoDescription": "Exibir configuração detalhada de um túnel", + "rotateDescription": "Gerar nova URL para o túnel", + "tailOpt": "Número de linhas de log a exibir", + "typeRequired": "Tipo de túnel é obrigatório.", + "noLogs": "Nenhum log disponível.", + "infoTitle": "Info do túnel: {type}", + "rotated": "URL do túnel rotacionada: {url}", + "confirmRotate": "Rotacionar túnel {type}? (uma nova URL será gerada)" }, "stop": { "description": "Parar o servidor OmniRoute", diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index cb92fe8afa..a76f83e7e6 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -51,6 +51,28 @@ test("provider-store.mjs exporta removeProviderConnectionByProvider", async () = assert.equal(typeof mod.removeProviderConnectionByProvider, "function"); }); +test("tunnel.mjs exporta subcomandos status/logs/info/rotate", async () => { + const mod = await import("../../bin/cli/commands/tunnel.mjs"); + assert.equal(typeof mod.registerTunnel, "function"); + assert.equal(typeof mod.runTunnelStatusCommand, "function"); + assert.equal(typeof mod.runTunnelLogsCommand, "function"); + assert.equal(typeof mod.runTunnelInfoCommand, "function"); + assert.equal(typeof mod.runTunnelRotateCommand, "function"); +}); + +test("tunnel — registerTunnel registra list/create/stop/status/logs/info/rotate", async () => { + const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs"); + const { Command } = await import("commander"); + const prog = new Command().exitOverride(); + registerTunnel(prog); + const tunnelCmd = prog.commands.find((c) => c.name() === "tunnel"); + assert.ok(tunnelCmd, "tunnel command deve existir"); + const names = tunnelCmd.commands.map((c) => c.name()); + for (const sub of ["list", "create", "stop", "status", "logs", "info", "rotate"]) { + assert.ok(names.includes(sub), `tunnel ${sub} deve existir`); + } +}); + test("health components com alertsOnly=true não lança", async () => { // Server não está rodando — função deve retornar 1 sem throw. const { runHealthComponentsCommand } = await import("../../bin/cli/commands/health.mjs");