Merge pull request #13 from Mechse/tanstack-query a1d86fcd
Modular Template Structure & TanStack Query Integration
Steve Simkins · 2025-08-07 15:37 27 file(s) · +1921 −55
.github/workflows/test-cli-options.yml +86 −14
17 17
          # Default template combinations
18 18
          - template: "default"
19 19
            rpc: true
20 +
            tanstackQuery: false
20 21
            linter: "eslint"
21 -
            test_name: "Default + RPC + ESLint"
22 +
            test_name: "Default + RPC + No TanStack Query + ESLint"
22 23
          - template: "default"
23 24
            rpc: true
25 +
            tanstackQuery: false
24 26
            linter: "biome"
25 -
            test_name: "Default + RPC + Biome"
27 +
            test_name: "Default + RPC + No TanStack Query + Biome"
26 28
          - template: "default"
27 29
            rpc: false
30 +
            tanstackQuery: false
28 31
            linter: "eslint"
29 -
            test_name: "Default + No RPC + ESLint"
32 +
            test_name: "Default + No RPC + No TanStack Query + ESLint"
30 33
          - template: "default"
31 34
            rpc: false
35 +
            tanstackQuery: false
32 36
            linter: "biome"
33 -
            test_name: "Default + No RPC + Biome"
37 +
            test_name: "Default + No RPC + No TanStack Query + Biome"
38 +
          - template: "default"
39 +
            rpc: true
40 +
            tanstackQuery: true
41 +
            linter: "eslint"
42 +
            test_name: "Default + RPC + TanStack Query + ESLint"
43 +
          - template: "default"
44 +
            rpc: true
45 +
            tanstackQuery: true
46 +
            linter: "biome"
47 +
            test_name: "Default + RPC + TanStack Query + Biome"
48 +
          - template: "default"
49 +
            rpc: false
50 +
            tanstackQuery: true
51 +
            linter: "eslint"
52 +
            test_name: "Default + No RPC + TanStack Query + ESLint"
53 +
          - template: "default"
54 +
            rpc: false
55 +
            tanstackQuery: true
56 +
            linter: "biome"
57 +
            test_name: "Default + No RPC + TanStack Query + Biome"
34 58
          
35 59
          # Tailwind template combinations
36 60
          - template: "tailwind"
37 61
            rpc: true
62 +
            tanstackQuery: false
38 63
            linter: "eslint"
39 -
            test_name: "Tailwind + RPC + ESLint"
64 +
            test_name: "Tailwind + RPC + No TanStack Query + ESLint"
40 65
          - template: "tailwind"
41 66
            rpc: true
67 +
            tanstackQuery: false
42 68
            linter: "biome"
43 -
            test_name: "Tailwind + RPC + Biome"
69 +
            test_name: "Tailwind + RPC + No TanStack Query + Biome"
44 70
          - template: "tailwind"
45 71
            rpc: false
72 +
            tanstackQuery: false
46 73
            linter: "eslint"
47 -
            test_name: "Tailwind + No RPC + ESLint"
74 +
            test_name: "Tailwind + No RPC + No TanStack Query + ESLint"
48 75
          - template: "tailwind"
49 76
            rpc: false
77 +
            tanstackQuery: false
50 78
            linter: "biome"
51 -
            test_name: "Tailwind + No RPC + Biome"
52 -
          
79 +
            test_name: "Tailwind + No RPC + No TanStack Query + Biome"
80 +
          - template: "tailwind"
81 +
            rpc: true
82 +
            tanstackQuery: true
83 +
            linter: "eslint"
84 +
            test_name: "Tailwind + RPC + TanStack Query + ESLint"
85 +
          - template: "tailwind"
86 +
            rpc: true
87 +
            tanstackQuery: true
88 +
            linter: "biome"
89 +
            test_name: "Tailwind + RPC + TanStack Query + Biome"
90 +
          - template: "tailwind"
91 +
            rpc: false
92 +
            tanstackQuery: true
93 +
            linter: "eslint"
94 +
            test_name: "Tailwind + No RPC + TanStack Query + ESLint"
95 +
          - template: "tailwind"
96 +
            rpc: false
97 +
            tanstackQuery: true
98 +
            linter: "biome"
99 +
            test_name: "Tailwind + No RPC + TanStack Query + Biome"
100 +
53 101
          # Shadcn template combinations
54 102
          - template: "shadcn"
55 103
            rpc: true
104 +
            tanstackQuery: false
56 105
            linter: "eslint"
57 -
            test_name: "Shadcn + RPC + ESLint"
106 +
            test_name: "Shadcn + RPC + No TanStack Query + ESLint"
58 107
          - template: "shadcn"
59 108
            rpc: true
109 +
            tanstackQuery: false
60 110
            linter: "biome"
61 -
            test_name: "Shadcn + RPC + Biome"
111 +
            test_name: "Shadcn + RPC + No TanStack Query + Biome"
62 112
          - template: "shadcn"
63 113
            rpc: false
114 +
            tanstackQuery: false
64 115
            linter: "eslint"
65 -
            test_name: "Shadcn + No RPC + ESLint"
116 +
            test_name: "Shadcn + No RPC + No TanStack Query + ESLint"
66 117
          - template: "shadcn"
67 118
            rpc: false
119 +
            tanstackQuery: false
68 120
            linter: "biome"
69 -
            test_name: "Shadcn + No RPC + Biome"
121 +
            test_name: "Shadcn + No RPC + No TanStack Query + Biome"
122 +
          - template: "shadcn"
123 +
            rpc: true
124 +
            tanstackQuery: true
125 +
            linter: "eslint"
126 +
            test_name: "Shadcn + RPC + TanStack Query + ESLint"
127 +
          - template: "shadcn"
128 +
            rpc: true
129 +
            tanstackQuery: true
130 +
            linter: "biome"
131 +
            test_name: "Shadcn + RPC + TanStack Query + Biome"
132 +
          - template: "shadcn"
133 +
            rpc: false
134 +
            tanstackQuery: true
135 +
            linter: "eslint"
136 +
            test_name: "Shadcn + No RPC + TanStack Query + ESLint"
137 +
          - template: "shadcn"
138 +
            rpc: false
139 +
            tanstackQuery: true
140 +
            linter: "biome"
141 +
            test_name: "Shadcn + No RPC + TanStack Query + Biome"
70 142
71 143
    steps:
72 144
      - name: Checkout repository
163 235
      - name: Cleanup test project
164 236
        if: always()
165 237
        run: |
166 -
          rm -rf test-project-${{ matrix.template }}-${{ matrix.rpc }}-${{ matrix.linter }}
238 +
          rm -rf test-project-${{ matrix.template }}-${{ matrix.rpc }}-${{ matrix.linter }}
biome.json +1 −1
1 1
{
2 -
	"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
2 +
	"$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
3 3
	"vcs": {
4 4
		"enabled": false,
5 5
		"clientKind": "git",
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/index.ts +4 −0
24 24
	)
25 25
	.option("--branch <branch>", "specify a branch to use from the repository")
26 26
	.option("--rpc", "use Hono RPC client for type-safe API communication")
27 +
	.option(
28 +
		"--tsquery",
29 +
		"use TanStack Query for data fetching and state management",
30 +
	)
27 31
	.option("--linter <linter>", "specify the linter to use (eslint or biome)")
28 32
	.action(create);
29 33
src/installers/tanstack-query.ts (added) +67 −0
1 +
import path from "node:path";
2 +
import fs from "fs-extra";
3 +
import type { ProjectOptions } from "@/types";
4 +
import yoctoSpinner from "yocto-spinner";
5 +
import pc from "picocolors";
6 +
import { consola } from "consola";
7 +
import { addPackageDependency } from "@/utils/add-package-dependency";
8 +
import { EXTRAS_DIR } from "@/utils";
9 +
import { nameGenerator } from "@/utils/name-generator";
10 +
11 +
export const tanstackQueryInstaller = async (
12 +
	options: Required<ProjectOptions>,
13 +
): Promise<boolean> => {
14 +
	const spinner = yoctoSpinner({
15 +
		text: "Setting up TanStack Query...",
16 +
	}).start();
17 +
18 +
	try {
19 +
		const { projectName, rpc, shadcn, tailwind, tanstackQuery } = options;
20 +
21 +
		const projectPath = path.resolve(process.cwd(), projectName);
22 +
		spinner.text = "Installing TanStack Query...";
23 +
		await addPackageDependency({
24 +
			dependencies: ["@tanstack/react-query"],
25 +
			target: "client",
26 +
			projectName,
27 +
		});
28 +
29 +
		const selectedTemplate = nameGenerator("App.tsx", {
30 +
			rpc,
31 +
			shadcn,
32 +
			tailwind,
33 +
			tanstackQuery,
34 +
		});
35 +
36 +
		const appTsxSrc = path.join(
37 +
			EXTRAS_DIR,
38 +
			"client",
39 +
			"src",
40 +
			"App.tsx",
41 +
			selectedTemplate,
42 +
		);
43 +
		const appTsxTarget = path.join(projectPath, "client", "src", "App.tsx");
44 +
		fs.copySync(appTsxSrc, appTsxTarget);
45 +
46 +
		const mainTsxSrc = path.join(
47 +
			EXTRAS_DIR,
48 +
			"client",
49 +
			"src",
50 +
			"main.tsx",
51 +
			nameGenerator("main.tsx", { tanstackQuery }),
52 +
		);
53 +
		const mainTsxTarget = path.join(projectPath, "client", "src", "main.tsx");
54 +
		fs.copySync(mainTsxSrc, mainTsxTarget);
55 +
56 +
		spinner.success("TanStack Query setup completed");
57 +
		return true;
58 +
	} catch (err: unknown) {
59 +
		spinner.error("Failed to set up TanStack Query");
60 +
		if (err instanceof Error) {
61 +
			consola.error(pc.red("Error:"), err.message);
62 +
		} else {
63 +
			consola.error(pc.red("Error: Unknown error"));
64 +
		}
65 +
		return false;
66 +
	}
67 +
};
src/lib/create-project.ts +3 −0
3 3
import { installDependencies } from "./install-dependencies";
4 4
import { promptForOptions } from "./prompt-for-options";
5 5
import { scaffoldTemplate } from "./scaffold-template";
6 +
import { installPackages } from "./install-packages";
6 7
7 8
export async function createProject(
8 9
	projectDirectory: string,
22 23
	if (!scaffolded) {
23 24
		return null;
24 25
	}
26 +
27 +
	await installPackages(projectOptions);
25 28
26 29
	const gitInitialized = await initializeGit(
27 30
		projectOptions.projectName ?? projectDirectory,
src/lib/index.ts +0 −1
2 2
export * from "./display-banner";
3 3
export * from "./initialize-git";
4 4
export * from "./install-dependencies";
5 -
export * from "./patch-files-rpc";
6 5
export * from "./prompt-for-options";
7 6
export * from "./scaffold-template";
8 7
export * from "./setup-biome";
src/lib/install-packages.ts (added) +27 −0
1 +
import type { ProjectOptions } from "@/types";
2 +
import { setupBiome } from "./setup-biome";
3 +
import path from "node:path";
4 +
import { tanstackQueryInstaller } from "@/installers/tanstack-query";
5 +
import { rpcInstaller } from "@/installers/rpc";
6 +
7 +
export async function installPackages(
8 +
	options: Required<ProjectOptions>,
9 +
): Promise<boolean> {
10 +
	const { projectName, rpc, linter, tanstackQuery } = options;
11 +
12 +
	const projectPath = path.resolve(process.cwd(), projectName);
13 +
14 +
	if (rpc) {
15 +
		await rpcInstaller(options);
16 +
	}
17 +
18 +
	if (linter === "biome") {
19 +
		await setupBiome(projectPath);
20 +
	}
21 +
22 +
	if (tanstackQuery) {
23 +
		await tanstackQueryInstaller(options);
24 +
	}
25 +
26 +
	return false;
27 +
}
src/lib/patch-files-rpc.ts → src/installers/rpc.ts +18 −28
4 4
import fs from "fs-extra";
5 5
import pc from "picocolors";
6 6
import yoctoSpinner from "yocto-spinner";
7 -
import {
8 -
	defaultTemplate,
9 -
	honoClientTemplate,
10 -
	honoRpcTemplate,
11 -
	shadcnTemplate,
12 -
	tailwindTemplate,
13 -
} from "@/utils/templates";
7 +
import { honoClientTemplate, honoRpcTemplate } from "@/utils/templates";
8 +
import type { ProjectOptions } from "@/types";
9 +
import { EXTRAS_DIR } from "@/utils";
10 +
import { nameGenerator } from "@/utils/name-generator";
14 11
15 -
export async function patchFilesForRPC(
16 -
	projectPath: string,
17 -
	templateChoice: string,
12 +
export async function rpcInstaller(
13 +
	options: Required<ProjectOptions>,
18 14
): Promise<boolean> {
19 15
	const spinner = yoctoSpinner({ text: "Setting up RPC client..." }).start();
20 16
21 17
	try {
18 +
		const { projectName, rpc, shadcn, tailwind } = options;
19 +
		const projectPath = path.resolve(process.cwd(), projectName);
20 +
22 21
		// 1. Update client package.json to ensure hono client is installed
23 22
		const clientPkgPath = path.join(projectPath, "client", "package.json");
24 23
		const clientPkg = await fs.readJson(clientPkgPath);
52 51
		await fs.writeFile(clientHelperPath, honoClientTemplate, "utf8");
53 52
54 53
		// 5. Update App.tsx based on template selection using switch statement
55 -
		const appTsxPath = path.join(projectPath, "client", "src", "App.tsx");
56 -
57 -
		// Determine template content based on the template type
58 -
		let updatedAppContent: string;
54 +
		const appTsxSrc = path.join(
55 +
			EXTRAS_DIR,
56 +
			"client",
57 +
			"src",
58 +
			"App.tsx",
59 +
			nameGenerator("App.tsx", { tailwind, shadcn, rpc }),
60 +
		);
61 +
		const appTsxTarget = path.join(projectPath, "client", "src", "App.tsx");
59 62
60 -
		// Select template based on choice
61 -
		switch (templateChoice) {
62 -
			case "shadcn":
63 -
				updatedAppContent = shadcnTemplate;
64 -
				break;
65 -
			case "tailwind":
66 -
				updatedAppContent = tailwindTemplate;
67 -
				break;
68 -
			default:
69 -
				updatedAppContent = defaultTemplate;
70 -
				break;
71 -
		}
72 -
73 -
		await fs.writeFile(appTsxPath, updatedAppContent, "utf8");
63 +
		fs.copySync(appTsxSrc, appTsxTarget);
74 64
		spinner.success("RPC client setup completed");
75 65
		return true;
76 66
	} catch (err: unknown) {
src/lib/prompt-for-options.ts +24 −0
93 93
		linter = linterResponse as "eslint" | "biome";
94 94
	}
95 95
96 +
	let useTanstackQuery = options.tanstackQuery;
97 +
98 +
	if (!options.yes && !options.tanstackQuery) {
99 +
		const { data: tanstackQueryResponse, error } = await tryCatch(
100 +
			consola.prompt(
101 +
				"Would you like to enable TanStack Query for data fetching and state management?",
102 +
				{
103 +
					type: "confirm",
104 +
					initial: false,
105 +
				},
106 +
			),
107 +
		);
108 +
109 +
		if (error) {
110 +
			consola.error("Project creation cancelled.");
111 +
			process.exit(1);
112 +
		}
113 +
114 +
		useTanstackQuery = tanstackQueryResponse;
115 +
	}
116 +
96 117
	return {
97 118
		...options,
98 119
		projectName,
99 120
		template: templateChoice,
121 +
		tailwind: templateChoice === "tailwind" || templateChoice === "shadcn",
122 +
		shadcn: templateChoice === "shadcn",
100 123
		rpc: useRpc,
101 124
		linter,
125 +
		tanstackQuery: useTanstackQuery,
102 126
	};
103 127
}
src/lib/scaffold-template.ts +1 −11
6 6
import type { ProjectOptions } from "@/types";
7 7
import { DEFAULT_REPO } from "@/utils/constants";
8 8
import { TEMPLATES } from "@/utils/templates";
9 -
import { patchFilesForRPC } from "./patch-files-rpc";
10 -
import { setupBiome } from "./setup-biome";
11 9
12 10
export async function scaffoldTemplate(
13 11
	options: Required<ProjectOptions>,
14 12
): Promise<boolean> {
15 -
	const { projectName, template, repo, branch, rpc, linter } = options;
13 +
	const { projectName, template, repo, branch } = options;
16 14
17 15
	const projectPath = path.resolve(process.cwd(), projectName);
18 16
53 51
		if (fs.existsSync(gitDir)) {
54 52
			await fs.remove(gitDir);
55 53
			console.log(pc.blue("Removed .git directory"));
56 -
		}
57 -
58 -
		if (rpc) {
59 -
			await patchFilesForRPC(projectPath, template);
60 -
		}
61 -
62 -
		if (linter === "biome") {
63 -
			await setupBiome(projectPath);
64 54
		}
65 55
66 56
		return true;
src/templates/extras/client/src/App.tsx/App-with-rpc-shadcn-tailwind-tanstackquery.tsx (added) +70 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { Button } from "./components/ui/button";
4 +
import { hcWithType } from "server/dist/client";
5 +
import { useMutation } from "@tanstack/react-query";
6 +
7 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
8 +
9 +
const client = hcWithType(SERVER_URL);
10 +
11 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
12 +
13 +
function App() {
14 +
	const [data, setData] = useState<
15 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
16 +
	>();
17 +
18 +
	const { mutate: sendRequest } = useMutation({
19 +
		mutationFn: async () => {
20 +
			try {
21 +
				const res = await client.hello.$get();
22 +
				if (!res.ok) {
23 +
					console.log("Error fetching data");
24 +
					return;
25 +
				}
26 +
				const data = await res.json();
27 +
				setData(data);
28 +
			} catch (error) {
29 +
				console.log(error);
30 +
			}
31 +
		},
32 +
	});
33 +
34 +
	return (
35 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
36 +
			<a
37 +
				href="https://github.com/stevedylandev/bhvr"
38 +
				target="_blank"
39 +
				rel="noopener"
40 +
			>
41 +
				<img
42 +
					src={beaver}
43 +
					className="w-16 h-16 cursor-pointer"
44 +
					alt="beaver logo"
45 +
				/>
46 +
			</a>
47 +
			<h1 className="text-5xl font-black">bhvr</h1>
48 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
49 +
			<p>A typesafe fullstack monorepo</p>
50 +
			<div className="flex items-center gap-4">
51 +
				<Button onClick={() => sendRequest()}>Call API</Button>
52 +
				<Button variant="secondary" asChild>
53 +
					<a target="_blank" href="https://bhvr.dev" rel="noopener">
54 +
						Docs
55 +
					</a>
56 +
				</Button>
57 +
			</div>
58 +
			{data && (
59 +
				<pre className="bg-gray-100 p-4 rounded-md">
60 +
					<code>
61 +
						Message: {data.message} <br />
62 +
						Success: {data.success.toString()}
63 +
					</code>
64 +
				</pre>
65 +
			)}
66 +
		</div>
67 +
	);
68 +
}
69 +
70 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-rpc-shadcn-tailwind.tsx (added) +67 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { Button } from "./components/ui/button";
4 +
import { hcWithType } from "server/dist/client";
5 +
6 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
7 +
8 +
const client = hcWithType(SERVER_URL);
9 +
10 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
11 +
12 +
function App() {
13 +
	const [data, setData] = useState<
14 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
15 +
	>();
16 +
17 +
	async function sendRequest() {
18 +
		try {
19 +
			const res = await client.hello.$get();
20 +
			if (!res.ok) {
21 +
				console.log("Error fetching data");
22 +
				return;
23 +
			}
24 +
			const data = await res.json();
25 +
			setData(data);
26 +
		} catch (error) {
27 +
			console.log(error);
28 +
		}
29 +
	}
30 +
31 +
	return (
32 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
33 +
			<a
34 +
				href="https://github.com/stevedylandev/bhvr"
35 +
				target="_blank"
36 +
				rel="noopener"
37 +
			>
38 +
				<img
39 +
					src={beaver}
40 +
					className="w-16 h-16 cursor-pointer"
41 +
					alt="beaver logo"
42 +
				/>
43 +
			</a>
44 +
			<h1 className="text-5xl font-black">bhvr</h1>
45 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
46 +
			<p>A typesafe fullstack monorepo</p>
47 +
			<div className="flex items-center gap-4">
48 +
				<Button onClick={sendRequest}>Call API</Button>
49 +
				<Button variant="secondary" asChild>
50 +
					<a target="_blank" href="https://bhvr.dev" rel="noopener">
51 +
						Docs
52 +
					</a>
53 +
				</Button>
54 +
			</div>
55 +
			{data && (
56 +
				<pre className="bg-gray-100 p-4 rounded-md">
57 +
					<code>
58 +
						Message: {data.message} <br />
59 +
						Success: {data.success.toString()}
60 +
					</code>
61 +
				</pre>
62 +
			)}
63 +
		</div>
64 +
	);
65 +
}
66 +
67 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-rpc-tailwind-tanstackquery.tsx (added) +78 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { hcWithType } from "server/dist/client";
4 +
import { useMutation } from "@tanstack/react-query";
5 +
6 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
7 +
8 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
9 +
10 +
const client = hcWithType(SERVER_URL);
11 +
12 +
function App() {
13 +
	const [data, setData] = useState<
14 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
15 +
	>();
16 +
17 +
	const { mutate: sendRequest } = useMutation({
18 +
		mutationFn: async () => {
19 +
			try {
20 +
				const res = await client.hello.$get();
21 +
				if (!res.ok) {
22 +
					console.log("Error fetching data");
23 +
					return;
24 +
				}
25 +
				const data = await res.json();
26 +
				setData(data);
27 +
			} catch (error) {
28 +
				console.log(error);
29 +
			}
30 +
		},
31 +
	});
32 +
33 +
	return (
34 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
35 +
			<a
36 +
				href="https://github.com/stevedylandev/bhvr"
37 +
				target="_blank"
38 +
				rel="noopener"
39 +
			>
40 +
				<img
41 +
					src={beaver}
42 +
					className="w-16 h-16 cursor-pointer"
43 +
					alt="beaver logo"
44 +
				/>
45 +
			</a>
46 +
			<h1 className="text-5xl font-black">bhvr</h1>
47 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
48 +
			<p>A typesafe fullstack monorepo</p>
49 +
			<div className="flex items-center gap-4">
50 +
				<button
51 +
					type="button"
52 +
					onClick={() => sendRequest()}
53 +
					className="bg-black text-white px-2.5 py-1.5 rounded-md"
54 +
				>
55 +
					Call API
56 +
				</button>
57 +
				<a
58 +
					target="_blank"
59 +
					href="https://bhvr.dev"
60 +
					className="border-1 border-black text-black px-2.5 py-1.5 rounded-md"
61 +
					rel="noopener"
62 +
				>
63 +
					Docs
64 +
				</a>
65 +
			</div>
66 +
			{data && (
67 +
				<pre className="bg-gray-100 p-4 rounded-md">
68 +
					<code>
69 +
						Message: {data.message} <br />
70 +
						Success: {data.success.toString()}
71 +
					</code>
72 +
				</pre>
73 +
			)}
74 +
		</div>
75 +
	);
76 +
}
77 +
78 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-rpc-tailwind.tsx (added) +75 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { hcWithType } from "server/dist/client";
4 +
5 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
6 +
7 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
8 +
9 +
const client = hcWithType(SERVER_URL);
10 +
11 +
function App() {
12 +
	const [data, setData] = useState<
13 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
14 +
	>();
15 +
16 +
	async function sendRequest() {
17 +
		try {
18 +
			const res = await client.hello.$get();
19 +
			if (!res.ok) {
20 +
				console.log("Error fetching data");
21 +
				return;
22 +
			}
23 +
			const data = await res.json();
24 +
			setData(data);
25 +
		} catch (error) {
26 +
			console.log(error);
27 +
		}
28 +
	}
29 +
30 +
	return (
31 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
32 +
			<a
33 +
				href="https://github.com/stevedylandev/bhvr"
34 +
				target="_blank"
35 +
				rel="noopener"
36 +
			>
37 +
				<img
38 +
					src={beaver}
39 +
					className="w-16 h-16 cursor-pointer"
40 +
					alt="beaver logo"
41 +
				/>
42 +
			</a>
43 +
			<h1 className="text-5xl font-black">bhvr</h1>
44 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
45 +
			<p>A typesafe fullstack monorepo</p>
46 +
			<div className="flex items-center gap-4">
47 +
				<button
48 +
					type="button"
49 +
					onClick={sendRequest}
50 +
					className="bg-black text-white px-2.5 py-1.5 rounded-md"
51 +
				>
52 +
					Call API
53 +
				</button>
54 +
				<a
55 +
					target="_blank"
56 +
					href="https://bhvr.dev"
57 +
					className="border-1 border-black text-black px-2.5 py-1.5 rounded-md"
58 +
					rel="noopener"
59 +
				>
60 +
					Docs
61 +
				</a>
62 +
			</div>
63 +
			{data && (
64 +
				<pre className="bg-gray-100 p-4 rounded-md">
65 +
					<code>
66 +
						Message: {data.message} <br />
67 +
						Success: {data.success.toString()}
68 +
					</code>
69 +
				</pre>
70 +
			)}
71 +
		</div>
72 +
	);
73 +
}
74 +
75 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-rpc-tanstackquery.tsx (added) +75 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { hcWithType } from "server/dist/client";
4 +
import { useMutation } from "@tanstack/react-query";
5 +
import "./App.css";
6 +
7 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
8 +
9 +
const client = hcWithType(SERVER_URL);
10 +
11 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
12 +
13 +
function App() {
14 +
	const [data, setData] = useState<
15 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
16 +
	>();
17 +
18 +
	const { mutate: sendRequest } = useMutation({
19 +
		mutationFn: async () => {
20 +
			try {
21 +
				const res = await client.hello.$get();
22 +
				if (!res.ok) {
23 +
					console.log("Error fetching data");
24 +
					return;
25 +
				}
26 +
				const data = await res.json();
27 +
				setData(data);
28 +
			} catch (error) {
29 +
				console.log(error);
30 +
			}
31 +
		},
32 +
	});
33 +
34 +
	return (
35 +
		<>
36 +
			<div>
37 +
				<a
38 +
					href="https://github.com/stevedylandev/bhvr"
39 +
					target="_blank"
40 +
					rel="noopener"
41 +
				>
42 +
					<img src={beaver} className="logo" alt="beaver logo" />
43 +
				</a>
44 +
			</div>
45 +
			<h1>bhvr</h1>
46 +
			<h2>Bun + Hono + Vite + React</h2>
47 +
			<p>A typesafe fullstack monorepo</p>
48 +
			<div className="card">
49 +
				<div className="button-container">
50 +
					<button type="button" onClick={() => sendRequest()}>
51 +
						Call API
52 +
					</button>
53 +
					<a
54 +
						className="docs-link"
55 +
						target="_blank"
56 +
						href="https://bhvr.dev"
57 +
						rel="noopener"
58 +
					>
59 +
						Docs
60 +
					</a>
61 +
				</div>
62 +
				{data && (
63 +
					<pre className="response">
64 +
						<code>
65 +
							Message: {data.message} <br />
66 +
							Success: {data.success.toString()}
67 +
						</code>
68 +
					</pre>
69 +
				)}
70 +
			</div>
71 +
		</>
72 +
	);
73 +
}
74 +
75 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-rpc.tsx (added) +72 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { hcWithType } from "server/dist/client";
4 +
import "./App.css";
5 +
6 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
7 +
8 +
const client = hcWithType(SERVER_URL);
9 +
10 +
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
11 +
12 +
function App() {
13 +
	const [data, setData] = useState<
14 +
		Awaited<ReturnType<ResponseType["json"]>> | undefined
15 +
	>();
16 +
17 +
	async function sendRequest() {
18 +
		try {
19 +
			const res = await client.hello.$get();
20 +
			if (!res.ok) {
21 +
				console.log("Error fetching data");
22 +
				return;
23 +
			}
24 +
			const data = await res.json();
25 +
			setData(data);
26 +
		} catch (error) {
27 +
			console.log(error);
28 +
		}
29 +
	}
30 +
31 +
	return (
32 +
		<>
33 +
			<div>
34 +
				<a
35 +
					href="https://github.com/stevedylandev/bhvr"
36 +
					target="_blank"
37 +
					rel="noopener"
38 +
				>
39 +
					<img src={beaver} className="logo" alt="beaver logo" />
40 +
				</a>
41 +
			</div>
42 +
			<h1>bhvr</h1>
43 +
			<h2>Bun + Hono + Vite + React</h2>
44 +
			<p>A typesafe fullstack monorepo</p>
45 +
			<div className="card">
46 +
				<div className="button-container">
47 +
					<button type="button" onClick={sendRequest}>
48 +
						Call API
49 +
					</button>
50 +
					<a
51 +
						className="docs-link"
52 +
						target="_blank"
53 +
						href="https://bhvr.dev"
54 +
						rel="noopener"
55 +
					>
56 +
						Docs
57 +
					</a>
58 +
				</div>
59 +
				{data && (
60 +
					<pre className="response">
61 +
						<code>
62 +
							Message: {data.message} <br />
63 +
							Success: {data.success.toString()}
64 +
						</code>
65 +
					</pre>
66 +
				)}
67 +
			</div>
68 +
		</>
69 +
	);
70 +
}
71 +
72 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-shadcn-tailwind-tanstackquery.tsx (added) +60 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import type { ApiResponse } from "shared";
4 +
import { Button } from "./components/ui/button";
5 +
import { useMutation } from "@tanstack/react-query";
6 +
7 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
8 +
9 +
function App() {
10 +
	const [data, setData] = useState<ApiResponse | undefined>();
11 +
12 +
	const { mutate: sendRequest } = useMutation({
13 +
		mutationFn: async () => {
14 +
			try {
15 +
				const req = await fetch(`${SERVER_URL}/hello`);
16 +
				const res: ApiResponse = await req.json();
17 +
				setData(res);
18 +
			} catch (error) {
19 +
				console.log(error);
20 +
			}
21 +
		},
22 +
	});
23 +
24 +
	return (
25 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
26 +
			<a
27 +
				href="https://github.com/stevedylandev/bhvr"
28 +
				target="_blank"
29 +
				rel="noopener"
30 +
			>
31 +
				<img
32 +
					src={beaver}
33 +
					className="w-16 h-16 cursor-pointer"
34 +
					alt="beaver logo"
35 +
				/>
36 +
			</a>
37 +
			<h1 className="text-5xl font-black">bhvr</h1>
38 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
39 +
			<p>A typesafe fullstack monorepo</p>
40 +
			<div className="flex items-center gap-4">
41 +
				<Button onClick={() => sendRequest()}>Call API</Button>
42 +
				<Button variant="secondary" asChild>
43 +
					<a target="_blank" href="https://bhvr.dev" rel="noopener">
44 +
						Docs
45 +
					</a>
46 +
				</Button>
47 +
			</div>
48 +
			{data && (
49 +
				<pre className="bg-gray-100 p-4 rounded-md">
50 +
					<code>
51 +
						Message: {data.message} <br />
52 +
						Success: {data.success.toString()}
53 +
					</code>
54 +
				</pre>
55 +
			)}
56 +
		</div>
57 +
	);
58 +
}
59 +
60 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-tailwind-tanstackquery.tsx (added) +68 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import type { ApiResponse } from "shared";
4 +
import { useMutation } from "@tanstack/react-query";
5 +
6 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
7 +
8 +
function App() {
9 +
	const [data, setData] = useState<ApiResponse | undefined>();
10 +
11 +
	const { mutate: sendRequest } = useMutation({
12 +
		mutationFn: async () => {
13 +
			try {
14 +
				const req = await fetch(`${SERVER_URL}/hello`);
15 +
				const res: ApiResponse = await req.json();
16 +
				setData(res);
17 +
			} catch (error) {
18 +
				console.log(error);
19 +
			}
20 +
		},
21 +
	});
22 +
23 +
	return (
24 +
		<div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
25 +
			<a
26 +
				href="https://github.com/stevedylandev/bhvr"
27 +
				target="_blank"
28 +
				rel="noopener"
29 +
			>
30 +
				<img
31 +
					src={beaver}
32 +
					className="w-16 h-16 cursor-pointer"
33 +
					alt="beaver logo"
34 +
				/>
35 +
			</a>
36 +
			<h1 className="text-5xl font-black">bhvr</h1>
37 +
			<h2 className="text-2xl font-bold">Bun + Hono + Vite + React</h2>
38 +
			<p>A typesafe fullstack monorepo</p>
39 +
			<div className="flex items-center gap-4">
40 +
				<button
41 +
					type="button"
42 +
					onClick={() => sendRequest()}
43 +
					className="bg-black text-white px-2.5 py-1.5 rounded-md"
44 +
				>
45 +
					Call API
46 +
				</button>
47 +
				<a
48 +
					target="_blank"
49 +
					href="https://bhvr.dev"
50 +
					className="border-1 border-black text-black px-2.5 py-1.5 rounded-md"
51 +
					rel="noopener"
52 +
				>
53 +
					Docs
54 +
				</a>
55 +
			</div>
56 +
			{data && (
57 +
				<pre className="bg-gray-100 p-4 rounded-md">
58 +
					<code>
59 +
						Message: {data.message} <br />
60 +
						Success: {data.success.toString()}
61 +
					</code>
62 +
				</pre>
63 +
			)}
64 +
		</div>
65 +
	);
66 +
}
67 +
68 +
export default App;
src/templates/extras/client/src/App.tsx/App-with-tanstackquery.tsx (added) +62 −0
1 +
import { useState } from "react";
2 +
import beaver from "./assets/beaver.svg";
3 +
import { useMutation } from "@tanstack/react-query";
4 +
import type { ApiResponse } from "shared";
5 +
import "./App.css";
6 +
7 +
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
8 +
9 +
function App() {
10 +
	const [data, setData] = useState<ApiResponse | undefined>();
11 +
12 +
	const { mutate: sendRequest } = useMutation({
13 +
		mutationFn: async () => {
14 +
			const req = await fetch(`${SERVER_URL}/hello`);
15 +
			const res: ApiResponse = await req.json();
16 +
			setData(res);
17 +
		},
18 +
		onError: (err) => console.log(err),
19 +
	});
20 +
21 +
	return (
22 +
		<>
23 +
			<div>
24 +
				<a
25 +
					href="https://github.com/stevedylandev/bhvr"
26 +
					target="_blank"
27 +
					rel="noopener"
28 +
				>
29 +
					<img src={beaver} className="logo" alt="beaver logo" />
30 +
				</a>
31 +
			</div>
32 +
			<h1>bhvr</h1>
33 +
			<h2>Bun + Hono + Vite + React</h2>
34 +
			<p>A typesafe fullstack monorepo</p>
35 +
			<div className="card">
36 +
				<div className="button-container">
37 +
					<button type="button" onClick={() => sendRequest()}>
38 +
						Call API
39 +
					</button>
40 +
					<a
41 +
						className="docs-link"
42 +
						target="_blank"
43 +
						href="https://bhvr.dev"
44 +
						rel="noopener"
45 +
					>
46 +
						Docs
47 +
					</a>
48 +
				</div>
49 +
				{data && (
50 +
					<pre className="response">
51 +
						<code>
52 +
							Message: {data.message} <br />
53 +
							Success: {data.success.toString()}
54 +
						</code>
55 +
					</pre>
56 +
				)}
57 +
			</div>
58 +
		</>
59 +
	);
60 +
}
61 +
62 +
export default App;
src/templates/extras/client/src/main.tsx/main-with-tanstackquery.tsx (added) +22 −0
1 +
import { StrictMode } from "react";
2 +
import { createRoot } from "react-dom/client";
3 +
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 +
import "./index.css";
5 +
import App from "./App.tsx";
6 +
7 +
const queryClient = new QueryClient();
8 +
9 +
const rootElement = document.getElementById("root");
10 +
11 +
if (!rootElement) {
12 +
	throw new Error(
13 +
		"Root element not found. Check if it's in your index.html or if the id is correct.",
14 +
	);
15 +
}
16 +
createRoot(rootElement).render(
17 +
	<StrictMode>
18 +
		<QueryClientProvider client={queryClient}>
19 +
			<App />
20 +
		</QueryClientProvider>
21 +
	</StrictMode>,
22 +
);
src/types.ts +3 −0
10 10
	repo?: string;
11 11
	template?: string;
12 12
	branch?: string;
13 +
	tailwind?: boolean;
14 +
	shadcn?: boolean;
13 15
	rpc?: boolean;
14 16
	linter?: "eslint" | "biome";
17 +
	tanstackQuery?: boolean;
15 18
};
16 19
17 20
export interface ProjectResult {
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 (added) +46 −0
1 +
import path from "node:path";
2 +
import { execa } from "execa";
3 +
4 +
export interface AddPackageDependencyOptions {
5 +
	dependencies: string[];
6 +
	devMode?: boolean;
7 +
	projectName: string;
8 +
	target?: "client" | "server";
9 +
}
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
29 +
	const projectPath = path.resolve(process.cwd(), projectName);
30 +
	let workingDir = projectPath;
31 +
32 +
	if (target) {
33 +
		workingDir = path.join(projectPath, target);
34 +
	}
35 +
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 +
		);
45 +
	}
46 +
};
src/utils/constants.ts +1 −0
1 1
export const DEFAULT_REPO = "stevedylandev/bhvr";
2 +
export const EXTRAS_DIR = "./src/templates/extras";
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 +
};