@guchenyo/create-temp 0.0.1
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 +41 -0
- package/bin/cli.js +16 -0
- package/lib/scaffold.js +598 -0
- package/optional-packages.json +34 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# create-temp
|
|
2
|
+
|
|
3
|
+
Local template scaffolding CLI for this repository. It clones the latest
|
|
4
|
+
templates from GitHub on each run (SSH access required).
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm link
|
|
10
|
+
create-temp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or pass args directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
create-temp backend/express/express-no-ts my-api
|
|
17
|
+
create-temp front/web/vue3/vue3-no-ts my-web
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use with npm create:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm create @guchen_0521/temp@latest -- backend/express/express-no-ts my-api
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Interactive selection (TUI):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm create @guchen_0521/temp@latest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Git installed and available in PATH
|
|
35
|
+
- SSH access to `ssh://git@ssh.github.com:443/GuChena/template.git`
|
|
36
|
+
|
|
37
|
+
## Publish
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm publish --access public
|
|
41
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { scaffold } from "../lib/scaffold.js";
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2).filter((arg) => arg !== "--");
|
|
5
|
+
const [templateArg, targetArg] = args;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
await scaffold({
|
|
9
|
+
templateKey: templateArg,
|
|
10
|
+
targetDir: targetArg,
|
|
11
|
+
});
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14
|
+
console.error(message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFile,
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
cancel,
|
|
17
|
+
intro,
|
|
18
|
+
isCancel,
|
|
19
|
+
multiselect,
|
|
20
|
+
note,
|
|
21
|
+
outro,
|
|
22
|
+
select,
|
|
23
|
+
text,
|
|
24
|
+
} from "@clack/prompts";
|
|
25
|
+
import gradient from "gradient-string";
|
|
26
|
+
import ora from "ora";
|
|
27
|
+
|
|
28
|
+
const IGNORE_NAMES = new Set([
|
|
29
|
+
".git",
|
|
30
|
+
"node_modules",
|
|
31
|
+
".DS_Store",
|
|
32
|
+
".pnpm",
|
|
33
|
+
".pnpm-store",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
const TEMPLATE_REPO_URL =
|
|
38
|
+
process.env.TEMPLATE_REPO_URL || "https://github.com/GuChena/template.git";
|
|
39
|
+
const CLONE_RETRIES = 3;
|
|
40
|
+
const GIT_SSH_COMMAND =
|
|
41
|
+
"ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6";
|
|
42
|
+
const PROMPT_CANCELLED_MESSAGE = "已取消操作。";
|
|
43
|
+
const PROMPT_BACK_VALUE = "__back";
|
|
44
|
+
const OPTIONAL_PACKAGES_PATH = new URL(
|
|
45
|
+
"../optional-packages.json",
|
|
46
|
+
import.meta.url
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function assertInteractive() {
|
|
50
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
51
|
+
throw new Error("交互式界面需要终端 TTY,请传入模板和项目名参数。");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isInteractive() {
|
|
56
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleCancel(value) {
|
|
60
|
+
if (isCancel(value)) {
|
|
61
|
+
cancel(PROMPT_CANCELLED_MESSAGE);
|
|
62
|
+
throw new Error(PROMPT_CANCELLED_MESSAGE);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function applyGradientPerLine(text, gradientFn) {
|
|
68
|
+
return text
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((line) => (line ? gradientFn(line) : line))
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function cloneTemplateRepo() {
|
|
75
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "template-cli-"));
|
|
76
|
+
const repoDir = path.join(tempDir, `repo-${Date.now().toString(36)}`);
|
|
77
|
+
|
|
78
|
+
let lastError = null;
|
|
79
|
+
for (let attempt = 1; attempt <= CLONE_RETRIES; attempt += 1) {
|
|
80
|
+
try {
|
|
81
|
+
const env = { ...process.env };
|
|
82
|
+
if (TEMPLATE_REPO_URL.startsWith("ssh://")) {
|
|
83
|
+
env.GIT_SSH_COMMAND = GIT_SSH_COMMAND;
|
|
84
|
+
}
|
|
85
|
+
await execFileAsync(
|
|
86
|
+
"git",
|
|
87
|
+
["clone", "--depth", "1", TEMPLATE_REPO_URL, repoDir],
|
|
88
|
+
{ env }
|
|
89
|
+
);
|
|
90
|
+
return { repoDir, tempDir };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
lastError = error;
|
|
93
|
+
if (attempt < CLONE_RETRIES) {
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 500));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw lastError;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function findTemplateDirs(rootDir) {
|
|
103
|
+
const results = [];
|
|
104
|
+
const entries = await readdir(rootDir, { withFileTypes: true });
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.isDirectory() || IGNORE_NAMES.has(entry.name)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
112
|
+
const packageJsonPath = path.join(fullPath, "package.json");
|
|
113
|
+
try {
|
|
114
|
+
const info = await stat(packageJsonPath);
|
|
115
|
+
if (info.isFile()) {
|
|
116
|
+
results.push(fullPath);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Not a template root; keep searching.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const nested = await findTemplateDirs(fullPath);
|
|
124
|
+
results.push(...nested);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatTemplateKey(projectRoot, templateDir) {
|
|
131
|
+
return path.relative(projectRoot, templateDir).split(path.sep).join("/");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeTemplateKey(templateKey) {
|
|
135
|
+
if (!templateKey) {
|
|
136
|
+
return templateKey;
|
|
137
|
+
}
|
|
138
|
+
return templateKey
|
|
139
|
+
.trim()
|
|
140
|
+
.replaceAll("\\", "/")
|
|
141
|
+
.replace(/^\.\/+/, "")
|
|
142
|
+
.replace(/\/+$/, "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function loadOptionalPackageRules() {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readFile(OPTIONAL_PACKAGES_PATH, "utf8");
|
|
148
|
+
return JSON.parse(raw);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error && error.code === "ENOENT") {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function matchOptionalRule(rule, templateKey) {
|
|
158
|
+
if (!rule || !templateKey) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const normalizedTemplate = normalizeTemplateKey(templateKey);
|
|
163
|
+
|
|
164
|
+
if (rule.match) {
|
|
165
|
+
const normalizedMatch = normalizeTemplateKey(rule.match);
|
|
166
|
+
if (normalizedMatch === normalizedTemplate) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const matchWithSlash = normalizedMatch.endsWith("/")
|
|
170
|
+
? normalizedMatch
|
|
171
|
+
: `${normalizedMatch}/`;
|
|
172
|
+
return normalizedTemplate.startsWith(matchWithSlash);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (rule.matchPrefix) {
|
|
176
|
+
const normalizedPrefix = normalizeTemplateKey(rule.matchPrefix);
|
|
177
|
+
const prefixWithSlash = normalizedPrefix.endsWith("/")
|
|
178
|
+
? normalizedPrefix
|
|
179
|
+
: `${normalizedPrefix}/`;
|
|
180
|
+
return normalizedTemplate.startsWith(prefixWithSlash);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function collectOptionalPackages(rules, templateKey) {
|
|
187
|
+
if (!rules || !Array.isArray(rules.rules)) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const collected = [];
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
|
|
194
|
+
rules.rules.forEach((rule) => {
|
|
195
|
+
if (!matchOptionalRule(rule, templateKey)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!Array.isArray(rule.packages)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
rule.packages.forEach((pkg) => {
|
|
204
|
+
if (!pkg || !pkg.name || typeof pkg.name !== "string") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (seen.has(pkg.name)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
seen.add(pkg.name);
|
|
211
|
+
collected.push({
|
|
212
|
+
name: pkg.name,
|
|
213
|
+
dev: Boolean(pkg.dev),
|
|
214
|
+
version: pkg.version,
|
|
215
|
+
description: pkg.description,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return collected;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function promptOptionalPackages(optionalPackages) {
|
|
224
|
+
if (!isInteractive() || optionalPackages.length === 0) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const choices = optionalPackages.map((pkg) => ({
|
|
229
|
+
value: pkg.name,
|
|
230
|
+
label: pkg.description ? `${pkg.name} - ${pkg.description}` : pkg.name,
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
const selected = handleCancel(
|
|
234
|
+
await multiselect({
|
|
235
|
+
message: "选择可选依赖:",
|
|
236
|
+
options: choices,
|
|
237
|
+
required: false,
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (!Array.isArray(selected) || selected.length === 0) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (isInteractive() && process.stdout.isTTY) {
|
|
246
|
+
const gray = "\x1b[90m";
|
|
247
|
+
const dim = "\x1b[2m";
|
|
248
|
+
const reset = "\x1b[0m";
|
|
249
|
+
process.stdout.write("\x1b[1A\x1b[2K");
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
`${gray}│${reset} ${dim}${selected.join(", ")}${reset}\n`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const selectedSet = new Set(selected);
|
|
256
|
+
return optionalPackages.filter((pkg) => selectedSet.has(pkg.name));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildTemplateTree(templates) {
|
|
260
|
+
const root = { name: "", children: new Map(), template: null };
|
|
261
|
+
templates.forEach((template) => {
|
|
262
|
+
const parts = template.key.split("/");
|
|
263
|
+
let node = root;
|
|
264
|
+
parts.forEach((part, index) => {
|
|
265
|
+
if (!node.children.has(part)) {
|
|
266
|
+
node.children.set(part, {
|
|
267
|
+
name: part,
|
|
268
|
+
children: new Map(),
|
|
269
|
+
template: null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
node = node.children.get(part);
|
|
273
|
+
if (index === parts.length - 1) {
|
|
274
|
+
node.template = template;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
return root;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function promptSelectTemplateTree(templates) {
|
|
282
|
+
assertInteractive();
|
|
283
|
+
const tree = buildTemplateTree(templates);
|
|
284
|
+
const parents = [];
|
|
285
|
+
const pathStack = [];
|
|
286
|
+
let current = tree;
|
|
287
|
+
|
|
288
|
+
while (true) {
|
|
289
|
+
const choices = Array.from(current.children.values())
|
|
290
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
291
|
+
.map((node) => ({
|
|
292
|
+
value: node.name,
|
|
293
|
+
label: node.name,
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
if (parents.length > 0) {
|
|
297
|
+
choices.unshift({ value: PROMPT_BACK_VALUE, label: "返回上一级" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const selection = handleCancel(
|
|
301
|
+
await select({
|
|
302
|
+
message:
|
|
303
|
+
parents.length === 0
|
|
304
|
+
? "选择模板类别:"
|
|
305
|
+
: `选择模板目录: ${pathStack.join("/")}`,
|
|
306
|
+
options: choices,
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (!selection || typeof selection !== "string") {
|
|
311
|
+
throw new Error("模板选择无效。");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (selection === PROMPT_BACK_VALUE) {
|
|
315
|
+
current = parents.pop();
|
|
316
|
+
pathStack.pop();
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const next = current.children.get(selection);
|
|
321
|
+
if (!next) {
|
|
322
|
+
throw new Error("模板选择无效。");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (next.template) {
|
|
326
|
+
return next.template;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
parents.push(current);
|
|
330
|
+
pathStack.push(selection);
|
|
331
|
+
current = next;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function promptTargetDir() {
|
|
336
|
+
assertInteractive();
|
|
337
|
+
const response = handleCancel(
|
|
338
|
+
await text({
|
|
339
|
+
message: "项目名称:",
|
|
340
|
+
placeholder: "my-app",
|
|
341
|
+
validate: (value) =>
|
|
342
|
+
value && value.trim().length > 0 ? undefined : "请输入项目名称。",
|
|
343
|
+
})
|
|
344
|
+
);
|
|
345
|
+
return response.trim();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function ensureEmptyDir(targetDir) {
|
|
349
|
+
try {
|
|
350
|
+
const items = await readdir(targetDir);
|
|
351
|
+
if (items.length > 0) {
|
|
352
|
+
throw new Error(`Target directory is not empty: ${targetDir}`);
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error && error.code === "ENOENT") {
|
|
356
|
+
await mkdir(targetDir, { recursive: true });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function copyDir(sourceDir, targetDir) {
|
|
364
|
+
await mkdir(targetDir, { recursive: true });
|
|
365
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
366
|
+
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
if (IGNORE_NAMES.has(entry.name)) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
373
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
374
|
+
|
|
375
|
+
if (entry.isDirectory()) {
|
|
376
|
+
await copyDir(sourcePath, targetPath);
|
|
377
|
+
} else if (entry.isFile()) {
|
|
378
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
379
|
+
await copyFile(sourcePath, targetPath);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function applyOptionalPackages(targetPath, packages) {
|
|
385
|
+
if (!packages || packages.length === 0) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const packageJsonPath = path.join(targetPath, "package.json");
|
|
390
|
+
const raw = await readFile(packageJsonPath, "utf8");
|
|
391
|
+
const pkgJson = JSON.parse(raw);
|
|
392
|
+
|
|
393
|
+
const hadDependencies = Object.prototype.hasOwnProperty.call(
|
|
394
|
+
pkgJson,
|
|
395
|
+
"dependencies"
|
|
396
|
+
);
|
|
397
|
+
const hadDevDependencies = Object.prototype.hasOwnProperty.call(
|
|
398
|
+
pkgJson,
|
|
399
|
+
"devDependencies"
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const dependencies = hadDependencies ? { ...pkgJson.dependencies } : {};
|
|
403
|
+
const devDependencies = hadDevDependencies
|
|
404
|
+
? { ...pkgJson.devDependencies }
|
|
405
|
+
: {};
|
|
406
|
+
|
|
407
|
+
let changed = false;
|
|
408
|
+
|
|
409
|
+
packages.forEach((pkg) => {
|
|
410
|
+
if (dependencies[pkg.name] || devDependencies[pkg.name]) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const version = pkg.version || "latest";
|
|
414
|
+
if (pkg.dev) {
|
|
415
|
+
devDependencies[pkg.name] = version;
|
|
416
|
+
} else {
|
|
417
|
+
dependencies[pkg.name] = version;
|
|
418
|
+
}
|
|
419
|
+
changed = true;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (!changed) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (hadDependencies || Object.keys(dependencies).length > 0) {
|
|
427
|
+
pkgJson.dependencies = dependencies;
|
|
428
|
+
}
|
|
429
|
+
if (hadDevDependencies || Object.keys(devDependencies).length > 0) {
|
|
430
|
+
pkgJson.devDependencies = devDependencies;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
await writeFile(packageJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function scaffold({ templateKey, targetDir }) {
|
|
437
|
+
const interactive = isInteractive();
|
|
438
|
+
if (interactive) {
|
|
439
|
+
intro("create-temp");
|
|
440
|
+
console.log("");
|
|
441
|
+
console.log(
|
|
442
|
+
applyGradientPerLine(
|
|
443
|
+
["轻量模板脚手架", "按提示选择模板并设置项目名称"].join("\n"),
|
|
444
|
+
gradient(["#8ec5ff", "#b6e3ff"])
|
|
445
|
+
)
|
|
446
|
+
);
|
|
447
|
+
console.log("");
|
|
448
|
+
console.log(
|
|
449
|
+
applyGradientPerLine(
|
|
450
|
+
[
|
|
451
|
+
"可用模板与用途:",
|
|
452
|
+
" backend/",
|
|
453
|
+
" - express/express-no-ts: Node.js + Express API 服务",
|
|
454
|
+
" front/",
|
|
455
|
+
" - web/vue3/vue3-no-ts: Vue3 前端站点",
|
|
456
|
+
].join("\n"),
|
|
457
|
+
gradient(["#9de7ff", "#b7f7d4"])
|
|
458
|
+
)
|
|
459
|
+
);
|
|
460
|
+
console.log("");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let repoDir = null;
|
|
464
|
+
let tempDir = null;
|
|
465
|
+
const cloneSpinner = interactive
|
|
466
|
+
? ora({
|
|
467
|
+
text: "正在获取模板...",
|
|
468
|
+
spinner: "dots",
|
|
469
|
+
interval: 50,
|
|
470
|
+
}).start()
|
|
471
|
+
: null;
|
|
472
|
+
try {
|
|
473
|
+
const cloneResult = await cloneTemplateRepo();
|
|
474
|
+
repoDir = cloneResult.repoDir;
|
|
475
|
+
tempDir = cloneResult.tempDir;
|
|
476
|
+
if (cloneSpinner) {
|
|
477
|
+
cloneSpinner.stop();
|
|
478
|
+
console.log(`T ${gradient(["#8ec5ff", "#b6e3ff"])("加载完成")}`);
|
|
479
|
+
}
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (cloneSpinner) {
|
|
482
|
+
cloneSpinner.fail("模板获取失败");
|
|
483
|
+
}
|
|
484
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
485
|
+
throw new Error(`模板仓库拉取失败,请检查网络或权限。${message}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const templateRoots = [
|
|
489
|
+
path.join(repoDir, "backend"),
|
|
490
|
+
path.join(repoDir, "front"),
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
const templates = [];
|
|
494
|
+
for (const root of templateRoots) {
|
|
495
|
+
try {
|
|
496
|
+
const dirs = await findTemplateDirs(root);
|
|
497
|
+
dirs.forEach((dir) => {
|
|
498
|
+
templates.push({
|
|
499
|
+
key: formatTemplateKey(repoDir, dir),
|
|
500
|
+
dir,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
} catch {
|
|
504
|
+
// Missing template root is fine.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (templates.length === 0) {
|
|
509
|
+
throw new Error("未在 backend/ 或 front/ 下找到模板。");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let selectedTemplate = null;
|
|
513
|
+
if (templateKey) {
|
|
514
|
+
const normalizedKey = normalizeTemplateKey(templateKey);
|
|
515
|
+
selectedTemplate = templates.find(
|
|
516
|
+
(template) => normalizeTemplateKey(template.key) === normalizedKey
|
|
517
|
+
);
|
|
518
|
+
if (!selectedTemplate) {
|
|
519
|
+
const keys = templates.map((template) => template.key).join(", ");
|
|
520
|
+
throw new Error(`未找到模板,可选项: ${keys}`);
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
selectedTemplate = await promptSelectTemplateTree(templates);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const optionalRules = await loadOptionalPackageRules();
|
|
527
|
+
const optionalPackages = collectOptionalPackages(
|
|
528
|
+
optionalRules,
|
|
529
|
+
selectedTemplate.key
|
|
530
|
+
);
|
|
531
|
+
const selectedOptionalPackages =
|
|
532
|
+
optionalPackages.length > 0
|
|
533
|
+
? await promptOptionalPackages(optionalPackages)
|
|
534
|
+
: [];
|
|
535
|
+
|
|
536
|
+
const resolvedTargetDir = targetDir || (await promptTargetDir());
|
|
537
|
+
const targetPath = path.resolve(process.cwd(), resolvedTargetDir);
|
|
538
|
+
|
|
539
|
+
let buildSpinner = null;
|
|
540
|
+
try {
|
|
541
|
+
if (interactive) {
|
|
542
|
+
buildSpinner = ora({
|
|
543
|
+
text: "正在生成项目...",
|
|
544
|
+
spinner: "dots",
|
|
545
|
+
interval: 50,
|
|
546
|
+
}).start();
|
|
547
|
+
}
|
|
548
|
+
if (buildSpinner) {
|
|
549
|
+
buildSpinner.text = "项目初始化中 (1/3)";
|
|
550
|
+
}
|
|
551
|
+
await ensureEmptyDir(targetPath);
|
|
552
|
+
if (buildSpinner) {
|
|
553
|
+
buildSpinner.text = "项目初始化中 (2/3)";
|
|
554
|
+
}
|
|
555
|
+
await copyDir(selectedTemplate.dir, targetPath);
|
|
556
|
+
if (buildSpinner) {
|
|
557
|
+
buildSpinner.text = "项目初始化中 (3/3)";
|
|
558
|
+
}
|
|
559
|
+
await applyOptionalPackages(targetPath, selectedOptionalPackages);
|
|
560
|
+
if (buildSpinner) {
|
|
561
|
+
buildSpinner.succeed(" 项目生成完成,可执行以下指令");
|
|
562
|
+
}
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (buildSpinner) {
|
|
565
|
+
buildSpinner.fail("项目生成失败");
|
|
566
|
+
}
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (tempDir) {
|
|
571
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const userAgent = process.env.npm_config_user_agent || "";
|
|
575
|
+
let packageManager = "npm";
|
|
576
|
+
if (userAgent.includes("pnpm")) {
|
|
577
|
+
packageManager = "pnpm";
|
|
578
|
+
} else if (userAgent.includes("yarn")) {
|
|
579
|
+
packageManager = "yarn";
|
|
580
|
+
} else if (userAgent.includes("bun")) {
|
|
581
|
+
packageManager = "bun";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (interactive) {
|
|
585
|
+
const cyan = "\x1b[36m";
|
|
586
|
+
const bold = "\x1b[1m";
|
|
587
|
+
const reset = "\x1b[0m";
|
|
588
|
+
console.log(
|
|
589
|
+
`${cyan} cd ${resolvedTargetDir}\n ${packageManager} install\n ${packageManager} run dev${reset}`
|
|
590
|
+
);
|
|
591
|
+
} else {
|
|
592
|
+
console.log(`Created project in ${targetPath}`);
|
|
593
|
+
console.log("Next steps:");
|
|
594
|
+
console.log(` cd ${resolvedTargetDir}`);
|
|
595
|
+
console.log(` ${packageManager} install`);
|
|
596
|
+
console.log(` ${packageManager} run dev`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rules": [
|
|
3
|
+
{
|
|
4
|
+
"match": "backend/express/express-no-ts",
|
|
5
|
+
"packages": [
|
|
6
|
+
{
|
|
7
|
+
"name": "sequelize",
|
|
8
|
+
"dev": false,
|
|
9
|
+
"description": "ORM 数据库工具"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "mysql2",
|
|
13
|
+
"dev": false,
|
|
14
|
+
"description": "MySQL 驱动"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"matchPrefix": "front/web/vue3/",
|
|
20
|
+
"packages": [
|
|
21
|
+
{
|
|
22
|
+
"name": "axios",
|
|
23
|
+
"dev": false,
|
|
24
|
+
"description": "HTTP 请求库"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "eslint",
|
|
28
|
+
"dev": true,
|
|
29
|
+
"description": "代码规范检查"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guchenyo/create-temp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Project scaffolding CLI (templates pulled from GitHub)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-temp": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"optional-packages.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@clack/prompts": "^0.7.0",
|
|
21
|
+
"gradient-string": "^2.0.2",
|
|
22
|
+
"ora": "^8.0.1"
|
|
23
|
+
}
|
|
24
|
+
}
|