diff --git a/packages/tern-cli/package.json b/packages/tern-cli/package.json index 9a6716e..4515fd5 100644 --- a/packages/tern-cli/package.json +++ b/packages/tern-cli/package.json @@ -4,20 +4,28 @@ "description": "Interactive webhook setup wizard for tern", "license": "MIT", "bin": { - "tern": "./dist/index.js" + "tern": "./dist/cli.js" }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/cli.js", + "types": "./dist/cli.d.ts", "files": [ "dist", "README.md" ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node --test" }, "dependencies": { - "@clack/prompts": "latest", - "@hookflo/tern-dev": "latest" - } + "@hookflo/tern-dev": "latest", + "ink": "^5.2.1", + "react": "^18.3.1", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.12" + }, + "type": "module" } diff --git a/packages/tern-cli/src/browser.ts b/packages/tern-cli/src/browser.ts deleted file mode 100644 index 902caa9..0000000 --- a/packages/tern-cli/src/browser.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/cli.tsx b/packages/tern-cli/src/cli.tsx new file mode 100644 index 0000000..61e2f0d --- /dev/null +++ b/packages/tern-cli/src/cli.tsx @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import React, { useState } from 'react' +import { render, Box, useApp } from 'ink' +import { Logo } from './ui/Logo.js' +import { SelectPrompt } from './ui/SelectPrompt.js' +import { Loading } from './ui/Spinner.js' +import { Complete } from './ui/Complete.js' +import { scaffold } from './scaffold.js' + +type Step = 'platform' | 'framework' | 'loading' | 'done' + +const PLATFORMS = [ + { label: 'Stripe', value: 'stripe' }, + { label: 'GitHub', value: 'github' }, + { label: 'Clerk', value: 'clerk' }, + { label: 'Dodo Payments', value: 'dodopayments' }, + { label: 'Shopify', value: 'shopify' }, + { label: 'Polar', value: 'polar' }, + { label: 'Other', value: 'other' }, +] + +const FRAMEWORKS = [ + { label: 'Hono', value: 'hono' }, + { label: 'Next.js', value: 'nextjs' }, + { label: 'Cloudflare Workers', value: 'cloudflare' }, + { label: 'Express', value: 'express' }, +] + +interface Result { + filePath: string + envKeys: string[] +} + +const App = () => { + const { exit } = useApp() + const [step, setStep] = useState('platform') + const [platform, setPlatform] = useState('') + const [framework, setFramework] = useState('') + const [result, setResult] = useState(null) + + const handlePlatform = ({ value }: { value: string }) => { + setPlatform(value) + setStep('framework') + } + + const handleFramework = async ({ value }: { value: string }) => { + setFramework(value) + setStep('loading') + const res = await scaffold({ platform, framework: value }) + setResult(res) + setStep('done') + setTimeout(() => exit(), 100) + } + + return ( + + + {step === 'platform' && ( + + )} + {step === 'framework' && ( + + )} + {step === 'loading' && } + {step === 'done' && result && ( + + )} + + ) +} + +render() diff --git a/packages/tern-cli/src/clipboard.ts b/packages/tern-cli/src/clipboard.ts deleted file mode 100644 index a3bb605..0000000 --- a/packages/tern-cli/src/clipboard.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 0f24ce9..0000000 --- a/packages/tern-cli/src/colors.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** 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 deleted file mode 100644 index 80eefa7..0000000 --- a/packages/tern-cli/src/config.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index a2c92bd..0000000 --- a/packages/tern-cli/src/files.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 index 19a7589..69857e5 100644 --- a/packages/tern-cli/src/index.ts +++ b/packages/tern-cli/src/index.ts @@ -1,63 +1 @@ -#!/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); -}); +import './cli.js' diff --git a/packages/tern-cli/src/install.ts b/packages/tern-cli/src/install.ts deleted file mode 100644 index 3b1cc32..0000000 --- a/packages/tern-cli/src/install.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 78aa6f9..0000000 --- a/packages/tern-cli/src/print.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/scaffold.ts b/packages/tern-cli/src/scaffold.ts new file mode 100644 index 0000000..c4db182 --- /dev/null +++ b/packages/tern-cli/src/scaffold.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getTemplate, getEnvKeys } from './templates.js' + +interface ScaffoldOptions { + platform: string + framework: string +} + +interface ScaffoldResult { + filePath: string + envKeys: string[] +} + +export async function scaffold({ platform, framework }: ScaffoldOptions): Promise { + const filePath = getFilePath(framework, platform) + const template = getTemplate(framework, platform) + const envKeys = getEnvKeys(platform) + + const absoluteFilePath = path.join(process.cwd(), filePath) + fs.mkdirSync(path.dirname(absoluteFilePath), { recursive: true }) + fs.writeFileSync(absoluteFilePath, template, 'utf8') + + const envPath = path.join(process.cwd(), '.env') + if (!fs.existsSync(envPath)) { + const envContent = envKeys.map((k) => `${k}=`).join('\n') + '\n' + fs.writeFileSync(envPath, envContent, 'utf8') + } else { + const existing = fs.readFileSync(envPath, 'utf8') + const missing = envKeys.filter((k) => !existing.includes(`${k}=`)) + if (missing.length) { + fs.appendFileSync(envPath, `\n${missing.map((k) => `${k}=`).join('\n')}\n`) + } + } + + return { filePath, envKeys } +} + +function getFilePath(framework: string, platform: string): string { + switch (framework) { + case 'hono': + case 'express': + return `src/routes/webhooks/${platform}.ts` + case 'nextjs': + return `app/api/webhooks/${platform}/route.ts` + case 'cloudflare': + return 'src/index.ts' + default: + return `src/webhooks/${platform}.ts` + } +} diff --git a/packages/tern-cli/src/templates.ts b/packages/tern-cli/src/templates.ts index 6df3c3e..bc7d5fa 100644 --- a/packages/tern-cli/src/templates.ts +++ b/packages/tern-cli/src/templates.ts @@ -1,144 +1,21 @@ -/** 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); +export function getTemplate(framework: string, platform: string): string { + const env = `${platform.toUpperCase().replace(/-/g, '_')}_WEBHOOK_SECRET` 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); + case 'hono': + return `import { Hono } from 'hono'\nimport { createWebhookHandler } from '@hookflo/tern/hono'\n\nconst router = new Hono()\n\nrouter.post('/${platform}', createWebhookHandler({\n platform: '${platform}',\n secret: process.env.${env}!,\n // queue: { retries: 3 }, // uncomment + add QSTASH_* env vars for guaranteed delivery\n handler: async (payload) => {\n console.log('${platform} event:', payload?.type)\n // TODO: handle payload\n return { received: true }\n },\n}))\n\nexport default router\n` + case 'nextjs': + return `import { createWebhookHandler } from '@hookflo/tern/nextjs'\n\nexport const POST = createWebhookHandler({\n platform: '${platform}',\n secret: process.env.${env}!,\n // queue: true, // uncomment + add QSTASH_* env vars for guaranteed delivery (Vercel only)\n // queue: { token: process.env.QSTASH_TOKEN!, retries: 3 }, // explicit config\n handler: async (payload) => {\n console.log('${platform} event:', payload?.type)\n // TODO: handle payload\n return { received: true }\n },\n})\n` + case 'express': + return `import { Router } from 'express'\nimport { createWebhookMiddleware } from '@hookflo/tern/express'\n\nconst router = Router()\n\n// Note: register this router BEFORE express.json() in src/index.ts\nrouter.post('/${platform}',\n createWebhookMiddleware({\n platform: '${platform}',\n secret: process.env.${env}!,\n }),\n (req, res) => {\n const { payload } = (req as any).webhook\n console.log('${platform} event:', payload?.type)\n // TODO: handle payload\n res.json({ received: true })\n }\n)\n\nexport default router\n` + case 'cloudflare': + return `import { createWebhookHandler } from '@hookflo/tern/cloudflare'\n\nexport interface Env {\n WEBHOOK_SECRET: string // set via: wrangler secret put WEBHOOK_SECRET\n}\n\nexport default {\n async fetch(request: Request, env: Env): Promise {\n const url = new URL(request.url)\n\n if (url.pathname === '/webhooks/${platform}' && request.method === 'POST') {\n const handler = createWebhookHandler({\n platform: '${platform}',\n secret: env.WEBHOOK_SECRET, // Workers use env.*, not process.env\n handler: async (payload) => {\n console.log('${platform} event:', payload?.type)\n // TODO: handle payload\n return { received: true }\n },\n })\n return handler(request, env)\n }\n\n return new Response('not found', { status: 404 })\n },\n}\n` default: - return genericTemplate(platform, envVar, platformLabel); + return `export default async function handler(request: Request) {\n const payload = await request.json()\n console.log('${platform} event:', payload?.type)\n return new Response(JSON.stringify({ received: true }), { status: 200 })\n}\n` } } -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 } - ) -} -`; +export function getEnvKeys(platform: string): string[] { + const secret = `${platform.toUpperCase().replace(/-/g, '_')}_WEBHOOK_SECRET` + return [secret] } diff --git a/packages/tern-cli/src/tunnel.ts b/packages/tern-cli/src/tunnel.ts deleted file mode 100644 index cf3dc5e..0000000 --- a/packages/tern-cli/src/tunnel.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 6468abf..0000000 --- a/packages/tern-cli/src/types/clack-prompts.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/ui/Complete.tsx b/packages/tern-cli/src/ui/Complete.tsx new file mode 100644 index 0000000..cc75c0e --- /dev/null +++ b/packages/tern-cli/src/ui/Complete.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { Box, Text } from 'ink' + +interface Props { + filePath: string + envKeys: string[] + framework: string +} + +export const Complete = ({ filePath, envKeys, framework }: Props) => ( + + ✓ Created {filePath} + + + Add to your .env: + + {envKeys.map((k) => ( + {k}= + ))} + + + + + next steps: + 1. fill in your .env values above + 2. npm run dev + (or wrangler dev for Cloudflare) + framework: {framework} + + +) diff --git a/packages/tern-cli/src/ui/Logo.tsx b/packages/tern-cli/src/ui/Logo.tsx new file mode 100644 index 0000000..734c7fe --- /dev/null +++ b/packages/tern-cli/src/ui/Logo.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { Box, Text } from 'ink' + +export const Logo = () => ( + + {` + ████████╗███████╗██████╗ ███╗ ██╗ + ██╔══╝██╔════╝██╔══██╗████╗ ██║ + ██║ █████╗ ██████╔╝██╔██╗ ██║ + ██║ ██╔══╝ ██╔══██╗██║╚██╗██║ + ██║ ███████╗██║ ██║██║ ╚████║ + ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝`} + + v0.1.0 · webhook toolkit + +) diff --git a/packages/tern-cli/src/ui/SelectPrompt.tsx b/packages/tern-cli/src/ui/SelectPrompt.tsx new file mode 100644 index 0000000..2d786be --- /dev/null +++ b/packages/tern-cli/src/ui/SelectPrompt.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Box, Text } from 'ink' +import SelectInput from 'ink-select-input' + +interface Item { label: string; value: string } + +interface Props { + question: string + items: Item[] + onSelect: (item: Item) => void +} + +export const SelectPrompt = ({ question, items, onSelect }: Props) => ( + + ◆ {question} + + +) diff --git a/packages/tern-cli/src/ui/Spinner.tsx b/packages/tern-cli/src/ui/Spinner.tsx new file mode 100644 index 0000000..e6a6bab --- /dev/null +++ b/packages/tern-cli/src/ui/Spinner.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { Box, Text } from 'ink' +import Spinner from 'ink-spinner' + +export const Loading = ({ label }: { label: string }) => ( + + + {label} + +) diff --git a/packages/tern-cli/src/wizard.ts b/packages/tern-cli/src/wizard.ts deleted file mode 100644 index a3dc810..0000000 --- a/packages/tern-cli/src/wizard.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 index c1954ba..f8601d5 100644 --- a/packages/tern-cli/tsconfig.json +++ b/packages/tern-cli/tsconfig.json @@ -9,7 +9,11 @@ "rootDir": "src", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] }