src/utils/helpers.ts 8.8 K raw
1
import figlet from "figlet";
2
import chalk from "chalk";
3
import { execa } from "execa";
4
import ora from "ora";
5
import path from "node:path";
6
import fs from "fs-extra";
7
import {
8
	honoRpcTemplate,
9
	shadcnTemplate,
10
	tailwindTemplate,
11
	defaultTemplate,
12
	TEMPLATES,
13
} from "./templates";
14
import type { ProjectOptions, ProjectResult } from "../types";
15
import degit from "degit";
16
import prompts from "prompts";
17
18
export const DEFAULT_REPO = "stevedylandev/bhvr";
19
20
export function displayBanner() {
21
	try {
22
		const text = figlet.textSync("bhvr", {
23
			font: "Big",
24
			horizontalLayout: "default",
25
			verticalLayout: "default",
26
			width: 80,
27
			whitespaceBreak: true,
28
		});
29
30
		console.log("\n");
31
		console.log(chalk.yellowBright(text));
32
	} catch (error) {
33
		console.log("\n");
34
		console.log(chalk.yellowBright("B H V R"));
35
		console.log(chalk.yellow("=========="));
36
	}
37
38
	console.log(`\n${chalk.cyan("🦫 Lets build 🦫")}\n`);
39
	console.log(`${chalk.blue("https://github.com/stevedylandev/bhvr")}\n`);
40
}
41
42
export async function patchFilesForRPC(
43
	projectPath: string,
44
	templateChoice: string,
45
): Promise<boolean> {
46
	const spinner = ora("Setting up RPC client...").start();
47
48
	try {
49
		// 1. Update client package.json to ensure hono client is installed
50
		const clientPkgPath = path.join(projectPath, "client", "package.json");
51
		const clientPkg = await fs.readJson(clientPkgPath);
52
53
		if (!clientPkg.dependencies.hono) {
54
			await execa("bun", ["install", "hono"], { cwd: projectPath });
55
		}
56
57
		await fs.writeJson(clientPkgPath, clientPkg, { spaces: 2 });
58
59
		// 2. Update server package.json dev script for RPC
60
		const serverPkgPath = path.join(projectPath, "server", "package.json");
61
		const serverPkg = await fs.readJson(serverPkgPath);
62
63
		// Update the dev script to include TypeScript compilation
64
		serverPkg.scripts.dev = "bun --watch run src/index.ts & tsc --watch";
65
66
		await fs.writeJson(serverPkgPath, serverPkg, { spaces: 2 });
67
68
		// 3. Server modification for RPC export type
69
		const serverIndexPath = path.join(projectPath, "server", "src", "index.ts");
70
		await fs.writeFile(serverIndexPath, honoRpcTemplate, "utf8");
71
72
		// 4. Update App.tsx based on template selection using switch statement
73
		const appTsxPath = path.join(projectPath, "client", "src", "App.tsx");
74
75
		// Determine template content based on the template type
76
		let updatedAppContent: string;
77
78
		// Select template based on choice
79
		switch (templateChoice) {
80
			case "shadcn":
81
				updatedAppContent = shadcnTemplate;
82
				break;
83
			case "tailwind":
84
				updatedAppContent = tailwindTemplate;
85
				break;
86
			default:
87
				updatedAppContent = defaultTemplate;
88
				break;
89
		}
90
91
		await fs.writeFile(appTsxPath, updatedAppContent, "utf8");
92
		spinner.succeed("RPC client setup completed");
93
		return true;
94
	} catch (err: unknown) {
95
		spinner.fail("Failed to set up RPC client");
96
		if (err instanceof Error) {
97
			console.error(chalk.red("Error:"), err.message);
98
		} else {
99
			console.error(chalk.red("Error: Unknown error"));
100
		}
101
		return false;
102
	}
103
}
104
105
export async function createProject(
106
	projectDirectory: string,
107
	options: ProjectOptions,
108
): Promise<ProjectResult | null> {
109
	let projectName = projectDirectory;
110
111
	if (!projectName && !options.yes) {
112
		const response = await prompts({
113
			type: "text",
114
			name: "projectName",
115
			message: "What is the name of your project?",
116
			initial: "my-bhvr-app",
117
		});
118
119
		if (!response.projectName) {
120
			console.log(chalk.yellow("Project creation cancelled."));
121
			return null;
122
		}
123
124
		projectName = response.projectName;
125
	} else if (!projectName) {
126
		projectName = "my-bhvr-app";
127
	}
128
129
	let templateChoice = options.template || "default";
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
	const projectPath = path.resolve(process.cwd(), projectName);
154
155
	if (fs.existsSync(projectPath)) {
156
		const files = fs.readdirSync(projectPath);
157
158
		if (files.length > 0 && !options.yes) {
159
			const { overwrite } = await prompts({
160
				type: "confirm",
161
				name: "overwrite",
162
				message: `The directory ${projectName} already exists and is not empty. Do you want to overwrite it?`,
163
				initial: false,
164
			});
165
166
			if (!overwrite) {
167
				console.log(chalk.yellow("Project creation cancelled."));
168
				return null;
169
			}
170
171
			await fs.emptyDir(projectPath);
172
		}
173
	}
174
175
	fs.ensureDirSync(projectPath);
176
177
	const repoPath = options.repo || DEFAULT_REPO;
178
	const templateConfig =
179
		TEMPLATES[templateChoice as keyof typeof TEMPLATES] || TEMPLATES.default;
180
	const branch = options.branch || (templateConfig?.branch ?? "main");
181
	const repoUrl = `${repoPath}#${branch}`;
182
	const spinner = ora("Downloading template...").start();
183
184
	try {
185
		const emitter = degit(repoUrl, {
186
			cache: false,
187
			force: true,
188
			verbose: false,
189
		});
190
191
		await emitter.clone(projectPath);
192
		spinner.succeed(
193
			`Template downloaded successfully (${templateChoice} template)`,
194
		);
195
196
		const pkgJsonPath = path.join(projectPath, "package.json");
197
		if (fs.existsSync(pkgJsonPath)) {
198
			const pkgJson = await fs.readJson(pkgJsonPath);
199
			pkgJson.name = projectName;
200
			await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
201
		}
202
203
		const gitDir = path.join(projectPath, ".git");
204
		if (fs.existsSync(gitDir)) {
205
			await fs.remove(gitDir);
206
			console.log(chalk.blue("Removed .git directory"));
207
		}
208
209
		let useRpc = options.rpc;
210
211
		if (!options.yes && !options.rpc) {
212
			const rpcResponse = await prompts({
213
				type: "confirm",
214
				name: "useRpc",
215
				message: "Use Hono RPC client for type-safe API communication?",
216
				initial: false,
217
			});
218
219
			if (rpcResponse.useRpc === undefined) {
220
				console.log(chalk.yellow("Project creation cancelled."));
221
				return null;
222
			}
223
224
			useRpc = rpcResponse.useRpc;
225
		}
226
227
		if (useRpc) {
228
			await patchFilesForRPC(projectPath, templateChoice);
229
		}
230
231
		let gitInitialized = false;
232
233
		if (!options.yes) {
234
			const gitResponse = await prompts({
235
				type: "confirm",
236
				name: "initGit",
237
				message: "Initialize a git repository?",
238
				initial: true,
239
			});
240
241
			if (gitResponse.initGit) {
242
				try {
243
					spinner.start("Initializing git repository...");
244
					await execa("git", ["init"], { cwd: projectPath });
245
					spinner.succeed("Git repository initialized");
246
					gitInitialized = true;
247
				} catch (err: unknown) {
248
					spinner.fail(
249
						"Failed to initialize git repository. Is git installed?",
250
					);
251
					if (err instanceof Error) {
252
						console.error(chalk.red("Git error:"), err.message);
253
					} else {
254
						console.error(chalk.red("Git error: Unknown error"));
255
					}
256
				}
257
			}
258
		} else {
259
			try {
260
				spinner.start("Initializing git repository...");
261
				await execa("git", ["init"], { cwd: projectPath });
262
				spinner.succeed("Git repository initialized");
263
				gitInitialized = true;
264
			} catch (err) {
265
				spinner.fail("Failed to initialize git repository. Is git installed?");
266
			}
267
		}
268
269
		let dependenciesInstalled = false;
270
271
		if (!options.yes) {
272
			const depsResponse = await prompts({
273
				type: "confirm",
274
				name: "installDeps",
275
				message: "Install dependencies?",
276
				initial: true,
277
			});
278
279
			if (depsResponse.installDeps) {
280
				spinner.start("Installing dependencies...");
281
				try {
282
					await execa("bun", ["install"], { cwd: projectPath });
283
					spinner.succeed("Dependencies installed with bun");
284
					dependenciesInstalled = true;
285
				} catch (bunErr) {
286
					try {
287
						spinner.text = "Installing dependencies with npm...";
288
						await execa("npm", ["install"], { cwd: projectPath });
289
						spinner.succeed("Dependencies installed with npm");
290
						dependenciesInstalled = true;
291
					} catch (npmErr) {
292
						spinner.fail("Failed to install dependencies.");
293
						console.log(
294
							chalk.yellow(
295
								"You can install them manually after navigating to the project directory.",
296
							),
297
						);
298
					}
299
				}
300
			}
301
		} else {
302
			spinner.start("Installing dependencies...");
303
			try {
304
				await execa("bun", ["install"], { cwd: projectPath });
305
				spinner.succeed("Dependencies installed with bun");
306
				dependenciesInstalled = true;
307
			} catch (bunErr) {
308
				try {
309
					spinner.text = "Installing dependencies with npm...";
310
					await execa("npm", ["install"], { cwd: projectPath });
311
					spinner.succeed("Dependencies installed with npm");
312
					dependenciesInstalled = true;
313
				} catch (npmErr) {
314
					spinner.fail(
315
						"Failed to install dependencies. You can install them manually later.",
316
					);
317
				}
318
			}
319
		}
320
321
		return {
322
			projectName,
323
			gitInitialized,
324
			dependenciesInstalled,
325
			template: templateChoice,
326
		};
327
	} catch (err) {
328
		spinner.fail("Failed to download template");
329
		throw err;
330
	}
331
}