@guchen_0521/create-temp 0.1.5 → 0.1.7

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 CHANGED
@@ -23,6 +23,12 @@ Use with npm create:
23
23
  npm create @guchen_0521/temp@latest -- backend/express/express-no-ts my-api
24
24
  ```
25
25
 
26
+ Interactive selection (TUI):
27
+
28
+ ```bash
29
+ npm create @guchen_0521/temp@latest
30
+ ```
31
+
26
32
  ## Requirements
27
33
 
28
34
  - Git installed and available in PATH
package/lib/scaffold.js CHANGED
@@ -3,14 +3,16 @@ import {
3
3
  mkdir,
4
4
  mkdtemp,
5
5
  readdir,
6
+ readFile,
6
7
  rm,
7
8
  stat,
9
+ writeFile,
8
10
  } from "node:fs/promises";
9
11
  import { execFile } from "node:child_process";
10
- import { createInterface } from "node:readline/promises";
11
12
  import { promisify } from "node:util";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
15
+ import prompts from "prompts";
14
16
 
15
17
  const IGNORE_NAMES = new Set([
16
18
  ".git",
@@ -27,6 +29,24 @@ const TEMPLATE_REPO_URL =
27
29
  const CLONE_RETRIES = 3;
28
30
  const GIT_SSH_COMMAND =
29
31
  "ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6";
32
+ const PROMPT_CANCELLED_MESSAGE = "Prompt cancelled.";
33
+ const PROMPT_BACK_VALUE = "__back";
34
+ const OPTIONAL_PACKAGES_PATH = new URL(
35
+ "../optional-packages.json",
36
+ import.meta.url
37
+ );
38
+
39
+ function assertInteractive() {
40
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
41
+ throw new Error(
42
+ "Interactive prompts require a TTY. Pass template and target arguments."
43
+ );
44
+ }
45
+ }
46
+
47
+ function isInteractive() {
48
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
49
+ }
30
50
 
31
51
  async function cloneTemplateRepo() {
32
52
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "template-cli-"));
@@ -101,33 +121,211 @@ function normalizeTemplateKey(templateKey) {
101
121
  .replace(/^\.\/+/, "");
102
122
  }
103
123
 
104
- async function promptSelectTemplate(templates) {
105
- const rl = createInterface({ input: process.stdin, output: process.stdout });
106
- console.log("Available templates:");
107
- templates.forEach((template, index) => {
108
- console.log(` ${index + 1}) ${template.key}`);
124
+ async function loadOptionalPackageRules() {
125
+ try {
126
+ const raw = await readFile(OPTIONAL_PACKAGES_PATH, "utf8");
127
+ return JSON.parse(raw);
128
+ } catch (error) {
129
+ if (error && error.code === "ENOENT") {
130
+ return null;
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ function matchOptionalRule(rule, templateKey) {
137
+ if (!rule || !templateKey) {
138
+ return false;
139
+ }
140
+
141
+ const normalizedTemplate = normalizeTemplateKey(templateKey);
142
+
143
+ if (rule.match) {
144
+ return normalizeTemplateKey(rule.match) === normalizedTemplate;
145
+ }
146
+
147
+ if (rule.matchPrefix) {
148
+ const normalizedPrefix = normalizeTemplateKey(rule.matchPrefix);
149
+ const prefixWithSlash = normalizedPrefix.endsWith("/")
150
+ ? normalizedPrefix
151
+ : `${normalizedPrefix}/`;
152
+ return normalizedTemplate.startsWith(prefixWithSlash);
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ function collectOptionalPackages(rules, templateKey) {
159
+ if (!rules || !Array.isArray(rules.rules)) {
160
+ return [];
161
+ }
162
+
163
+ const collected = [];
164
+ const seen = new Set();
165
+
166
+ rules.rules.forEach((rule) => {
167
+ if (!matchOptionalRule(rule, templateKey)) {
168
+ return;
169
+ }
170
+
171
+ if (!Array.isArray(rule.packages)) {
172
+ return;
173
+ }
174
+
175
+ rule.packages.forEach((pkg) => {
176
+ if (!pkg || !pkg.name || typeof pkg.name !== "string") {
177
+ return;
178
+ }
179
+ if (seen.has(pkg.name)) {
180
+ return;
181
+ }
182
+ seen.add(pkg.name);
183
+ collected.push({
184
+ name: pkg.name,
185
+ dev: Boolean(pkg.dev),
186
+ version: pkg.version,
187
+ description: pkg.description,
188
+ });
189
+ });
109
190
  });
110
191
 
111
- const answer = await rl.question("Select a template number: ");
112
- rl.close();
192
+ return collected;
193
+ }
194
+
195
+ async function promptOptionalPackages(optionalPackages) {
196
+ if (!isInteractive() || optionalPackages.length === 0) {
197
+ return [];
198
+ }
199
+
200
+ const choices = optionalPackages.map((pkg) => ({
201
+ title: `${pkg.name}${pkg.dev ? " (dev)" : ""}${
202
+ pkg.description ? ` - ${pkg.description}` : ""
203
+ }`,
204
+ value: pkg.name,
205
+ }));
206
+
207
+ const response = await prompts(
208
+ {
209
+ type: "multiselect",
210
+ name: "selected",
211
+ message: "Select optional packages:",
212
+ choices,
213
+ },
214
+ {
215
+ onCancel: () => {
216
+ throw new Error(PROMPT_CANCELLED_MESSAGE);
217
+ },
218
+ }
219
+ );
113
220
 
114
- const choice = Number.parseInt(answer.trim(), 10);
115
- if (!Number.isInteger(choice) || choice < 1 || choice > templates.length) {
116
- throw new Error("Invalid template selection.");
221
+ const selected = Array.isArray(response.selected) ? response.selected : [];
222
+ if (selected.length === 0) {
223
+ return [];
117
224
  }
118
225
 
119
- return templates[choice - 1];
226
+ const selectedSet = new Set(selected);
227
+ return optionalPackages.filter((pkg) => selectedSet.has(pkg.name));
120
228
  }
121
229
 
122
- async function promptTargetDir() {
123
- const rl = createInterface({ input: process.stdin, output: process.stdout });
124
- const answer = await rl.question("Project folder name: ");
125
- rl.close();
126
- const trimmed = answer.trim();
127
- if (!trimmed) {
128
- throw new Error("Project folder name is required.");
230
+ function buildTemplateTree(templates) {
231
+ const root = { name: "", children: new Map(), template: null };
232
+ templates.forEach((template) => {
233
+ const parts = template.key.split("/");
234
+ let node = root;
235
+ parts.forEach((part, index) => {
236
+ if (!node.children.has(part)) {
237
+ node.children.set(part, {
238
+ name: part,
239
+ children: new Map(),
240
+ template: null,
241
+ });
242
+ }
243
+ node = node.children.get(part);
244
+ if (index === parts.length - 1) {
245
+ node.template = template;
246
+ }
247
+ });
248
+ });
249
+ return root;
250
+ }
251
+
252
+ async function promptSelectTemplateTree(templates) {
253
+ assertInteractive();
254
+ const tree = buildTemplateTree(templates);
255
+ const parents = [];
256
+ let current = tree;
257
+
258
+ while (true) {
259
+ const choices = Array.from(current.children.values())
260
+ .sort((left, right) => left.name.localeCompare(right.name))
261
+ .map((node) => ({
262
+ title: node.name,
263
+ value: node.name,
264
+ }));
265
+
266
+ if (parents.length > 0) {
267
+ choices.unshift({ title: "<- Back", value: PROMPT_BACK_VALUE });
268
+ }
269
+
270
+ const { selection } = await prompts(
271
+ {
272
+ type: "select",
273
+ name: "selection",
274
+ message:
275
+ parents.length === 0
276
+ ? "Select a template category:"
277
+ : "Select a folder:",
278
+ choices,
279
+ },
280
+ {
281
+ onCancel: () => {
282
+ throw new Error(PROMPT_CANCELLED_MESSAGE);
283
+ },
284
+ }
285
+ );
286
+
287
+ if (!selection) {
288
+ throw new Error("Invalid template selection.");
289
+ }
290
+
291
+ if (selection === PROMPT_BACK_VALUE) {
292
+ current = parents.pop();
293
+ continue;
294
+ }
295
+
296
+ const next = current.children.get(selection);
297
+ if (!next) {
298
+ throw new Error("Invalid template selection.");
299
+ }
300
+
301
+ if (next.template) {
302
+ return next.template;
303
+ }
304
+
305
+ parents.push(current);
306
+ current = next;
129
307
  }
130
- return trimmed;
308
+ }
309
+
310
+ async function promptTargetDir() {
311
+ assertInteractive();
312
+ const response = await prompts(
313
+ {
314
+ type: "text",
315
+ name: "targetDir",
316
+ message: "Project folder name:",
317
+ validate: (value) =>
318
+ value && value.trim().length > 0
319
+ ? true
320
+ : "Project folder name is required.",
321
+ },
322
+ {
323
+ onCancel: () => {
324
+ throw new Error(PROMPT_CANCELLED_MESSAGE);
325
+ },
326
+ }
327
+ );
328
+ return response.targetDir.trim();
131
329
  }
132
330
 
133
331
  async function ensureEmptyDir(targetDir) {
@@ -166,6 +364,56 @@ async function copyDir(sourceDir, targetDir) {
166
364
  }
167
365
  }
168
366
 
367
+ async function applyOptionalPackages(targetPath, packages) {
368
+ if (!packages || packages.length === 0) {
369
+ return;
370
+ }
371
+
372
+ const packageJsonPath = path.join(targetPath, "package.json");
373
+ const raw = await readFile(packageJsonPath, "utf8");
374
+ const pkgJson = JSON.parse(raw);
375
+
376
+ const hadDependencies = Object.prototype.hasOwnProperty.call(
377
+ pkgJson,
378
+ "dependencies"
379
+ );
380
+ const hadDevDependencies = Object.prototype.hasOwnProperty.call(
381
+ pkgJson,
382
+ "devDependencies"
383
+ );
384
+
385
+ const dependencies = hadDependencies ? { ...pkgJson.dependencies } : {};
386
+ const devDependencies = hadDevDependencies ? { ...pkgJson.devDependencies } : {};
387
+
388
+ let changed = false;
389
+
390
+ packages.forEach((pkg) => {
391
+ if (dependencies[pkg.name] || devDependencies[pkg.name]) {
392
+ return;
393
+ }
394
+ const version = pkg.version || "latest";
395
+ if (pkg.dev) {
396
+ devDependencies[pkg.name] = version;
397
+ } else {
398
+ dependencies[pkg.name] = version;
399
+ }
400
+ changed = true;
401
+ });
402
+
403
+ if (!changed) {
404
+ return;
405
+ }
406
+
407
+ if (hadDependencies || Object.keys(dependencies).length > 0) {
408
+ pkgJson.dependencies = dependencies;
409
+ }
410
+ if (hadDevDependencies || Object.keys(devDependencies).length > 0) {
411
+ pkgJson.devDependencies = devDependencies;
412
+ }
413
+
414
+ await writeFile(packageJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
415
+ }
416
+
169
417
  export async function scaffold({ templateKey, targetDir }) {
170
418
  let repoDir = null;
171
419
  let tempDir = null;
@@ -215,14 +463,25 @@ export async function scaffold({ templateKey, targetDir }) {
215
463
  throw new Error(`Template not found. Available: ${keys}`);
216
464
  }
217
465
  } else {
218
- selectedTemplate = await promptSelectTemplate(templates);
466
+ selectedTemplate = await promptSelectTemplateTree(templates);
219
467
  }
220
468
 
469
+ const optionalRules = await loadOptionalPackageRules();
470
+ const optionalPackages = collectOptionalPackages(
471
+ optionalRules,
472
+ selectedTemplate.key
473
+ );
474
+ const selectedOptionalPackages =
475
+ optionalPackages.length > 0
476
+ ? await promptOptionalPackages(optionalPackages)
477
+ : [];
478
+
221
479
  const resolvedTargetDir = targetDir || (await promptTargetDir());
222
480
  const targetPath = path.resolve(process.cwd(), resolvedTargetDir);
223
481
 
224
482
  await ensureEmptyDir(targetPath);
225
483
  await copyDir(selectedTemplate.dir, targetPath);
484
+ await applyOptionalPackages(targetPath, selectedOptionalPackages);
226
485
 
227
486
  if (tempDir) {
228
487
  await rm(tempDir, { recursive: true, force: true });
@@ -0,0 +1,30 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "match": "backend/express/express-no-ts",
5
+ "packages": [
6
+ {
7
+ "name": "sequelize",
8
+ "dev": false
9
+ },
10
+ {
11
+ "name": "mysql2",
12
+ "dev": false
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "matchPrefix": "front/web/vue3/",
18
+ "packages": [
19
+ {
20
+ "name": "axios",
21
+ "dev": false
22
+ },
23
+ {
24
+ "name": "eslint",
25
+ "dev": true
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guchen_0521/create-temp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Project scaffolding CLI (templates pulled from GitHub)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,14 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
+ "optional-packages.json",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=18"
18
+ },
19
+ "dependencies": {
20
+ "prompts": "^2.4.2"
17
21
  }
18
22
  }