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.
- package/README.md +92 -0
- package/bin/starter-structure-cli.js +942 -0
- package/package.json +34 -0
- package/scripts/check-templates.js +66 -0
- package/templates/backend-only/express-mongoose-jwt/.env.example +6 -0
- package/templates/backend-only/express-mongoose-jwt/README.md +43 -0
- package/templates/backend-only/express-mongoose-jwt/config/db.js +19 -0
- package/templates/backend-only/express-mongoose-jwt/controllers/auth/index.js +91 -0
- package/templates/backend-only/express-mongoose-jwt/index.js +44 -0
- package/templates/backend-only/express-mongoose-jwt/middleware/authenticate.js +35 -0
- package/templates/backend-only/express-mongoose-jwt/middleware/error-handler.js +16 -0
- package/templates/backend-only/express-mongoose-jwt/middleware/not-found.js +7 -0
- package/templates/backend-only/express-mongoose-jwt/models/user.js +54 -0
- package/templates/backend-only/express-mongoose-jwt/package.json +30 -0
- package/templates/backend-only/express-mongoose-jwt/routes/auth/index.js +12 -0
- package/templates/backend-only/express-mongoose-jwt/routes/index.js +16 -0
- package/templates/backend-only/express-mongoose-jwt/utils/api-response.js +20 -0
- package/templates/backend-only/express-mongoose-jwt/utils/generate-token.js +9 -0
|
@@ -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
|
+
});
|