starter-structure-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,942 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import {
10
+ cancel,
11
+ confirm,
12
+ intro,
13
+ isCancel,
14
+ note,
15
+ outro,
16
+ select,
17
+ text
18
+ } from "@clack/prompts";
19
+ import pc from "picocolors";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const templatesRoot = path.resolve(__dirname, "..", "templates");
24
+
25
+ const CATEGORY_LABELS = {
26
+ fullstack: "Fullstack",
27
+ "monorepo-client-server": "Monorepo (client/server)",
28
+ "monorepo-turbo-pnpm": "Monorepo (Turbo + pnpm)",
29
+ "backend-only": "Backend only",
30
+ "frontend-only": "Frontend only",
31
+ single: "Single app"
32
+ };
33
+
34
+ const CATEGORY_ALIASES = {
35
+ frontend: ["frontend-only", "single"],
36
+ frontendonly: ["frontend-only", "single"],
37
+ single: ["single"],
38
+ backend: ["backend-only"],
39
+ backendonly: ["backend-only"],
40
+ api: ["backend-only"],
41
+ fullstack: ["fullstack"],
42
+ monorepo: ["monorepo-client-server", "monorepo-turbo-pnpm"],
43
+ turbo: ["monorepo-turbo-pnpm"],
44
+ turborepo: ["monorepo-turbo-pnpm"],
45
+ clientserver: ["monorepo-client-server"]
46
+ };
47
+
48
+ const TOKEN_LABELS = {
49
+ react: "React",
50
+ nextjs: "Next.js",
51
+ vue: "Vue",
52
+ vite: "Vite",
53
+ ts: "TypeScript",
54
+ js: "JavaScript",
55
+ tailwind: "Tailwind CSS",
56
+ shadcn: "shadcn/ui",
57
+ express: "Express",
58
+ nestjs: "NestJS",
59
+ fastify: "Fastify",
60
+ prisma: "Prisma",
61
+ mongoose: "Mongoose",
62
+ sequelize: "Sequelize",
63
+ mongodb: "MongoDB",
64
+ mysql: "MySQL",
65
+ postgres: "PostgreSQL",
66
+ jwt: "JWT",
67
+ nextauth: "NextAuth",
68
+ admin: "Admin",
69
+ dashboard: "Dashboard",
70
+ landing: "Landing",
71
+ seo: "SEO",
72
+ pos: "POS",
73
+ gym: "Gym",
74
+ crm: "CRM",
75
+ ecommerce: "E-commerce",
76
+ auth: "Auth",
77
+ rbac: "RBAC",
78
+ api: "API",
79
+ client: "Client",
80
+ server: "Server",
81
+ turbo: "Turborepo",
82
+ pnpm: "pnpm"
83
+ };
84
+
85
+ const FILTER_GROUPS = [
86
+ {
87
+ key: "frontend",
88
+ label: "Frontend",
89
+ tokens: ["react", "nextjs", "vue"]
90
+ },
91
+ {
92
+ key: "frontendTool",
93
+ label: "Frontend tooling",
94
+ tokens: ["vite"]
95
+ },
96
+ {
97
+ key: "language",
98
+ label: "Language",
99
+ tokens: ["ts", "js"]
100
+ },
101
+ {
102
+ key: "styling",
103
+ label: "Styling",
104
+ tokens: ["tailwind", "shadcn"]
105
+ },
106
+ {
107
+ key: "backend",
108
+ label: "Backend",
109
+ tokens: ["express", "nestjs", "fastify"]
110
+ },
111
+ {
112
+ key: "orm",
113
+ label: "ORM / ODM",
114
+ tokens: ["prisma", "mongoose", "sequelize"]
115
+ },
116
+ {
117
+ key: "database",
118
+ label: "Database",
119
+ tokens: ["mongodb", "mysql", "postgres"]
120
+ },
121
+ {
122
+ key: "auth",
123
+ label: "Auth",
124
+ tokens: ["jwt", "nextauth"]
125
+ }
126
+ ];
127
+
128
+ const ARG_TO_FILTER_TOKEN = {
129
+ "--frontend": "frontend",
130
+ "--backend": "backend",
131
+ "--styling": "styling",
132
+ "--orm": "orm",
133
+ "--database": "database",
134
+ "--auth": "auth",
135
+ "--language": "language"
136
+ };
137
+
138
+ const TOKEN_ALIASES = {
139
+ "next.js": "nextjs",
140
+ next: "nextjs",
141
+ "next-js": "nextjs",
142
+ reactjs: "react",
143
+ "react.js": "react",
144
+ vuejs: "vue",
145
+ "vue.js": "vue",
146
+ tailwindcss: "tailwind",
147
+ "tailwind-css": "tailwind",
148
+ typescript: "ts",
149
+ javascript: "js",
150
+ postgresql: "postgres",
151
+ mongo: "mongodb",
152
+ mongodb: "mongodb",
153
+ turborepo: "turbo",
154
+ monorepo: "monorepo"
155
+ };
156
+
157
+ const SUPPORTED_PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn"]);
158
+
159
+ function normalizeToken(value) {
160
+ const cleaned = value
161
+ .trim()
162
+ .toLowerCase()
163
+ .replace(/[\\/_]+/g, "-")
164
+ .replace(/\s+/g, "-")
165
+ .replace(/[^a-z0-9-]/g, "")
166
+ .replace(/-+/g, "-")
167
+ .replace(/^-|-$/g, "");
168
+
169
+ return TOKEN_ALIASES[cleaned] ?? cleaned;
170
+ }
171
+
172
+ function tokenize(value) {
173
+ return value
174
+ .split(/[\s,/:+|]+/g)
175
+ .map(normalizeToken)
176
+ .filter(Boolean);
177
+ }
178
+
179
+ function humanizeToken(token) {
180
+ return TOKEN_LABELS[token] ?? token.toUpperCase();
181
+ }
182
+
183
+ function humanizeCategory(category) {
184
+ return CATEGORY_LABELS[category] ?? category;
185
+ }
186
+
187
+ function isEmptyDir(dir) {
188
+ if (!fs.existsSync(dir)) return true;
189
+ return fs.readdirSync(dir).length === 0;
190
+ }
191
+
192
+ function copyDir(src, dest) {
193
+ fs.mkdirSync(dest, { recursive: true });
194
+
195
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
196
+ const sourcePath = path.join(src, entry.name);
197
+ const destinationPath = path.join(dest, entry.name);
198
+
199
+ if (entry.isDirectory()) {
200
+ copyDir(sourcePath, destinationPath);
201
+ continue;
202
+ }
203
+
204
+ fs.copyFileSync(sourcePath, destinationPath);
205
+ }
206
+ }
207
+
208
+ function walkFiles(dir, callback) {
209
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
210
+ const entryPath = path.join(dir, entry.name);
211
+ if (entry.isDirectory()) {
212
+ walkFiles(entryPath, callback);
213
+ continue;
214
+ }
215
+
216
+ callback(entryPath);
217
+ }
218
+ }
219
+
220
+ function walkPaths(dir, collected = []) {
221
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
222
+ const entryPath = path.join(dir, entry.name);
223
+ collected.push(entryPath);
224
+
225
+ if (entry.isDirectory()) {
226
+ walkPaths(entryPath, collected);
227
+ }
228
+ }
229
+
230
+ return collected;
231
+ }
232
+
233
+ function replaceInFile(filePath, replacements) {
234
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
235
+ return;
236
+ }
237
+
238
+ const base = path.basename(filePath).toLowerCase();
239
+ const ext = path.extname(filePath).toLowerCase();
240
+ const textLikeExtensions = new Set([
241
+ ".js",
242
+ ".jsx",
243
+ ".ts",
244
+ ".tsx",
245
+ ".json",
246
+ ".md",
247
+ ".yml",
248
+ ".yaml",
249
+ ".env",
250
+ ".txt",
251
+ ".cjs",
252
+ ".mjs",
253
+ ".html",
254
+ ".css",
255
+ ".scss",
256
+ ".npmrc"
257
+ ]);
258
+
259
+ const isTextLike =
260
+ textLikeExtensions.has(ext) ||
261
+ base === ".gitignore" ||
262
+ base === ".env" ||
263
+ base === ".env.example" ||
264
+ base === "readme.md";
265
+
266
+ if (!isTextLike) {
267
+ return;
268
+ }
269
+
270
+ let content = fs.readFileSync(filePath, "utf8");
271
+ for (const [from, to] of Object.entries(replacements)) {
272
+ content = content.split(from).join(to);
273
+ }
274
+ fs.writeFileSync(filePath, content, "utf8");
275
+ }
276
+
277
+ function renamePlaceholderPaths(rootDir, replacements) {
278
+ const paths = walkPaths(rootDir).sort((left, right) => right.length - left.length);
279
+
280
+ for (const currentPath of paths) {
281
+ const baseName = path.basename(currentPath);
282
+ let nextName = baseName;
283
+
284
+ for (const [from, to] of Object.entries(replacements)) {
285
+ nextName = nextName.split(from).join(to);
286
+ }
287
+
288
+ if (nextName === baseName) {
289
+ continue;
290
+ }
291
+
292
+ fs.renameSync(currentPath, path.join(path.dirname(currentPath), nextName));
293
+ }
294
+ }
295
+
296
+ function resolveCategories(input) {
297
+ if (!input) {
298
+ return [];
299
+ }
300
+
301
+ const normalized = normalizeToken(input);
302
+ if (CATEGORY_ALIASES[normalized]) {
303
+ return CATEGORY_ALIASES[normalized];
304
+ }
305
+
306
+ return [normalized];
307
+ }
308
+
309
+ function addDerivedTokens(tokenSet, category) {
310
+ tokenSet.add(category);
311
+
312
+ if (category === "fullstack") {
313
+ tokenSet.add("frontend");
314
+ tokenSet.add("backend");
315
+ }
316
+
317
+ if (category === "frontend-only" || category === "single") {
318
+ tokenSet.add("frontend");
319
+ }
320
+
321
+ if (category === "backend-only") {
322
+ tokenSet.add("backend");
323
+ tokenSet.add("api");
324
+ }
325
+
326
+ if (category.startsWith("monorepo")) {
327
+ tokenSet.add("monorepo");
328
+ }
329
+
330
+ if (category === "monorepo-client-server") {
331
+ tokenSet.add("client");
332
+ tokenSet.add("server");
333
+ }
334
+
335
+ if (category === "monorepo-turbo-pnpm") {
336
+ tokenSet.add("turbo");
337
+ tokenSet.add("pnpm");
338
+ }
339
+
340
+ if (tokenSet.has("mongoose")) {
341
+ tokenSet.add("mongodb");
342
+ }
343
+
344
+ if (tokenSet.has("prisma") || tokenSet.has("mongoose") || tokenSet.has("sequelize")) {
345
+ tokenSet.add("orm");
346
+ }
347
+
348
+ if (tokenSet.has("express") || tokenSet.has("nestjs") || tokenSet.has("fastify")) {
349
+ tokenSet.add("backend");
350
+ tokenSet.add("api");
351
+ }
352
+
353
+ if (tokenSet.has("react") || tokenSet.has("nextjs") || tokenSet.has("vue")) {
354
+ tokenSet.add("frontend");
355
+ }
356
+
357
+ if (tokenSet.has("tailwind") || tokenSet.has("shadcn")) {
358
+ tokenSet.add("styling");
359
+ }
360
+
361
+ if (tokenSet.has("jwt") || tokenSet.has("nextauth")) {
362
+ tokenSet.add("auth");
363
+ }
364
+ }
365
+
366
+ function getFeatureValue(tokens, allowedTokens) {
367
+ for (const token of allowedTokens) {
368
+ if (tokens.has(token)) {
369
+ return token;
370
+ }
371
+ }
372
+
373
+ return undefined;
374
+ }
375
+
376
+ function buildDisplayParts(slugTokens) {
377
+ const parts = [];
378
+ const seen = new Set();
379
+
380
+ for (const token of slugTokens) {
381
+ if (seen.has(token)) {
382
+ continue;
383
+ }
384
+
385
+ const label = humanizeToken(token);
386
+ if (label === token.toUpperCase() && token.length > 6) {
387
+ parts.push(token);
388
+ seen.add(token);
389
+ continue;
390
+ }
391
+
392
+ parts.push(label);
393
+ seen.add(token);
394
+ }
395
+
396
+ return parts;
397
+ }
398
+
399
+ function discoverTemplates(rootDir) {
400
+ if (!fs.existsSync(rootDir)) {
401
+ return [];
402
+ }
403
+
404
+ const templates = [];
405
+ const categories = fs
406
+ .readdirSync(rootDir, { withFileTypes: true })
407
+ .filter((entry) => entry.isDirectory())
408
+ .map((entry) => entry.name);
409
+
410
+ for (const category of categories) {
411
+ const categoryDir = path.join(rootDir, category);
412
+ const templateDirs = fs
413
+ .readdirSync(categoryDir, { withFileTypes: true })
414
+ .filter((entry) => entry.isDirectory())
415
+ .map((entry) => entry.name);
416
+
417
+ for (const slug of templateDirs) {
418
+ const absolutePath = path.join(categoryDir, slug);
419
+ const tokenSet = new Set([
420
+ ...tokenize(category),
421
+ ...slug.split("-").map(normalizeToken).filter(Boolean)
422
+ ]);
423
+
424
+ addDerivedTokens(tokenSet, category);
425
+
426
+ const slugTokens = slug.split("-").map(normalizeToken).filter(Boolean);
427
+ const displayParts = buildDisplayParts(slugTokens);
428
+
429
+ templates.push({
430
+ id: `${category}/${slug}`,
431
+ category,
432
+ slug,
433
+ absolutePath,
434
+ tokens: tokenSet,
435
+ features: Object.fromEntries(
436
+ FILTER_GROUPS.map((group) => [group.key, getFeatureValue(tokenSet, group.tokens)])
437
+ ),
438
+ label: `${displayParts.join(" + ")} (${humanizeCategory(category)})`
439
+ });
440
+ }
441
+ }
442
+
443
+ return templates.sort((left, right) => left.id.localeCompare(right.id));
444
+ }
445
+
446
+ function parseArgs(argv) {
447
+ const args = {
448
+ help: false,
449
+ list: false,
450
+ yes: false,
451
+ install: undefined,
452
+ packageManager: undefined,
453
+ projectName: undefined,
454
+ templateRef: undefined,
455
+ category: undefined,
456
+ comboTokens: [],
457
+ positionals: []
458
+ };
459
+
460
+ for (let index = 0; index < argv.length; index += 1) {
461
+ const current = argv[index];
462
+
463
+ if (current === "create" || current === "new") {
464
+ continue;
465
+ }
466
+
467
+ if (current === "-h" || current === "--help") {
468
+ args.help = true;
469
+ continue;
470
+ }
471
+
472
+ if (current === "--list") {
473
+ args.list = true;
474
+ continue;
475
+ }
476
+
477
+ if (current === "-y" || current === "--yes") {
478
+ args.yes = true;
479
+ continue;
480
+ }
481
+
482
+ if (current === "--install") {
483
+ args.install = true;
484
+ continue;
485
+ }
486
+
487
+ if (current === "--no-install") {
488
+ args.install = false;
489
+ continue;
490
+ }
491
+
492
+ const [flag, inlineValue] = current.split("=", 2);
493
+ const takesValue =
494
+ flag === "--name" ||
495
+ flag === "--project-name" ||
496
+ flag === "--template" ||
497
+ flag === "-t" ||
498
+ flag === "--category" ||
499
+ flag === "-c" ||
500
+ flag === "--stack" ||
501
+ flag === "--combo" ||
502
+ flag === "--package-manager" ||
503
+ flag === "-p" ||
504
+ flag === "--frontend" ||
505
+ flag === "--backend" ||
506
+ flag === "--styling" ||
507
+ flag === "--orm" ||
508
+ flag === "--database" ||
509
+ flag === "--auth" ||
510
+ flag === "--language";
511
+
512
+ if (!takesValue) {
513
+ args.positionals.push(current);
514
+ continue;
515
+ }
516
+
517
+ const value = inlineValue ?? argv[index + 1];
518
+ if (!inlineValue) {
519
+ index += 1;
520
+ }
521
+
522
+ if (!value) {
523
+ throw new Error(`Missing value for ${flag}`);
524
+ }
525
+
526
+ if (flag === "--name" || flag === "--project-name") {
527
+ args.projectName = value;
528
+ continue;
529
+ }
530
+
531
+ if (flag === "--template" || flag === "-t") {
532
+ args.templateRef = value;
533
+ continue;
534
+ }
535
+
536
+ if (flag === "--category" || flag === "-c") {
537
+ args.category = value;
538
+ continue;
539
+ }
540
+
541
+ if (flag === "--stack" || flag === "--combo") {
542
+ args.comboTokens.push(...tokenize(value));
543
+ continue;
544
+ }
545
+
546
+ if (flag === "--package-manager" || flag === "-p") {
547
+ args.packageManager = value.toLowerCase();
548
+ continue;
549
+ }
550
+
551
+ if (ARG_TO_FILTER_TOKEN[flag]) {
552
+ args.comboTokens.push(...tokenize(value));
553
+ }
554
+ }
555
+
556
+ if (!args.projectName && args.positionals.length > 0) {
557
+ args.projectName = args.positionals[0];
558
+ args.comboTokens.push(...args.positionals.slice(1).flatMap(tokenize));
559
+ }
560
+
561
+ return args;
562
+ }
563
+
564
+ function printHelp() {
565
+ console.log(`
566
+ starter-structure-cli
567
+
568
+ Usage:
569
+ npx starter-structure-cli <project-name>
570
+ npx starter-structure-cli <project-name> react vite ts tailwind express prisma mysql
571
+ npx starter-structure-cli <project-name> --category fullstack --frontend react --backend express --orm prisma --database mysql
572
+ npx starter-structure-cli <project-name> --template fullstack/react-vite-ts-tailwind-express-prisma-mysql
573
+ npx starter-structure-cli --list
574
+
575
+ Options:
576
+ -h, --help Show help
577
+ --list List discovered templates
578
+ -y, --yes Skip optional prompts when selection is already unambiguous
579
+ --install Run package manager install after scaffold
580
+ --no-install Do not install dependencies
581
+ -p, --package-manager npm | pnpm | yarn
582
+ -c, --category fullstack | frontend-only | single | backend-only | monorepo | turbo
583
+ -t, --template Exact template slug or category/slug
584
+ --stack, --combo Freeform stack query, e.g. "nextjs tailwind prisma mysql"
585
+ --frontend react | nextjs | vue
586
+ --backend express | nestjs | fastify
587
+ --styling tailwind | shadcn
588
+ --orm prisma | mongoose | sequelize
589
+ --database mongodb | mysql | postgres
590
+ --auth jwt | nextauth
591
+ --language ts | js
592
+ `.trim());
593
+ }
594
+
595
+ function listTemplates(templates) {
596
+ const grouped = new Map();
597
+
598
+ for (const template of templates) {
599
+ if (!grouped.has(template.category)) {
600
+ grouped.set(template.category, []);
601
+ }
602
+ grouped.get(template.category).push(template);
603
+ }
604
+
605
+ console.log("Available templates:\n");
606
+ for (const [category, items] of grouped.entries()) {
607
+ console.log(`${humanizeCategory(category)}:`);
608
+ for (const template of items) {
609
+ console.log(` - ${template.id}`);
610
+ }
611
+ console.log("");
612
+ }
613
+ }
614
+
615
+ function resolveTemplateByReference(templates, reference) {
616
+ const normalizedReference = normalizeToken(reference.replace(/\\/g, "/").replace(/\//g, "-"));
617
+ const exactMatches = templates.filter((template) => {
618
+ const normalizedId = normalizeToken(template.id.replace(/\//g, "-"));
619
+ const normalizedSlug = normalizeToken(template.slug);
620
+ return normalizedId === normalizedReference || normalizedSlug === normalizedReference;
621
+ });
622
+
623
+ if (exactMatches.length === 1) {
624
+ return exactMatches[0];
625
+ }
626
+
627
+ return undefined;
628
+ }
629
+
630
+ function filterTemplates(templates, categoryInput, comboTokens) {
631
+ let matches = [...templates];
632
+
633
+ const categories = resolveCategories(categoryInput);
634
+ if (categories.length > 0) {
635
+ matches = matches.filter((template) => categories.includes(template.category));
636
+ }
637
+
638
+ if (comboTokens.length > 0) {
639
+ matches = matches.filter((template) =>
640
+ comboTokens.every((token) => template.tokens.has(token))
641
+ );
642
+ }
643
+
644
+ return matches;
645
+ }
646
+
647
+ function getAvailableCategories(templates) {
648
+ return [...new Set(templates.map((template) => template.category))];
649
+ }
650
+
651
+ function getAvailableFeatureValues(templates, group) {
652
+ return [
653
+ ...new Set(
654
+ templates
655
+ .map((template) => template.features[group.key])
656
+ .filter(Boolean)
657
+ )
658
+ ];
659
+ }
660
+
661
+ async function chooseCategory(templates) {
662
+ const categories = getAvailableCategories(templates);
663
+
664
+ if (categories.length <= 1) {
665
+ return categories[0];
666
+ }
667
+
668
+ const category = await select({
669
+ message: "Choose a template category:",
670
+ options: categories.map((value) => ({
671
+ value,
672
+ label: humanizeCategory(value)
673
+ }))
674
+ });
675
+
676
+ if (isCancel(category)) {
677
+ return undefined;
678
+ }
679
+
680
+ return category;
681
+ }
682
+
683
+ async function chooseByFeatures(templates) {
684
+ let candidates = [...templates];
685
+
686
+ for (const group of FILTER_GROUPS) {
687
+ const values = getAvailableFeatureValues(candidates, group);
688
+ if (values.length <= 1) {
689
+ continue;
690
+ }
691
+
692
+ const selection = await select({
693
+ message: `Choose ${group.label.toLowerCase()}:`,
694
+ options: [
695
+ { value: "__skip__", label: `Skip ${group.label.toLowerCase()} filter` },
696
+ ...values.map((value) => ({
697
+ value,
698
+ label: humanizeToken(value)
699
+ }))
700
+ ]
701
+ });
702
+
703
+ if (isCancel(selection)) {
704
+ return undefined;
705
+ }
706
+
707
+ if (selection === "__skip__") {
708
+ continue;
709
+ }
710
+
711
+ candidates = candidates.filter((template) => template.features[group.key] === selection);
712
+
713
+ if (candidates.length <= 1) {
714
+ break;
715
+ }
716
+ }
717
+
718
+ return candidates;
719
+ }
720
+
721
+ async function chooseTemplate(templates) {
722
+ if (templates.length === 1) {
723
+ return templates[0];
724
+ }
725
+
726
+ const templateId = await select({
727
+ message: "Choose a template:",
728
+ options: templates.map((template) => ({
729
+ value: template.id,
730
+ label: template.label
731
+ }))
732
+ });
733
+
734
+ if (isCancel(templateId)) {
735
+ return undefined;
736
+ }
737
+
738
+ return templates.find((template) => template.id === templateId);
739
+ }
740
+
741
+ function getSuggestedPackageManager(template) {
742
+ if (template.tokens.has("pnpm")) {
743
+ return "pnpm";
744
+ }
745
+
746
+ return "npm";
747
+ }
748
+
749
+ function validateProjectName(value) {
750
+ if (!value || value.trim().length === 0) {
751
+ return "Please enter a project name.";
752
+ }
753
+
754
+ if (value.includes("/") || value.includes("\\")) {
755
+ return "Use a simple folder name without slashes.";
756
+ }
757
+
758
+ return undefined;
759
+ }
760
+
761
+ function installDependencies(targetDir, packageManager) {
762
+ const result = spawnSync(packageManager, ["install"], {
763
+ cwd: targetDir,
764
+ stdio: "inherit",
765
+ shell: process.platform === "win32"
766
+ });
767
+
768
+ if (result.status !== 0) {
769
+ throw new Error(`${packageManager} install failed`);
770
+ }
771
+ }
772
+
773
+ function formatTemplateSummary(templates) {
774
+ return templates.map((template) => `- ${template.id}`).join("\n");
775
+ }
776
+
777
+ async function main() {
778
+ const args = parseArgs(process.argv.slice(2));
779
+ const templates = discoverTemplates(templatesRoot);
780
+
781
+ if (args.help) {
782
+ printHelp();
783
+ return;
784
+ }
785
+
786
+ if (templates.length === 0) {
787
+ throw new Error(`No templates found in ${templatesRoot}`);
788
+ }
789
+
790
+ if (args.list) {
791
+ listTemplates(templates);
792
+ return;
793
+ }
794
+
795
+ intro(pc.cyan("starter-structure-cli"));
796
+
797
+ let projectName = args.projectName;
798
+ if (!projectName) {
799
+ projectName = await text({
800
+ message: "Project folder name?",
801
+ placeholder: "my-app",
802
+ defaultValue: "my-app",
803
+ validate: validateProjectName
804
+ });
805
+ if (isCancel(projectName)) {
806
+ return cancel("Cancelled.");
807
+ }
808
+ } else {
809
+ const validationError = validateProjectName(projectName);
810
+ if (validationError) {
811
+ return cancel(validationError);
812
+ }
813
+ }
814
+
815
+ let selectedTemplate =
816
+ args.templateRef ? resolveTemplateByReference(templates, args.templateRef) : undefined;
817
+
818
+ if (args.templateRef && !selectedTemplate) {
819
+ note(formatTemplateSummary(templates), "Available templates");
820
+ return cancel(`Template not found: ${args.templateRef}`);
821
+ }
822
+
823
+ let candidates = selectedTemplate
824
+ ? [selectedTemplate]
825
+ : filterTemplates(templates, args.category, [...new Set(args.comboTokens)]);
826
+
827
+ if (!selectedTemplate && candidates.length === 0) {
828
+ note(
829
+ formatTemplateSummary(templates),
830
+ "No template matched the requested combination"
831
+ );
832
+ return cancel("Adjust your stack filters or choose a template explicitly.");
833
+ }
834
+
835
+ if (!selectedTemplate) {
836
+ const categoryWasSupplied = Boolean(args.category);
837
+
838
+ if (!categoryWasSupplied) {
839
+ const chosenCategory = await chooseCategory(candidates);
840
+ if (!chosenCategory) {
841
+ return cancel("Cancelled.");
842
+ }
843
+ candidates = candidates.filter((template) => template.category === chosenCategory);
844
+ }
845
+
846
+ if (candidates.length > 1 && args.comboTokens.length === 0) {
847
+ const narrowed = await chooseByFeatures(candidates);
848
+ if (!narrowed) {
849
+ return cancel("Cancelled.");
850
+ }
851
+ candidates = narrowed;
852
+ }
853
+
854
+ if (candidates.length > 1 && args.yes) {
855
+ note(formatTemplateSummary(candidates), "Multiple templates still match");
856
+ return cancel("Use --template or add more stack filters.");
857
+ }
858
+
859
+ selectedTemplate = await chooseTemplate(candidates);
860
+ if (!selectedTemplate) {
861
+ return cancel("Cancelled.");
862
+ }
863
+ }
864
+
865
+ let packageManager = args.packageManager;
866
+ if (!packageManager) {
867
+ const suggested = getSuggestedPackageManager(selectedTemplate);
868
+
869
+ if (args.yes) {
870
+ packageManager = suggested;
871
+ } else {
872
+ packageManager = await select({
873
+ message: "Choose a package manager:",
874
+ options: ["pnpm", "npm", "yarn"].map((value) => ({
875
+ value,
876
+ label: value
877
+ })),
878
+ initialValue: suggested
879
+ });
880
+ if (isCancel(packageManager)) {
881
+ return cancel("Cancelled.");
882
+ }
883
+ }
884
+ }
885
+
886
+ if (!SUPPORTED_PACKAGE_MANAGERS.has(packageManager)) {
887
+ return cancel(`Unsupported package manager: ${packageManager}`);
888
+ }
889
+
890
+ let shouldInstall = args.install;
891
+ if (shouldInstall === undefined) {
892
+ if (args.yes) {
893
+ shouldInstall = false;
894
+ } else {
895
+ shouldInstall = await confirm({
896
+ message: "Install dependencies now?",
897
+ initialValue: false
898
+ });
899
+ if (isCancel(shouldInstall)) {
900
+ return cancel("Cancelled.");
901
+ }
902
+ }
903
+ }
904
+
905
+ const targetDir = path.resolve(process.cwd(), projectName);
906
+ if (fs.existsSync(targetDir) && !isEmptyDir(targetDir)) {
907
+ return cancel(`Target directory is not empty: ${targetDir}`);
908
+ }
909
+
910
+ if (isEmptyDir(selectedTemplate.absolutePath)) {
911
+ note(selectedTemplate.id, "Selected template directory is empty");
912
+ return cancel("Add your real template files before generating a project.");
913
+ }
914
+
915
+ copyDir(selectedTemplate.absolutePath, targetDir);
916
+ renamePlaceholderPaths(targetDir, { "__APP_NAME__": projectName });
917
+ walkFiles(targetDir, (filePath) => {
918
+ replaceInFile(filePath, { "__APP_NAME__": projectName });
919
+ });
920
+
921
+ if (shouldInstall) {
922
+ note(`${packageManager} install`, "Installing dependencies");
923
+ installDependencies(targetDir, packageManager);
924
+ }
925
+
926
+ outro(
927
+ [
928
+ pc.green(`Created ${projectName}`),
929
+ `Template: ${selectedTemplate.id}`,
930
+ `Next:`,
931
+ ` cd ${projectName}`,
932
+ shouldInstall ? "" : ` ${packageManager} install`
933
+ ]
934
+ .filter(Boolean)
935
+ .join("\n")
936
+ );
937
+ }
938
+
939
+ main().catch((error) => {
940
+ console.error(pc.red("Error:"), error.message);
941
+ process.exit(1);
942
+ });