src/index.ts 10.5 K raw
1
#!/usr/bin/env node
2
3
import fs from "fs-extra";
4
import path from "node:path";
5
import { fileURLToPath } from "node:url";
6
import prompts from "prompts";
7
import { program } from "commander";
8
import chalk from "chalk";
9
import ora from "ora";
10
import { execa } from "execa";
11
import degit from "degit";
12
import figlet from "figlet";
13
import {
14
	defaultTemplate,
15
	shadcnTemplate,
16
	tailwindTemplate,
17
	honoRpcTemplate,
18
} from "./utils/templates";
19
20
const __filename = fileURLToPath(import.meta.url);
21
const __dirname = path.dirname(__filename);
22
23
// GitHub repository for the template
24
const DEFAULT_REPO = "stevedylandev/bhvr";
25
26
interface TemplateInfo {
27
	branch: string;
28
	description: string;
29
}
30
31
const TEMPLATES: Record<string, TemplateInfo> = {
32
	default: {
33
		branch: "main",
34
		description: "Basic setup with Bun, Hono, Vite and React",
35
	},
36
	tailwind: { branch: "tailwindcss", description: "Basic setup + TailwindCSS" },
37
	shadcn: {
38
		branch: "shadcn-ui",
39
		description: "Basic setup + TailwindCSS + shadcn/ui",
40
	},
41
};
42
43
interface ProjectOptions {
44
	yes?: boolean;
45
	typescript?: boolean;
46
	repo?: string;
47
	template?: string;
48
	branch?: string;
49
	rpc?: boolean;
50
}
51
52
function displayBanner() {
53
	const text = figlet.textSync("bhvr", {
54
		font: "Big",
55
		horizontalLayout: "default",
56
		verticalLayout: "default",
57
		width: 80,
58
		whitespaceBreak: true,
59
	});
60
61
	console.log("\n");
62
	console.log(chalk.yellowBright(text));
63
	console.log(`\n${chalk.cyan("🦫 Lets build 🦫")}\n`);
64
	console.log(`${chalk.blue("https://github.com/stevedylandev/bhvr")}\n`);
65
}
66
67
program
68
	.name("create-bhvr")
69
	.description("Create a bhvr monorepo starter project")
70
	.argument("[project-directory]", "directory to create the project in")
71
	.option("-y, --yes", "skip confirmation prompts")
72
	.option("--ts, --typescript", "use TypeScript (default)")
73
	.option(
74
		"--repo <repo>",
75
		"specify a custom GitHub repository as source",
76
		DEFAULT_REPO,
77
	)
78
	.option(
79
		"--template <template>",
80
		"specify a template (default, tailwind, shadcn)",
81
		"default",
82
	)
83
	.option("--branch <branch>", "specify a branch to use from the repository")
84
	.option("--rpc", "use Hono RPC client for type-safe API communication")
85
	.action(async (projectDirectory, options) => {
86
		try {
87
			displayBanner();
88
			const result = await createProject(projectDirectory, options);
89
			if (result) {
90
				console.log(chalk.green.bold("🎉 Project created successfully!"));
91
				console.log("\nNext steps:");
92
93
				if (!result.dependenciesInstalled) {
94
					console.log(chalk.cyan(`  cd ${result.projectName}`));
95
					console.log(chalk.cyan("  bun install"));
96
				} else {
97
					console.log(chalk.cyan(`  cd ${result.projectName}`));
98
				}
99
100
				console.log(chalk.cyan("  bun run dev:client   # Start the client"));
101
				console.log(
102
					chalk.cyan(
103
						"  bun run dev:server   # Start the server in another terminal",
104
					),
105
				);
106
				console.log(chalk.cyan("  bun run dev          # Start all"));
107
				process.exit(0);
108
			}
109
		} catch (err) {
110
			console.error(chalk.red("Error creating project:"), err);
111
			process.exit(1);
112
		}
113
	});
114
115
program.parse();
116
117
interface ProjectResult {
118
	projectName: string;
119
	gitInitialized: boolean;
120
	dependenciesInstalled: boolean;
121
	template: string;
122
}
123
124
async function createProject(
125
	projectDirectory: string,
126
	options: ProjectOptions,
127
): Promise<ProjectResult | null> {
128
	let projectName = projectDirectory;
129
130
	if (!projectName && !options.yes) {
131
		const response = await prompts({
132
			type: "text",
133
			name: "projectName",
134
			message: "What is the name of your project?",
135
			initial: "my-bhvr-app",
136
		});
137
138
		if (!response.projectName) {
139
			console.log(chalk.yellow("Project creation cancelled."));
140
			return null;
141
		}
142
143
		projectName = response.projectName;
144
	} else if (!projectName) {
145
		projectName = "my-bhvr-app";
146
	}
147
148
	let templateChoice = options.template || "default";
149
150
	if (!options.yes && !options.branch) {
151
		const templateChoices = Object.keys(TEMPLATES).map((key) => ({
152
			title: `${key} (${TEMPLATES[key]?.description})`,
153
			value: key,
154
		}));
155
156
		const templateResponse = await prompts({
157
			type: "select",
158
			name: "template",
159
			message: "Select a template:",
160
			choices: templateChoices,
161
			initial: 0,
162
		});
163
164
		if (templateResponse.template === undefined) {
165
			console.log(chalk.yellow("Project creation cancelled."));
166
			return null;
167
		}
168
169
		templateChoice = templateResponse.template;
170
	}
171
172
	const projectPath = path.resolve(process.cwd(), projectName);
173
174
	if (fs.existsSync(projectPath)) {
175
		const files = fs.readdirSync(projectPath);
176
177
		if (files.length > 0 && !options.yes) {
178
			const { overwrite } = await prompts({
179
				type: "confirm",
180
				name: "overwrite",
181
				message: `The directory ${projectName} already exists and is not empty. Do you want to overwrite it?`,
182
				initial: false,
183
			});
184
185
			if (!overwrite) {
186
				console.log(chalk.yellow("Project creation cancelled."));
187
				return null;
188
			}
189
190
			await fs.emptyDir(projectPath);
191
		}
192
	}
193
194
	fs.ensureDirSync(projectPath);
195
196
	const repoPath = options.repo || DEFAULT_REPO;
197
	const templateConfig =
198
		TEMPLATES[templateChoice as keyof typeof TEMPLATES] || TEMPLATES.default;
199
	const branch = options.branch || (templateConfig?.branch ?? "main");
200
	const repoUrl = `${repoPath}#${branch}`;
201
202
	const spinner = ora("Downloading template...").start();
203
204
	try {
205
		const emitter = degit(repoUrl, {
206
			cache: false,
207
			force: true,
208
			verbose: false,
209
		});
210
211
		await emitter.clone(projectPath);
212
		spinner.succeed(
213
			`Template downloaded successfully (${templateChoice} template)`,
214
		);
215
216
		const pkgJsonPath = path.join(projectPath, "package.json");
217
		if (fs.existsSync(pkgJsonPath)) {
218
			const pkgJson = await fs.readJson(pkgJsonPath);
219
			pkgJson.name = projectName;
220
			await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
221
		}
222
223
		const gitDir = path.join(projectPath, ".git");
224
		if (fs.existsSync(gitDir)) {
225
			await fs.remove(gitDir);
226
			console.log(chalk.blue("Removed .git directory"));
227
		}
228
229
		let useRpc = options.rpc;
230
231
		if (!options.yes && !options.rpc) {
232
			const rpcResponse = await prompts({
233
				type: "confirm",
234
				name: "useRpc",
235
				message: "Use Hono RPC client for type-safe API communication?",
236
				initial: false,
237
			});
238
239
			if (rpcResponse.useRpc === undefined) {
240
				console.log(chalk.yellow("Project creation cancelled."));
241
				return null;
242
			}
243
244
			useRpc = rpcResponse.useRpc;
245
		}
246
247
		if (useRpc) {
248
			await patchFilesForRPC(projectPath, templateChoice);
249
		}
250
251
		let gitInitialized = false;
252
253
		if (!options.yes) {
254
			const gitResponse = await prompts({
255
				type: "confirm",
256
				name: "initGit",
257
				message: "Initialize a git repository?",
258
				initial: true,
259
			});
260
261
			if (gitResponse.initGit) {
262
				try {
263
					spinner.start("Initializing git repository...");
264
					await execa("git", ["init"], { cwd: projectPath });
265
					spinner.succeed("Git repository initialized");
266
					gitInitialized = true;
267
				} catch (err: unknown) {
268
					spinner.fail(
269
						"Failed to initialize git repository. Is git installed?",
270
					);
271
					if (err instanceof Error) {
272
						console.error(chalk.red("Git error:"), err.message);
273
					} else {
274
						console.error(chalk.red("Git error: Unknown error"));
275
					}
276
				}
277
			}
278
		} else {
279
			try {
280
				spinner.start("Initializing git repository...");
281
				await execa("git", ["init"], { cwd: projectPath });
282
				spinner.succeed("Git repository initialized");
283
				gitInitialized = true;
284
			} catch (err) {
285
				spinner.fail("Failed to initialize git repository. Is git installed?");
286
			}
287
		}
288
289
		let dependenciesInstalled = false;
290
291
		if (!options.yes) {
292
			const depsResponse = await prompts({
293
				type: "confirm",
294
				name: "installDeps",
295
				message: "Install dependencies?",
296
				initial: true,
297
			});
298
299
			if (depsResponse.installDeps) {
300
				spinner.start("Installing dependencies...");
301
				try {
302
					await execa("bun", ["install"], { cwd: projectPath });
303
					spinner.succeed("Dependencies installed with bun");
304
					dependenciesInstalled = true;
305
				} catch (bunErr) {
306
					try {
307
						spinner.text = "Installing dependencies with npm...";
308
						await execa("npm", ["install"], { cwd: projectPath });
309
						spinner.succeed("Dependencies installed with npm");
310
						dependenciesInstalled = true;
311
					} catch (npmErr) {
312
						spinner.fail("Failed to install dependencies.");
313
						console.log(
314
							chalk.yellow(
315
								"You can install them manually after navigating to the project directory.",
316
							),
317
						);
318
					}
319
				}
320
			}
321
		} else {
322
			spinner.start("Installing dependencies...");
323
			try {
324
				await execa("bun", ["install"], { cwd: projectPath });
325
				spinner.succeed("Dependencies installed with bun");
326
				dependenciesInstalled = true;
327
			} catch (bunErr) {
328
				try {
329
					spinner.text = "Installing dependencies with npm...";
330
					await execa("npm", ["install"], { cwd: projectPath });
331
					spinner.succeed("Dependencies installed with npm");
332
					dependenciesInstalled = true;
333
				} catch (npmErr) {
334
					spinner.fail(
335
						"Failed to install dependencies. You can install them manually later.",
336
					);
337
				}
338
			}
339
		}
340
341
		return {
342
			projectName,
343
			gitInitialized,
344
			dependenciesInstalled,
345
			template: templateChoice,
346
		};
347
	} catch (err) {
348
		spinner.fail("Failed to download template");
349
		throw err;
350
	}
351
}
352
353
async function patchFilesForRPC(
354
	projectPath: string,
355
	templateChoice: string,
356
): Promise<boolean> {
357
	const spinner = ora("Setting up RPC client...").start();
358
359
	try {
360
		// 1. Update client package.json to ensure hono client is installed
361
		const clientPkgPath = path.join(projectPath, "client", "package.json");
362
		const clientPkg = await fs.readJson(clientPkgPath);
363
364
		if (!clientPkg.dependencies.hono) {
365
			await execa("bun", ["install", "hono"], { cwd: projectPath });
366
		}
367
368
		await fs.writeJson(clientPkgPath, clientPkg, { spaces: 2 });
369
370
		// 2. Server modification for RPC export type
371
		const serverIndexPath = path.join(projectPath, "server", "src", "index.ts");
372
		await fs.writeFile(serverIndexPath, honoRpcTemplate, "utf8");
373
374
		// 3. Update App.tsx based on template selection using switch statement
375
		const appTsxPath = path.join(projectPath, "client", "src", "App.tsx");
376
377
		// Determine template content based on the template type
378
		let updatedAppContent: string;
379
380
		// Select template based on choice
381
		switch (templateChoice) {
382
			case "shadcn":
383
				updatedAppContent = shadcnTemplate;
384
				break;
385
			case "tailwind":
386
				updatedAppContent = tailwindTemplate;
387
				break;
388
			default:
389
				updatedAppContent = defaultTemplate;
390
				break;
391
		}
392
393
		await fs.writeFile(appTsxPath, updatedAppContent, "utf8");
394
		spinner.succeed("RPC client setup completed");
395
		return true;
396
	} catch (err: unknown) {
397
		spinner.fail("Failed to set up RPC client");
398
		if (err instanceof Error) {
399
			console.error(chalk.red("Error:"), err.message);
400
		} else {
401
			console.error(chalk.red("Error: Unknown error"));
402
		}
403
		return false;
404
	}
405
}