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