@lingfan-engine/create 0.3.3 → 0.3.4
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/bin/create.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/cli.ts +135 -55
- package/src/cli/index.ts +21 -0
- package/src/cli/install.ts +60 -0
- package/src/cli/prompts.ts +51 -4
- package/src/generate/index.ts +7 -2
- package/src/generate/project.ts +42 -28
- package/src/overlay/index.ts +1 -0
- package/src/overlay/merge.ts +27 -1
- package/src/shared/index.ts +8 -1
- package/src/shared/package-manager.ts +87 -0
- package/src/shared/texts.ts +21 -11
- package/template/README.md +14 -10
- package/template/__PROJECT__/package.json +2 -1
- package/template/__PROJECT__/package.patch.json +5 -0
- package/template/__PROJECT__/src/host/index.ts +1 -0
- package/template/__PROJECT__/src/host/native-ports.ts +56 -0
- package/template/__PROJECT__/src/main.ts +5 -2
- package/template/overlays/react/src/main.tsx +3 -0
- package/template/overlays/tauri/README.md +6 -5
- package/template/overlays/tauri/package.patch.json +1 -4
- package/template/overlays/vue/src/main.ts +3 -0
- package/template/template-meta.json +2 -2
- package/template/overlays/tauri/src/main.ts +0 -101
package/bin/create.mjs
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* `src/cli/cli.ts`,也不该被迫装一整套构建链。垫片用本包自带依赖 `tsx` 装载真实 CLI,
|
|
9
9
|
* 呈现与编排逻辑仍然只有一份。
|
|
10
10
|
*
|
|
11
|
-
* 用法:`npm create @lingfan-engine [工程名] [-- --
|
|
11
|
+
* 用法:`npm create @lingfan-engine [工程名] [-- --template vue-tauri]`;
|
|
12
12
|
* 不带参数即进入交互问答。
|
|
13
13
|
*/
|
|
14
14
|
import { tsImport } from "tsx/esm/api";
|
|
15
15
|
|
|
16
|
-
const { runCreateCli } = await tsImport("../src/cli/
|
|
16
|
+
const { runCreateCli } = await tsImport("../src/cli/index.ts", import.meta.url);
|
|
17
17
|
const code = await runCreateCli(process.argv.slice(2));
|
|
18
18
|
if (code !== 0) process.exit(code);
|
package/package.json
CHANGED
package/src/cli/cli.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* `generate` 域,「版本从哪来」在 `registry` 域。本文件只负责把这些串起来,
|
|
6
6
|
* 并把失败转成人能读的提示与退出码——库代码一律抛异常,只有这里决定措辞。
|
|
7
7
|
*/
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
8
|
import { resolve as resolvePath } from "node:path";
|
|
10
|
-
import { defaultCreateTexts } from "../shared/index.ts";
|
|
9
|
+
import { currentPackageManager, defaultCreateTexts } from "../shared/index.ts";
|
|
11
10
|
import { isValidProjectName, suggestProjectName } from "../shared/index.ts";
|
|
12
11
|
import {
|
|
13
12
|
layerBases,
|
|
14
13
|
generateProject,
|
|
15
14
|
collectDependencyNames,
|
|
15
|
+
isOccupied,
|
|
16
|
+
projectDirOf,
|
|
17
|
+
type ExistingTarget,
|
|
16
18
|
} from "../generate/index.ts";
|
|
17
19
|
import {
|
|
18
20
|
checkEngineCompatibility,
|
|
@@ -22,20 +24,24 @@ import {
|
|
|
22
24
|
type TemplateMeta,
|
|
23
25
|
} from "../registry/index.ts";
|
|
24
26
|
import {
|
|
27
|
+
askExisting,
|
|
25
28
|
askOption,
|
|
26
29
|
askProjectName,
|
|
27
30
|
createTerminalIo,
|
|
28
31
|
type PromptTexts,
|
|
29
32
|
} from "./prompts.ts";
|
|
33
|
+
import { installProject, renderNextSteps } from "./install.ts";
|
|
30
34
|
|
|
31
35
|
/** 解析后的命令行参数;缺席项为 undefined,交由问答或模板缺省值补 */
|
|
32
36
|
export interface CreateArgs {
|
|
33
37
|
readonly projectName?: string;
|
|
38
|
+
readonly template?: string;
|
|
34
39
|
readonly framework?: string;
|
|
35
40
|
readonly shell?: string;
|
|
36
41
|
readonly sources?: string;
|
|
37
42
|
readonly dir?: string;
|
|
38
43
|
readonly install: boolean;
|
|
44
|
+
readonly existing?: ExistingTarget;
|
|
39
45
|
readonly yes: boolean;
|
|
40
46
|
readonly help: boolean;
|
|
41
47
|
}
|
|
@@ -48,14 +54,19 @@ class ArgError extends Error {}
|
|
|
48
54
|
*
|
|
49
55
|
* 支持 `--flag value` 与 `--flag=value` 两种写法:前者是手敲的习惯,
|
|
50
56
|
* 后者在脚本里拼接更省事,两者都认不需要额外成本。
|
|
57
|
+
*
|
|
58
|
+
* 依赖缺省**不**安装:装依赖是几十秒到几分钟的副作用,脚本里更希望它显式请求
|
|
59
|
+
* (`--install`)。旧脚本里的 `--no-install` 仍然认,等于什么都不做。
|
|
51
60
|
*/
|
|
52
61
|
export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
53
62
|
let projectName: string | undefined;
|
|
63
|
+
let template: string | undefined;
|
|
54
64
|
let framework: string | undefined;
|
|
55
65
|
let shell: string | undefined;
|
|
56
66
|
let sources: string | undefined;
|
|
57
67
|
let dir: string | undefined;
|
|
58
|
-
let install =
|
|
68
|
+
let install = false;
|
|
69
|
+
let existing: ExistingTarget | undefined;
|
|
59
70
|
let yes = false;
|
|
60
71
|
let help = false;
|
|
61
72
|
|
|
@@ -72,6 +83,9 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
|
72
83
|
return next;
|
|
73
84
|
};
|
|
74
85
|
switch (flag) {
|
|
86
|
+
case "--template":
|
|
87
|
+
template = takeValue();
|
|
88
|
+
break;
|
|
75
89
|
case "-f":
|
|
76
90
|
case "--framework":
|
|
77
91
|
framework = takeValue();
|
|
@@ -88,9 +102,15 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
|
88
102
|
case "--dir":
|
|
89
103
|
dir = takeValue();
|
|
90
104
|
break;
|
|
105
|
+
case "--install":
|
|
106
|
+
install = true;
|
|
107
|
+
break;
|
|
91
108
|
case "--no-install":
|
|
92
109
|
install = false;
|
|
93
110
|
break;
|
|
111
|
+
case "--overwrite":
|
|
112
|
+
existing = "clear";
|
|
113
|
+
break;
|
|
94
114
|
case "-y":
|
|
95
115
|
case "--yes":
|
|
96
116
|
yes = true;
|
|
@@ -107,7 +127,18 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
|
107
127
|
projectName = flag;
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
projectName,
|
|
132
|
+
template,
|
|
133
|
+
framework,
|
|
134
|
+
shell,
|
|
135
|
+
sources,
|
|
136
|
+
dir,
|
|
137
|
+
install,
|
|
138
|
+
existing,
|
|
139
|
+
yes,
|
|
140
|
+
help,
|
|
141
|
+
};
|
|
111
142
|
}
|
|
112
143
|
|
|
113
144
|
/** 拆出 `--flag=value`;无等号时第二项为 undefined */
|
|
@@ -153,9 +184,11 @@ export async function runCreateCli(argv: readonly string[]): Promise<number> {
|
|
|
153
184
|
try {
|
|
154
185
|
const projectName = await resolveName(args, promptTexts, io);
|
|
155
186
|
if (projectName === undefined) return 1;
|
|
187
|
+
const preset = resolvePreset(args.template, meta);
|
|
188
|
+
if (preset === undefined) return 1;
|
|
156
189
|
const framework = await resolveOption(
|
|
157
190
|
"framework",
|
|
158
|
-
args.framework,
|
|
191
|
+
args.framework ?? preset.framework,
|
|
159
192
|
meta,
|
|
160
193
|
promptTexts,
|
|
161
194
|
io,
|
|
@@ -163,20 +196,26 @@ export async function runCreateCli(argv: readonly string[]): Promise<number> {
|
|
|
163
196
|
if (framework === undefined) return 1;
|
|
164
197
|
const shell = await resolveOption(
|
|
165
198
|
"shell",
|
|
166
|
-
args.shell,
|
|
199
|
+
args.shell ?? preset.shell,
|
|
167
200
|
meta,
|
|
168
201
|
promptTexts,
|
|
169
202
|
io,
|
|
170
203
|
);
|
|
171
204
|
if (shell === undefined) return 1;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
205
|
+
// 故事源不再提问:骨架自带整条 TS 创作链,它就是缺省形态。要手写 `.story`
|
|
206
|
+
// 的人在命令行给 `--sources story` 或用 `--template` 带上第三段即可。
|
|
207
|
+
const sources = args.sources ?? preset.sources ?? "ts";
|
|
208
|
+
if (!meta.sources.some((option) => option.id === sources)) {
|
|
209
|
+
process.stderr.write(`${texts.sourcesUnknown}:${sources}\n`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
const existing = await resolveExisting({
|
|
213
|
+
args,
|
|
214
|
+
projectName,
|
|
177
215
|
io,
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
texts: promptTexts,
|
|
217
|
+
});
|
|
218
|
+
if (existing === undefined) return 1;
|
|
180
219
|
return await generate({
|
|
181
220
|
args,
|
|
182
221
|
meta,
|
|
@@ -185,12 +224,81 @@ export async function runCreateCli(argv: readonly string[]): Promise<number> {
|
|
|
185
224
|
framework,
|
|
186
225
|
shell,
|
|
187
226
|
sources,
|
|
227
|
+
existing,
|
|
188
228
|
});
|
|
189
229
|
} finally {
|
|
190
230
|
io?.close();
|
|
191
231
|
}
|
|
192
232
|
}
|
|
193
233
|
|
|
234
|
+
/**
|
|
235
|
+
* 解析 `--template`:写作 `框架[-壳[-故事源]]`。
|
|
236
|
+
*
|
|
237
|
+
* 只做切分,合法性交给调用方——它已经要按模板声明校验一遍,这里再判一次
|
|
238
|
+
* 会得到两套口径,日后加形态容易漏改其一。
|
|
239
|
+
*/
|
|
240
|
+
function resolvePreset(
|
|
241
|
+
template: string | undefined,
|
|
242
|
+
meta: TemplateMeta,
|
|
243
|
+
): { framework?: string; shell?: string; sources?: string } | undefined {
|
|
244
|
+
if (template === undefined) return {};
|
|
245
|
+
const parts = template.split("-").filter((part) => part !== "");
|
|
246
|
+
if (parts.length === 0 || parts.length > 3) {
|
|
247
|
+
process.stderr.write(
|
|
248
|
+
`${defaultCreateTexts.templateMalformed}:${template}\n`,
|
|
249
|
+
);
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const [framework, shell, sources] = parts;
|
|
253
|
+
const known = (options: readonly { id: string }[], id: string): boolean =>
|
|
254
|
+
options.some((option) => option.id === id);
|
|
255
|
+
if (!known(meta.frameworks, framework)) {
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
`${defaultCreateTexts.frameworkUnknown}:${framework}\n`,
|
|
258
|
+
);
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
if (shell !== undefined && !known(meta.shells, shell)) {
|
|
262
|
+
process.stderr.write(`${defaultCreateTexts.shellUnknown}:${shell}\n`);
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (sources !== undefined && !known(meta.sources, sources)) {
|
|
266
|
+
process.stderr.write(`${defaultCreateTexts.sourcesUnknown}:${sources}\n`);
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
return { framework, shell, sources };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 决定目标目录非空时怎么处置。
|
|
274
|
+
*
|
|
275
|
+
* 缺省是取消:覆盖别人的工程无法撤销,`--yes` 也不该替人做这个决定。
|
|
276
|
+
* `--overwrite` 是显式表态,交互模式下也会问一次。
|
|
277
|
+
*/
|
|
278
|
+
async function resolveExisting(input: {
|
|
279
|
+
readonly args: CreateArgs;
|
|
280
|
+
readonly projectName: string;
|
|
281
|
+
readonly io: Parameters<typeof askExisting>[0] | undefined;
|
|
282
|
+
readonly texts: PromptTexts & { readonly targetExists: string };
|
|
283
|
+
}): Promise<ExistingTarget | undefined> {
|
|
284
|
+
const projectDir = projectDirOf({
|
|
285
|
+
targetBase: resolvePath(input.args.dir ?? process.cwd()),
|
|
286
|
+
projectName: input.projectName,
|
|
287
|
+
});
|
|
288
|
+
if (!isOccupied(projectDir)) return "cancel";
|
|
289
|
+
if (input.args.existing !== undefined) return input.args.existing;
|
|
290
|
+
if (input.io === undefined) {
|
|
291
|
+
process.stderr.write(`${input.texts.targetExists}:${projectDir}\n`);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const picked = await askExisting(input.io, input.texts);
|
|
295
|
+
if (picked === "cancel") {
|
|
296
|
+
process.stderr.write(`${input.texts.targetExists}:${projectDir}\n`);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
return picked;
|
|
300
|
+
}
|
|
301
|
+
|
|
194
302
|
/** 生成一步:查版本 → 写文件 → 可选安装 → 打印后续步骤 */
|
|
195
303
|
async function generate(input: {
|
|
196
304
|
readonly args: CreateArgs;
|
|
@@ -200,8 +308,10 @@ async function generate(input: {
|
|
|
200
308
|
readonly framework: string;
|
|
201
309
|
readonly shell: string;
|
|
202
310
|
readonly sources: string;
|
|
311
|
+
readonly existing: ExistingTarget;
|
|
203
312
|
}): Promise<number> {
|
|
204
313
|
const texts = defaultCreateTexts;
|
|
314
|
+
const manager = currentPackageManager();
|
|
205
315
|
const targetBase = resolvePath(input.args.dir ?? process.cwd());
|
|
206
316
|
const layout = {
|
|
207
317
|
templateRoot: input.templateRoot,
|
|
@@ -233,7 +343,7 @@ async function generate(input: {
|
|
|
233
343
|
process.stderr.write(`${texts.stageCopy}\n`);
|
|
234
344
|
let result;
|
|
235
345
|
try {
|
|
236
|
-
result = generateProject({ ...layout, resolved });
|
|
346
|
+
result = generateProject({ ...layout, existing: input.existing, resolved });
|
|
237
347
|
} catch (error) {
|
|
238
348
|
process.stderr.write(`${describe(error)}\n`);
|
|
239
349
|
return 1;
|
|
@@ -242,7 +352,7 @@ async function generate(input: {
|
|
|
242
352
|
let installed = false;
|
|
243
353
|
if (input.args.install) {
|
|
244
354
|
process.stderr.write(`${texts.stageInstall}\n`);
|
|
245
|
-
installed = await
|
|
355
|
+
installed = await installProject(result.projectDir, manager);
|
|
246
356
|
if (!installed) process.stderr.write(`${texts.warnInstallFailed}\n`);
|
|
247
357
|
}
|
|
248
358
|
|
|
@@ -253,7 +363,7 @@ async function generate(input: {
|
|
|
253
363
|
`共 ${result.fileCount} 个文件,框架 ${result.framework},壳 ${result.shell},故事源 ${result.sources}`,
|
|
254
364
|
"",
|
|
255
365
|
installed ? "依赖已安装。" : "依赖未安装。",
|
|
256
|
-
|
|
366
|
+
renderNextSteps(result.projectDir, manager, texts),
|
|
257
367
|
"",
|
|
258
368
|
].join("\n"),
|
|
259
369
|
);
|
|
@@ -278,36 +388,24 @@ async function resolveName(
|
|
|
278
388
|
}
|
|
279
389
|
|
|
280
390
|
/**
|
|
281
|
-
*
|
|
391
|
+
* 解析框架/壳:值必须在模板声明的表里。
|
|
282
392
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
393
|
+
* 表是唯一事实来源——元数据没声明的形态,叠加层目录未必存在,放过去会生成出
|
|
394
|
+
* 静默缺文件的工程。故事源不在这里判:它只剩「作者手写 `.story`」这一种偏离,
|
|
395
|
+
* 由调用方按同一张表查一次即可。
|
|
285
396
|
*/
|
|
286
397
|
async function resolveOption(
|
|
287
|
-
kind: "framework" | "shell"
|
|
398
|
+
kind: "framework" | "shell",
|
|
288
399
|
value: string | undefined,
|
|
289
400
|
meta: TemplateMeta,
|
|
290
|
-
texts: PromptTexts
|
|
401
|
+
texts: PromptTexts,
|
|
291
402
|
io: Parameters<typeof askOption>[0] | undefined,
|
|
292
403
|
): Promise<string | undefined> {
|
|
293
|
-
const options =
|
|
294
|
-
kind === "framework"
|
|
295
|
-
? meta.frameworks
|
|
296
|
-
: kind === "shell"
|
|
297
|
-
? meta.shells
|
|
298
|
-
: meta.sources;
|
|
404
|
+
const options = kind === "framework" ? meta.frameworks : meta.shells;
|
|
299
405
|
const invalidText =
|
|
300
|
-
kind === "framework"
|
|
301
|
-
? texts.frameworkUnknown
|
|
302
|
-
: kind === "shell"
|
|
303
|
-
? texts.shellUnknown
|
|
304
|
-
: texts.sourcesUnknown;
|
|
406
|
+
kind === "framework" ? texts.frameworkUnknown : texts.shellUnknown;
|
|
305
407
|
const label =
|
|
306
|
-
kind === "framework"
|
|
307
|
-
? texts.promptFramework
|
|
308
|
-
: kind === "shell"
|
|
309
|
-
? texts.promptShell
|
|
310
|
-
: texts.promptSources;
|
|
408
|
+
kind === "framework" ? texts.promptFramework : texts.promptShell;
|
|
311
409
|
const picked =
|
|
312
410
|
value !== undefined || io === undefined
|
|
313
411
|
? (value ?? options[0]?.id)
|
|
@@ -319,24 +417,6 @@ async function resolveOption(
|
|
|
319
417
|
return picked;
|
|
320
418
|
}
|
|
321
419
|
|
|
322
|
-
/**
|
|
323
|
-
* 在工程目录里装依赖。
|
|
324
|
-
*
|
|
325
|
-
* 只有 pnpm:本仓与模板的工程都以 pnpm 为准(锁文件、workspace 协议改写都依赖它),
|
|
326
|
-
* 换别的包管理器会生成第二份锁文件,日后发布还要再处理一遍。
|
|
327
|
-
*/
|
|
328
|
-
async function install(dir: string): Promise<boolean> {
|
|
329
|
-
return await new Promise<boolean>((done) => {
|
|
330
|
-
const child = spawn("pnpm", ["install"], {
|
|
331
|
-
cwd: dir,
|
|
332
|
-
stdio: "inherit",
|
|
333
|
-
shell: process.platform === "win32",
|
|
334
|
-
});
|
|
335
|
-
child.on("error", () => done(false));
|
|
336
|
-
child.on("close", (code) => done(code === 0));
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
420
|
/** 把异常折成一行提示:只暴露信息,不暴露堆栈 */
|
|
341
421
|
function describe(error: unknown): string {
|
|
342
422
|
return error instanceof Error ? error.message : String(error);
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 域唯一出口。
|
|
3
|
+
*
|
|
4
|
+
* 只做转发:参数解析(`cli.ts`)、交互问答(`prompts.ts`)、依赖安装与后续命令
|
|
5
|
+
* 渲染(`install.ts`)三层各自成文件,调用方从这里进出,不穿过文件名。
|
|
6
|
+
*/
|
|
7
|
+
export { parseArgs, runCreateCli } from "./cli.ts";
|
|
8
|
+
export type { CreateArgs } from "./cli.ts";
|
|
9
|
+
export {
|
|
10
|
+
askExisting,
|
|
11
|
+
askOption,
|
|
12
|
+
askProjectName,
|
|
13
|
+
createTerminalIo,
|
|
14
|
+
pickOption,
|
|
15
|
+
} from "./prompts.ts";
|
|
16
|
+
export type { PromptIo, PromptTexts } from "./prompts.ts";
|
|
17
|
+
export {
|
|
18
|
+
fillManagerCommands,
|
|
19
|
+
installProject,
|
|
20
|
+
renderNextSteps,
|
|
21
|
+
} from "./install.ts";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生成之后的两个副作用:装依赖、打印下一步。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:这两件事都要按调用方的包管理器变写法,而组合编排
|
|
5
|
+
* (`cli.ts`)不该关心命令怎么拼。生成器是给**别人**用的工具,本仓开发固定
|
|
6
|
+
* pnpm 是仓库约定,不是产物约定——`npm create` 起的进程就该看到 npm 的命令。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import type { CreateTexts, PackageManager } from "../shared/index.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 在生成的工程里装依赖。
|
|
13
|
+
*
|
|
14
|
+
* 命令与可执行名都取自 `manager`:装了 npm 的人未必装了 pnpm,反过来也一样,
|
|
15
|
+
* 猜错就是一句「找不到命令」。`shell` 在 Windows 上是必需的——npm 与 pnpm 的
|
|
16
|
+
* 可执行是 `.cmd`,不带 shell 直接 spawn 会 ENOENT。
|
|
17
|
+
*/
|
|
18
|
+
export async function installProject(
|
|
19
|
+
dir: string,
|
|
20
|
+
manager: PackageManager,
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
return await new Promise<boolean>((done) => {
|
|
23
|
+
const child = spawn(manager.id, [manager.install], {
|
|
24
|
+
cwd: dir,
|
|
25
|
+
stdio: "inherit",
|
|
26
|
+
shell: process.platform === "win32",
|
|
27
|
+
});
|
|
28
|
+
child.on("error", () => done(false));
|
|
29
|
+
child.on("close", (code) => done(code === 0));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把文案里的 `<install>` / `<dev>` 换成该包管理器的实际写法。
|
|
35
|
+
*
|
|
36
|
+
* 生成后的提示若写死 `pnpm install`,用 npm 的人第一步就会撞上「没有这个命令」
|
|
37
|
+
* ——这类提示写错比不写更糟,因为它看起来像官方指引。
|
|
38
|
+
*/
|
|
39
|
+
export function fillManagerCommands(
|
|
40
|
+
text: string,
|
|
41
|
+
manager: PackageManager,
|
|
42
|
+
): string {
|
|
43
|
+
return text
|
|
44
|
+
.split("<install>")
|
|
45
|
+
.join(`${manager.id} ${manager.install}`)
|
|
46
|
+
.split("<dev>")
|
|
47
|
+
.join(`${manager.id} ${manager.script}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 生成后的三步提示:`cd` 到工程目录、装依赖、跑起来 */
|
|
51
|
+
export function renderNextSteps(
|
|
52
|
+
projectDir: string,
|
|
53
|
+
manager: PackageManager,
|
|
54
|
+
texts: CreateTexts,
|
|
55
|
+
): string {
|
|
56
|
+
return fillManagerCommands(
|
|
57
|
+
texts.nextSteps.split("<name>").join(projectDir),
|
|
58
|
+
manager,
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/cli/prompts.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 交互问答。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 生成器要问三件事:工程名、框架、壳。故事源形态不再是问题——骨架自带整条 TS
|
|
5
|
+
* 创作链,它就是缺省形态;要手艺写的 `.story` 用 `--sources story` 指定即可。
|
|
6
|
+
*
|
|
7
|
+
* 目标目录非空时另问一次怎么处置:那是覆盖别人的东西,必须由使用者当场表态,
|
|
8
|
+
* 不能拿缺省值糊过去(缺省是取消)。
|
|
6
9
|
*
|
|
7
10
|
* 实现只用 `node:readline` 的 promise 版:它随 Node 自带,不引第三方交互库。
|
|
8
11
|
* 提问写 stderr 而不是 stdout,产物路径之类的结果才走 stdout,这样
|
|
@@ -11,18 +14,24 @@
|
|
|
11
14
|
import { createInterface } from "node:readline/promises";
|
|
12
15
|
import { isValidProjectName, suggestProjectName } from "../shared/index.ts";
|
|
13
16
|
import type { TemplateOption } from "../registry/index.ts";
|
|
17
|
+
import type { ExistingTarget } from "../generate/index.ts";
|
|
14
18
|
|
|
15
19
|
/** 问答需要的一份文案;由调用方从共享文案表传入,便于测试替换 */
|
|
16
20
|
export interface PromptTexts {
|
|
17
21
|
readonly promptProjectName: string;
|
|
18
22
|
readonly promptFramework: string;
|
|
19
23
|
readonly promptShell: string;
|
|
20
|
-
readonly
|
|
24
|
+
readonly promptExisting: string;
|
|
21
25
|
readonly nameRequired: string;
|
|
22
26
|
readonly nameInvalid: string;
|
|
23
27
|
readonly frameworkUnknown: string;
|
|
24
28
|
readonly shellUnknown: string;
|
|
25
|
-
readonly
|
|
29
|
+
readonly existingIgnore: string;
|
|
30
|
+
readonly existingIgnoreHint: string;
|
|
31
|
+
readonly existingClear: string;
|
|
32
|
+
readonly existingClearHint: string;
|
|
33
|
+
readonly existingCancel: string;
|
|
34
|
+
readonly existingCancelHint: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/** 问答依赖的输入输出面;默认接真实终端,测试可换成脚本化实现 */
|
|
@@ -94,6 +103,44 @@ export async function askOption(
|
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 目标目录已存在且有内容时问怎么处置。
|
|
108
|
+
*
|
|
109
|
+
* 三项的顺序是「保留 → 清空 → 取消」,但**回车取的是取消**:前面的选项会改动
|
|
110
|
+
* 磁盘上的既有文件,回车只是「没想好」,不该替使用者做破坏性决定。
|
|
111
|
+
*/
|
|
112
|
+
export async function askExisting(
|
|
113
|
+
io: PromptIo,
|
|
114
|
+
texts: PromptTexts,
|
|
115
|
+
): Promise<ExistingTarget> {
|
|
116
|
+
const options: readonly TemplateOption[] = [
|
|
117
|
+
{
|
|
118
|
+
id: "ignore",
|
|
119
|
+
label: texts.existingIgnore,
|
|
120
|
+
hint: texts.existingIgnoreHint,
|
|
121
|
+
},
|
|
122
|
+
{ id: "clear", label: texts.existingClear, hint: texts.existingClearHint },
|
|
123
|
+
{
|
|
124
|
+
id: "cancel",
|
|
125
|
+
label: texts.existingCancel,
|
|
126
|
+
hint: texts.existingCancelHint,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
const lines = options
|
|
130
|
+
.map((option, index) => ` ${index + 1}. ${option.label} —— ${option.hint}`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
process.stderr.write(`${texts.promptExisting}:\n${lines}\n`);
|
|
133
|
+
for (;;) {
|
|
134
|
+
const answer = (
|
|
135
|
+
await io.question(`选择(1-${options.length},回车取消):`)
|
|
136
|
+
).trim();
|
|
137
|
+
if (answer === "") return "cancel";
|
|
138
|
+
const picked = pickOption(options, answer);
|
|
139
|
+
if (picked !== undefined) return picked as ExistingTarget;
|
|
140
|
+
process.stderr.write(`${texts.promptExisting}:${answer}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
/**
|
|
98
145
|
* 把一次输入解析成选项 id;解析不出返回 undefined。
|
|
99
146
|
*
|
package/src/generate/index.ts
CHANGED
|
@@ -7,11 +7,16 @@ export {
|
|
|
7
7
|
collectDependencyNames,
|
|
8
8
|
DEFAULT_SOURCES,
|
|
9
9
|
generateProject,
|
|
10
|
+
isOccupied,
|
|
10
11
|
layerBases,
|
|
11
|
-
|
|
12
|
+
projectDirOf,
|
|
12
13
|
SKELETON_DIR,
|
|
13
14
|
} from "./project.ts";
|
|
14
|
-
export type {
|
|
15
|
+
export type {
|
|
16
|
+
ExistingTarget,
|
|
17
|
+
GenerateOptions,
|
|
18
|
+
GenerateResult,
|
|
19
|
+
} from "./project.ts";
|
|
15
20
|
export {
|
|
16
21
|
isTextPath,
|
|
17
22
|
PLACEHOLDERS,
|