Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 10 additions & 3 deletions packages/tern-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@
],
"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",
"minimist": "^1.2.8"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/minimist": "^1.2.5"
}
}
35 changes: 35 additions & 0 deletions packages/tern-cli/src/cli.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import React from 'react'
import { render } from 'ink'
import { run } from './commands/scaffold.js'
import { Banner } from './ui/Banner.js'
import { Done } from './ui/Done.js'
import { EnvBlock } from './ui/EnvBlock.js'

const banner = render(<Banner />)

run()
.then(({ framework, routePath, port, envVar }) => {
banner.unmount()
render(
<>
<EnvBlock
vars={[
{ key: envVar, description: 'platform webhook signing secret', required: true },
{ key: 'QSTASH_TOKEN', description: 'Upstash QStash token for queue retries', required: false },
{ key: 'QSTASH_CURRENT_SIGNING_KEY', description: 'QStash current signing key', required: false },
{ key: 'QSTASH_NEXT_SIGNING_KEY', description: 'QStash next signing key', required: false },
{ key: 'SLACK_WEBHOOK_URL', description: 'Slack alerting endpoint', required: false },
{ key: 'DISCORD_WEBHOOK_URL', description: 'Discord alerting endpoint', required: false },
{ key: 'PORT', description: 'local server port', required: false },
]}
/>
<Done framework={framework} routePath={routePath} port={port} />
</>,
)
})
.catch((err: unknown) => {
banner.unmount()
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})
28 changes: 28 additions & 0 deletions packages/tern-cli/src/commands/scaffold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createConfig } from '../config'
import { createHandlerFile, createSupportFiles, getFilePath, getWebhookPath } from '../files'
import { installTern } from '../install'
import { getTemplate } from '../templates'
import { startTunnel } from '../tunnel'
import { askQuestions, ENV_VARS, getPlatformLabel } from '../wizard'

export async function run(): Promise<{ framework: string; routePath: string; port: number; envVar: string }> {
const { platform, framework, action, port } = await askQuestions()
const envVar = ENV_VARS[platform] ?? 'WEBHOOK_SECRET'

if (action !== 'tunnel') {
await installTern()
const filePath = getFilePath(framework, platform)
const content = getTemplate(framework, platform, envVar)
createHandlerFile(filePath, content)
createSupportFiles(framework, platform, envVar)
}

const webhookPath = getWebhookPath(framework, platform)

if (action !== 'handler') {
createConfig(port, webhookPath, platform, framework)
startTunnel(port, webhookPath, getPlatformLabel(platform))
}

return { framework, routePath: webhookPath, port: Number(port), envVar }
}
32 changes: 6 additions & 26 deletions packages/tern-cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,29 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as clack from "@clack/prompts";
import { CYAN, RESET } from "./colors";
import * as fs from 'node:fs'
import * as path from 'node:path'

/** 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 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": {}
},

"block": { "paths": [], "methods": [], "headers": {} },
"log": ""
}
`;
`

fs.writeFileSync(configPath, config, "utf8");
clack.log.success(`created ${CYAN}tern.config.json${RESET}`);
fs.writeFileSync(configPath, config, 'utf8')
}
152 changes: 104 additions & 48 deletions packages/tern-cli/src/files.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,120 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as clack from "@clack/prompts";
import { CYAN, RESET } from "./colors";
import * as fs from 'node:fs'
import * as path from 'node:path'
import { getDotEnvTemplate, getEnvModuleTemplate, getServerEntryTemplate, getWebhookIndexTemplate } from './templates'

/** 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 'nextjs': {
const cwd = process.cwd()
if (fs.existsSync(path.join(cwd, 'src/app'))) return `src/app/api/webhooks/${platform}/route.ts`
return `app/api/webhooks/${platform}/route.ts`
}
case 'express':
case 'hono':
return `src/routes/webhooks/${platform}.ts`
case 'cloudflare':
return 'src/index.ts'
default:
return `webhooks/${platform}.ts`
}
}

export function getWebhookPath(framework: string, platform: string): string {
return framework === 'nextjs' ? `/api/webhooks/${platform}` : `/webhooks/${platform}`
}

case "express":
return hasSrc
? `src/routes/webhooks/${platform}.ts`
: `routes/webhooks/${platform}.ts`;
export function createHandlerFile(filePath: string, content: string): void {
const fullPath = path.join(process.cwd(), filePath)
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, content, 'utf8')
return
}
const current = fs.readFileSync(fullPath, 'utf8')
if (current !== content) fs.writeFileSync(fullPath, content, 'utf8')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent silent overwrite of existing scaffold targets

createHandlerFile now overwrites any existing file whenever the generated content differs, with no confirmation or --force gate. Since run() invokes this for primary handlers and support files (src/index.ts, src/routes/webhooks/index.ts, src/env.ts), rerunning scaffold in an existing Express/Hono/Cloudflare codebase can silently replace user-maintained entrypoints and routes, which is a destructive regression from the previous confirm-before-overwrite behavior.

Useful? React with 👍 / 👎.

}

case "hono":
return hasSrc
? `src/routes/webhooks/${platform}.ts`
: `src/routes/webhooks/${platform}.ts`;
export function createSupportFiles(framework: string, platform: string, envVar: string): void {
if (framework === 'hono' || framework === 'express') {
const routerIndex = getWebhookIndexTemplate(framework, platform)
const entry = getServerEntryTemplate(framework)
if (routerIndex) createHandlerFile('src/routes/webhooks/index.ts', routerIndex)
if (entry) createHandlerFile('src/index.ts', entry)
createHandlerFile('src/env.ts', getEnvModuleTemplate(envVar))
ensureNodeTsConfig()
}

case "cloudflare":
return hasSrc ? `src/webhooks/${platform}.ts` : `webhooks/${platform}.ts`;
ensureDotEnv(envVar)
ensureGitIgnoreDotEnv()
ensurePackageScripts(framework)
}

default:
return `webhooks/${platform}.ts`;
function ensureDotEnv(envVar: string): void {
const envPath = path.join(process.cwd(), '.env')
if (!fs.existsSync(envPath)) {
fs.writeFileSync(envPath, getDotEnvTemplate(envVar), 'utf8')
return
}

const existing = fs.readFileSync(envPath, 'utf8')
if (!existing.includes(`${envVar}=`)) {
fs.writeFileSync(envPath, `${getDotEnvTemplate(envVar)}\n${existing}`, 'utf8')
}
}

/** Returns webhook route path used by tern config and forwarding. */
export function getWebhookPath(platform: string): string {
return `/api/webhooks/${platform}`;
function ensureGitIgnoreDotEnv(): void {
const ignorePath = path.join(process.cwd(), '.gitignore')
if (!fs.existsSync(ignorePath)) {
fs.writeFileSync(ignorePath, '.env\n', 'utf8')
return
}
const existing = fs.readFileSync(ignorePath, 'utf8')
if (!existing.split('\n').includes('.env')) {
fs.writeFileSync(ignorePath, `${existing.trimEnd()}\n.env\n`, 'utf8')
}
}

/** Creates a handler file, confirming before overwrite. */
export async function createHandlerFile(
filePath: string,
content: string,
): Promise<void> {
const fullPath = path.join(process.cwd(), filePath);
function ensurePackageScripts(framework: string): void {
const packagePath = path.join(process.cwd(), 'package.json')
if (!fs.existsSync(packagePath)) return

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;
}
const raw = fs.readFileSync(packagePath, 'utf8')
const pkg = JSON.parse(raw) as Record<string, any>
pkg.scripts = pkg.scripts ?? {}

if (framework === 'hono' || framework === 'express') {
pkg.scripts.dev = 'tsx --env-file=.env watch src/index.ts'
pkg.scripts.build = 'tsc'
pkg.scripts.start = 'node --env-file=.env dist/index.js'
} else if (framework === 'nextjs') {
pkg.scripts.dev = 'next dev'
pkg.scripts.build = 'next build'
pkg.scripts.start = 'next start'
} else if (framework === 'cloudflare') {
pkg.scripts.dev = 'wrangler dev'
pkg.scripts.deploy = 'wrangler deploy'
}

fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, "utf8");
clack.log.success(`created ${CYAN}${filePath}${RESET}`);
fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
}

function ensureNodeTsConfig(): void {
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json')
const config = {
compilerOptions: {
target: 'ES2022',
module: 'NodeNext',
moduleResolution: 'NodeNext',
lib: ['ES2022'],
strict: true,
skipLibCheck: true,
outDir: 'dist',
rootDir: 'src',
},
include: ['src'],
}

if (!fs.existsSync(tsconfigPath)) {
fs.writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
}
}
64 changes: 1 addition & 63 deletions packages/tern-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<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);
});
import './cli.js'
Loading
Loading