@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 +6 -0
- package/lib/scaffold.js +280 -21
- package/optional-packages.json +30 -0
- package/package.json +5 -1
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
|
|
221
|
+
const selected = Array.isArray(response.selected) ? response.selected : [];
|
|
222
|
+
if (selected.length === 0) {
|
|
223
|
+
return [];
|
|
117
224
|
}
|
|
118
225
|
|
|
119
|
-
|
|
226
|
+
const selectedSet = new Set(selected);
|
|
227
|
+
return optionalPackages.filter((pkg) => selectedSet.has(pkg.name));
|
|
120
228
|
}
|
|
121
229
|
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
}
|