Files
2026-07-13 13:39:12 +08:00

348 lines
11 KiB
JavaScript

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(t("tunnel.listDescription"))
.option("--json", t("common.jsonOpt"))
.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(t("tunnel.createDescription"))
.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(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 <type>")
.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 <type>")
.description(t("tunnel.logsDescription"))
.option("--tail <n>", 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 <type>")
.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 <type>")
.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 = {}) {
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(t("tunnel.notAvailable"));
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(t("tunnel.noTunnels"));
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(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.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;
}
}
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;
}
}