Skip to content

Commit 1338015

Browse files
committed
feat(tern-cli): switch scaffolding UX to ink and harden templates
1 parent 7cf32e6 commit 1338015

16 files changed

Lines changed: 422 additions & 451 deletions

File tree

packages/tern-cli/package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@
1414
],
1515
"scripts": {
1616
"build": "tsc -p tsconfig.json",
17-
"typecheck": "tsc --noEmit -p tsconfig.json"
17+
"typecheck": "tsc --noEmit -p tsconfig.json",
18+
"test": "node --test"
1819
},
1920
"dependencies": {
20-
"@clack/prompts": "latest",
21-
"@hookflo/tern-dev": "latest"
21+
"@hookflo/tern-dev": "latest",
22+
"ink": "^5.2.1",
23+
"react": "^18.3.1",
24+
"minimist": "^1.2.8"
25+
},
26+
"devDependencies": {
27+
"@types/react": "^18.3.12",
28+
"@types/minimist": "^1.2.5"
2229
}
2330
}

packages/tern-cli/src/cli.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env node
2+
import React from 'react'
3+
import { render } from 'ink'
4+
import { run } from './commands/scaffold.js'
5+
import { Banner } from './ui/Banner.js'
6+
import { Done } from './ui/Done.js'
7+
import { EnvBlock } from './ui/EnvBlock.js'
8+
9+
const banner = render(<Banner />)
10+
11+
run()
12+
.then(({ framework, routePath, port, envVar }) => {
13+
banner.unmount()
14+
render(
15+
<>
16+
<EnvBlock
17+
vars={[
18+
{ key: envVar, description: 'platform webhook signing secret', required: true },
19+
{ key: 'QSTASH_TOKEN', description: 'Upstash QStash token for queue retries', required: false },
20+
{ key: 'QSTASH_CURRENT_SIGNING_KEY', description: 'QStash current signing key', required: false },
21+
{ key: 'QSTASH_NEXT_SIGNING_KEY', description: 'QStash next signing key', required: false },
22+
{ key: 'SLACK_WEBHOOK_URL', description: 'Slack alerting endpoint', required: false },
23+
{ key: 'DISCORD_WEBHOOK_URL', description: 'Discord alerting endpoint', required: false },
24+
{ key: 'PORT', description: 'local server port', required: false },
25+
]}
26+
/>
27+
<Done framework={framework} routePath={routePath} port={port} />
28+
</>,
29+
)
30+
})
31+
.catch((err: unknown) => {
32+
banner.unmount()
33+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
34+
process.exit(1)
35+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createConfig } from '../config'
2+
import { createHandlerFile, createSupportFiles, getFilePath, getWebhookPath } from '../files'
3+
import { installTern } from '../install'
4+
import { getTemplate } from '../templates'
5+
import { startTunnel } from '../tunnel'
6+
import { askQuestions, ENV_VARS, getPlatformLabel } from '../wizard'
7+
8+
export async function run(): Promise<{ framework: string; routePath: string; port: number; envVar: string }> {
9+
const { platform, framework, action, port } = await askQuestions()
10+
const envVar = ENV_VARS[platform] ?? 'WEBHOOK_SECRET'
11+
12+
if (action !== 'tunnel') {
13+
await installTern()
14+
const filePath = getFilePath(framework, platform)
15+
const content = getTemplate(framework, platform, envVar)
16+
createHandlerFile(filePath, content)
17+
createSupportFiles(framework, platform, envVar)
18+
}
19+
20+
const webhookPath = getWebhookPath(framework, platform)
21+
22+
if (action !== 'handler') {
23+
createConfig(port, webhookPath, platform, framework)
24+
startTunnel(port, webhookPath, getPlatformLabel(platform))
25+
}
26+
27+
return { framework, routePath: webhookPath, port: Number(port), envVar }
28+
}

packages/tern-cli/src/config.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,29 @@
1-
import * as fs from "node:fs";
2-
import * as path from "node:path";
3-
import * as clack from "@clack/prompts";
4-
import { CYAN, RESET } from "./colors";
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
53

6-
/** Writes tern.config.json using current wizard selections. */
74
export function createConfig(
85
port: string,
96
webhookPath: string,
107
platform: string,
118
framework: string,
129
): void {
13-
const configPath = path.join(process.cwd(), "tern.config.json");
10+
const configPath = path.join(process.cwd(), 'tern.config.json')
1411
const config = `{
1512
"$schema": "./tern-config.schema.json",
16-
1713
"port": ${Number(port)},
18-
1914
"path": "${webhookPath}",
20-
2115
"platform": "${platform}",
22-
2316
"framework": "${framework}",
24-
2517
"uiPort": 2019,
26-
2718
"relay": "wss://tern-relay.hookflo-tern.workers.dev",
28-
2919
"maxEvents": 500,
30-
3120
"ttl": 30,
32-
3321
"rateLimit": 100,
34-
3522
"allowIp": [],
36-
37-
"block": {
38-
"paths": [],
39-
"methods": [],
40-
"headers": {}
41-
},
42-
23+
"block": { "paths": [], "methods": [], "headers": {} },
4324
"log": ""
4425
}
45-
`;
26+
`
4627

47-
fs.writeFileSync(configPath, config, "utf8");
48-
clack.log.success(`created ${CYAN}tern.config.json${RESET}`);
28+
fs.writeFileSync(configPath, config, 'utf8')
4929
}

packages/tern-cli/src/files.ts

Lines changed: 104 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,120 @@
1-
import * as fs from "node:fs";
2-
import * as path from "node:path";
3-
import * as clack from "@clack/prompts";
4-
import { CYAN, RESET } from "./colors";
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
import { getDotEnvTemplate, getEnvModuleTemplate, getServerEntryTemplate, getWebhookIndexTemplate } from './templates'
54

6-
/** Returns the target handler file path for a framework/platform pair. */
75
export function getFilePath(framework: string, platform: string): string {
8-
const cwd = process.cwd();
9-
const hasSrc = fs.existsSync(path.join(cwd, "src"));
10-
116
switch (framework) {
12-
case "nextjs":
13-
if (fs.existsSync(path.join(cwd, "src/app"))) {
14-
return `src/app/api/webhooks/${platform}/route.ts`;
15-
}
16-
if (fs.existsSync(path.join(cwd, "app"))) {
17-
return `app/api/webhooks/${platform}/route.ts`;
18-
}
19-
return `app/api/webhooks/${platform}/route.ts`;
7+
case 'nextjs': {
8+
const cwd = process.cwd()
9+
if (fs.existsSync(path.join(cwd, 'src/app'))) return `src/app/api/webhooks/${platform}/route.ts`
10+
return `app/api/webhooks/${platform}/route.ts`
11+
}
12+
case 'express':
13+
case 'hono':
14+
return `src/routes/webhooks/${platform}.ts`
15+
case 'cloudflare':
16+
return 'src/index.ts'
17+
default:
18+
return `webhooks/${platform}.ts`
19+
}
20+
}
21+
22+
export function getWebhookPath(framework: string, platform: string): string {
23+
return framework === 'nextjs' ? `/api/webhooks/${platform}` : `/webhooks/${platform}`
24+
}
2025

21-
case "express":
22-
return hasSrc
23-
? `src/routes/webhooks/${platform}.ts`
24-
: `routes/webhooks/${platform}.ts`;
26+
export function createHandlerFile(filePath: string, content: string): void {
27+
const fullPath = path.join(process.cwd(), filePath)
28+
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
29+
if (!fs.existsSync(fullPath)) {
30+
fs.writeFileSync(fullPath, content, 'utf8')
31+
return
32+
}
33+
const current = fs.readFileSync(fullPath, 'utf8')
34+
if (current !== content) fs.writeFileSync(fullPath, content, 'utf8')
35+
}
2536

26-
case "hono":
27-
return hasSrc
28-
? `src/routes/webhooks/${platform}.ts`
29-
: `src/routes/webhooks/${platform}.ts`;
37+
export function createSupportFiles(framework: string, platform: string, envVar: string): void {
38+
if (framework === 'hono' || framework === 'express') {
39+
const routerIndex = getWebhookIndexTemplate(framework, platform)
40+
const entry = getServerEntryTemplate(framework)
41+
if (routerIndex) createHandlerFile('src/routes/webhooks/index.ts', routerIndex)
42+
if (entry) createHandlerFile('src/index.ts', entry)
43+
createHandlerFile('src/env.ts', getEnvModuleTemplate(envVar))
44+
ensureNodeTsConfig()
45+
}
3046

31-
case "cloudflare":
32-
return hasSrc ? `src/webhooks/${platform}.ts` : `webhooks/${platform}.ts`;
47+
ensureDotEnv(envVar)
48+
ensureGitIgnoreDotEnv()
49+
ensurePackageScripts(framework)
50+
}
3351

34-
default:
35-
return `webhooks/${platform}.ts`;
52+
function ensureDotEnv(envVar: string): void {
53+
const envPath = path.join(process.cwd(), '.env')
54+
if (!fs.existsSync(envPath)) {
55+
fs.writeFileSync(envPath, getDotEnvTemplate(envVar), 'utf8')
56+
return
57+
}
58+
59+
const existing = fs.readFileSync(envPath, 'utf8')
60+
if (!existing.includes(`${envVar}=`)) {
61+
fs.writeFileSync(envPath, `${getDotEnvTemplate(envVar)}\n${existing}`, 'utf8')
3662
}
3763
}
3864

39-
/** Returns webhook route path used by tern config and forwarding. */
40-
export function getWebhookPath(platform: string): string {
41-
return `/api/webhooks/${platform}`;
65+
function ensureGitIgnoreDotEnv(): void {
66+
const ignorePath = path.join(process.cwd(), '.gitignore')
67+
if (!fs.existsSync(ignorePath)) {
68+
fs.writeFileSync(ignorePath, '.env\n', 'utf8')
69+
return
70+
}
71+
const existing = fs.readFileSync(ignorePath, 'utf8')
72+
if (!existing.split('\n').includes('.env')) {
73+
fs.writeFileSync(ignorePath, `${existing.trimEnd()}\n.env\n`, 'utf8')
74+
}
4275
}
4376

44-
/** Creates a handler file, confirming before overwrite. */
45-
export async function createHandlerFile(
46-
filePath: string,
47-
content: string,
48-
): Promise<void> {
49-
const fullPath = path.join(process.cwd(), filePath);
77+
function ensurePackageScripts(framework: string): void {
78+
const packagePath = path.join(process.cwd(), 'package.json')
79+
if (!fs.existsSync(packagePath)) return
5080

51-
if (fs.existsSync(fullPath)) {
52-
const overwrite = await clack.confirm({
53-
message: `${path.basename(fullPath)} already exists. overwrite?`,
54-
});
55-
if (clack.isCancel(overwrite) || !overwrite) {
56-
clack.log.warn(`skipped ${filePath}`);
57-
return;
58-
}
81+
const raw = fs.readFileSync(packagePath, 'utf8')
82+
const pkg = JSON.parse(raw) as Record<string, any>
83+
pkg.scripts = pkg.scripts ?? {}
84+
85+
if (framework === 'hono' || framework === 'express') {
86+
pkg.scripts.dev = 'tsx --env-file=.env watch src/index.ts'
87+
pkg.scripts.build = 'tsc'
88+
pkg.scripts.start = 'node --env-file=.env dist/index.js'
89+
} else if (framework === 'nextjs') {
90+
pkg.scripts.dev = 'next dev'
91+
pkg.scripts.build = 'next build'
92+
pkg.scripts.start = 'next start'
93+
} else if (framework === 'cloudflare') {
94+
pkg.scripts.dev = 'wrangler dev'
95+
pkg.scripts.deploy = 'wrangler deploy'
5996
}
6097

61-
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
62-
fs.writeFileSync(fullPath, content, "utf8");
63-
clack.log.success(`created ${CYAN}${filePath}${RESET}`);
98+
fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
99+
}
100+
101+
function ensureNodeTsConfig(): void {
102+
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json')
103+
const config = {
104+
compilerOptions: {
105+
target: 'ES2022',
106+
module: 'NodeNext',
107+
moduleResolution: 'NodeNext',
108+
lib: ['ES2022'],
109+
strict: true,
110+
skipLibCheck: true,
111+
outDir: 'dist',
112+
rootDir: 'src',
113+
},
114+
include: ['src'],
115+
}
116+
117+
if (!fs.existsSync(tsconfigPath)) {
118+
fs.writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
119+
}
64120
}

packages/tern-cli/src/index.ts

Lines changed: 1 addition & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1 @@
1-
#!/usr/bin/env node
2-
import * as clack from "@clack/prompts";
3-
import { GRAY, RESET } from "./colors";
4-
import { createConfig } from "./config";
5-
import { createHandlerFile, getFilePath, getWebhookPath } from "./files";
6-
import { installTern } from "./install";
7-
import { printEnvBox, printLogo } from "./print";
8-
import { getTemplate } from "./templates";
9-
import { startTunnel } from "./tunnel";
10-
import { askQuestions, ENV_VARS, getPlatformLabel } from "./wizard";
11-
12-
/** CLI entrypoint for @hookflo/tern-cli. */
13-
export async function main(): Promise<void> {
14-
printLogo();
15-
16-
const { platform, framework, action, port } = await askQuestions();
17-
18-
if (action === "handler") {
19-
await installTern();
20-
const filePath = getFilePath(framework, platform);
21-
const envVar = ENV_VARS[platform];
22-
const content = getTemplate(
23-
framework,
24-
platform,
25-
envVar,
26-
getPlatformLabel(platform),
27-
);
28-
await createHandlerFile(filePath, content);
29-
if (envVar) printEnvBox(envVar);
30-
clack.outro("handler ready · add the env variable above to get started");
31-
return;
32-
}
33-
34-
if (action === "tunnel") {
35-
const webhookPath = getWebhookPath(platform);
36-
createConfig(port, webhookPath, platform, framework);
37-
clack.log.step("connecting...");
38-
startTunnel(port, webhookPath, getPlatformLabel(platform));
39-
return;
40-
}
41-
42-
await installTern();
43-
const filePath = getFilePath(framework, platform);
44-
const webhookPath = getWebhookPath(platform);
45-
const envVar = ENV_VARS[platform];
46-
const content = getTemplate(
47-
framework,
48-
platform,
49-
envVar ?? "",
50-
getPlatformLabel(platform),
51-
);
52-
await createHandlerFile(filePath, content);
53-
createConfig(port, webhookPath, platform, framework);
54-
if (envVar) printEnvBox(envVar);
55-
clack.log.step("connecting...");
56-
startTunnel(port, webhookPath, getPlatformLabel(platform));
57-
}
58-
59-
main().catch((err: unknown) => {
60-
const message = err instanceof Error ? err.message : String(err);
61-
console.error(`\n ${GRAY}error: ${message}${RESET}\n`);
62-
process.exit(1);
63-
});
1+
import './cli.js'

0 commit comments

Comments
 (0)