diff --git a/packages/tern-cli/package.json b/packages/tern-cli/package.json index 9a6716e..192d2ec 100644 --- a/packages/tern-cli/package.json +++ b/packages/tern-cli/package.json @@ -17,7 +17,11 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@clack/prompts": "latest", - "@hookflo/tern-dev": "latest" + "@hookflo/tern-dev": "latest", + "@clack/prompts": "latest" + }, + "type": "module", + "engines": { + "node": ">=18" } } diff --git a/packages/tern-cli/src/colors.ts b/packages/tern-cli/src/colors.ts index 0f24ce9..67d1012 100644 --- a/packages/tern-cli/src/colors.ts +++ b/packages/tern-cli/src/colors.ts @@ -4,8 +4,10 @@ export const GREEN = "\x1b[38;2;16;185;129m"; export const CYAN = "\x1b[38;2;6;182;212m"; /** ANSI yellow used for env variables. */ export const YELLOW = "\x1b[38;2;245;158;11m"; -/** ANSI gray used for muted labels. */ -export const GRAY = "\x1b[38;2;107;105;99m"; +/** ANSI gray used for dark borders. */ +export const GRAY = "\x1b[38;2;55;55;55m"; +/** ANSI muted used for labels and secondary information. */ +export const MUTED = "\x1b[38;2;75;75;75m"; /** ANSI white used for primary text. */ export const WHITE = "\x1b[38;2;240;237;232m"; /** ANSI red used for errors. */ diff --git a/packages/tern-cli/src/config.ts b/packages/tern-cli/src/config.ts index 80eefa7..eb03eb6 100644 --- a/packages/tern-cli/src/config.ts +++ b/packages/tern-cli/src/config.ts @@ -1,7 +1,5 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import * as clack from "@clack/prompts"; -import { CYAN, RESET } from "./colors"; /** Writes tern.config.json using current wizard selections. */ export function createConfig( @@ -45,5 +43,4 @@ export function createConfig( `; fs.writeFileSync(configPath, config, "utf8"); - clack.log.success(`created ${CYAN}tern.config.json${RESET}`); } diff --git a/packages/tern-cli/src/files.ts b/packages/tern-cli/src/files.ts index a2c92bd..5a666fe 100644 --- a/packages/tern-cli/src/files.ts +++ b/packages/tern-cli/src/files.ts @@ -1,7 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as clack from "@clack/prompts"; -import { CYAN, RESET } from "./colors"; /** Returns the target handler file path for a framework/platform pair. */ export function getFilePath(framework: string, platform: string): string { @@ -45,7 +44,7 @@ export function getWebhookPath(platform: string): string { export async function createHandlerFile( filePath: string, content: string, -): Promise { +): Promise { const fullPath = path.join(process.cwd(), filePath); if (fs.existsSync(fullPath)) { @@ -53,12 +52,11 @@ export async function createHandlerFile( message: `${path.basename(fullPath)} already exists. overwrite?`, }); if (clack.isCancel(overwrite) || !overwrite) { - clack.log.warn(`skipped ${filePath}`); - return; + return false; } } fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content, "utf8"); - clack.log.success(`created ${CYAN}${filePath}${RESET}`); + return true; } diff --git a/packages/tern-cli/src/index.ts b/packages/tern-cli/src/index.ts index 19a7589..d10ce03 100644 --- a/packages/tern-cli/src/index.ts +++ b/packages/tern-cli/src/index.ts @@ -1,19 +1,26 @@ #!/usr/bin/env node -import * as clack from "@clack/prompts"; -import { GRAY, RESET } from "./colors"; -import { createConfig } from "./config"; -import { createHandlerFile, getFilePath, getWebhookPath } from "./files"; -import { installTern } from "./install"; -import { printEnvBox, printLogo } from "./print"; -import { getTemplate } from "./templates"; -import { startTunnel } from "./tunnel"; -import { askQuestions, ENV_VARS, getPlatformLabel } from "./wizard"; +import "./patch-clack.js"; +import { GRAY, RESET } from "./colors.js"; +import { createConfig } from "./config.js"; +import { createHandlerFile, getFilePath, getWebhookPath } from "./files.js"; +import { installTern } from "./install.js"; +import { printEnvBlock, printLogo, printStep, printStepDone, printStepFile, printSummary } from "./print.js"; +import { getTemplate } from "./templates.js"; +import { startTunnel } from "./tunnel.js"; +import { askQuestions, ENV_VARS, getPlatformLabel } from "./wizard.js"; + +function actionLabel(action: "both" | "handler" | "tunnel"): string { + if (action === "both") return "handler + local testing"; + if (action === "handler") return "handler only"; + return "local testing only"; +} /** CLI entrypoint for @hookflo/tern-cli. */ export async function main(): Promise { - printLogo(); + printLogo("v0.1.0"); const { platform, framework, action, port } = await askQuestions(); + printSummary(getPlatformLabel(platform), framework === "nextjs" ? "Next.js" : framework, actionLabel(action), action === "handler" ? undefined : port); if (action === "handler") { await installTern(); @@ -25,16 +32,23 @@ export async function main(): Promise { envVar, getPlatformLabel(platform), ); - await createHandlerFile(filePath, content); - if (envVar) printEnvBox(envVar); - clack.outro("handler ready · add the env variable above to get started"); + printStep("creating webhook handler"); + const created = await createHandlerFile(filePath, content); + if (created) { + printStepFile(filePath); + } else { + printStepDone(`skipped ${filePath}`); + } + if (envVar) printEnvBlock(envVar); + printStepDone("handler ready"); return; } if (action === "tunnel") { const webhookPath = getWebhookPath(platform); + printStep("creating tern.config.json"); createConfig(port, webhookPath, platform, framework); - clack.log.step("connecting..."); + printStepDone("created tern.config.json"); startTunnel(port, webhookPath, getPlatformLabel(platform)); return; } @@ -49,10 +63,17 @@ export async function main(): Promise { envVar ?? "", getPlatformLabel(platform), ); - await createHandlerFile(filePath, content); + printStep("creating webhook handler"); + const created = await createHandlerFile(filePath, content); + if (created) { + printStepFile(filePath); + } else { + printStepDone(`skipped ${filePath}`); + } + printStep("creating tern.config.json"); createConfig(port, webhookPath, platform, framework); - if (envVar) printEnvBox(envVar); - clack.log.step("connecting..."); + printStepDone("created tern.config.json"); + if (envVar) printEnvBlock(envVar); startTunnel(port, webhookPath, getPlatformLabel(platform)); } diff --git a/packages/tern-cli/src/install.ts b/packages/tern-cli/src/install.ts index 3b1cc32..2567884 100644 --- a/packages/tern-cli/src/install.ts +++ b/packages/tern-cli/src/install.ts @@ -1,6 +1,6 @@ import { execSync } from "node:child_process"; import * as fs from "node:fs"; -import * as clack from "@clack/prompts"; +import { printStep, printStepDone } from "./print.js"; /** Detects the package manager install command from lockfiles. */ export function detectPackageManager(): string { @@ -11,14 +11,12 @@ export function detectPackageManager(): string { /** Installs @hookflo/tern and reports status in the wizard. */ export async function installTern(): Promise { - const spinner = clack.spinner(); - spinner.start("installing @hookflo/tern"); + printStep("installing @hookflo/tern"); try { const pm = detectPackageManager(); execSync(`${pm} @hookflo/tern`, { stdio: "pipe" }); - spinner.stop("installed @hookflo/tern"); + printStepDone("installed @hookflo/tern"); } catch { - spinner.stop("could not install @hookflo/tern"); - clack.log.warn("run manually: npm install @hookflo/tern"); + printStepDone("could not install @hookflo/tern · run manually: npm install @hookflo/tern"); } } diff --git a/packages/tern-cli/src/patch-clack.ts b/packages/tern-cli/src/patch-clack.ts new file mode 100644 index 0000000..5f21259 --- /dev/null +++ b/packages/tern-cli/src/patch-clack.ts @@ -0,0 +1,24 @@ +import { GREEN } from "./colors.js"; + +let clackPatched = false; + +export function patchClackColors(): void { + if (clackPatched) return; + clackPatched = true; + + process.env.FORCE_COLOR = "3"; + + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { + if (typeof chunk === "string") { + chunk = chunk + .replace(/\x1b\[32m/g, GREEN) + .replace(/\x1b\[36m/g, GREEN) + .replace(/\x1b\[2;32m/g, GREEN); + } + + return originalWrite(chunk as never, ...(args as never[])); + }) as typeof process.stdout.write; +} + +patchClackColors(); diff --git a/packages/tern-cli/src/print.ts b/packages/tern-cli/src/print.ts index 78aa6f9..702588e 100644 --- a/packages/tern-cli/src/print.ts +++ b/packages/tern-cli/src/print.ts @@ -1,50 +1,166 @@ -import * as clack from "@clack/prompts"; -import { CYAN, GRAY, GREEN, RESET, YELLOW } from "./colors"; +import { CYAN, GRAY, GREEN, MUTED, RED, RESET, WHITE, YELLOW } from "./colors.js"; + +const LABEL_WIDTH = 16; /** Prints the tern ASCII startup logo and intro message. */ -export function printLogo(): void { - console.log(`${GREEN} ████████╗███████╗██████╗ ███╗ ██╗`); - console.log(" ██║ ██╔════╝██╔══██╗████╗ ██║"); - console.log(" ██║ █████╗ ██████╔╝██╔██╗██║"); - console.log(" ██║ ██╔══╝ ██╔══██╗██║╚████║"); - console.log(" ██║ ███████╗██║ ██║██║ ╚███║"); - console.log(` ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝${RESET}`); - console.log(`\n ${GRAY}v0.1.0 · webhook toolkit${RESET}\n`); - clack.intro(" tern · webhook toolkit "); +export function printLogo(version: string): void { + console.log(); + console.log(); + console.log(` ${GREEN}████████╗███████╗██████╗ ███╗ ██╗${RESET}`); + console.log(` ${GREEN} ██║ ██╔════╝██╔══██╗████╗ ██║${RESET}`); + console.log(` ${GREEN} ██║ █████╗ ██████╔╝██╔██╗██║${RESET}`); + console.log(` ${GREEN} ██║ ██╔══╝ ██╔══██╗██║╚████║${RESET}`); + console.log(` ${GREEN} ██║ ███████╗██║ ██║██║ ╚███║${RESET}`); + console.log(` ${GREEN} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝${RESET}`); + console.log(); + console.log(` ${MUTED}${version} · webhook toolkit${RESET}`); + console.log(); + console.log(); +} + +export function printDivider(): void { + console.log(` ${GREEN}${"─".repeat(42)}${RESET}`); + console.log(); +} + +export function printRow( + icon: string, + label: string, + value: string, + valueColor: string = WHITE, +): void { + console.log( + ` ${GREEN}${icon}${RESET} ` + + `${MUTED}${label.padEnd(LABEL_WIDTH)}${RESET}` + + `${valueColor}${value}${RESET}`, + ); +} + +export function printPipe(): void { + console.log(` ${GREEN}│${RESET}`); +} + +export function printSummary( + platform: string, + framework: string, + action: string, + port?: string, +): void { + console.log(); + printDivider(); + printPipe(); + printRow("│", "platform", platform, GREEN); + printRow("│", "framework", framework, GREEN); + printRow("│", "action", action, GREEN); + if (port) { + printRow("│", "port", port, GREEN); + } + printPipe(); + printDivider(); +} + +export function printStep(message: string): void { + console.log(` ${GREEN}├${RESET} ${MUTED}${message}${RESET}`); } -/** Prints the environment variable helper box. */ -export function printEnvBox(envVar: string): void { +export function printStepDone(message: string): void { + console.log(` ${GREEN}└${RESET} ${GREEN}✓${RESET} ${WHITE}${message}${RESET}`); console.log(); - console.log(` ${GRAY}┌─ add this env variable ${"─".repeat(20)}┐${RESET}`); - console.log(` ${GRAY}│${RESET}`); - console.log(` ${GRAY}│${RESET} ${YELLOW}${envVar}${RESET}=`); - console.log(` ${GRAY}│${RESET}`); - console.log(` ${GRAY}└${"─".repeat(44)}┘${RESET}`); +} + +export function printStepFile(filePath: string): void { + console.log(` ${GREEN}└${RESET} ${GREEN}✓${RESET} ${CYAN}${filePath}${RESET}`); + console.log(); +} + +export function printEnvBlock(envVar: string): void { + console.log(); + console.log(` ${GREEN}├${RESET} ${MUTED}add this env variable${RESET}`); + console.log(` ${GREEN}│${RESET}`); + console.log(` ${GREEN}│${RESET} ${YELLOW}${envVar}${RESET}=`); + console.log(` ${GREEN}│${RESET}`); console.log(); } +export function startConnectingAnimation(): () => void { + const width = 32; + let filled = 0; + let stopped = false; + + const interval = setInterval(() => { + if (stopped) return; + if (filled < width) filled += 2; + const bar = GREEN + "█".repeat(filled) + GRAY + "░".repeat(width - filled) + RESET; + process.stdout.write(`\r ${GREEN}├${RESET} [${bar}${GREEN}]${RESET} `); + }, 60); + + return () => { + stopped = true; + clearInterval(interval); + process.stdout.write(`\r${" ".repeat(80)}\r`); + }; +} + /** Prints the webhook destination URL box after connection succeeds. */ export function printUrlBox( platformLabel: string, url: string, copied: boolean, ): void { - const line1 = ` paste this in ${platformLabel} webhook settings:`; - const width = Math.max(line1.length, url.length + 4) + 2; - const pad = (s: string): string => s + " ".repeat(width - s.length); + const boxWidth = Math.max(url.length + 6, 48); + const inner = (text: string, visLen: number): string => + ` ${GREEN}│${RESET} ${text}${" ".repeat(boxWidth - visLen - 4)}${GREEN}│${RESET}`; console.log(); - console.log(` ${GREEN}┌${"─".repeat(width)}┐${RESET}`); - console.log(` ${GREEN}│${RESET}${" ".repeat(width)}${GREEN}│${RESET}`); - console.log(` ${GREEN}│${RESET}${pad(line1)}${GREEN}│${RESET}`); - console.log(` ${GREEN}│${RESET}${" ".repeat(width)}${GREEN}│${RESET}`); - console.log(` ${GREEN}│${RESET} ${CYAN}${url}${RESET}${" ".repeat(width - url.length - 2)}${GREEN}│${RESET}`); - console.log(` ${GREEN}│${RESET}${" ".repeat(width)}${GREEN}│${RESET}`); + console.log(` ${GREEN}┌${"─".repeat(boxWidth)}┐${RESET}`); + console.log(` ${GREEN}│${RESET}${" ".repeat(boxWidth)}${GREEN}│${RESET}`); + console.log( + inner( + `${MUTED}paste in ${platformLabel} webhook settings${RESET}`, + `paste in ${platformLabel} webhook settings`.length, + ), + ); + console.log(` ${GREEN}│${RESET}${" ".repeat(boxWidth)}${GREEN}│${RESET}`); + console.log(inner(`${CYAN}${url}${RESET}`, url.length)); + console.log(` ${GREEN}│${RESET}${" ".repeat(boxWidth)}${GREEN}│${RESET}`); if (copied) { - console.log(` ${GREEN}│${RESET} ${GREEN}✓ copied to clipboard${RESET}${" ".repeat(width - 23)}${GREEN}│${RESET}`); + console.log(inner(`${GREEN}✓ copied to clipboard${RESET}`, 21)); + console.log(` ${GREEN}│${RESET}${" ".repeat(boxWidth)}${GREEN}│${RESET}`); } - console.log(` ${GREEN}│${RESET}${" ".repeat(width)}${GREEN}│${RESET}`); - console.log(` ${GREEN}└${"─".repeat(width)}┘${RESET}`); + console.log(` ${GREEN}└${"─".repeat(boxWidth)}┘${RESET}`); + console.log(); +} + +export function printListeningState(port: string, uiPort: string, ttl: number): void { + console.log(); + printDivider(); + printPipe(); + printRow("├", "webhook debugger", `localhost:${uiPort}`, CYAN); + printRow("├", "forwarding", `localhost:${port}`, CYAN); + printRow("├", "session ends", `in ${ttl} min`, MUTED); + printPipe(); + console.log(` ${GREEN}└${RESET} ${GREEN}● listening${MUTED} · Ctrl+C to stop${RESET}`); + console.log(); +} + +export function printEvent( + method: string, + path: string, + status: number, + latencyMs: number, +): void { + const statusColor = status < 300 ? GREEN : status < 500 ? YELLOW : RED; + console.log( + ` ${GREEN}├${RESET} ` + + `${WHITE}${method.padEnd(6)}${RESET}` + + `${CYAN}${path.padEnd(36)}${RESET}` + + `${statusColor}${status}${RESET}` + + ` ${MUTED}${latencyMs}ms${RESET}`, + ); +} + +export function printExit(): void { + console.log(); + console.log(` ${GREEN}└${RESET} ${MUTED}session ended · all event data cleared${RESET}`); console.log(); } diff --git a/packages/tern-cli/src/tunnel.ts b/packages/tern-cli/src/tunnel.ts index cf3dc5e..b715acf 100644 --- a/packages/tern-cli/src/tunnel.ts +++ b/packages/tern-cli/src/tunnel.ts @@ -1,8 +1,15 @@ import { spawn } from "node:child_process"; -import { CYAN, GRAY, GREEN, RESET } from "./colors"; -import { copyToClipboard } from "./clipboard"; -import { openBrowser } from "./browser"; -import { printUrlBox } from "./print"; +import { copyToClipboard } from "./clipboard.js"; +import { openBrowser } from "./browser.js"; +import { + printEvent, + printExit, + printListeningState, + printStep, + printStepDone, + printUrlBox, + startConnectingAnimation, +} from "./print.js"; /** Starts tern-dev forwarding and streams connection updates. */ export function startTunnel( @@ -10,6 +17,10 @@ export function startTunnel( webhookPath: string, platformLabel: string, ): void { + console.log(); + printStep("connecting"); + const stopAnimation = startConnectingAnimation(); + const child = spawn( "npx", ["--yes", "@hookflo/tern-dev", "--port", port, "--path", webhookPath], @@ -19,37 +30,54 @@ export function startTunnel( let urlFound = false; let dashboardPort: string | null = null; + const handleLine = (line: string): void => { + if (!line.trim()) return; + + const dashMatch = line.match(/dashboard\s+http:\/\/localhost:(\d+)/i); + if (dashMatch && !dashboardPort) { + dashboardPort = dashMatch[1]; + } + + const match = line.match(/https:\/\/[^\s]+\/s\/[a-zA-Z0-9_-]+/); + if (match && !urlFound) { + urlFound = true; + stopAnimation(); + printStepDone("connected"); + + const url = match[0]; + const copied = copyToClipboard(url); + printUrlBox(platformLabel, url, copied); + + const resolvedUiPort = dashboardPort ?? "2019"; + openBrowser(`http://localhost:${resolvedUiPort}`); + printListeningState(port, resolvedUiPort, 60); + return; + } + + const eventMatch = line.match(/\b(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\S+)\s+(\d{3})\s+(\d+)ms/); + if (eventMatch) { + const [, method, path, status, latency] = eventMatch; + printEvent(method, path, Number(status), Number(latency)); + } + }; + child.stdout?.on("data", (data: Buffer) => { const lines = data.toString().split("\n"); for (const line of lines) { - const dashMatch = line.match(/localhost:(\d+)/); - if (dashMatch && !dashboardPort) { - dashboardPort = dashMatch[1]; - openBrowser(`http://localhost:${dashboardPort}`); - } - - const match = line.match(/https:\/\/[^\s]+\/s\/[a-zA-Z0-9_-]+/); - if (match && !urlFound) { - urlFound = true; - const url = match[0]; - const copied = copyToClipboard(url); - printUrlBox(platformLabel, url, copied); - const dashboardUrl = `localhost:${dashboardPort ?? "2019"}`; - console.log(` opening webhook debugger · ${CYAN}${dashboardUrl}${RESET}\n`); - if (!dashboardPort) { - openBrowser("http://localhost:2019"); - } - console.log(` ${GREEN}●${RESET} listening for events`); - console.log(` ${GRAY}Ctrl+C to stop · auto-ends in 60 min${RESET}\n`); - } + handleLine(line); } }); + child.stderr?.on("data", () => { + // Keep child stderr hidden to preserve clean CLI output aesthetics. + }); + child.on("exit", () => process.exit(0)); process.on("SIGINT", () => { + stopAnimation(); child.kill("SIGINT"); - console.log(`\n ${GRAY}session ended · all event data cleared${RESET}\n`); + printExit(); process.exit(0); }); } diff --git a/packages/tern-cli/tsconfig.json b/packages/tern-cli/tsconfig.json index c1954ba..90b6071 100644 --- a/packages/tern-cli/tsconfig.json +++ b/packages/tern-cli/tsconfig.json @@ -1,12 +1,12 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", "strict": true, "declaration": true, - "outDir": "dist", - "rootDir": "src", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true diff --git a/src/cli.ts b/src/cli.ts index d4c3b2b..18434e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import minimist from "minimist"; import { resolveConfig, TernConfig, validateConfig } from "./config"; import { EventStore } from "./event-store"; import { forward, setLocalTlsCredentials } from "./forwarder"; -import { error, info, printBanner, printHelp, printLogo, printSafetyBanner, success, warn } from "./logger"; +import { error, info, printBanner, printConnected, printHelp, printLogo, printReconnecting, printRequest, printSafetyBanner, printSessionEnded, warn } from "./logger"; import { RelayClient } from "./relay-client"; import { RelayConnectedMessage, RelayMessage, StatusPayload } from "./types"; import { UiServer } from "./ui-server"; @@ -287,7 +287,7 @@ async function main(): Promise { const forwardTarget = cliArgs.forwardTarget ?? `localhost:${config.port ?? 0}${config.path && config.path !== "/" ? config.path : ""}`; printBanner(payload.url, forwardTarget, uiPort ?? (config.uiPort ?? 2019), Boolean(config.noUi)); printSafetyBanner(config.ttl); - success("connected ✓"); + printConnected(); if (config.ttl !== undefined) { clearTimers(); @@ -316,7 +316,7 @@ async function main(): Promise { relayClient.on("reconnecting", ({ attempt, delayMs }) => { setStatus({ connected: false, state: "reconnecting" }); - warn(`reconnecting... (attempt ${attempt}, ${Math.max(1, Math.round(delayMs / 1000))}s)`); + printReconnecting(attempt, Math.max(1, Math.round(delayMs / 1000))); }); relayClient.on("disconnect", () => { @@ -332,9 +332,13 @@ async function main(): Promise { if (event.error && event.status === null) { warn(event.error); } - const statusLabel = event.status ? `${event.status}` : "ERR"; - info( - `${event.method} ${event.path} → ${statusLabel} ${event.latency ?? 0}ms`, + const statusCode = event.status ?? 500; + printRequest( + event.method, + event.path, + statusCode, + event.latency ?? 0, + event.sourceIp ?? "unknown", ); appendAuditLog(config, { method: event.method, @@ -366,10 +370,14 @@ async function main(): Promise { } process.on("SIGINT", () => { - shutdown("session ended · all event data cleared"); + printSessionEnded(); + shutdown(); }); - process.on("SIGTERM", () => shutdown("session ended · all event data cleared")); + process.on("SIGTERM", () => { + printSessionEnded(); + shutdown(); + }); relayClient.connect(config.relay ?? "wss://tern-relay.hookflo-tern.workers.dev"); } diff --git a/src/logger.ts b/src/logger.ts index 6af94ce..fd6cc4d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,75 +1,66 @@ -const ANSI = { - reset: "\x1b[0m", - bold: "\x1b[1m", - green: "\x1b[38;2;16;185;129m", - cyan: "\x1b[36m", - red: "\x1b[38;2;239;68;68m", - yellow: "\x1b[38;2;245;158;11m", - gray: "\x1b[38;2;107;105;99m", - white: "\x1b[38;2;240;237;232m", -} as const; - -const PREFIX = `${ANSI.gray}tern ›${ANSI.reset}`; - -function withColor(color: string, value: string): string { - return `${color}${value}${ANSI.reset}`; +export const GREEN = "\x1b[38;2;16;185;129m"; +export const CYAN = "\x1b[38;2;6;182;212m"; +export const YELLOW = "\x1b[38;2;245;158;11m"; +export const GRAY = "\x1b[38;2;55;55;55m"; +export const MUTED = "\x1b[38;2;75;75;75m"; +export const WHITE = "\x1b[38;2;240;237;232m"; +export const RED = "\x1b[38;2;239;68;68m"; +export const RESET = "\x1b[0m"; +export const BOLD = "\x1b[1m"; + +const LABEL_WIDTH = 16; + +export function printDivider(): void { + console.log(` ${GREEN}${"─".repeat(42)}${RESET}`); + console.log(); } -function formatLabel(label: string): string { - return `${ANSI.gray}${label}${ANSI.reset} ${ANSI.gray}→${ANSI.reset}`; +export function printPipe(): void { + console.log(` ${GREEN}│${RESET}`); } -function formatRequestLine(message: string): string { - const match = message.match(/^(\S+)\s+(\S+)\s+→\s+(\S+)\s+(\d+ms)$/); - if (!match) { - return `${ANSI.white}${message}${ANSI.reset}`; - } - - const [, method, path, status, latency] = match; - const statusCode = Number(status); - let statusColor: string = ANSI.red; - if (Number.isFinite(statusCode)) { - if (statusCode >= 200 && statusCode < 300) statusColor = ANSI.green; - else if (statusCode >= 400 && statusCode < 500) statusColor = ANSI.yellow; - } - - return `${ANSI.cyan}${method}${ANSI.reset} ${ANSI.white}${path}${ANSI.reset} ${ANSI.gray}→${ANSI.reset} ${statusColor}${status}${ANSI.reset} ${ANSI.gray}${latency}${ANSI.reset}`; +export function printRow( + icon: string, + label: string, + value: string, + valueColor: string = WHITE, +): void { + console.log( + ` ${GREEN}${icon}${RESET} ` + + `${MUTED}${label.padEnd(LABEL_WIDTH)}${RESET}` + + `${valueColor}${value}${RESET}`, + ); } export function info(message: string): void { - process.stdout.write(`${PREFIX} ${formatRequestLine(message)}\n`); + console.log(` ${GRAY}tern › ${RESET}${WHITE}${message}${RESET}`); } export function success(message: string): void { - process.stdout.write( - `${withColor(ANSI.green, PREFIX)} ${withColor(ANSI.green, message)}\n`, - ); + console.log(` ${GREEN}tern › ${RESET}${GREEN}${message}${RESET}`); } export function warn(message: string): void { - process.stdout.write(`${PREFIX} ${withColor(ANSI.gray, message)}\n`); + console.log(` ${MUTED}tern › ${message}${RESET}`); } export function error(message: string): void { - process.stderr.write( - `${withColor(ANSI.red, `${PREFIX} error:`)} ${withColor(ANSI.red, message)}\n`, - ); + console.error(` ${RED}tern › error ${message}${RESET}`); } export function printLogo(version: string): void { - const logo = [ - " ████████╗███████╗██████╗ ███╗ ██╗", - " ██║ ██╔════╝██╔══██╗████╗ ██║", - " ██║ █████╗ ██████╔╝██╔██╗██║", - " ██║ ██╔══╝ ██╔══██╗██║╚████║", - " ██║ ███████╗██║ ██║██║ ╚███║", - " ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝", - ]; - - process.stdout.write(`\n${withColor(ANSI.green, logo.join("\n"))}\n`); - process.stdout.write( - `${ANSI.gray} v${version} · open source webhook tunnel${ANSI.reset}\n`, - ); + console.log(); + console.log(); + console.log(` ${GREEN}████████╗███████╗██████╗ ███╗ ██╗${RESET}`); + console.log(` ${GREEN} ██║ ██╔════╝██╔══██╗████╗ ██║${RESET}`); + console.log(` ${GREEN} ██║ █████╗ ██████╔╝██╔██╗██║${RESET}`); + console.log(` ${GREEN} ██║ ██╔══╝ ██╔══██╗██║╚████║${RESET}`); + console.log(` ${GREEN} ██║ ███████╗██║ ██║██║ ╚███║${RESET}`); + console.log(` ${GREEN} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝${RESET}`); + console.log(); + console.log(` ${MUTED}v${version} · open source webhook tunnel${RESET}`); + console.log(); + console.log(); } export function printBanner( @@ -78,36 +69,58 @@ export function printBanner( uiPort: number, noUi: boolean, ): void { - process.stdout.write("\n"); - process.stdout.write( - ` ${formatLabel("tunnel")} ${withColor(ANSI.green, tunnelUrl)}\n`, - ); - if (!noUi) { - process.stdout.write( - ` ${formatLabel("dashboard")} ${withColor(ANSI.cyan, `http://localhost:${uiPort}`)}\n`, - ); - } - process.stdout.write( - ` ${formatLabel("forwarding")} ${withColor(ANSI.white, forwardTarget)}\n`, - ); - process.stdout.write("\n"); - process.stdout.write( - ` ${ANSI.gray}Ctrl+C to end session · use --ttl 60 to auto-kill${ANSI.reset}\n\n`, - ); + console.log(); + printDivider(); + printPipe(); + printRow("│", "tunnel", tunnelUrl, CYAN); + if (!noUi) printRow("│", "dashboard", `http://localhost:${uiPort}`, CYAN); + printRow("│", "forwarding", forwardTarget, WHITE); + printPipe(); + printDivider(); + console.log(); +} + +export function printConnected(): void { + console.log(` ${GREEN}└${RESET} ${GREEN}● connected ✓${RESET}`); + console.log(); +} + +export function printReconnecting(attempt: number, delay: number): void { + console.log(` ${MUTED}tern › reconnecting ${GRAY}attempt ${attempt} ${delay}s${RESET}`); } export function printSafetyBanner(ttl?: number): void { - if (ttl === undefined) { - process.stdout.write( - ` ${ANSI.gray}no ttl set — tunnel runs until Ctrl+C${ANSI.reset}\n\n`, - ); - return; + if (ttl) { + console.log(` ${MUTED}auto-kill in ${ttl} minutes · Ctrl+C to stop now${RESET}`); + } else { + console.log(` ${MUTED}no ttl set · runs until Ctrl+C${RESET}`); } - process.stdout.write( - ` ${ANSI.gray}auto-kill in ${ttl} minutes${ANSI.reset}\n\n`, + console.log(); +} + +export function printRequest( + method: string, + path: string, + status: number, + latencyMs: number, + _sourceIp: string, +): void { + const statusColor = status < 300 ? GREEN : status < 500 ? YELLOW : RED; + console.log( + ` ${GRAY}tern › ${RESET}` + + `${WHITE}${method.padEnd(6)}${RESET}` + + `${CYAN}${path.padEnd(36)}${RESET}` + + `${statusColor}${status}${RESET}` + + ` ${MUTED}${latencyMs}ms${RESET}`, ); } +export function printSessionEnded(): void { + console.log(); + console.log(` ${GRAY}[tern] session ended · tunnel closed, all event data cleared${RESET}`); + console.log(); +} + export function printHelp(version: string): void { const lines = [ `@hookflo/tern-dev v${version}`,