Skip to content

Commit b44b89f

Browse files
committed
refactor: align tern-cli with complete build directive
1 parent c4272b3 commit b44b89f

12 files changed

Lines changed: 498 additions & 355 deletions

File tree

packages/tern-cli/src/browser.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { exec } from "node:child_process";
22

3-
/** Opens the given URL in the user's default browser. */
3+
/** Opens a URL in the default browser when possible. */
44
export function openBrowser(url: string): void {
5-
const platform = process.platform;
6-
if (platform === "darwin") {
7-
exec(`open ${url}`);
8-
return;
5+
try {
6+
const p = process.platform;
7+
if (p === "darwin") exec(`open "${url}"`);
8+
else if (p === "win32") exec(`start "${url}"`);
9+
else exec(`xdg-open "${url}"`);
10+
} catch {
11+
// silent fail
912
}
10-
if (platform === "win32") {
11-
exec(`start ${url}`);
12-
return;
13-
}
14-
exec(`xdg-open ${url}`);
1513
}

packages/tern-cli/src/clipboard.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { execSync } from "node:child_process";
22

3-
/** Copies text to the system clipboard using OS-specific commands. */
4-
export function copyToClipboard(text: string): void {
5-
const platform = process.platform;
6-
if (platform === "darwin") {
7-
execSync(`echo "${text}" | pbcopy`);
8-
return;
3+
/** Copies text to clipboard and returns true when successful. */
4+
export function copyToClipboard(text: string): boolean {
5+
try {
6+
const p = process.platform;
7+
if (p === "darwin") execSync(`printf '%s' "${text}" | pbcopy`);
8+
else if (p === "win32") execSync(`echo|set /p="${text}" | clip`);
9+
else execSync(`printf '%s' "${text}" | xclip -selection clipboard`);
10+
return true;
11+
} catch {
12+
return false;
913
}
10-
if (platform === "win32") {
11-
execSync(`echo ${text} | clip`);
12-
return;
13-
}
14-
execSync(`echo "${text}" | xclip -selection clipboard`);
1514
}

packages/tern-cli/src/colors.ts

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,16 @@
1-
/** ANSI brand colors for CLI output. */
2-
export const colors = {
3-
green: "\x1b[38;2;16;185;129m",
4-
pink: "\x1b[38;2;236;72;153m",
5-
cyan: "\x1b[38;2;6;182;212m",
6-
yellow: "\x1b[38;2;245;158;11m",
7-
gray: "\x1b[38;2;107;105;99m",
8-
white: "\x1b[38;2;240;237;232m",
9-
red: "\x1b[38;2;239;68;68m",
10-
reset: "\x1b[0m",
11-
bold: "\x1b[1m",
12-
} as const;
13-
14-
/** Wraps text in ANSI color codes. */
15-
export function colorize(text: string, color: string): string {
16-
return `${color}${text}${colors.reset}`;
17-
}
18-
19-
/** Styles path/url values in cyan. */
20-
export function cyan(text: string): string {
21-
return colorize(text, colors.cyan);
22-
}
23-
24-
/** Styles env var values in yellow. */
25-
export function yellow(text: string): string {
26-
return colorize(text, colors.yellow);
27-
}
1+
/** ANSI green used for success states and logo. */
2+
export const GREEN = "\x1b[38;2;16;185;129m";
3+
/** ANSI cyan used for URLs and file paths. */
4+
export const CYAN = "\x1b[38;2;6;182;212m";
5+
/** ANSI yellow used for env variables. */
6+
export const YELLOW = "\x1b[38;2;245;158;11m";
7+
/** ANSI gray used for muted labels. */
8+
export const GRAY = "\x1b[38;2;107;105;99m";
9+
/** ANSI white used for primary text. */
10+
export const WHITE = "\x1b[38;2;240;237;232m";
11+
/** ANSI red used for errors. */
12+
export const RED = "\x1b[38;2;239;68;68m";
13+
/** ANSI reset sequence. */
14+
export const RESET = "\x1b[0m";
15+
/** ANSI bold sequence. */
16+
export const BOLD = "\x1b[1m";

packages/tern-cli/src/config.ts

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,31 @@
1-
import fs from "node:fs";
2-
import path from "node:path";
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
33
import * as clack from "@clack/prompts";
4-
import { cyan } from "./colors";
5-
import { Framework, Platform, webhookPathFor } from "./templates";
4+
import { CYAN, RESET } from "./colors";
65

76
/** Creates tern.config.json if it does not already exist. */
8-
export function ensureConfig(opts: { framework: Framework; platform: Platform; port: number }): { created: boolean; webhookPath: string } {
7+
export function createConfig(
8+
port: string,
9+
webhookPath: string,
10+
platform: string,
11+
framework: string,
12+
): void {
913
const configPath = path.join(process.cwd(), "tern.config.json");
10-
const webhookPath = webhookPathFor(opts.framework, opts.platform);
1114

12-
if (fs.existsSync(configPath)) {
13-
return { created: false, webhookPath };
14-
}
15-
16-
const contents = `{
17-
"$schema": "./tern-config.schema.json",
18-
19-
"port": ${opts.port},
20-
21-
"path": "${webhookPath}",
22-
23-
"platform": "${opts.platform}",
24-
25-
"framework": "${opts.framework}",
26-
27-
"uiPort": 2019,
28-
29-
"ttl": 60,
30-
31-
"relay": "wss://tern-relay.hookflo-tern.workers.dev",
32-
33-
"maxEvents": 500
34-
}
35-
`;
36-
37-
fs.writeFileSync(configPath, contents, "utf8");
38-
clack.log.success(`created ${cyan("tern.config.json")}`);
39-
return { created: true, webhookPath };
15+
if (fs.existsSync(configPath)) return;
16+
17+
const config = {
18+
$schema: "./tern-config.schema.json",
19+
port: Number(port),
20+
path: webhookPath,
21+
platform,
22+
framework,
23+
uiPort: 2019,
24+
ttl: 60,
25+
relay: "wss://tern-relay.hookflo-tern.workers.dev",
26+
maxEvents: 500,
27+
};
28+
29+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
30+
clack.log.success(`created ${CYAN}tern.config.json${RESET}`);
4031
}

packages/tern-cli/src/files.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,64 @@
1-
import fs from "node:fs";
2-
import path from "node:path";
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
33
import * as clack from "@clack/prompts";
4-
import { cyan } from "./colors";
5-
import { Framework, Platform, handlerPathsFor, handlerTemplate, nestModuleTemplate } from "./templates";
6-
7-
/** Generates webhook handler files for the selected framework/platform. */
8-
export async function generateHandlerFiles(framework: Framework, platform: Platform): Promise<void> {
9-
for (const relativePath of handlerPathsFor(framework, platform)) {
10-
const absolutePath = path.join(process.cwd(), relativePath);
11-
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
12-
13-
if (fs.existsSync(absolutePath)) {
14-
const overwrite = await clack.confirm({ message: `${path.basename(relativePath)} already exists. overwrite?` });
15-
if (clack.isCancel(overwrite) || !overwrite) {
16-
continue;
4+
import { CYAN, RESET } from "./colors";
5+
6+
/** Returns the target handler file path for a framework/platform pair. */
7+
export function getFilePath(framework: string, platform: string): string {
8+
const cwd = process.cwd();
9+
const hasSrc = fs.existsSync(path.join(cwd, "src"));
10+
11+
switch (framework) {
12+
case "nextjs":
13+
if (fs.existsSync(path.join(cwd, "src/app"))) {
14+
return `src/app/api/webhooks/${platform}/route.ts`;
1715
}
18-
}
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`;
20+
21+
case "express":
22+
return hasSrc
23+
? `src/routes/webhooks/${platform}.ts`
24+
: `routes/webhooks/${platform}.ts`;
25+
26+
case "hono":
27+
return hasSrc
28+
? `src/routes/webhooks/${platform}.ts`
29+
: `src/routes/webhooks/${platform}.ts`;
30+
31+
case "cloudflare":
32+
return hasSrc ? `src/webhooks/${platform}.ts` : `webhooks/${platform}.ts`;
1933

20-
const content = relativePath.endsWith(".module.ts")
21-
? nestModuleTemplate(platform)
22-
: handlerTemplate(framework, platform);
34+
default:
35+
return `webhooks/${platform}.ts`;
36+
}
37+
}
38+
39+
/** Returns webhook route path used by tern config and forwarding. */
40+
export function getWebhookPath(platform: string): string {
41+
return `/api/webhooks/${platform}`;
42+
}
2343

24-
fs.writeFileSync(absolutePath, `${content}\n`, "utf8");
25-
clack.log.success(`created ${cyan(relativePath)}`);
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);
50+
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+
}
2659
}
60+
61+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
62+
fs.writeFileSync(fullPath, content, "utf8");
63+
clack.log.success(`created ${CYAN}${filePath}${RESET}`);
2764
}

packages/tern-cli/src/index.ts

Lines changed: 49 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,63 @@
11
#!/usr/bin/env node
22
import * as clack from "@clack/prompts";
3-
import { openBrowser } from "./browser";
4-
import { ensureConfig } from "./config";
5-
import { yellow, colorize, colors } from "./colors";
6-
import { generateHandlerFiles } from "./files";
7-
import { envVarForPlatform } from "./templates";
8-
import { startSession } from "./tunnel";
9-
import { runWizard } from "./wizard";
10-
11-
function printLogo(): void {
12-
const pink = colors.pink;
13-
const gray = colors.gray;
14-
const reset = colors.reset;
15-
process.stdout.write(`${pink} ████████╗███████╗██████╗ ███╗ ██╗\n`);
16-
process.stdout.write(` ██║ ██╔════╝██╔══██╗████╗ ██║\n`);
17-
process.stdout.write(` ██║ █████╗ ██████╔╝██╔██╗██║\n`);
18-
process.stdout.write(` ██║ ██╔══╝ ██╔══██╗██║╚████║\n`);
19-
process.stdout.write(` ██║ ███████╗██║ ██║██║ ╚███║\n`);
20-
process.stdout.write(` ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══╝${reset}\n`);
21-
process.stdout.write(`${gray} v0.1.0 · webhook toolkit${reset}\n\n`);
22-
}
23-
24-
/** Entrypoint for the tern interactive setup CLI. */
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. */
2513
export async function main(): Promise<void> {
26-
process.on("SIGINT", () => {
27-
clack.outro("session ended · all event data cleared");
28-
process.exit(0);
29-
});
30-
3114
printLogo();
32-
clack.intro(" tern · webhook toolkit ");
3315

34-
const answers = await runWizard();
35-
36-
if (answers.action !== "tunnel") {
37-
await generateHandlerFiles(answers.framework, answers.platform);
38-
const envVar = envVarForPlatform(answers.platform);
39-
if (envVar) {
40-
clack.note(`${yellow(envVar)}=`, "add to .env.local");
41-
}
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;
4232
}
4333

44-
if (answers.action === "handler") {
45-
clack.outro("ready");
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));
4639
return;
4740
}
4841

49-
const port = answers.port ?? "3000";
50-
const { webhookPath } = ensureConfig({
51-
framework: answers.framework,
52-
platform: answers.platform,
53-
port: Number(port),
54-
});
55-
56-
await startSession({
57-
port,
58-
webhookPath,
59-
platform: answers.platform,
60-
});
61-
62-
clack.log.info(`opening dashboard · ${colorize("localhost:2019", colors.cyan)}`);
63-
openBrowser("http://localhost:2019");
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));
6457
}
6558

66-
main().catch((error: unknown) => {
67-
const message = error instanceof Error ? error.message : String(error);
68-
clack.log.error(message);
59+
main().catch((err: unknown) => {
60+
const message = err instanceof Error ? err.message : String(err);
61+
console.error(`\n ${GRAY}error: ${message}${RESET}\n`);
6962
process.exit(1);
7063
});

packages/tern-cli/src/install.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { execSync } from "node:child_process";
2+
import * as fs from "node:fs";
3+
import * as clack from "@clack/prompts";
4+
5+
/** Detects the package manager install command from lockfiles. */
6+
export function detectPackageManager(): string {
7+
if (fs.existsSync("yarn.lock")) return "yarn add";
8+
if (fs.existsSync("pnpm-lock.yaml")) return "pnpm add";
9+
return "npm install";
10+
}
11+
12+
/** Installs @hookflo/tern and reports status in the wizard. */
13+
export async function installTern(): Promise<void> {
14+
const spinner = clack.spinner();
15+
spinner.start("installing @hookflo/tern");
16+
try {
17+
const pm = detectPackageManager();
18+
execSync(`${pm} @hookflo/tern`, { stdio: "pipe" });
19+
spinner.stop("installed @hookflo/tern");
20+
} catch {
21+
spinner.stop("could not install @hookflo/tern");
22+
clack.log.warn("run manually: npm install @hookflo/tern");
23+
}
24+
}

0 commit comments

Comments
 (0)