diff --git a/packages/tern-cli/README.md b/packages/tern-cli/README.md new file mode 100644 index 0000000..374e79c --- /dev/null +++ b/packages/tern-cli/README.md @@ -0,0 +1,16 @@ +# @hookflo/tern-cli + +Interactive webhook setup wizard for the tern ecosystem. + +## Usage + +```bash +npx @hookflo/tern-cli +``` + +## What it does + +- guides you through webhook handler setup +- generates handler files in the right location +- starts local webhook testing tunnel +- opens debugging dashboard automatically diff --git a/packages/tern-cli/package.json b/packages/tern-cli/package.json new file mode 100644 index 0000000..9a6716e --- /dev/null +++ b/packages/tern-cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@hookflo/tern-cli", + "version": "0.1.0", + "description": "Interactive webhook setup wizard for tern", + "license": "MIT", + "bin": { + "tern": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@clack/prompts": "latest", + "@hookflo/tern-dev": "latest" + } +} diff --git a/packages/tern-cli/src/browser.ts b/packages/tern-cli/src/browser.ts new file mode 100644 index 0000000..902caa9 --- /dev/null +++ b/packages/tern-cli/src/browser.ts @@ -0,0 +1,13 @@ +import { exec } from "node:child_process"; + +/** Opens a URL in the default browser when possible. */ +export function openBrowser(url: string): void { + try { + const p = process.platform; + if (p === "darwin") exec(`open "${url}"`); + else if (p === "win32") exec(`start "${url}"`); + else exec(`xdg-open "${url}"`); + } catch { + // silent fail + } +} diff --git a/packages/tern-cli/src/clipboard.ts b/packages/tern-cli/src/clipboard.ts new file mode 100644 index 0000000..a3bb605 --- /dev/null +++ b/packages/tern-cli/src/clipboard.ts @@ -0,0 +1,14 @@ +import { execSync } from "node:child_process"; + +/** Copies text to clipboard and returns true when successful. */ +export function copyToClipboard(text: string): boolean { + try { + const p = process.platform; + if (p === "darwin") execSync(`printf '%s' "${text}" | pbcopy`); + else if (p === "win32") execSync(`echo|set /p="${text}" | clip`); + else execSync(`printf '%s' "${text}" | xclip -selection clipboard`); + return true; + } catch { + return false; + } +} diff --git a/packages/tern-cli/src/colors.ts b/packages/tern-cli/src/colors.ts new file mode 100644 index 0000000..0f24ce9 --- /dev/null +++ b/packages/tern-cli/src/colors.ts @@ -0,0 +1,16 @@ +/** ANSI green used for success states and logo. */ +export const GREEN = "\x1b[38;2;16;185;129m"; +/** ANSI cyan used for URLs and file paths. */ +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 white used for primary text. */ +export const WHITE = "\x1b[38;2;240;237;232m"; +/** ANSI red used for errors. */ +export const RED = "\x1b[38;2;239;68;68m"; +/** ANSI reset sequence. */ +export const RESET = "\x1b[0m"; +/** ANSI bold sequence. */ +export const BOLD = "\x1b[1m"; diff --git a/packages/tern-cli/src/config.ts b/packages/tern-cli/src/config.ts new file mode 100644 index 0000000..80eefa7 --- /dev/null +++ b/packages/tern-cli/src/config.ts @@ -0,0 +1,49 @@ +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( + port: string, + webhookPath: string, + platform: string, + framework: string, +): void { + const configPath = path.join(process.cwd(), "tern.config.json"); + const config = `{ + "$schema": "./tern-config.schema.json", + + "port": ${Number(port)}, + + "path": "${webhookPath}", + + "platform": "${platform}", + + "framework": "${framework}", + + "uiPort": 2019, + + "relay": "wss://tern-relay.hookflo-tern.workers.dev", + + "maxEvents": 500, + + "ttl": 30, + + "rateLimit": 100, + + "allowIp": [], + + "block": { + "paths": [], + "methods": [], + "headers": {} + }, + + "log": "" +} +`; + + 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 new file mode 100644 index 0000000..a2c92bd --- /dev/null +++ b/packages/tern-cli/src/files.ts @@ -0,0 +1,64 @@ +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 { + const cwd = process.cwd(); + const hasSrc = fs.existsSync(path.join(cwd, "src")); + + switch (framework) { + case "nextjs": + if (fs.existsSync(path.join(cwd, "src/app"))) { + return `src/app/api/webhooks/${platform}/route.ts`; + } + if (fs.existsSync(path.join(cwd, "app"))) { + return `app/api/webhooks/${platform}/route.ts`; + } + return `app/api/webhooks/${platform}/route.ts`; + + case "express": + return hasSrc + ? `src/routes/webhooks/${platform}.ts` + : `routes/webhooks/${platform}.ts`; + + case "hono": + return hasSrc + ? `src/routes/webhooks/${platform}.ts` + : `src/routes/webhooks/${platform}.ts`; + + case "cloudflare": + return hasSrc ? `src/webhooks/${platform}.ts` : `webhooks/${platform}.ts`; + + default: + return `webhooks/${platform}.ts`; + } +} + +/** Returns webhook route path used by tern config and forwarding. */ +export function getWebhookPath(platform: string): string { + return `/api/webhooks/${platform}`; +} + +/** Creates a handler file, confirming before overwrite. */ +export async function createHandlerFile( + filePath: string, + content: string, +): Promise { + 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) { + clack.log.warn(`skipped ${filePath}`); + return; + } + } + + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf8"); + clack.log.success(`created ${CYAN}${filePath}${RESET}`); +} diff --git a/packages/tern-cli/src/index.ts b/packages/tern-cli/src/index.ts new file mode 100644 index 0000000..19a7589 --- /dev/null +++ b/packages/tern-cli/src/index.ts @@ -0,0 +1,63 @@ +#!/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"; + +/** CLI entrypoint for @hookflo/tern-cli. */ +export async function main(): Promise { + printLogo(); + + const { platform, framework, action, port } = await askQuestions(); + + if (action === "handler") { + await installTern(); + const filePath = getFilePath(framework, platform); + const envVar = ENV_VARS[platform]; + const content = getTemplate( + framework, + platform, + envVar, + getPlatformLabel(platform), + ); + await createHandlerFile(filePath, content); + if (envVar) printEnvBox(envVar); + clack.outro("handler ready · add the env variable above to get started"); + return; + } + + if (action === "tunnel") { + const webhookPath = getWebhookPath(platform); + createConfig(port, webhookPath, platform, framework); + clack.log.step("connecting..."); + startTunnel(port, webhookPath, getPlatformLabel(platform)); + return; + } + + await installTern(); + const filePath = getFilePath(framework, platform); + const webhookPath = getWebhookPath(platform); + const envVar = ENV_VARS[platform]; + const content = getTemplate( + framework, + platform, + envVar ?? "", + getPlatformLabel(platform), + ); + await createHandlerFile(filePath, content); + createConfig(port, webhookPath, platform, framework); + if (envVar) printEnvBox(envVar); + clack.log.step("connecting..."); + startTunnel(port, webhookPath, getPlatformLabel(platform)); +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`\n ${GRAY}error: ${message}${RESET}\n`); + process.exit(1); +}); diff --git a/packages/tern-cli/src/install.ts b/packages/tern-cli/src/install.ts new file mode 100644 index 0000000..3b1cc32 --- /dev/null +++ b/packages/tern-cli/src/install.ts @@ -0,0 +1,24 @@ +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as clack from "@clack/prompts"; + +/** Detects the package manager install command from lockfiles. */ +export function detectPackageManager(): string { + if (fs.existsSync("yarn.lock")) return "yarn add"; + if (fs.existsSync("pnpm-lock.yaml")) return "pnpm add"; + return "npm install"; +} + +/** Installs @hookflo/tern and reports status in the wizard. */ +export async function installTern(): Promise { + const spinner = clack.spinner(); + spinner.start("installing @hookflo/tern"); + try { + const pm = detectPackageManager(); + execSync(`${pm} @hookflo/tern`, { stdio: "pipe" }); + spinner.stop("installed @hookflo/tern"); + } catch { + spinner.stop("could not install @hookflo/tern"); + clack.log.warn("run manually: npm install @hookflo/tern"); + } +} diff --git a/packages/tern-cli/src/print.ts b/packages/tern-cli/src/print.ts new file mode 100644 index 0000000..78aa6f9 --- /dev/null +++ b/packages/tern-cli/src/print.ts @@ -0,0 +1,50 @@ +import * as clack from "@clack/prompts"; +import { CYAN, GRAY, GREEN, RESET, YELLOW } from "./colors"; + +/** 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 "); +} + +/** Prints the environment variable helper box. */ +export function printEnvBox(envVar: string): void { + 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}`); + console.log(); +} + +/** 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); + + 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}`); + if (copied) { + console.log(` ${GREEN}│${RESET} ${GREEN}✓ copied to clipboard${RESET}${" ".repeat(width - 23)}${GREEN}│${RESET}`); + } + console.log(` ${GREEN}│${RESET}${" ".repeat(width)}${GREEN}│${RESET}`); + console.log(` ${GREEN}└${"─".repeat(width)}┘${RESET}`); + console.log(); +} diff --git a/packages/tern-cli/src/templates.ts b/packages/tern-cli/src/templates.ts new file mode 100644 index 0000000..6df3c3e --- /dev/null +++ b/packages/tern-cli/src/templates.ts @@ -0,0 +1,144 @@ +/** Returns handler template source code for framework/platform combination. */ +export function getTemplate( + framework: string, + platform: string, + envVar: string, + platformLabel: string, +): string { + if (platform === "other") return getOtherTemplate(framework); + + switch (framework) { + case "nextjs": + return nextjsTemplate(platform, envVar, platformLabel); + case "express": + return expressTemplate(platform, envVar, platformLabel); + case "hono": + return honoTemplate(platform, envVar, platformLabel); + case "cloudflare": + return cloudflareTemplate(platform, envVar, platformLabel); + default: + return genericTemplate(platform, envVar, platformLabel); + } +} + +function nextjsTemplate(p: string, env: string, label: string): string { + return `import { createWebhookHandler } from '@hookflo/tern/nextjs' + +export const POST = createWebhookHandler({ + platform: '${p}', + secret: process.env.${env}!, + handler: async (payload, metadata) => { + // TODO: handle ${label} webhook + console.log('received ${label} event:', payload) + return { received: true } + }, +}) +`; +} + +function expressTemplate(p: string, env: string, label: string): string { + return `import express from 'express' +import { createWebhookMiddleware } from '@hookflo/tern/express' + +const router = express.Router() + +router.post( + '/api/webhooks/${p}', + express.raw({ type: '*/*' }), + createWebhookMiddleware({ + platform: '${p}', + secret: process.env.${env}!, + }), + (req, res) => { + const event = (req as any).webhook?.payload + // TODO: handle ${label} webhook + console.log('received ${label} event:', event) + res.json({ received: true }) + }, +) + +export default router +`; +} + +function honoTemplate(p: string, env: string, label: string): string { + return `import { Hono } from 'hono' +import { createWebhookHandler } from '@hookflo/tern/hono' + +const app = new Hono() + +app.post( + '/api/webhooks/${p}', + createWebhookHandler({ + platform: '${p}', + secret: process.env.${env}!, + handler: async (payload, metadata, c) => { + // TODO: handle ${label} webhook + console.log('received ${label} event:', payload) + return c.json({ received: true }) + }, + }) +) + +export default app +`; +} + +function cloudflareTemplate(p: string, env: string, label: string): string { + return `import { createWebhookHandler } from '@hookflo/tern/cloudflare' + +export const onRequestPost = createWebhookHandler({ + platform: '${p}', + secretEnv: '${env}', + handler: async (payload) => { + // TODO: handle ${label} webhook + console.log('received ${label} event:', payload) + return { received: true, payload } + }, +}) +`; +} + +function genericTemplate(p: string, env: string, label: string): string { + return `import { WebhookVerificationService } from '@hookflo/tern' + +export async function handleWebhook(request: Request) { + const result = await WebhookVerificationService.verify(request, { + platform: '${p}', + secret: process.env.${env}!, + }) + + if (!result.isValid) { + return new Response( + JSON.stringify({ error: result.error }), + { status: 400 } + ) + } + + // TODO: handle ${label} webhook + console.log('received ${label} event:', result.payload) + return new Response( + JSON.stringify({ received: true }), + { status: 200 } + ) +} +`; +} + +function getOtherTemplate(_framework: string): string { + return `// TODO: add webhook verification for your platform +// see https://github.com/Hookflo/tern for supported platforms + +export async function handleWebhook(request: Request) { + // verify signature here + + const body = await request.json() + console.log('received webhook:', body) + + return new Response( + JSON.stringify({ received: true }), + { status: 200 } + ) +} +`; +} diff --git a/packages/tern-cli/src/tunnel.ts b/packages/tern-cli/src/tunnel.ts new file mode 100644 index 0000000..cf3dc5e --- /dev/null +++ b/packages/tern-cli/src/tunnel.ts @@ -0,0 +1,55 @@ +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"; + +/** Starts tern-dev forwarding and streams connection updates. */ +export function startTunnel( + port: string, + webhookPath: string, + platformLabel: string, +): void { + const child = spawn( + "npx", + ["--yes", "@hookflo/tern-dev", "--port", port, "--path", webhookPath], + { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env } }, + ); + + let urlFound = false; + let dashboardPort: string | null = null; + + 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`); + } + } + }); + + child.on("exit", () => process.exit(0)); + + process.on("SIGINT", () => { + child.kill("SIGINT"); + console.log(`\n ${GRAY}session ended · all event data cleared${RESET}\n`); + process.exit(0); + }); +} diff --git a/packages/tern-cli/src/types/clack-prompts.d.ts b/packages/tern-cli/src/types/clack-prompts.d.ts new file mode 100644 index 0000000..6468abf --- /dev/null +++ b/packages/tern-cli/src/types/clack-prompts.d.ts @@ -0,0 +1,29 @@ +declare module "@clack/prompts" { + export function intro(message: string): void; + export function outro(message: string): void; + export function note(message: string, title?: string): void; + export function cancel(message: string): void; + export const log: { + success(message: string): void; + info(message: string): void; + warn(message: string): void; + error(message: string): void; + step(message: string): void; + }; + export function isCancel(value: unknown): boolean; + export function confirm(options: { message: string }): Promise; + export function select(options: { + message: string; + options: Array<{ value: T; label: string }>; + }): Promise; + export function text(options: { + message: string; + placeholder?: string; + defaultValue?: string; + validate?: (value: string) => string | void; + }): Promise; + export function spinner(): { + start(message: string): void; + stop(message?: string): void; + }; +} diff --git a/packages/tern-cli/src/wizard.ts b/packages/tern-cli/src/wizard.ts new file mode 100644 index 0000000..a3dc810 --- /dev/null +++ b/packages/tern-cli/src/wizard.ts @@ -0,0 +1,114 @@ +import * as clack from "@clack/prompts"; + +/** Platform option metadata shown in the setup wizard. */ +export const PLATFORMS = [ + { value: "stripe", label: "Stripe" }, + { value: "github", label: "GitHub" }, + { value: "clerk", label: "Clerk" }, + { value: "shopify", label: "Shopify" }, + { value: "dodopayments", label: "Dodo Payments" }, + { value: "paddle", label: "Paddle" }, + { value: "lemonsqueezy", label: "Lemon Squeezy" }, + { value: "polar", label: "Polar" }, + { value: "workos", label: "WorkOS" }, + { value: "gitlab", label: "GitLab" }, + { value: "sentry", label: "Sentry" }, + { value: "razorpay", label: "Razorpay" }, + { value: "other", label: "Other" }, +] as const; + +/** Framework option metadata shown in the setup wizard. */ +export const FRAMEWORKS = [ + { value: "nextjs", label: "Next.js" }, + { value: "express", label: "Express" }, + { value: "hono", label: "Hono" }, + { value: "cloudflare", label: "Cloudflare Workers" }, + { value: "other", label: "Other" }, +] as const; + +/** Secret env variable names by platform. */ +export const ENV_VARS: Record = { + stripe: "STRIPE_WEBHOOK_SECRET", + github: "GITHUB_WEBHOOK_SECRET", + clerk: "CLERK_WEBHOOK_SECRET", + shopify: "SHOPIFY_WEBHOOK_SECRET", + dodopayments: "DODOPAYMENTS_WEBHOOK_SECRET", + paddle: "PADDLE_WEBHOOK_SECRET", + lemonsqueezy: "LEMONSQUEEZY_WEBHOOK_SECRET", + polar: "POLAR_WEBHOOK_SECRET", + workos: "WORKOS_WEBHOOK_SECRET", + gitlab: "GITLAB_WEBHOOK_SECRET", + sentry: "SENTRY_CLIENT_SECRET", + razorpay: "RAZORPAY_WEBHOOK_SECRET", +}; + +/** Platform value type. */ +export type Platform = (typeof PLATFORMS)[number]["value"]; +/** Framework value type. */ +export type Framework = (typeof FRAMEWORKS)[number]["value"]; +/** Selected action type. */ +export type Action = "both" | "handler" | "tunnel"; + +/** Collected wizard answers. */ +export interface WizardAnswers { + platform: Platform; + framework: Framework; + action: Action; + 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 framework = await clack.select({ + message: "which framework are you using?", + options: [...FRAMEWORKS], + }); + handleCancel(framework); + + 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") { + 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 }; +} + +/** Looks up the display label for a platform key. */ +export function getPlatformLabel(platform: string): string { + const found = PLATFORMS.find((p) => p.value === platform); + return found?.label ?? platform; +} diff --git a/packages/tern-cli/tsconfig.json b/packages/tern-cli/tsconfig.json new file mode 100644 index 0000000..c1954ba --- /dev/null +++ b/packages/tern-cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/src/cli.ts b/src/cli.ts index 7d3c239..d4c3b2b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import fs from "node:fs"; +import net from "node:net"; import minimist from "minimist"; import { resolveConfig, TernConfig, validateConfig } from "./config"; import { EventStore } from "./event-store"; @@ -182,6 +183,23 @@ function appendAuditLog(config: TernConfig, event: { method: string; path: strin }); } +function findAvailablePort(startPort: number, maxAttempts = 10): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(startPort, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + server.close(() => resolve(addr.port)); + }); + server.on("error", () => { + if (maxAttempts <= 1) { + reject(new Error(`no available port found starting from ${startPort}`)); + } else { + findAvailablePort(startPort + 1, maxAttempts - 1).then(resolve).catch(reject); + } + }); + }); +} + async function main(): Promise { const version = loadVersion(); const cliArgs = parseCliArgs(); @@ -210,6 +228,7 @@ async function main(): Promise { const eventStore = new EventStore(config.maxEvents ?? 500); const relayClient = new RelayClient(); const wsServer = new WsServer(); + const uiPort = config.noUi ? null : await findAvailablePort(config.uiPort ?? 2019); let status: StatusPayload = { connected: false, @@ -266,7 +285,7 @@ async function main(): Promise { } const forwardTarget = cliArgs.forwardTarget ?? `localhost:${config.port ?? 0}${config.path && config.path !== "/" ? config.path : ""}`; - printBanner(payload.url, forwardTarget, config.uiPort ?? 2019, Boolean(config.noUi)); + printBanner(payload.url, forwardTarget, uiPort ?? (config.uiPort ?? 2019), Boolean(config.noUi)); printSafetyBanner(config.ttl); success("connected ✓"); @@ -340,9 +359,10 @@ async function main(): Promise { version, }); - const httpServer = uiServer.start(config.uiPort ?? 2019); + const httpServer = uiServer.start(uiPort ?? (config.uiPort ?? 2019)); wsServer.attach(httpServer, "/ws"); wsServer.setStatus(status); + info(`Dashboard listening on http://localhost:${uiPort ?? (config.uiPort ?? 2019)}`); } process.on("SIGINT", () => { diff --git a/src/config.ts b/src/config.ts index 7a9e833..4261c53 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ export interface TernConfig { noUi?: boolean; relay?: string; maxEvents?: number; + platform?: string; + framework?: string; // New control flags ttl?: number; @@ -93,6 +95,8 @@ export function resolveConfig(cliArgs: Partial): TernConfig { noUi: cliArgs.noUi ?? fileConfig.noUi ?? DEFAULT_CONFIG.noUi, relay: cliArgs.relay ?? fileConfig.relay ?? DEFAULT_CONFIG.relay, maxEvents: cliArgs.maxEvents ?? fileConfig.maxEvents ?? DEFAULT_CONFIG.maxEvents, + platform: cliArgs.platform ?? fileConfig.platform, + framework: cliArgs.framework ?? fileConfig.framework, ttl: cliArgs.ttl ?? fileConfig.ttl, rateLimit: cliArgs.rateLimit ?? fileConfig.rateLimit, allowIp: cliArgs.allowIp ?? fileConfig.allowIp, diff --git a/src/ui-server.ts b/src/ui-server.ts index 6725f93..d5b1adb 100644 --- a/src/ui-server.ts +++ b/src/ui-server.ts @@ -46,6 +46,9 @@ export class UiServer { sessionId: status.sessionId, port: this.options.localPort, version: this.options.version, + platform: this.options.forwardConfig.platform ?? null, + framework: this.options.forwardConfig.framework ?? null, + webhookPath: this.options.forwardConfig.path ?? "/", }); return; } diff --git a/src/ui/index.html b/src/ui/index.html index ddcd4cd..fc6c87b 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -1082,6 +1082,85 @@ font-weight: 600; } + .snippet-fab { + position: fixed; + right: 20px; + bottom: 80px; + border: 1px solid var(--accent-border); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: 999px; + padding: 10px 14px; + z-index: 100; + cursor: pointer; + font-family: var(--sans); + font-size: 13px; + } + .snippet-fab:hover { + background: var(--bg-quaternary); + } + .snippet-popup { + position: fixed; + bottom: 80px; + right: 20px; + background: var(--bg-secondary); + border: 1px solid var(--accent-border); + border-radius: 8px; + padding: 12px 14px; + z-index: 100; + width: min(220px, calc(100vw - 24px)); + display: none; + animation: snippetSlideUp 200ms ease; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + } + .snippet-popup.open { + display: block; + } + .snippet-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 8px; + } + .snippet-title { + color: var(--text-primary); + font-size: 12px; + } + .snippet-close { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + } + .snippet-copy { + margin-top: 10px; + border: 1px solid var(--accent-border); + background: var(--accent-bg); + color: var(--accent); + border-radius: 6px; + width: 100%; + padding: 7px 10px; + cursor: pointer; + font-family: var(--mono); + font-size: 11px; + } + .snippet-body { + color: var(--text-secondary); + font-size: 12px; + } + @keyframes snippetSlideUp { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + /* ── TOAST ── */ .toast { position: fixed; @@ -1807,6 +1886,18 @@
D compare panel
+ +
+
+
Handler snippet
+ +
+
npm i @hookflo/tern
+ +
+