From c4272b3fcc5abb2ae0d4e1eab79919aae2f5bfa8 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:14:23 +0530 Subject: [PATCH 1/3] docs: expand tern-cli README with usage and feature list --- packages/tern-cli/README.md | 16 ++ packages/tern-cli/package.json | 23 ++ packages/tern-cli/src/browser.ts | 15 ++ packages/tern-cli/src/clipboard.ts | 15 ++ packages/tern-cli/src/colors.ts | 27 +++ packages/tern-cli/src/config.ts | 40 ++++ packages/tern-cli/src/files.ts | 27 +++ packages/tern-cli/src/index.ts | 70 ++++++ packages/tern-cli/src/templates.ts | 110 ++++++++++ packages/tern-cli/src/tunnel.ts | 72 +++++++ .../tern-cli/src/types/clack-prompts.d.ts | 23 ++ packages/tern-cli/src/wizard.ts | 65 ++++++ packages/tern-cli/tsconfig.json | 15 ++ src/config.ts | 4 + src/ui-server.ts | 3 + src/ui/index.html | 200 +++++++++++++++++- tern-config.schema.json | 10 + 17 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 packages/tern-cli/README.md create mode 100644 packages/tern-cli/package.json create mode 100644 packages/tern-cli/src/browser.ts create mode 100644 packages/tern-cli/src/clipboard.ts create mode 100644 packages/tern-cli/src/colors.ts create mode 100644 packages/tern-cli/src/config.ts create mode 100644 packages/tern-cli/src/files.ts create mode 100644 packages/tern-cli/src/index.ts create mode 100644 packages/tern-cli/src/templates.ts create mode 100644 packages/tern-cli/src/tunnel.ts create mode 100644 packages/tern-cli/src/types/clack-prompts.d.ts create mode 100644 packages/tern-cli/src/wizard.ts create mode 100644 packages/tern-cli/tsconfig.json 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..45267e4 --- /dev/null +++ b/packages/tern-cli/src/browser.ts @@ -0,0 +1,15 @@ +import { exec } from "node:child_process"; + +/** Opens the given URL in the user's default browser. */ +export function openBrowser(url: string): void { + const platform = process.platform; + if (platform === "darwin") { + exec(`open ${url}`); + return; + } + if (platform === "win32") { + exec(`start ${url}`); + return; + } + exec(`xdg-open ${url}`); +} diff --git a/packages/tern-cli/src/clipboard.ts b/packages/tern-cli/src/clipboard.ts new file mode 100644 index 0000000..2c15e61 --- /dev/null +++ b/packages/tern-cli/src/clipboard.ts @@ -0,0 +1,15 @@ +import { execSync } from "node:child_process"; + +/** Copies text to the system clipboard using OS-specific commands. */ +export function copyToClipboard(text: string): void { + const platform = process.platform; + if (platform === "darwin") { + execSync(`echo "${text}" | pbcopy`); + return; + } + if (platform === "win32") { + execSync(`echo ${text} | clip`); + return; + } + execSync(`echo "${text}" | xclip -selection clipboard`); +} diff --git a/packages/tern-cli/src/colors.ts b/packages/tern-cli/src/colors.ts new file mode 100644 index 0000000..36242a8 --- /dev/null +++ b/packages/tern-cli/src/colors.ts @@ -0,0 +1,27 @@ +/** ANSI brand colors for CLI output. */ +export const colors = { + green: "\x1b[38;2;16;185;129m", + pink: "\x1b[38;2;236;72;153m", + cyan: "\x1b[38;2;6;182;212m", + yellow: "\x1b[38;2;245;158;11m", + gray: "\x1b[38;2;107;105;99m", + white: "\x1b[38;2;240;237;232m", + red: "\x1b[38;2;239;68;68m", + reset: "\x1b[0m", + bold: "\x1b[1m", +} as const; + +/** Wraps text in ANSI color codes. */ +export function colorize(text: string, color: string): string { + return `${color}${text}${colors.reset}`; +} + +/** Styles path/url values in cyan. */ +export function cyan(text: string): string { + return colorize(text, colors.cyan); +} + +/** Styles env var values in yellow. */ +export function yellow(text: string): string { + return colorize(text, colors.yellow); +} diff --git a/packages/tern-cli/src/config.ts b/packages/tern-cli/src/config.ts new file mode 100644 index 0000000..0498527 --- /dev/null +++ b/packages/tern-cli/src/config.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as clack from "@clack/prompts"; +import { cyan } from "./colors"; +import { Framework, Platform, webhookPathFor } from "./templates"; + +/** Creates tern.config.json if it does not already exist. */ +export function ensureConfig(opts: { framework: Framework; platform: Platform; port: number }): { created: boolean; webhookPath: string } { + const configPath = path.join(process.cwd(), "tern.config.json"); + const webhookPath = webhookPathFor(opts.framework, opts.platform); + + if (fs.existsSync(configPath)) { + return { created: false, webhookPath }; + } + + const contents = `{ + "$schema": "./tern-config.schema.json", + + "port": ${opts.port}, + + "path": "${webhookPath}", + + "platform": "${opts.platform}", + + "framework": "${opts.framework}", + + "uiPort": 2019, + + "ttl": 60, + + "relay": "wss://tern-relay.hookflo-tern.workers.dev", + + "maxEvents": 500 +} +`; + + fs.writeFileSync(configPath, contents, "utf8"); + clack.log.success(`created ${cyan("tern.config.json")}`); + return { created: true, webhookPath }; +} diff --git a/packages/tern-cli/src/files.ts b/packages/tern-cli/src/files.ts new file mode 100644 index 0000000..186ba18 --- /dev/null +++ b/packages/tern-cli/src/files.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as clack from "@clack/prompts"; +import { cyan } from "./colors"; +import { Framework, Platform, handlerPathsFor, handlerTemplate, nestModuleTemplate } from "./templates"; + +/** Generates webhook handler files for the selected framework/platform. */ +export async function generateHandlerFiles(framework: Framework, platform: Platform): Promise { + for (const relativePath of handlerPathsFor(framework, platform)) { + const absolutePath = path.join(process.cwd(), relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + + if (fs.existsSync(absolutePath)) { + const overwrite = await clack.confirm({ message: `${path.basename(relativePath)} already exists. overwrite?` }); + if (clack.isCancel(overwrite) || !overwrite) { + continue; + } + } + + const content = relativePath.endsWith(".module.ts") + ? nestModuleTemplate(platform) + : handlerTemplate(framework, platform); + + fs.writeFileSync(absolutePath, `${content}\n`, "utf8"); + clack.log.success(`created ${cyan(relativePath)}`); + } +} diff --git a/packages/tern-cli/src/index.ts b/packages/tern-cli/src/index.ts new file mode 100644 index 0000000..b1a5865 --- /dev/null +++ b/packages/tern-cli/src/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import * as clack from "@clack/prompts"; +import { openBrowser } from "./browser"; +import { ensureConfig } from "./config"; +import { yellow, colorize, colors } from "./colors"; +import { generateHandlerFiles } from "./files"; +import { envVarForPlatform } from "./templates"; +import { startSession } from "./tunnel"; +import { runWizard } from "./wizard"; + +function printLogo(): void { + const pink = colors.pink; + const gray = colors.gray; + const reset = colors.reset; + process.stdout.write(`${pink} ████████╗███████╗██████╗ ███╗ ██╗\n`); + process.stdout.write(` ██║ ██╔════╝██╔══██╗████╗ ██║\n`); + process.stdout.write(` ██║ █████╗ ██████╔╝██╔██╗██║\n`); + process.stdout.write(` ██║ ██╔══╝ ██╔══██╗██║╚████║\n`); + process.stdout.write(` ██║ ███████╗██║ ██║██║ ╚███║\n`); + process.stdout.write(` ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝${reset}\n`); + process.stdout.write(`${gray} v0.1.0 · webhook toolkit${reset}\n\n`); +} + +/** Entrypoint for the tern interactive setup CLI. */ +export async function main(): Promise { + process.on("SIGINT", () => { + clack.outro("session ended · all event data cleared"); + process.exit(0); + }); + + printLogo(); + clack.intro(" tern · webhook toolkit "); + + const answers = await runWizard(); + + if (answers.action !== "tunnel") { + await generateHandlerFiles(answers.framework, answers.platform); + const envVar = envVarForPlatform(answers.platform); + if (envVar) { + clack.note(`${yellow(envVar)}=`, "add to .env.local"); + } + } + + if (answers.action === "handler") { + clack.outro("ready"); + return; + } + + const port = answers.port ?? "3000"; + const { webhookPath } = ensureConfig({ + framework: answers.framework, + platform: answers.platform, + port: Number(port), + }); + + await startSession({ + port, + webhookPath, + platform: answers.platform, + }); + + clack.log.info(`opening dashboard · ${colorize("localhost:2019", colors.cyan)}`); + openBrowser("http://localhost:2019"); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + clack.log.error(message); + process.exit(1); +}); diff --git a/packages/tern-cli/src/templates.ts b/packages/tern-cli/src/templates.ts new file mode 100644 index 0000000..2feabbf --- /dev/null +++ b/packages/tern-cli/src/templates.ts @@ -0,0 +1,110 @@ +/** Supported platforms for handler templates. */ +export const PLATFORMS = ["stripe", "github", "clerk", "sentry", "twilio", "svix", "other"] as const; +/** Supported frameworks for handler templates. */ +export const FRAMEWORKS = ["nextjs", "express", "fastify", "nestjs", "other"] as const; + +/** Platform identifier union. */ +export type Platform = (typeof PLATFORMS)[number]; +/** Framework identifier union. */ +export type Framework = (typeof FRAMEWORKS)[number]; + +const ENV_VARS: Record = { + stripe: "STRIPE_WEBHOOK_SECRET", + github: "GITHUB_WEBHOOK_SECRET", + clerk: "CLERK_WEBHOOK_SECRET", + sentry: "SENTRY_CLIENT_SECRET", + twilio: "TWILIO_AUTH_TOKEN", + svix: "SVIX_WEBHOOK_SECRET", + other: null, +}; + +const PLATFORM_LABELS: Record = { + stripe: "Stripe", + github: "GitHub", + clerk: "Clerk", + sentry: "Sentry", + twilio: "Twilio", + svix: "Svix", + other: "Other", +}; + +/** Returns the env var name required by a platform. */ +export function envVarForPlatform(platform: Platform): string | null { + return ENV_VARS[platform]; +} + +/** Returns human label for a platform value. */ +export function platformLabel(platform: Platform): string { + return PLATFORM_LABELS[platform]; +} + +/** Builds the webhook path for config generation. */ +export function webhookPathFor(framework: Framework, platform: Platform): string { + if (framework === "other") return `/webhooks/${platform}`; + return `/api/webhooks/${platform}`; +} + +/** Returns file paths that should be generated for a framework/platform pair. */ +export function handlerPathsFor(framework: Framework, platform: Platform): string[] { + if (framework === "nextjs") return [`app/api/webhooks/${platform}/route.ts`]; + if (framework === "express" || framework === "fastify") return [`routes/webhooks/${platform}.ts`]; + if (framework === "nestjs") return [`src/webhooks/${platform}.controller.ts`, `src/webhooks/${platform}.module.ts`]; + return [`webhooks/${platform}.ts`]; +} + +function payloadSwitch(platform: Platform): string { + if (platform === "github") { + return ` const event = result.payload\n const eventType = req.headers.get('x-github-event') || req.headers['x-github-event']\n\n switch (eventType) {\n case 'push':\n // TODO: handle push event\n break\n\n case 'pull_request':\n // TODO: handle pull request event\n break\n\n default:\n console.log('unhandled event type:', eventType)\n }`; + } + + const cases: Record = { + stripe: ["payment_intent.succeeded", "customer.subscription.created"], + clerk: ["user.created", "user.updated"], + sentry: ["event_alert_triggered", "metric_alert_fired"], + twilio: ["message.received", "message.delivered"], + svix: ["message.created", "message.attempt.exhausted"], + other: ["event.created", "event.updated"], + }; + + const [a, b] = cases[platform]; + return ` const event = result.payload\n\n switch (event.type) {\n case '${a}':\n // TODO: handle event\n break\n\n case '${b}':\n // TODO: handle event\n break\n\n default:\n console.log('unhandled event type:', event.type)\n }`; +} + +function genericHandler(platform: Platform): string { + if (platform === "other") { + return `export async function handler(req: unknown, res: unknown) {\n // TODO: implement your webhook handler\n return { received: true }\n}`; + } + + const envVar = envVarForPlatform(platform); + return `import { verify } from '@hookflo/tern'\n\nexport async function handler(req: any, res?: any) {\n const result = await verify(req, '${platform}', process.env.${envVar}!)\n\n if (!result.isValid) {\n return { status: 400, body: { error: result.error } }\n }\n\n${payloadSwitch(platform)}\n\n return { received: true }\n}`; +} + +/** Returns the handler snippet for a given framework/platform pair. */ +export function handlerTemplate(framework: Framework, platform: Platform): string { + if (platform === "other") return genericHandler(platform); + + const envVar = envVarForPlatform(platform); + if (framework === "nextjs") { + return `import { verify } from '@hookflo/tern'\nimport { NextRequest, NextResponse } from 'next/server'\n\nexport async function POST(req: NextRequest) {\n const result = await verify(req, '${platform}', process.env.${envVar}!)\n\n if (!result.isValid) {\n return NextResponse.json({ error: result.error }, { status: 400 })\n }\n\n${payloadSwitch(platform)}\n\n return NextResponse.json({ received: true })\n}`; + } + + if (framework === "express" || framework === "fastify") { + const pre = framework === "express" + ? `import express from 'express'\nimport { verify } from '@hookflo/tern'\n\nconst router = express.Router()\n\nrouter.post('/api/webhooks/${platform}', express.raw({ type: '*/*' }), async (req, res) => {` + : `import { verify } from '@hookflo/tern'\n\nexport default async function webhookRoute(fastify: any) {\n fastify.post('/api/webhooks/${platform}', async (req: any, reply: any) => {`; + const post = framework === "express" ? `\n res.json({ received: true })\n})\n\nexport default router` : `\n return reply.send({ received: true })\n })\n}`; + return `${pre}\n const result = await verify(req, '${platform}', process.env.${envVar}!)\n\n if (!result.isValid) {\n${framework === "express" ? " return res.status(400).json({ error: result.error })" : " return reply.status(400).send({ error: result.error })"}\n }\n\n${payloadSwitch(platform).replace(/^/gm, framework === "express" ? "" : " ")}\n${post}`; + } + + if (framework === "nestjs") { + return `import { Body, Controller, Headers, HttpCode, Post } from '@nestjs/common'\nimport { verify } from '@hookflo/tern'\n\n@Controller('api/webhooks/${platform}')\nexport class ${PLATFORM_LABELS[platform]}WebhookController {\n @Post()\n @HttpCode(200)\n async handle(@Body() body: unknown, @Headers() headers: Record) {\n const result = await verify({ body, headers }, '${platform}', process.env.${envVar}!)\n\n if (!result.isValid) {\n return { error: result.error }\n }\n\n const event = result.payload\n // TODO: handle event.type\n return { received: true, type: event?.type }\n }\n}`; + } + + return genericHandler(platform); +} + +/** Returns a minimal NestJS module template for the selected platform. */ +export function nestModuleTemplate(platform: Platform): string { + const className = `${platformLabel(platform).replace(/\W/g, "")}WebhookController`; + return `import { Module } from '@nestjs/common'\nimport { ${className} } from './${platform}.controller'\n\n@Module({\n controllers: [${className}],\n})\nexport class ${platformLabel(platform).replace(/\W/g, "")}WebhookModule {}`; +} diff --git a/packages/tern-cli/src/tunnel.ts b/packages/tern-cli/src/tunnel.ts new file mode 100644 index 0000000..9caaeb0 --- /dev/null +++ b/packages/tern-cli/src/tunnel.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import * as clack from "@clack/prompts"; +import { copyToClipboard } from "./clipboard"; +import { colorize, colors, cyan } from "./colors"; +import { platformLabel, Platform } from "./templates"; + +const URL_REGEX = /(https:\/\/[a-zA-Z0-9.-]*relay[\w.-]*)/; + +/** Starts tern-dev and returns a promise that resolves when the share URL appears. */ +export function startSession(opts: { port: string; webhookPath: string; platform: Platform }): Promise { + clack.log.step("connecting..."); + + const child = spawn("npx", ["@hookflo/tern-dev", "--port", opts.port, "--path", opts.webhookPath, "--no-ui"], { + stdio: ["inherit", "pipe", "pipe"], + env: process.env, + }); + + const printListening = (): void => { + process.stdout.write(`${colorize("●", colors.green)} listening for webhook events\n`); + process.stdout.write("Ctrl+C to stop · session auto-ends in 60 min\n"); + }; + + return new Promise((resolve, reject) => { + let resolved = false; + + child.stdout.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + const url = text.match(URL_REGEX)?.[1]; + + if (!resolved && url) { + resolved = true; + try { + copyToClipboard(url); + } catch { + // best effort only + } + + const provider = platformLabel(opts.platform); + const pink = colors.pink; + const reset = colors.reset; + process.stdout.write(`${pink}┌─────────────────────────────────────────────────┐${reset}\n`); + process.stdout.write(`${pink}│ │${reset}\n`); + process.stdout.write(`${pink}│ paste this in ${provider} webhook settings: │${reset}\n`); + process.stdout.write(`${pink}│ │${reset}\n`); + process.stdout.write(`${pink}│ ${cyan(url)}${pink}${" ".repeat(Math.max(1, 46 - url.length))}│${reset}\n`); + process.stdout.write(`${pink}│ │${reset}\n`); + process.stdout.write(`${pink}│ ${colorize("✓", colors.green)} copied to clipboard │${reset}\n`); + process.stdout.write(`${pink}│ │${reset}\n`); + process.stdout.write(`${pink}└─────────────────────────────────────────────────┘${reset}\n`); + printListening(); + resolve(); + } + + const eventLine = text.match(/(POST|GET|PUT|PATCH|DELETE)\s+(\/[^\s]+).*?(\d{3}).*?(\d+ms)/i); + if (eventLine) { + const status = Number(eventLine[3]); + const statusColor = status >= 400 ? colors.red : colors.green; + process.stdout.write(`tern › ${colorize(eventLine[1], colors.pink)} ${colorize(eventLine[2], colors.white)} → ${colorize(eventLine[3], statusColor)} ${colorize(eventLine[4], colors.gray)}\n`); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + + child.on("exit", (code) => { + if (!resolved) { + reject(new Error(`tern-dev exited with code ${code ?? 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..d0c25b4 --- /dev/null +++ b/packages/tern-cli/src/types/clack-prompts.d.ts @@ -0,0 +1,23 @@ +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 const log: { + success(message: string): void; + info(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; +} diff --git a/packages/tern-cli/src/wizard.ts b/packages/tern-cli/src/wizard.ts new file mode 100644 index 0000000..098a402 --- /dev/null +++ b/packages/tern-cli/src/wizard.ts @@ -0,0 +1,65 @@ +import * as clack from "@clack/prompts"; +import { Framework, Platform } from "./templates"; + +/** Wizard answers collected from the interactive setup. */ +export interface WizardAnswers { + platform: Platform; + framework: Framework; + action: "both" | "handler" | "tunnel"; + port?: string; +} + +/** Runs the four-question setup wizard. */ +export async function runWizard(): Promise { + const platform = await clack.select({ + message: "which platform are you integrating?", + options: [ + { value: "stripe", label: "Stripe" }, + { value: "github", label: "GitHub" }, + { value: "clerk", label: "Clerk" }, + { value: "sentry", label: "Sentry" }, + { value: "twilio", label: "Twilio" }, + { value: "svix", label: "Svix" }, + { value: "other", label: "Other" }, + ], + }); + if (clack.isCancel(platform)) process.exit(0); + + const framework = await clack.select({ + message: "which framework are you using?", + options: [ + { value: "nextjs", label: "Next.js" }, + { value: "express", label: "Express" }, + { value: "fastify", label: "Fastify" }, + { value: "nestjs", label: "NestJS" }, + { value: "other", label: "Other" }, + ], + }); + if (clack.isCancel(framework)) process.exit(0); + + const action = await clack.select<"both" | "handler" | "tunnel">({ + 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" }, + ], + }); + if (clack.isCancel(action)) process.exit(0); + + if (action === "handler") return { platform, framework, action }; + + const port = 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; + }, + }); + if (clack.isCancel(port)) process.exit(0); + + return { platform, framework, action, port }; +} 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/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..4292f45 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
+ +
+