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