Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/tern-cli/README.md
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions packages/tern-cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
13 changes: 13 additions & 0 deletions packages/tern-cli/src/browser.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 14 additions & 0 deletions packages/tern-cli/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 16 additions & 0 deletions packages/tern-cli/src/colors.ts
Original file line number Diff line number Diff line change
@@ -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";
49 changes: 49 additions & 0 deletions packages/tern-cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
64 changes: 64 additions & 0 deletions packages/tern-cli/src/files.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
63 changes: 63 additions & 0 deletions packages/tern-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
24 changes: 24 additions & 0 deletions packages/tern-cli/src/install.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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");
}
}
50 changes: 50 additions & 0 deletions packages/tern-cli/src/print.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading