Create name generator function to make it a no brainer selecting the correct template; Add unit tests; Add experimental internal script to check for template coverage b17d31b3
Maximilian Leodolter · 2025-08-06 09:27 12 file(s) · +1049 −38
scripts/check-template-combinations.ts (added) +445 −0
1 +
#!/usr/bin/env bun
2 +
// Experimental script that lets you check if every possible template combination is created.
3 +
// Please use with CAUTION - A lot of parts have been written by AI
4 +
5 +
import fs from "fs-extra";
6 +
import path from "node:path";
7 +
8 +
// Define the possible boolean options from ProjectOptions type
9 +
const BOOLEAN_OPTIONS = ["tailwind", "shadcn", "rpc", "tanstackQuery"] as const;
10 +
11 +
// Simulate nameGenerator function locally
12 +
const nameGenerator = (
13 +
	basename: string,
14 +
	possibleOptions: Record<string, boolean>,
15 +
) => {
16 +
	const dotIndex = basename.lastIndexOf(".");
17 +
	const filename = dotIndex === -1 ? basename : basename.substring(0, dotIndex);
18 +
	const extension = dotIndex === -1 ? "" : basename.substring(dotIndex + 1);
19 +
20 +
	const selectedOptions = Object.keys(possibleOptions)
21 +
		.filter((opt) => possibleOptions[opt])
22 +
		.map((opt) => opt.toLowerCase())
23 +
		.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
24 +
25 +
	if (selectedOptions.length > 0) {
26 +
		const suffix = ["-with", ...selectedOptions].join("-");
27 +
		return extension
28 +
			? `${filename}${suffix}.${extension}`
29 +
			: `${filename}${suffix}`;
30 +
	}
31 +
	return basename;
32 +
};
33 +
34 +
// Generate all possible combinations of boolean options
35 +
function generateAllCombinations(
36 +
	options: readonly string[],
37 +
): Array<Record<string, boolean>> {
38 +
	const combinations: Array<Record<string, boolean>> = [];
39 +
	const totalCombinations = Math.pow(2, options.length);
40 +
41 +
	for (let i = 0; i < totalCombinations; i++) {
42 +
		const combination: Record<string, boolean> = {};
43 +
		for (let j = 0; j < options.length; j++) {
44 +
			combination[options[j]] = Boolean(i & (1 << j));
45 +
		}
46 +
		combinations.push(combination);
47 +
	}
48 +
49 +
	return combinations;
50 +
}
51 +
52 +
// Parse installer files to find nameGenerator calls and hardcoded template patterns
53 +
// Recursively find all .ts files in installers directory
54 +
async function findInstallerFiles(dir: string): Promise<string[]> {
55 +
	const files: string[] = [];
56 +
	const entries = await fs.readdir(dir, { withFileTypes: true });
57 +
58 +
	for (const entry of entries) {
59 +
		const fullPath = path.join(dir, entry.name);
60 +
		if (entry.isDirectory()) {
61 +
			files.push(...(await findInstallerFiles(fullPath)));
62 +
		} else if (entry.isFile() && entry.name.endsWith(".ts")) {
63 +
			files.push(fullPath);
64 +
		}
65 +
	}
66 +
67 +
	return files;
68 +
}
69 +
70 +
async function parseInstallerFiles(): Promise<
71 +
	Array<{
72 +
		file: string;
73 +
		basename: string;
74 +
		usedOptions: string[];
75 +
		templatePath: string;
76 +
		type: "nameGenerator" | "hardcoded";
77 +
	}>
78 +
> {
79 +
	const installerFiles = await findInstallerFiles("src/installers");
80 +
	const templateCalls: Array<{
81 +
		file: string;
82 +
		basename: string;
83 +
		usedOptions: string[];
84 +
		templatePath: string;
85 +
		type: "nameGenerator" | "hardcoded";
86 +
	}> = [];
87 +
88 +
	for (const file of installerFiles) {
89 +
		const content = await fs.readFile(file, "utf-8");
90 +
91 +
		// Find nameGenerator calls with regex
92 +
		const nameGeneratorRegex =
93 +
			/nameGenerator\s*\(\s*["']([^"']+)["']\s*,\s*\{([^}]+)\}/g;
94 +
		let match;
95 +
96 +
		while ((match = nameGeneratorRegex.exec(content)) !== null) {
97 +
			const basename = match[1];
98 +
			const optionsStr = match[2];
99 +
100 +
			// Extract the options used in this call
101 +
			const usedOptions = BOOLEAN_OPTIONS.filter((option) =>
102 +
				optionsStr.includes(option),
103 +
			);
104 +
105 +
			// Try to find the template path from the surrounding context
106 +
			const lines = content.split("\n");
107 +
			const matchLine =
108 +
				content.substring(0, match.index).split("\n").length - 1;
109 +
110 +
			let templatePath = "client/src"; // default based on common pattern
111 +
112 +
			// Look for path.join with EXTRAS_DIR in nearby lines (could be multi-line)
113 +
			for (
114 +
				let i = Math.max(0, matchLine - 10);
115 +
				i <= Math.min(lines.length - 1, matchLine + 10);
116 +
				i++
117 +
			) {
118 +
				const line = lines[i].trim();
119 +
				if (line.includes("path.join") && line.includes("EXTRAS_DIR")) {
120 +
					// Find the complete path.join statement (might span multiple lines)
121 +
					let pathJoinContent = "";
122 +
					let j = i;
123 +
					let parenCount = 0;
124 +
					let foundStart = false;
125 +
126 +
					while (j < lines.length) {
127 +
						const currentLine = lines[j].trim();
128 +
						pathJoinContent += currentLine + " ";
129 +
130 +
						if (currentLine.includes("path.join")) {
131 +
							foundStart = true;
132 +
						}
133 +
134 +
						if (foundStart) {
135 +
							parenCount += (currentLine.match(/\(/g) || []).length;
136 +
							parenCount -= (currentLine.match(/\)/g) || []).length;
137 +
138 +
							if (parenCount === 0) {
139 +
								break;
140 +
							}
141 +
						}
142 +
						j++;
143 +
					}
144 +
145 +
					// Extract path components from the complete path.join
146 +
					const pathMatch = pathJoinContent.match(/EXTRAS_DIR[^,]*,([^)]+)\)/);
147 +
					if (pathMatch) {
148 +
						templatePath = pathMatch[1]
149 +
							.split(",")
150 +
							.map((s) =>
151 +
								s
152 +
									.trim()
153 +
									.replace(/['"]/g, "")
154 +
									.replace(/nameGenerator[^,]*/, basename),
155 +
							)
156 +
							.filter((s) => s && s !== basename)
157 +
							.join("/");
158 +
					}
159 +
					break;
160 +
				}
161 +
			}
162 +
163 +
			templateCalls.push({
164 +
				file,
165 +
				basename,
166 +
				usedOptions,
167 +
				templatePath,
168 +
				type: "nameGenerator",
169 +
			});
170 +
		}
171 +
172 +
		// Find hardcoded template patterns like: `App-with${tailwind ? "-tailwind" : ""}${shadcn ? "-shadcn" : ""}${rpc ? "-rpc" : ""}.tsx`
173 +
		const hardcodedRegex = /`([^`]*)-with\${[^`]+\$\{[^`]+}\.[^`]+`/g;
174 +
		let hardcodedMatch;
175 +
176 +
		while ((hardcodedMatch = hardcodedRegex.exec(content)) !== null) {
177 +
			const templatePattern = hardcodedMatch[1];
178 +
179 +
			// Extract basename from pattern (everything before "-with")
180 +
			const basenameMatch = templatePattern.match(/^([^-]+)/);
181 +
			if (basenameMatch) {
182 +
				const basename = `${basenameMatch[1]}.tsx`; // Add .tsx extension
183 +
184 +
				// Extract options from the template pattern
185 +
				const usedOptions = BOOLEAN_OPTIONS.filter((option) =>
186 +
					hardcodedMatch[0].includes(option),
187 +
				);
188 +
189 +
				// Find template path similar to nameGenerator
190 +
				const lines = content.split("\n");
191 +
				const matchLine =
192 +
					content.substring(0, hardcodedMatch.index).split("\n").length - 1;
193 +
194 +
				let templatePath = "client/src";
195 +
196 +
				for (
197 +
					let i = Math.max(0, matchLine - 10);
198 +
					i <= Math.min(lines.length - 1, matchLine + 10);
199 +
					i++
200 +
				) {
201 +
					const line = lines[i].trim();
202 +
					if (line.includes("path.join") && line.includes("EXTRAS_DIR")) {
203 +
						let pathJoinContent = "";
204 +
						let j = i;
205 +
						let parenCount = 0;
206 +
						let foundStart = false;
207 +
208 +
						while (j < lines.length) {
209 +
							const currentLine = lines[j].trim();
210 +
							pathJoinContent += currentLine + " ";
211 +
212 +
							if (currentLine.includes("path.join")) {
213 +
								foundStart = true;
214 +
							}
215 +
216 +
							if (foundStart) {
217 +
								parenCount += (currentLine.match(/\(/g) || []).length;
218 +
								parenCount -= (currentLine.match(/\)/g) || []).length;
219 +
220 +
								if (parenCount === 0) {
221 +
									break;
222 +
								}
223 +
							}
224 +
							j++;
225 +
						}
226 +
227 +
						const pathMatch = pathJoinContent.match(
228 +
							/EXTRAS_DIR[^,]*,([^)]+)\)/,
229 +
						);
230 +
						if (pathMatch) {
231 +
							templatePath = pathMatch[1]
232 +
								.split(",")
233 +
								.map((s) =>
234 +
									s
235 +
										.trim()
236 +
										.replace(/['"]/g, "")
237 +
										.replace(/selectedTemplate[^,]*/, basename),
238 +
								)
239 +
								.filter(
240 +
									(s) => s && s !== basename && !s.includes("selectedTemplate"),
241 +
								)
242 +
								.join("/");
243 +
						}
244 +
						break;
245 +
					}
246 +
				}
247 +
248 +
				templateCalls.push({
249 +
					file,
250 +
					basename,
251 +
					usedOptions,
252 +
					templatePath,
253 +
					type: "hardcoded",
254 +
				});
255 +
			}
256 +
		}
257 +
	}
258 +
259 +
	return templateCalls;
260 +
}
261 +
262 +
// Simulate hardcoded template naming (like in RPC installer)
263 +
const hardcodedGenerator = (
264 +
	basename: string,
265 +
	possibleOptions: Record<string, boolean>,
266 +
) => {
267 +
	const dotIndex = basename.lastIndexOf(".");
268 +
	const filename = dotIndex === -1 ? basename : basename.substring(0, dotIndex);
269 +
	const extension = dotIndex === -1 ? "" : basename.substring(dotIndex + 1);
270 +
271 +
	let result = filename + "-with";
272 +
273 +
	// Hardcoded follows specific order: tailwind, shadcn, rpc
274 +
	if (possibleOptions.tailwind) result += "-tailwind";
275 +
	if (possibleOptions.shadcn) result += "-shadcn";
276 +
	if (possibleOptions.rpc) result += "-rpc";
277 +
278 +
	return extension ? `${result}.${extension}` : result;
279 +
};
280 +
281 +
// Check if template files exist for all combinations
282 +
async function checkTemplateFiles() {
283 +
	console.log("🔍 Analyzing template patterns in installers...\n");
284 +
285 +
	const templateCalls = await parseInstallerFiles();
286 +
287 +
	if (templateCalls.length === 0) {
288 +
		console.log("❌ No template patterns found in installer files!");
289 +
		return;
290 +
	}
291 +
292 +
	const extrasDir = path.resolve("src/templates/extras");
293 +
294 +
	for (const call of templateCalls) {
295 +
		console.log(`📁 Analyzing: ${call.file} (${call.type})`);
296 +
		console.log(`   Basename: ${call.basename}`);
297 +
		console.log(`   Used Options: [${call.usedOptions.join(", ")}]`);
298 +
		console.log(`   Template Path: ${call.templatePath}`);
299 +
		console.log("");
300 +
301 +
		// Generate all possible combinations for the used options
302 +
		const allCombinations = generateAllCombinations(call.usedOptions);
303 +
		const templateDir = path.join(
304 +
			extrasDir,
305 +
			call.templatePath.replace(/["']/g, ""),
306 +
			call.basename,
307 +
		);
308 +
309 +
		console.log(
310 +
			`   📋 All possible template files for ${call.basename} (${call.type}):`,
311 +
		);
312 +
313 +
		let foundCount = 0;
314 +
		let missingCount = 0;
315 +
316 +
		for (const combination of allCombinations) {
317 +
			const templateName =
318 +
				call.type === "nameGenerator"
319 +
					? nameGenerator(call.basename, combination)
320 +
					: hardcodedGenerator(call.basename, combination);
321 +
			const fullTemplatePath = path.join(templateDir, templateName);
322 +
323 +
			const exists = await fs.pathExists(fullTemplatePath);
324 +
			const status = exists ? "✅" : "❌";
325 +
326 +
			if (exists) {
327 +
				foundCount++;
328 +
			} else {
329 +
				missingCount++;
330 +
			}
331 +
332 +
			// Show combination details
333 +
			const enabledOptions = Object.keys(combination)
334 +
				.filter((key) => combination[key])
335 +
				.join(", ");
336 +
337 +
			console.log(
338 +
				`     ${status} ${templateName} ${enabledOptions ? `(${enabledOptions})` : "(no options)"}`,
339 +
			);
340 +
341 +
			if (!exists) {
342 +
				console.log(`        Missing: ${fullTemplatePath}`);
343 +
			}
344 +
		}
345 +
346 +
		console.log("");
347 +
		console.log(`   📊 Summary: ${foundCount} found, ${missingCount} missing`);
348 +
		console.log("   " + "=".repeat(50));
349 +
		console.log("");
350 +
	}
351 +
352 +
	// Overall statistics
353 +
	console.log("🎯 Overall Analysis Complete!");
354 +
	console.log(
355 +
		`   Found ${templateCalls.length} template patterns in installers`,
356 +
	);
357 +
	console.log(
358 +
		`   - ${templateCalls.filter((c) => c.type === "nameGenerator").length} nameGenerator calls`,
359 +
	);
360 +
	console.log(
361 +
		`   - ${templateCalls.filter((c) => c.type === "hardcoded").length} hardcoded template patterns`,
362 +
	);
363 +
364 +
	// Consistency analysis
365 +
	console.log("");
366 +
	console.log("!  CONSISTENCY ISSUES DETECTED:");
367 +
	console.log(
368 +
		"   The RPC installer uses hardcoded template names with order: tailwind-shadcn-rpc",
369 +
	);
370 +
	console.log(
371 +
		"   The TanStack Query installer uses nameGenerator with alphabetical order: rpc-shadcn-tailwind-tanstackquery",
372 +
	);
373 +
	console.log("   But the actual template files follow the hardcoded pattern!");
374 +
	console.log("");
375 +
	console.log("💡 RECOMMENDATIONS:");
376 +
	console.log(
377 +
		"   1. Standardize on nameGenerator for consistency across all installers",
378 +
	);
379 +
	console.log(
380 +
		"   2. OR rename template files to match nameGenerator's alphabetical sorting",
381 +
	);
382 +
	console.log(
383 +
		"   3. OR update nameGenerator to use the same order as hardcoded pattern",
384 +
	);
385 +
	console.log("");
386 +
	console.log("🔍 MISSING TEMPLATE FILES:");
387 +
	const totalMissing = templateCalls.reduce((acc, call) => {
388 +
		const allCombinations = generateAllCombinations(call.usedOptions);
389 +
		return (
390 +
			acc +
391 +
			allCombinations.filter((combo) => {
392 +
				const templateName =
393 +
					call.type === "nameGenerator"
394 +
						? nameGenerator(call.basename, combo)
395 +
						: hardcodedGenerator(call.basename, combo);
396 +
				const templateDir = path.join(
397 +
					path.resolve("src/templates/extras"),
398 +
					call.templatePath.replace(/["']/g, ""),
399 +
					call.basename,
400 +
				);
401 +
				const fullTemplatePath = path.join(templateDir, templateName);
402 +
				return !require("fs-extra").pathExistsSync(fullTemplatePath);
403 +
			}).length
404 +
		);
405 +
	}, 0);
406 +
	console.log(`   Total missing template files: ${totalMissing}`);
407 +
408 +
	console.log("");
409 +
	console.log("🛠  COMMANDS TO CREATE MISSING FILES:");
410 +
411 +
	for (const call of templateCalls) {
412 +
		const allCombinations = generateAllCombinations(call.usedOptions);
413 +
		const templateDir = path.join(
414 +
			path.resolve("src/templates/extras"),
415 +
			call.templatePath.replace(/["']/g, ""),
416 +
			call.basename,
417 +
		);
418 +
419 +
		const missingFiles: string[] = [];
420 +
421 +
		for (const combination of allCombinations) {
422 +
			const templateName =
423 +
				call.type === "nameGenerator"
424 +
					? nameGenerator(call.basename, combination)
425 +
					: hardcodedGenerator(call.basename, combination);
426 +
			const fullTemplatePath = path.join(templateDir, templateName);
427 +
428 +
			const exists = require("fs-extra").pathExistsSync(fullTemplatePath);
429 +
			if (!exists) {
430 +
				missingFiles.push(`touch "${fullTemplatePath}"`);
431 +
			}
432 +
		}
433 +
434 +
		if (missingFiles.length > 0) {
435 +
			console.log(`   # Missing files for ${call.basename} (${call.type}):`);
436 +
			for (const cmd of missingFiles) {
437 +
				console.log(`   ${cmd}`);
438 +
			}
439 +
			console.log("");
440 +
		}
441 +
	}
442 +
}
443 +
444 +
// Run the analysis
445 +
checkTemplateFiles().catch(console.error);
src/installers/rpc.ts +2 −3
7 7
import { honoClientTemplate, honoRpcTemplate } from "@/utils/templates";
8 8
import type { ProjectOptions } from "@/types";
9 9
import { EXTRAS_DIR } from "@/utils";
10 +
import { nameGenerator } from "@/utils/name-generator";
10 11
11 12
export async function rpcInstaller(
12 13
	options: Required<ProjectOptions>,
50 51
		await fs.writeFile(clientHelperPath, honoClientTemplate, "utf8");
51 52
52 53
		// 5. Update App.tsx based on template selection using switch statement
53 -
		const selectedTemplate = `App-with${tailwind ? "-tailwind" : ""}${shadcn ? "-shadcn" : ""}${rpc ? "-rpc" : ""}.tsx`;
54 -
55 54
		const appTsxSrc = path.join(
56 55
			EXTRAS_DIR,
57 56
			"client",
58 57
			"src",
59 58
			"App.tsx",
60 -
			selectedTemplate,
59 +
			nameGenerator("App.tsx", { tailwind, shadcn, rpc }),
61 60
		);
62 61
		const appTsxTarget = path.join(projectPath, "client", "src", "App.tsx");
63 62
src/installers/tanstack-query.ts +8 −2
6 6
import { consola } from "consola";
7 7
import { addPackageDependency } from "@/utils/add-package-dependency";
8 8
import { EXTRAS_DIR } from "@/utils";
9 +
import { nameGenerator } from "@/utils/name-generator";
9 10
10 11
export const tanstackQueryInstaller = async (
11 12
	options: Required<ProjectOptions>,
25 26
			projectName,
26 27
		});
27 28
28 -
		const selectedTemplate = `App-with${tailwind ? "-tailwind" : ""}${shadcn ? "-shadcn" : ""}${rpc ? "-rpc" : ""}${tanstackQuery ? "-tanstackquery" : ""}.tsx`;
29 +
		const selectedTemplate = nameGenerator("App.tsx", {
30 +
			rpc,
31 +
			shadcn,
32 +
			tailwind,
33 +
			tanstackQuery,
34 +
		});
29 35
30 36
		const appTsxSrc = path.join(
31 37
			EXTRAS_DIR,
42 48
			"client",
43 49
			"src",
44 50
			"main.tsx",
45 -
			"main-with-tanstackquery.tsx",
51 +
			nameGenerator("main.tsx", { tanstackQuery }),
46 52
		);
47 53
		const mainTsxTarget = path.join(projectPath, "client", "src", "main.tsx");
48 54
		fs.copySync(mainTsxSrc, mainTsxTarget);
src/templates/extras/client/src/App.tsx/App-with-tailwind-rpc-tanstackquery.tsx → src/templates/extras/client/src/App.tsx/App-with-rpc-tailwind-tanstackquery.tsx +0 −0
src/templates/extras/client/src/App.tsx/App-with-tailwind-rpc.tsx → src/templates/extras/client/src/App.tsx/App-with-rpc-tailwind.tsx +0 −0
src/templates/extras/client/src/App.tsx/App-with-tailwind-shadcn-rpc-tanstackquery.tsx → src/templates/extras/client/src/App.tsx/App-with-rpc-shadcn-tailwind-tanstackquery.tsx +5 −1
33 33
34 34
	return (
35 35
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
36 -
			<a href="https://github.com/stevedylandev/bhvr" target="_blank" rel="noopener">
36 +
			<a
37 +
				href="https://github.com/stevedylandev/bhvr"
38 +
				target="_blank"
39 +
				rel="noopener"
40 +
			>
37 41
				<img
38 42
					src={beaver}
39 43
					className="w-16 h-16 cursor-pointer"
src/templates/extras/client/src/App.tsx/App-with-tailwind-shadcn-rpc.tsx → src/templates/extras/client/src/App.tsx/App-with-rpc-shadcn-tailwind.tsx +5 −1
30 30
31 31
	return (
32 32
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
33 -
			<a href="https://github.com/stevedylandev/bhvr" target="_blank" rel="noopener">
33 +
			<a
34 +
				href="https://github.com/stevedylandev/bhvr"
35 +
				target="_blank"
36 +
				rel="noopener"
37 +
			>
34 38
				<img
35 39
					src={beaver}
36 40
					className="w-16 h-16 cursor-pointer"
src/templates/extras/client/src/App.tsx/App-with-tailwind-shadcn-tanstackquery.tsx → src/templates/extras/client/src/App.tsx/App-with-shadcn-tailwind-tanstackquery.tsx +5 −1
23 23
24 24
	return (
25 25
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
26 -
			<a href="https://github.com/stevedylandev/bhvr" target="_blank" rel="noopener">
26 +
			<a
27 +
				href="https://github.com/stevedylandev/bhvr"
28 +
				target="_blank"
29 +
				rel="noopener"
30 +
			>
27 31
				<img
28 32
					src={beaver}
29 33
					className="w-16 h-16 cursor-pointer"
src/utils/add-package-dependency.test.ts (added) +369 −0
1 +
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
2 +
import path from "node:path";
3 +
4 +
// Import the function to test
5 +
import { addPackageDependency } from "./add-package-dependency";
6 +
7 +
// Mock execa
8 +
const mockExeca = mock(() => Promise.resolve({ stdout: "", stderr: "" }));
9 +
10 +
// Mock the execa module
11 +
mock.module("execa", () => ({
12 +
	execa: mockExeca,
13 +
}));
14 +
15 +
describe("addPackageDependency", () => {
16 +
	const testProjectName = "test-project";
17 +
	const testDependencies = ["react", "typescript"];
18 +
19 +
	beforeEach(() => {
20 +
		mockExeca.mockClear();
21 +
	});
22 +
23 +
	afterEach(() => {
24 +
		mock.restore();
25 +
	});
26 +
27 +
	describe("basic functionality", () => {
28 +
		it("should install dependencies in project root by default", async () => {
29 +
			await addPackageDependency({
30 +
				dependencies: testDependencies,
31 +
				projectName: testProjectName,
32 +
			});
33 +
34 +
			expect(mockExeca).toHaveBeenCalledTimes(1);
35 +
			expect(mockExeca).toHaveBeenCalledWith(
36 +
				"bun",
37 +
				["install", ...testDependencies],
38 +
				{
39 +
					cwd: path.resolve(process.cwd(), testProjectName),
40 +
				},
41 +
			);
42 +
		});
43 +
44 +
		it("should install dev dependencies when devMode is true", async () => {
45 +
			await addPackageDependency({
46 +
				dependencies: testDependencies,
47 +
				projectName: testProjectName,
48 +
				devMode: true,
49 +
			});
50 +
51 +
			expect(mockExeca).toHaveBeenCalledTimes(1);
52 +
			expect(mockExeca).toHaveBeenCalledWith(
53 +
				"bun",
54 +
				["install", "-D", ...testDependencies],
55 +
				{
56 +
					cwd: path.resolve(process.cwd(), testProjectName),
57 +
				},
58 +
			);
59 +
		});
60 +
61 +
		it("should handle single dependency", async () => {
62 +
			await addPackageDependency({
63 +
				dependencies: ["react"],
64 +
				projectName: testProjectName,
65 +
			});
66 +
67 +
			expect(mockExeca).toHaveBeenCalledWith("bun", ["install", "react"], {
68 +
				cwd: path.resolve(process.cwd(), testProjectName),
69 +
			});
70 +
		});
71 +
72 +
		it("should allow empty dependencies array", async () => {
73 +
			await addPackageDependency({
74 +
				dependencies: [],
75 +
				projectName: testProjectName,
76 +
			});
77 +
78 +
			expect(mockExeca).toHaveBeenCalledWith("bun", ["install"], {
79 +
				cwd: path.resolve(process.cwd(), testProjectName),
80 +
			});
81 +
		});
82 +
	});
83 +
84 +
	describe("target-specific installations", () => {
85 +
		it("should install dependencies in client directory when target is 'client'", async () => {
86 +
			await addPackageDependency({
87 +
				dependencies: testDependencies,
88 +
				projectName: testProjectName,
89 +
				target: "client",
90 +
			});
91 +
92 +
			expect(mockExeca).toHaveBeenCalledTimes(1);
93 +
			expect(mockExeca).toHaveBeenCalledWith(
94 +
				"bun",
95 +
				["install", ...testDependencies],
96 +
				{
97 +
					cwd: path.join(
98 +
						path.resolve(process.cwd(), testProjectName),
99 +
						"client",
100 +
					),
101 +
				},
102 +
			);
103 +
		});
104 +
105 +
		it("should install dependencies in server directory when target is 'server'", async () => {
106 +
			await addPackageDependency({
107 +
				dependencies: testDependencies,
108 +
				projectName: testProjectName,
109 +
				target: "server",
110 +
			});
111 +
112 +
			expect(mockExeca).toHaveBeenCalledTimes(1);
113 +
			expect(mockExeca).toHaveBeenCalledWith(
114 +
				"bun",
115 +
				["install", ...testDependencies],
116 +
				{
117 +
					cwd: path.join(
118 +
						path.resolve(process.cwd(), testProjectName),
119 +
						"server",
120 +
					),
121 +
				},
122 +
			);
123 +
		});
124 +
125 +
		it("should install dev dependencies in client directory", async () => {
126 +
			await addPackageDependency({
127 +
				dependencies: testDependencies,
128 +
				projectName: testProjectName,
129 +
				target: "client",
130 +
				devMode: true,
131 +
			});
132 +
133 +
			expect(mockExeca).toHaveBeenCalledWith(
134 +
				"bun",
135 +
				["install", "-D", ...testDependencies],
136 +
				{
137 +
					cwd: path.join(
138 +
						path.resolve(process.cwd(), testProjectName),
139 +
						"client",
140 +
					),
141 +
				},
142 +
			);
143 +
		});
144 +
145 +
		it("should install dev dependencies in server directory", async () => {
146 +
			await addPackageDependency({
147 +
				dependencies: testDependencies,
148 +
				projectName: testProjectName,
149 +
				target: "server",
150 +
				devMode: true,
151 +
			});
152 +
153 +
			expect(mockExeca).toHaveBeenCalledWith(
154 +
				"bun",
155 +
				["install", "-D", ...testDependencies],
156 +
				{
157 +
					cwd: path.join(
158 +
						path.resolve(process.cwd(), testProjectName),
159 +
						"server",
160 +
					),
161 +
				},
162 +
			);
163 +
		});
164 +
	});
165 +
166 +
	describe("edge cases and error scenarios", () => {
167 +
		it("should handle special characters in project name", async () => {
168 +
			const specialProjectName = "my-project_with.special-chars";
169 +
			await addPackageDependency({
170 +
				dependencies: ["react"],
171 +
				projectName: specialProjectName,
172 +
			});
173 +
174 +
			expect(mockExeca).toHaveBeenCalledWith("bun", ["install", "react"], {
175 +
				cwd: path.resolve(process.cwd(), specialProjectName),
176 +
			});
177 +
		});
178 +
179 +
		it("should handle dependencies with scoped packages", async () => {
180 +
			const scopedDependencies = ["@types/node", "@tanstack/react-query"];
181 +
			await addPackageDependency({
182 +
				dependencies: scopedDependencies,
183 +
				projectName: testProjectName,
184 +
			});
185 +
186 +
			expect(mockExeca).toHaveBeenCalledWith(
187 +
				"bun",
188 +
				["install", ...scopedDependencies],
189 +
				{
190 +
					cwd: path.resolve(process.cwd(), testProjectName),
191 +
				},
192 +
			);
193 +
		});
194 +
195 +
		it("should propagate execa errors", async () => {
196 +
			const testError = new Error("Installation failed");
197 +
			mockExeca.mockRejectedValueOnce(testError);
198 +
199 +
			await expect(
200 +
				addPackageDependency({
201 +
					dependencies: ["react"],
202 +
					projectName: testProjectName,
203 +
				}),
204 +
			).rejects.toThrow("Failed to install dependencies: Installation failed");
205 +
		});
206 +
207 +
		it("should handle undefined devMode (falsy)", async () => {
208 +
			await addPackageDependency({
209 +
				dependencies: testDependencies,
210 +
				projectName: testProjectName,
211 +
				devMode: undefined,
212 +
			});
213 +
214 +
			expect(mockExeca).toHaveBeenCalledWith(
215 +
				"bun",
216 +
				["install", ...testDependencies],
217 +
				{
218 +
					cwd: path.resolve(process.cwd(), testProjectName),
219 +
				},
220 +
			);
221 +
		});
222 +
223 +
		it("should handle false devMode explicitly", async () => {
224 +
			await addPackageDependency({
225 +
				dependencies: testDependencies,
226 +
				projectName: testProjectName,
227 +
				devMode: false,
228 +
			});
229 +
230 +
			expect(mockExeca).toHaveBeenCalledWith(
231 +
				"bun",
232 +
				["install", ...testDependencies],
233 +
				{
234 +
					cwd: path.resolve(process.cwd(), testProjectName),
235 +
				},
236 +
			);
237 +
		});
238 +
239 +
		it("should throw error for empty project name", async () => {
240 +
			await expect(
241 +
				addPackageDependency({
242 +
					dependencies: ["react"],
243 +
					projectName: "",
244 +
				}),
245 +
			).rejects.toThrow("Project name is required");
246 +
247 +
			expect(mockExeca).not.toHaveBeenCalled();
248 +
		});
249 +
250 +
		it("should throw error for whitespace-only project name", async () => {
251 +
			await expect(
252 +
				addPackageDependency({
253 +
					dependencies: ["react"],
254 +
					projectName: "   ",
255 +
				}),
256 +
			).rejects.toThrow("Project name is required");
257 +
258 +
			expect(mockExeca).not.toHaveBeenCalled();
259 +
		});
260 +
261 +
		it("should include target info in error messages", async () => {
262 +
			const testError = new Error("Installation failed");
263 +
			mockExeca.mockRejectedValueOnce(testError);
264 +
265 +
			await expect(
266 +
				addPackageDependency({
267 +
					dependencies: ["react"],
268 +
					projectName: testProjectName,
269 +
					target: "client",
270 +
				}),
271 +
			).rejects.toThrow(
272 +
				"Failed to install dependencies in client: Installation failed",
273 +
			);
274 +
		});
275 +
	});
276 +
277 +
	describe("path construction", () => {
278 +
		it("should construct correct absolute paths", async () => {
279 +
			const expectedPath = path.resolve(process.cwd(), testProjectName);
280 +
281 +
			await addPackageDependency({
282 +
				dependencies: ["react"],
283 +
				projectName: testProjectName,
284 +
			});
285 +
286 +
			const actualCall = mockExeca.mock.calls[0];
287 +
			expect(actualCall[2].cwd).toBe(expectedPath);
288 +
		});
289 +
290 +
		it("should construct correct client path", async () => {
291 +
			const expectedPath = path.join(
292 +
				path.resolve(process.cwd(), testProjectName),
293 +
				"client",
294 +
			);
295 +
296 +
			await addPackageDependency({
297 +
				dependencies: ["react"],
298 +
				projectName: testProjectName,
299 +
				target: "client",
300 +
			});
301 +
302 +
			const actualCall = mockExeca.mock.calls[0];
303 +
			expect(actualCall[2].cwd).toBe(expectedPath);
304 +
		});
305 +
306 +
		it("should construct correct server path", async () => {
307 +
			const expectedPath = path.join(
308 +
				path.resolve(process.cwd(), testProjectName),
309 +
				"server",
310 +
			);
311 +
312 +
			await addPackageDependency({
313 +
				dependencies: ["express"],
314 +
				projectName: testProjectName,
315 +
				target: "server",
316 +
			});
317 +
318 +
			const actualCall = mockExeca.mock.calls[0];
319 +
			expect(actualCall[2].cwd).toBe(expectedPath);
320 +
		});
321 +
	});
322 +
323 +
	describe("command structure validation", () => {
324 +
		it("should always use 'bun' as the command", async () => {
325 +
			await addPackageDependency({
326 +
				dependencies: ["react"],
327 +
				projectName: testProjectName,
328 +
			});
329 +
330 +
			expect(mockExeca.mock.calls[0][0]).toBe("bun");
331 +
		});
332 +
333 +
		it("should include 'install' as first argument", async () => {
334 +
			await addPackageDependency({
335 +
				dependencies: ["react"],
336 +
				projectName: testProjectName,
337 +
			});
338 +
339 +
			const args = mockExeca.mock.calls[0][1];
340 +
			expect(args[0]).toBe("install");
341 +
		});
342 +
343 +
		it("should preserve dependency order", async () => {
344 +
			const orderedDeps = ["zlib", "axios", "lodash"];
345 +
			await addPackageDependency({
346 +
				dependencies: orderedDeps,
347 +
				projectName: testProjectName,
348 +
			});
349 +
350 +
			const args = mockExeca.mock.calls[0][1];
351 +
			const depsInCall = args.slice(1); // Remove 'install'
352 +
			expect(depsInCall).toEqual(orderedDeps);
353 +
		});
354 +
355 +
		it("should use separate arguments for flags (not concatenated)", async () => {
356 +
			await addPackageDependency({
357 +
				dependencies: ["react"],
358 +
				projectName: testProjectName,
359 +
				devMode: true,
360 +
			});
361 +
362 +
			const args = mockExeca.mock.calls[0][1];
363 +
			expect(args).toEqual(["install", "-D", "react"]);
364 +
			// Ensure we're not using concatenated strings like "install -D"
365 +
			expect(args[0]).toBe("install");
366 +
			expect(args[1]).toBe("-D");
367 +
		});
368 +
	});
369 +
});
src/utils/add-package-dependency.ts +33 −30
1 1
import path from "node:path";
2 2
import { execa } from "execa";
3 3
4 -
export const addPackageDependency = async (opts: {
4 +
export interface AddPackageDependencyOptions {
5 5
	dependencies: string[];
6 6
	devMode?: boolean;
7 7
	projectName: string;
8 8
	target?: "client" | "server";
9 -
}) => {
10 -
	const { dependencies, devMode, projectName, target } = opts;
9 +
}
11 10
11 +
export const addPackageDependency = async (
12 +
	opts: AddPackageDependencyOptions,
13 +
) => {
14 +
	const { dependencies, devMode = false, projectName, target } = opts;
15 +
16 +
	// Early validation - only validate project name, allow empty dependencies
17 +
	if (!projectName.trim()) {
18 +
		throw new Error("Project name is required");
19 +
	}
20 +
21 +
	// Construct base command args once
22 +
	const baseArgs = ["install"];
23 +
	if (devMode) {
24 +
		baseArgs.push("-D");
25 +
	}
26 +
	const installArgs = [...baseArgs, ...dependencies];
27 +
28 +
	// Determine working directory
12 29
	const projectPath = path.resolve(process.cwd(), projectName);
30 +
	let workingDir = projectPath;
13 31
14 -
	if (target !== undefined) {
15 -
		if (target === "client") {
16 -
			const clientPath = path.join(projectPath, "client");
17 -
			await execa("bun", [`install${devMode ? " -D" : ""}`, ...dependencies], {
18 -
				cwd: clientPath,
19 -
			});
20 -
			return;
21 -
		}
32 +
	if (target) {
33 +
		workingDir = path.join(projectPath, target);
34 +
	}
22 35
23 -
		if (target === "server") {
24 -
			const serverPath = path.join(projectPath, "server");
25 -
			await execa(
26 -
				"bun",
27 -
				[
28 -
					`install${devMode ? " -D" : ""}`,
29 -
					devMode ? "-D" : "",
30 -
					...dependencies,
31 -
				],
32 -
				{
33 -
					cwd: serverPath,
34 -
				},
35 -
			);
36 -
			return;
37 -
		}
36 +
	try {
37 +
		await execa("bun", installArgs, {
38 +
			cwd: workingDir,
39 +
		});
40 +
	} catch (error) {
41 +
		const targetSuffix = target ? ` in ${target}` : "";
42 +
		throw new Error(
43 +
			`Failed to install dependencies${targetSuffix}: ${error instanceof Error ? error.message : String(error)}`,
44 +
		);
38 45
	}
39 -
40 -
	await execa("bun", ["install", ...dependencies], {
41 -
		cwd: projectPath,
42 -
	});
43 46
};
src/utils/name-generator.test.ts (added) +156 −0
1 +
import { describe, it, expect } from "bun:test";
2 +
import { nameGenerator } from "./name-generator";
3 +
4 +
describe("nameGenerator", () => {
5 +
	describe("nameGenerator function", () => {
6 +
		it("should return original basename for empty options", async () => {
7 +
			const result = nameGenerator("template.txt", {});
8 +
			expect(result).toBe("template.txt");
9 +
		});
10 +
11 +
		it("should return original basename when all options are false", async () => {
12 +
			const options = {
13 +
				rpc: false,
14 +
				shadcn: false,
15 +
				tailwind: false,
16 +
				tanstackQuery: false,
17 +
			};
18 +
			const result = nameGenerator("template.txt", options);
19 +
			expect(result).toBe("template.txt");
20 +
		});
21 +
22 +
		it("should return filename with '-with-' prefix when only one option is true", async () => {
23 +
			const options = {
24 +
				rpc: true,
25 +
				shadcn: false,
26 +
				tailwind: false,
27 +
				tanstackQuery: false,
28 +
			};
29 +
			const result = nameGenerator("template.txt", options);
30 +
			expect(result).toBe("template-with-rpc.txt");
31 +
		});
32 +
33 +
		it("should return multiple options with '-with-' prefix joined with dashes", async () => {
34 +
			const options = {
35 +
				rpc: true,
36 +
				shadcn: true,
37 +
				tailwind: false,
38 +
				tanstackQuery: false,
39 +
			};
40 +
			const result = nameGenerator("template.txt", options);
41 +
			expect(result).toBe("template-with-rpc-shadcn.txt");
42 +
		});
43 +
44 +
		it("should sort options alphabetically after '-with-' prefix", async () => {
45 +
			const options = {
46 +
				tailwind: true,
47 +
				rpc: true,
48 +
				shadcn: true,
49 +
				tanstackQuery: false,
50 +
			};
51 +
			const result = nameGenerator("template.txt", options);
52 +
			expect(result).toBe("template-with-rpc-shadcn-tailwind.txt");
53 +
		});
54 +
55 +
		it("should handle all options selected", async () => {
56 +
			const options = {
57 +
				tailwind: true,
58 +
				rpc: true,
59 +
				shadcn: true,
60 +
				tanstackQuery: true,
61 +
			};
62 +
			const result = nameGenerator("template.txt", options);
63 +
			expect(result).toBe(
64 +
				"template-with-rpc-shadcn-tailwind-tanstackquery.txt",
65 +
			);
66 +
		});
67 +
68 +
		it("should convert camelCase options to lowercase", async () => {
69 +
			const options = {
70 +
				tanstackQuery: true,
71 +
				rpc: false,
72 +
				shadcn: false,
73 +
				tailwind: false,
74 +
			};
75 +
			const result = nameGenerator("template.txt", options);
76 +
			expect(result).toBe("template-with-tanstackquery.txt");
77 +
		});
78 +
79 +
		it("should handle mixed selections with alphabetical sorting", async () => {
80 +
			const options = {
81 +
				tanstackQuery: true,
82 +
				tailwind: true,
83 +
				rpc: false,
84 +
				shadcn: false,
85 +
			};
86 +
			const result = nameGenerator("template.txt", options);
87 +
			expect(result).toBe("template-with-tailwind-tanstackquery.txt");
88 +
		});
89 +
90 +
		it("should handle different combinations maintaining alphabetical order", async () => {
91 +
			const options = {
92 +
				shadcn: true,
93 +
				tanstackQuery: true,
94 +
				rpc: false,
95 +
				tailwind: false,
96 +
			};
97 +
			const result = nameGenerator("template.txt", options);
98 +
			expect(result).toBe("template-with-shadcn-tanstackquery.txt");
99 +
		});
100 +
101 +
		it("should handle single tailwind option", async () => {
102 +
			const options = {
103 +
				rpc: false,
104 +
				shadcn: false,
105 +
				tailwind: true,
106 +
				tanstackQuery: false,
107 +
			};
108 +
			const result = nameGenerator("template.txt", options);
109 +
			expect(result).toBe("template-with-tailwind.txt");
110 +
		});
111 +
112 +
		it("should handle rpc and tanstackQuery combination", async () => {
113 +
			const options = {
114 +
				rpc: true,
115 +
				shadcn: false,
116 +
				tailwind: false,
117 +
				tanstackQuery: true,
118 +
			};
119 +
			const result = nameGenerator("template.txt", options);
120 +
			expect(result).toBe("template-with-rpc-tanstackquery.txt");
121 +
		});
122 +
123 +
		it("should handle different file extensions", async () => {
124 +
			const options = {
125 +
				rpc: true,
126 +
				shadcn: false,
127 +
				tailwind: false,
128 +
				tanstackQuery: false,
129 +
			};
130 +
			const result = nameGenerator("component.tsx", options);
131 +
			expect(result).toBe("component-with-rpc.tsx");
132 +
		});
133 +
134 +
		it("should handle files without extensions", async () => {
135 +
			const options = {
136 +
				tailwind: true,
137 +
				shadcn: false,
138 +
				rpc: false,
139 +
				tanstackQuery: false,
140 +
			};
141 +
			const result = nameGenerator("README", options);
142 +
			expect(result).toBe("README-with-tailwind");
143 +
		});
144 +
145 +
		it("should handle complex filenames", async () => {
146 +
			const options = {
147 +
				rpc: true,
148 +
				shadcn: true,
149 +
				tailwind: false,
150 +
				tanstackQuery: false,
151 +
			};
152 +
			const result = nameGenerator("my-app-config.json", options);
153 +
			expect(result).toBe("my-app-config-with-rpc-shadcn.json");
154 +
		});
155 +
	});
156 +
});
src/utils/name-generator.ts (added) +21 −0
1 +
export const nameGenerator = (
2 +
	basename: string,
3 +
	possibleOptions: Record<string, boolean>,
4 +
) => {
5 +
	const dotIndex = basename.lastIndexOf(".");
6 +
	const filename = dotIndex === -1 ? basename : basename.substring(0, dotIndex);
7 +
	const extension = dotIndex === -1 ? "" : basename.substring(dotIndex + 1);
8 +
9 +
	const selectedOptions = Object.keys(possibleOptions)
10 +
		.filter((opt) => possibleOptions[opt])
11 +
		.map((opt) => opt.toLowerCase())
12 +
		.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
13 +
14 +
	if (selectedOptions.length > 0) {
15 +
		const suffix = ["-with", ...selectedOptions].join("-");
16 +
		return extension
17 +
			? `${filename}${suffix}.${extension}`
18 +
			: `${filename}${suffix}`;
19 +
	}
20 +
	return basename;
21 +
};