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