From ec7cc0a5e449cbac09b4af7e16d3e5310598ed16 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:40:52 +0530 Subject: [PATCH 1/3] Revamp tern CLI/dev terminal aesthetics to oxide style --- packages/tern-cli/src/colors.ts | 6 +- packages/tern-cli/src/config.ts | 3 - packages/tern-cli/src/files.ts | 8 +- packages/tern-cli/src/index.ts | 40 +++++-- packages/tern-cli/src/install.ts | 10 +- packages/tern-cli/src/print.ts | 176 +++++++++++++++++++++++++------ packages/tern-cli/src/tunnel.ts | 74 +++++++++---- src/cli.ts | 24 +++-- src/logger.ts | 165 ++++++++++++++++------------- 9 files changed, 343 insertions(+), 163 deletions(-) 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..f920949 100644 --- a/packages/tern-cli/src/index.ts +++ b/packages/tern-cli/src/index.ts @@ -1,19 +1,25 @@ #!/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 { printEnvBlock, printLogo, printStep, printStepDone, printStepFile, printSummary } from "./print"; import { getTemplate } from "./templates"; import { startTunnel } from "./tunnel"; import { askQuestions, ENV_VARS, getPlatformLabel } from "./wizard"; +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 +31,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 +62,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..80222f1 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"; /** 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/print.ts b/packages/tern-cli/src/print.ts index 78aa6f9..5ed6201 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"; + +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..a8c0b9b 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 { + printEvent, + printExit, + printListeningState, + printStep, + printStepDone, + printUrlBox, + startConnectingAnimation, +} from "./print"; /** 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/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}`, From ec172a2ca68df54812974092706988c3422b292b Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:49:31 +0530 Subject: [PATCH 2/3] Remove clack dependency from tern-cli prompts --- packages/tern-cli/package-lock.json | 26 ------------ packages/tern-cli/package.json | 1 - packages/tern-cli/src/files.ts | 10 ++--- packages/tern-cli/src/prompts.ts | 65 +++++++++++++++++++++++++++++ packages/tern-cli/src/wizard.ts | 58 +++++++++---------------- 5 files changed, 90 insertions(+), 70 deletions(-) create mode 100644 packages/tern-cli/src/prompts.ts diff --git a/packages/tern-cli/package-lock.json b/packages/tern-cli/package-lock.json index a21ae0f..c9d8c3e 100644 --- a/packages/tern-cli/package-lock.json +++ b/packages/tern-cli/package-lock.json @@ -9,32 +9,12 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@clack/prompts": "latest", "@hookflo/tern-dev": "latest" }, "bin": { "tern": "dist/index.js" } }, - "node_modules/@clack/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", - "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", - "license": "MIT", - "dependencies": { - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", - "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", - "license": "MIT", - "dependencies": { - "@clack/core": "1.1.0", - "sisteransi": "^1.0.5" - } - }, "node_modules/@hookflo/tern-dev": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@hookflo/tern-dev/-/tern-dev-0.2.3.tgz", @@ -60,12 +40,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/packages/tern-cli/package.json b/packages/tern-cli/package.json index 9a6716e..2b60b70 100644 --- a/packages/tern-cli/package.json +++ b/packages/tern-cli/package.json @@ -17,7 +17,6 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@clack/prompts": "latest", "@hookflo/tern-dev": "latest" } } diff --git a/packages/tern-cli/src/files.ts b/packages/tern-cli/src/files.ts index 5a666fe..6cdf9a7 100644 --- a/packages/tern-cli/src/files.ts +++ b/packages/tern-cli/src/files.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import * as clack from "@clack/prompts"; +import { confirm } from "./prompts"; /** Returns the target handler file path for a framework/platform pair. */ export function getFilePath(framework: string, platform: string): string { @@ -48,10 +48,10 @@ export async function createHandlerFile( const fullPath = path.join(process.cwd(), filePath); if (fs.existsSync(fullPath)) { - const overwrite = await clack.confirm({ - message: `${path.basename(fullPath)} already exists. overwrite?`, - }); - if (clack.isCancel(overwrite) || !overwrite) { + const overwrite = await confirm( + `${path.basename(fullPath)} already exists. overwrite?`, + ); + if (!overwrite) { return false; } } diff --git a/packages/tern-cli/src/prompts.ts b/packages/tern-cli/src/prompts.ts new file mode 100644 index 0000000..1204174 --- /dev/null +++ b/packages/tern-cli/src/prompts.ts @@ -0,0 +1,65 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +export interface PromptOption { + value: T; + label: string; +} + +function withPrompt(question: string): Promise { + const rl = createInterface({ input, output }); + return rl + .question(question) + .then((answer) => { + rl.close(); + return answer; + }) + .catch((err) => { + rl.close(); + throw err; + }); +} + +export async function select( + message: string, + options: readonly PromptOption[], +): Promise { + console.log(`\n ${message}`); + options.forEach((option, index) => { + console.log(` ${index + 1}. ${option.label}`); + }); + + while (true) { + const answer = (await withPrompt("\n select option number: ")).trim(); + const selected = Number(answer); + if (Number.isInteger(selected) && selected >= 1 && selected <= options.length) { + return options[selected - 1].value; + } + console.log(" invalid selection. enter one of the numbers above."); + } +} + +export async function text( + message: string, + defaultValue: string, + validate?: (value: string) => string | undefined, +): Promise { + while (true) { + const answer = (await withPrompt(`\n ${message} (${defaultValue}): `)).trim(); + const value = answer || defaultValue; + const validationError = validate?.(value); + if (!validationError) { + return value; + } + console.log(` ${validationError}`); + } +} + +export async function confirm(message: string): Promise { + while (true) { + const answer = (await withPrompt(`\n ${message} (y/N): `)).trim().toLowerCase(); + if (answer === "y" || answer === "yes") return true; + if (!answer || answer === "n" || answer === "no") return false; + console.log(" please answer with y or n."); + } +} diff --git a/packages/tern-cli/src/wizard.ts b/packages/tern-cli/src/wizard.ts index a3dc810..569c754 100644 --- a/packages/tern-cli/src/wizard.ts +++ b/packages/tern-cli/src/wizard.ts @@ -1,4 +1,4 @@ -import * as clack from "@clack/prompts"; +import { select, text } from "./prompts"; /** Platform option metadata shown in the setup wizard. */ export const PLATFORMS = [ @@ -57,51 +57,33 @@ export interface WizardAnswers { port: string; } -function handleCancel(value: unknown): void { - if (!clack.isCancel(value)) return; - clack.cancel("cancelled"); - process.exit(0); -} - /** Runs the interactive setup wizard. */ export async function askQuestions(): Promise { - const platform = await clack.select({ - message: "which platform are you integrating?", - options: [...PLATFORMS], - }); - handleCancel(platform); + const platform = await select( + "which platform are you integrating?", + PLATFORMS, + ); - const framework = await clack.select({ - message: "which framework are you using?", - options: [...FRAMEWORKS], - }); - handleCancel(framework); + const framework = await select( + "which framework are you using?", + FRAMEWORKS, + ); - const action = await clack.select({ - message: "what would you like to do?", - options: [ - { value: "both", label: "set up webhook handler + test locally" }, - { value: "handler", label: "set up webhook handler only" }, - { value: "tunnel", label: "test locally only" }, - ], - }); - handleCancel(action); + const action = await select("what would you like to do?", [ + { value: "both", label: "set up webhook handler + test locally" }, + { value: "handler", label: "set up webhook handler only" }, + { value: "tunnel", label: "test locally only" }, + ]); let port = "3000"; if (action !== "handler") { - const entered = await clack.text({ - message: "which port is your app running on?", - placeholder: "3000", - defaultValue: "3000", - validate: (v: string) => { - const n = Number(v); - if (!Number.isInteger(n) || n < 1 || n > 65535) - return "enter a valid port number"; - return undefined; - }, + port = await text("which port is your app running on?", "3000", (v: string) => { + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + return "enter a valid port number"; + } + return undefined; }); - handleCancel(entered); - port = entered; } return { platform, framework, action, port }; From f0ff34a63cfb91054fcc390ff6ea9332254504f5 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:49:36 +0530 Subject: [PATCH 3/3] Restore clack prompts and migrate tern-cli build to ESM --- packages/tern-cli/package-lock.json | 26 +++++++++++ packages/tern-cli/package.json | 7 ++- packages/tern-cli/src/files.ts | 10 ++--- packages/tern-cli/src/index.ts | 17 ++++---- packages/tern-cli/src/install.ts | 2 +- packages/tern-cli/src/patch-clack.ts | 24 ++++++++++ packages/tern-cli/src/print.ts | 2 +- packages/tern-cli/src/prompts.ts | 65 ---------------------------- packages/tern-cli/src/tunnel.ts | 6 +-- packages/tern-cli/src/wizard.ts | 58 ++++++++++++++++--------- packages/tern-cli/tsconfig.json | 8 ++-- 11 files changed, 117 insertions(+), 108 deletions(-) create mode 100644 packages/tern-cli/src/patch-clack.ts delete mode 100644 packages/tern-cli/src/prompts.ts diff --git a/packages/tern-cli/package-lock.json b/packages/tern-cli/package-lock.json index c9d8c3e..a21ae0f 100644 --- a/packages/tern-cli/package-lock.json +++ b/packages/tern-cli/package-lock.json @@ -9,12 +9,32 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@clack/prompts": "latest", "@hookflo/tern-dev": "latest" }, "bin": { "tern": "dist/index.js" } }, + "node_modules/@clack/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", + "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "license": "MIT", + "dependencies": { + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@hookflo/tern-dev": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@hookflo/tern-dev/-/tern-dev-0.2.3.tgz", @@ -40,6 +60,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/packages/tern-cli/package.json b/packages/tern-cli/package.json index 2b60b70..192d2ec 100644 --- a/packages/tern-cli/package.json +++ b/packages/tern-cli/package.json @@ -17,6 +17,11 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@hookflo/tern-dev": "latest" + "@hookflo/tern-dev": "latest", + "@clack/prompts": "latest" + }, + "type": "module", + "engines": { + "node": ">=18" } } diff --git a/packages/tern-cli/src/files.ts b/packages/tern-cli/src/files.ts index 6cdf9a7..5a666fe 100644 --- a/packages/tern-cli/src/files.ts +++ b/packages/tern-cli/src/files.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { confirm } from "./prompts"; +import * as clack from "@clack/prompts"; /** Returns the target handler file path for a framework/platform pair. */ export function getFilePath(framework: string, platform: string): string { @@ -48,10 +48,10 @@ export async function createHandlerFile( const fullPath = path.join(process.cwd(), filePath); if (fs.existsSync(fullPath)) { - const overwrite = await confirm( - `${path.basename(fullPath)} already exists. overwrite?`, - ); - if (!overwrite) { + const overwrite = await clack.confirm({ + message: `${path.basename(fullPath)} already exists. overwrite?`, + }); + if (clack.isCancel(overwrite) || !overwrite) { return false; } } diff --git a/packages/tern-cli/src/index.ts b/packages/tern-cli/src/index.ts index f920949..d10ce03 100644 --- a/packages/tern-cli/src/index.ts +++ b/packages/tern-cli/src/index.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node -import { GRAY, RESET } from "./colors"; -import { createConfig } from "./config"; -import { createHandlerFile, getFilePath, getWebhookPath } from "./files"; -import { installTern } from "./install"; -import { printEnvBlock, printLogo, printStep, printStepDone, printStepFile, printSummary } 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"; diff --git a/packages/tern-cli/src/install.ts b/packages/tern-cli/src/install.ts index 80222f1..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 { printStep, printStepDone } from "./print"; +import { printStep, printStepDone } from "./print.js"; /** Detects the package manager install command from lockfiles. */ export function detectPackageManager(): string { 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 5ed6201..702588e 100644 --- a/packages/tern-cli/src/print.ts +++ b/packages/tern-cli/src/print.ts @@ -1,4 +1,4 @@ -import { CYAN, GRAY, GREEN, MUTED, RED, RESET, WHITE, YELLOW } from "./colors"; +import { CYAN, GRAY, GREEN, MUTED, RED, RESET, WHITE, YELLOW } from "./colors.js"; const LABEL_WIDTH = 16; diff --git a/packages/tern-cli/src/prompts.ts b/packages/tern-cli/src/prompts.ts deleted file mode 100644 index 1204174..0000000 --- a/packages/tern-cli/src/prompts.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; - -export interface PromptOption { - value: T; - label: string; -} - -function withPrompt(question: string): Promise { - const rl = createInterface({ input, output }); - return rl - .question(question) - .then((answer) => { - rl.close(); - return answer; - }) - .catch((err) => { - rl.close(); - throw err; - }); -} - -export async function select( - message: string, - options: readonly PromptOption[], -): Promise { - console.log(`\n ${message}`); - options.forEach((option, index) => { - console.log(` ${index + 1}. ${option.label}`); - }); - - while (true) { - const answer = (await withPrompt("\n select option number: ")).trim(); - const selected = Number(answer); - if (Number.isInteger(selected) && selected >= 1 && selected <= options.length) { - return options[selected - 1].value; - } - console.log(" invalid selection. enter one of the numbers above."); - } -} - -export async function text( - message: string, - defaultValue: string, - validate?: (value: string) => string | undefined, -): Promise { - while (true) { - const answer = (await withPrompt(`\n ${message} (${defaultValue}): `)).trim(); - const value = answer || defaultValue; - const validationError = validate?.(value); - if (!validationError) { - return value; - } - console.log(` ${validationError}`); - } -} - -export async function confirm(message: string): Promise { - while (true) { - const answer = (await withPrompt(`\n ${message} (y/N): `)).trim().toLowerCase(); - if (answer === "y" || answer === "yes") return true; - if (!answer || answer === "n" || answer === "no") return false; - console.log(" please answer with y or n."); - } -} diff --git a/packages/tern-cli/src/tunnel.ts b/packages/tern-cli/src/tunnel.ts index a8c0b9b..b715acf 100644 --- a/packages/tern-cli/src/tunnel.ts +++ b/packages/tern-cli/src/tunnel.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; -import { copyToClipboard } from "./clipboard"; -import { openBrowser } from "./browser"; +import { copyToClipboard } from "./clipboard.js"; +import { openBrowser } from "./browser.js"; import { printEvent, printExit, @@ -9,7 +9,7 @@ import { printStepDone, printUrlBox, startConnectingAnimation, -} from "./print"; +} from "./print.js"; /** Starts tern-dev forwarding and streams connection updates. */ export function startTunnel( diff --git a/packages/tern-cli/src/wizard.ts b/packages/tern-cli/src/wizard.ts index 569c754..a3dc810 100644 --- a/packages/tern-cli/src/wizard.ts +++ b/packages/tern-cli/src/wizard.ts @@ -1,4 +1,4 @@ -import { select, text } from "./prompts"; +import * as clack from "@clack/prompts"; /** Platform option metadata shown in the setup wizard. */ export const PLATFORMS = [ @@ -57,33 +57,51 @@ export interface WizardAnswers { port: string; } +function handleCancel(value: unknown): void { + if (!clack.isCancel(value)) return; + clack.cancel("cancelled"); + process.exit(0); +} + /** Runs the interactive setup wizard. */ export async function askQuestions(): Promise { - const platform = await select( - "which platform are you integrating?", - PLATFORMS, - ); + const platform = await clack.select({ + message: "which platform are you integrating?", + options: [...PLATFORMS], + }); + handleCancel(platform); - const framework = await select( - "which framework are you using?", - FRAMEWORKS, - ); + const framework = await clack.select({ + message: "which framework are you using?", + options: [...FRAMEWORKS], + }); + handleCancel(framework); - const action = await select("what would you like to do?", [ - { value: "both", label: "set up webhook handler + test locally" }, - { value: "handler", label: "set up webhook handler only" }, - { value: "tunnel", label: "test locally only" }, - ]); + const action = await clack.select({ + message: "what would you like to do?", + options: [ + { value: "both", label: "set up webhook handler + test locally" }, + { value: "handler", label: "set up webhook handler only" }, + { value: "tunnel", label: "test locally only" }, + ], + }); + handleCancel(action); let port = "3000"; if (action !== "handler") { - port = await text("which port is your app running on?", "3000", (v: string) => { - const n = Number(v); - if (!Number.isInteger(n) || n < 1 || n > 65535) { - return "enter a valid port number"; - } - return undefined; + const entered = await clack.text({ + message: "which port is your app running on?", + placeholder: "3000", + defaultValue: "3000", + validate: (v: string) => { + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 65535) + return "enter a valid port number"; + return undefined; + }, }); + handleCancel(entered); + port = entered; } return { platform, framework, action, port }; 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