@lingfan-engine/create 0.3.3 → 0.3.5

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.
Files changed (34) hide show
  1. package/bin/create.mjs +7 -3
  2. package/package.json +1 -1
  3. package/src/cli/answers.ts +275 -0
  4. package/src/cli/cli.ts +93 -194
  5. package/src/cli/index.ts +29 -0
  6. package/src/cli/install.ts +60 -0
  7. package/src/cli/prompts.ts +230 -31
  8. package/src/cli/stage.ts +113 -0
  9. package/src/generate/index.ts +7 -2
  10. package/src/generate/project.ts +99 -34
  11. package/src/overlay/index.ts +1 -0
  12. package/src/overlay/merge.ts +27 -1
  13. package/src/registry/index.ts +4 -0
  14. package/src/registry/template-meta.ts +73 -0
  15. package/src/shared/guards.ts +14 -0
  16. package/src/shared/index.ts +10 -1
  17. package/src/shared/package-manager.ts +87 -0
  18. package/src/shared/texts.ts +45 -17
  19. package/template/README.md +59 -32
  20. package/template/__PROJECT__/gitignore +2 -0
  21. package/template/__PROJECT__/package.json +2 -1
  22. package/template/__PROJECT__/package.patch.json +5 -0
  23. package/template/__PROJECT__/src/host/index.ts +1 -0
  24. package/template/__PROJECT__/src/host/native-ports.ts +56 -0
  25. package/template/__PROJECT__/src/main.ts +5 -2
  26. package/template/overlays/README.md +6 -0
  27. package/template/overlays/react/src/main.tsx +3 -0
  28. package/template/overlays/story/Resources/Stories/{title/title_main.story → manual/manual_intro.story} +15 -10
  29. package/template/overlays/story/package.patch.json +1 -17
  30. package/template/overlays/tauri/README.md +6 -5
  31. package/template/overlays/tauri/package.patch.json +1 -4
  32. package/template/overlays/vue/src/main.ts +3 -0
  33. package/template/template-meta.json +16 -12
  34. package/template/overlays/tauri/src/main.ts +0 -101
package/bin/create.mjs CHANGED
@@ -8,11 +8,15 @@
8
8
  * `src/cli/cli.ts`,也不该被迫装一整套构建链。垫片用本包自带依赖 `tsx` 装载真实 CLI,
9
9
  * 呈现与编排逻辑仍然只有一份。
10
10
  *
11
- * 用法:`npm create @lingfan-engine [工程名] [-- --framework vue --shell browser]`;
12
- * 不带参数即进入交互问答。
11
+ * 用法:`npm create @lingfan-engine [工程名] [选项]`。不带参数即进入交互问答,
12
+ * 依次问工程名、描述与形态;形态是一次多选,可叠加的层由模板元数据的互斥声明
13
+ * 决定哪些组合合法。
14
+ *
15
+ * `--template <id>` 是旧的分维写法替代品,只在脚本用法(CI 里用参数跳过问答)
16
+ * 场景出现,帮助里列在末段。
13
17
  */
14
18
  import { tsImport } from "tsx/esm/api";
15
19
 
16
- const { runCreateCli } = await tsImport("../src/cli/cli.ts", import.meta.url);
20
+ const { runCreateCli } = await tsImport("../src/cli/index.ts", import.meta.url);
17
21
  const code = await runCreateCli(process.argv.slice(2));
18
22
  if (code !== 0) process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/create",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "工程生成器:从模板骨架生成新工程,可选框架与壳",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 三个问题的答案合成。
3
+ *
4
+ * 为什么要独立成文件:答案不是「问一遍」的结果,而是三处来源按固定优先级叠出来的
5
+ * ——命令行参数 > 交互问答 > 模板缺省。三者的优先级、跳过的时机、非交互终端怎么
6
+ * 收场,都是同一件事的不同侧面,散在流程里就会各自漂移。
7
+ *
8
+ * 顺序固定为 工程名 → 描述 → 形态,且**已给出的项跳过、剩余项保持原顺序**:
9
+ * 名字决定目录与包名,描述与形态是「这个工程」之上的补充;把顺序交给问答脚本,
10
+ * 使用者就得跟着脚本的节奏改口。
11
+ *
12
+ * 形态用一次多选而不是三次单选:可叠加的层是一张网(选 Vue 就排除小程序,桌面壳
13
+ * 又与小程序互斥),逐维度问会把矛盾留到最后才暴露。
14
+ */
15
+ import {
16
+ isValidProjectName,
17
+ normalizeDescription,
18
+ suggestProjectName,
19
+ } from "../shared/index.ts";
20
+ import {
21
+ selectableLayers,
22
+ type TemplateMeta,
23
+ type TemplateOption,
24
+ } from "../registry/index.ts";
25
+ import {
26
+ askDescription,
27
+ askLayers,
28
+ askProjectName,
29
+ type PromptIo,
30
+ type PromptTexts,
31
+ } from "./prompts.ts";
32
+
33
+ /** 合成答案所需的参数面:只认本文件要用的字段,不依赖命令行类型 */
34
+ export interface AnswerSources {
35
+ readonly projectName?: string;
36
+ readonly description?: string;
37
+ readonly template?: string;
38
+ readonly framework?: string;
39
+ readonly shell?: string;
40
+ readonly sources?: string;
41
+ /** 全部取缺省、一个问题都不问 */
42
+ readonly yes: boolean;
43
+ }
44
+
45
+ /**
46
+ * 一次生成所需的全部答案。
47
+ *
48
+ * `description` 用空串表示「没有描述」:清单里因此不出现 `description` 键,
49
+ * 而不是出现一个空字符串——两者在工具链里的语义不同,空串会被显示成一句空话。
50
+ */
51
+ export interface CreateAnswers {
52
+ readonly projectName: string;
53
+ readonly description: string;
54
+ readonly framework: string;
55
+ readonly shell: string;
56
+ readonly sources: string;
57
+ }
58
+
59
+ /** 合成答案需要的文案:问答提示语加上参数校验的失败措辞 */
60
+ export interface AnswerTexts extends PromptTexts {
61
+ readonly frameworkUnknown: string;
62
+ readonly shellUnknown: string;
63
+ readonly sourcesUnknown: string;
64
+ readonly templateMalformed: string;
65
+ }
66
+
67
+ /**
68
+ * 按「参数 > 交互 > 缺省」合成答案;需要问却问不了时返回 undefined。
69
+ *
70
+ * 返回 undefined 的两种情形都已在这里把原因写给使用者:参数不合法(形态写法、
71
+ * 未知层 id),以及非交互终端下仍有未指定的项。调用方只需把 undefined 转成
72
+ * 退出码 1,不必重复判定。
73
+ */
74
+ export async function resolveAnswers(input: {
75
+ readonly args: AnswerSources;
76
+ readonly meta: TemplateMeta;
77
+ readonly templateRoot: string;
78
+ readonly io: PromptIo | undefined;
79
+ readonly texts: AnswerTexts;
80
+ }): Promise<CreateAnswers | undefined> {
81
+ const { args, meta, texts } = input;
82
+ // 形态先算:`--template` 写错时应当立刻失败,而不是先问完名字再告诉你写法不对。
83
+ const preset = resolvePreset(input.args.template, meta, texts);
84
+ if (preset === undefined) return undefined;
85
+ const formGiven =
86
+ args.template !== undefined ||
87
+ args.framework !== undefined ||
88
+ args.shell !== undefined ||
89
+ args.sources !== undefined;
90
+
91
+ const pending: string[] = [];
92
+ if (args.projectName === undefined) pending.push(texts.promptProjectName);
93
+ if (args.description === undefined) pending.push(texts.promptDescription);
94
+ if (!formGiven) pending.push(texts.promptLayers);
95
+ if (pending.length > 0 && input.io === undefined && !args.yes) {
96
+ process.stderr.write(
97
+ `${texts.nonInteractive}:${pending.join("、")}\n${texts.nonInteractiveHint}\n`,
98
+ );
99
+ return undefined;
100
+ }
101
+
102
+ const projectName = await resolveName(args, texts, input.io);
103
+ if (projectName === undefined) return undefined;
104
+ const description = await resolveDescription(args, texts, input.io);
105
+
106
+ const form = formGiven
107
+ ? resolvePinnedForm(args, preset, meta, texts)
108
+ : await resolvePickedForm(input.templateRoot, meta, texts, input.io);
109
+ if (form === undefined) return undefined;
110
+
111
+ return { projectName, description, ...form };
112
+ }
113
+
114
+ /**
115
+ * 工程名:参数优先且必须合法;参数缺席时取建议值,交互模式则问一次(打错重问)。
116
+ *
117
+ * 参数非法直接失败而不是退回问答:脚本里给了名字又给错,说明它预期这个名字生效,
118
+ * 悄悄换一个名字生成出去比报错更糟。
119
+ */
120
+ async function resolveName(
121
+ args: AnswerSources,
122
+ texts: AnswerTexts,
123
+ io: PromptIo | undefined,
124
+ ): Promise<string | undefined> {
125
+ if (args.projectName !== undefined) {
126
+ if (isValidProjectName(args.projectName)) return args.projectName;
127
+ process.stderr.write(`${texts.nameInvalid}:${args.projectName}\n`);
128
+ return undefined;
129
+ }
130
+ if (io === undefined) return suggestProjectName("");
131
+ return await askProjectName(io, texts, undefined);
132
+ }
133
+
134
+ /**
135
+ * 描述:参数优先,缺席则问一次;两处都按同一套归一处理。
136
+ *
137
+ * 参数给的描述也截断到上限:否则 `--description` 与交互输入的同一段文字会得到
138
+ * 不同结果,而它们最终写进同一份清单。
139
+ */
140
+ async function resolveDescription(
141
+ args: AnswerSources,
142
+ texts: AnswerTexts,
143
+ io: PromptIo | undefined,
144
+ ): Promise<string> {
145
+ if (args.description !== undefined) {
146
+ return normalizeDescription(args.description);
147
+ }
148
+ if (io === undefined) return "";
149
+ return await askDescription(io, texts);
150
+ }
151
+
152
+ /**
153
+ * 形态已由参数指定时的三维落位。
154
+ *
155
+ * 走的是「三维直接指定」的旧口头契约:任一维度缺席就取该维度首项,且**不做互斥
156
+ * 校验**——调用方是脚本,它自己拼出来的组合要么本来合法,要么是在刻意生成异常
157
+ * 样本。互斥校验只服务于问答,因为只有人会一次选到互相排斥的两层。
158
+ */
159
+ function resolvePinnedForm(
160
+ args: AnswerSources,
161
+ preset: Preset,
162
+ meta: TemplateMeta,
163
+ texts: AnswerTexts,
164
+ ): { framework: string; shell: string; sources: string } | undefined {
165
+ const framework = pickKnown(
166
+ args.framework ?? preset.framework,
167
+ meta.frameworks,
168
+ texts.frameworkUnknown,
169
+ );
170
+ if (framework === undefined) return undefined;
171
+ const shell = pickKnown(
172
+ args.shell ?? preset.shell,
173
+ meta.shells,
174
+ texts.shellUnknown,
175
+ );
176
+ if (shell === undefined) return undefined;
177
+ const sources = pickKnown(
178
+ args.sources ?? preset.sources,
179
+ meta.sources,
180
+ texts.sourcesUnknown,
181
+ );
182
+ if (sources === undefined) return undefined;
183
+ return { framework, shell, sources };
184
+ }
185
+
186
+ /**
187
+ * 形态靠问答决定时的三维落位:没选中该维度就取其首项。
188
+ *
189
+ * 判「可以选哪些层」用目录存在性而非 id 白名单:缺省层(`none`/`browser`/`ts`)
190
+ * 没有目录,因此不出现在选项面,只作为「这一维没选」的落点。
191
+ */
192
+ async function resolvePickedForm(
193
+ templateRoot: string,
194
+ meta: TemplateMeta,
195
+ texts: AnswerTexts,
196
+ io: PromptIo | undefined,
197
+ ): Promise<{ framework: string; shell: string; sources: string } | undefined> {
198
+ const layers = selectableLayers(templateRoot, meta);
199
+ const picked =
200
+ io === undefined ? [] : await askLayers(io, texts, layers, layers);
201
+ const firstOf = (
202
+ list: readonly TemplateOption[],
203
+ invalidText: string,
204
+ ): string | undefined => {
205
+ const hit = list.find((option) => picked.includes(option.id)) ?? list[0];
206
+ if (hit === undefined) {
207
+ process.stderr.write(`${invalidText}\n`);
208
+ return undefined;
209
+ }
210
+ return hit.id;
211
+ };
212
+ const framework = firstOf(meta.frameworks, texts.frameworkUnknown);
213
+ if (framework === undefined) return undefined;
214
+ const shell = firstOf(meta.shells, texts.shellUnknown);
215
+ if (shell === undefined) return undefined;
216
+ const sources = firstOf(meta.sources, texts.sourcesUnknown);
217
+ if (sources === undefined) return undefined;
218
+ return { framework, shell, sources };
219
+ }
220
+
221
+ /** 从一张表里取值:缺席取首项,取到却不在表里即报错 */
222
+ function pickKnown(
223
+ value: string | undefined,
224
+ options: readonly TemplateOption[],
225
+ invalidText: string,
226
+ ): string | undefined {
227
+ const picked = value ?? options[0]?.id;
228
+ if (picked !== undefined && options.some((option) => option.id === picked)) {
229
+ return picked;
230
+ }
231
+ process.stderr.write(`${invalidText}:${String(picked)}\n`);
232
+ return undefined;
233
+ }
234
+
235
+ /** `--template` 切出来的三维取值;缺席项为 undefined */
236
+ interface Preset {
237
+ readonly framework?: string;
238
+ readonly shell?: string;
239
+ readonly sources?: string;
240
+ }
241
+
242
+ /**
243
+ * 解析 `--template`:写作 `框架[-壳[-故事源]]`。
244
+ *
245
+ * 只做切分与「id 在该维度里」的校验,不参与互斥判断:它是给旧脚本的等价写法,
246
+ * 语义与手工分三维给参数一致。
247
+ */
248
+ function resolvePreset(
249
+ template: string | undefined,
250
+ meta: TemplateMeta,
251
+ texts: AnswerTexts,
252
+ ): Preset | undefined {
253
+ if (template === undefined) return {};
254
+ const parts = template.split("-").filter((part) => part !== "");
255
+ if (parts.length === 0 || parts.length > 3) {
256
+ process.stderr.write(`${texts.templateMalformed}:${template}\n`);
257
+ return undefined;
258
+ }
259
+ const [framework, shell, sources] = parts;
260
+ const known = (options: readonly TemplateOption[], id: string): boolean =>
261
+ options.some((option) => option.id === id);
262
+ if (!known(meta.frameworks, framework)) {
263
+ process.stderr.write(`${texts.frameworkUnknown}:${framework}\n`);
264
+ return undefined;
265
+ }
266
+ if (shell !== undefined && !known(meta.shells, shell)) {
267
+ process.stderr.write(`${texts.shellUnknown}:${shell}\n`);
268
+ return undefined;
269
+ }
270
+ if (sources !== undefined && !known(meta.sources, sources)) {
271
+ process.stderr.write(`${texts.sourcesUnknown}:${sources}\n`);
272
+ return undefined;
273
+ }
274
+ return { framework, shell, sources };
275
+ }
package/src/cli/cli.ts CHANGED
@@ -1,41 +1,36 @@
1
1
  /**
2
2
  * 命令行入口。
3
3
  *
4
- * 分工:参数解析与流程编排在这里,「问什么」在 `prompts.ts`,「生成什么」在
5
- * `generate` 域,「版本从哪来」在 `registry` 域。本文件只负责把这些串起来,
4
+ * 分工:参数解析与流程编排在这里,「答案怎么合成」在 `answers.ts`,「问答怎么问」
5
+ * 在 `prompts.ts`,「生成怎么做」在 `stage.ts`。本文件只负责把这四段串成一次运行,
6
6
  * 并把失败转成人能读的提示与退出码——库代码一律抛异常,只有这里决定措辞。
7
+ *
8
+ * 三个问题的顺序(工程名 → 描述 → 形态)由 `answers.ts` 保证,本文件不介入。
7
9
  */
8
- import { spawn } from "node:child_process";
9
10
  import { resolve as resolvePath } from "node:path";
10
11
  import { defaultCreateTexts } from "../shared/index.ts";
11
- import { isValidProjectName, suggestProjectName } from "../shared/index.ts";
12
12
  import {
13
- layerBases,
14
- generateProject,
15
- collectDependencyNames,
13
+ isOccupied,
14
+ projectDirOf,
15
+ type ExistingTarget,
16
16
  } from "../generate/index.ts";
17
- import {
18
- checkEngineCompatibility,
19
- readTemplateMeta,
20
- resolveTemplateRoot,
21
- resolveVersions,
22
- type TemplateMeta,
23
- } from "../registry/index.ts";
24
- import {
25
- askOption,
26
- askProjectName,
27
- createTerminalIo,
28
- type PromptTexts,
29
- } from "./prompts.ts";
17
+ import { readTemplateMeta, resolveTemplateRoot } from "../registry/index.ts";
18
+ import { resolveAnswers } from "./answers.ts";
19
+ import { askExisting, createTerminalIo, type PromptTexts } from "./prompts.ts";
20
+ import { runStage } from "./stage.ts";
30
21
 
31
22
  /** 解析后的命令行参数;缺席项为 undefined,交由问答或模板缺省值补 */
32
23
  export interface CreateArgs {
33
24
  readonly projectName?: string;
25
+ /** 工程描述;写进两份清单,空串或缺席表示不写该字段 */
26
+ readonly description?: string;
27
+ readonly template?: string;
34
28
  readonly framework?: string;
35
29
  readonly shell?: string;
36
30
  readonly sources?: string;
37
31
  readonly dir?: string;
38
32
  readonly install: boolean;
33
+ readonly existing?: ExistingTarget;
39
34
  readonly yes: boolean;
40
35
  readonly help: boolean;
41
36
  }
@@ -48,14 +43,20 @@ class ArgError extends Error {}
48
43
  *
49
44
  * 支持 `--flag value` 与 `--flag=value` 两种写法:前者是手敲的习惯,
50
45
  * 后者在脚本里拼接更省事,两者都认不需要额外成本。
46
+ *
47
+ * 依赖缺省**不**安装:装依赖是几十秒到几分钟的副作用,脚本里更希望它显式请求
48
+ * (`--install`)。旧脚本里的 `--no-install` 仍然认,等于什么都不做。
51
49
  */
52
50
  export function parseArgs(argv: readonly string[]): CreateArgs {
53
51
  let projectName: string | undefined;
52
+ let description: string | undefined;
53
+ let template: string | undefined;
54
54
  let framework: string | undefined;
55
55
  let shell: string | undefined;
56
56
  let sources: string | undefined;
57
57
  let dir: string | undefined;
58
- let install = true;
58
+ let install = false;
59
+ let existing: ExistingTarget | undefined;
59
60
  let yes = false;
60
61
  let help = false;
61
62
 
@@ -72,6 +73,12 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
72
73
  return next;
73
74
  };
74
75
  switch (flag) {
76
+ case "--description":
77
+ description = takeValue();
78
+ break;
79
+ case "--template":
80
+ template = takeValue();
81
+ break;
75
82
  case "-f":
76
83
  case "--framework":
77
84
  framework = takeValue();
@@ -88,9 +95,15 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
88
95
  case "--dir":
89
96
  dir = takeValue();
90
97
  break;
98
+ case "--install":
99
+ install = true;
100
+ break;
91
101
  case "--no-install":
92
102
  install = false;
93
103
  break;
104
+ case "--overwrite":
105
+ existing = "clear";
106
+ break;
94
107
  case "-y":
95
108
  case "--yes":
96
109
  yes = true;
@@ -107,7 +120,19 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
107
120
  projectName = flag;
108
121
  }
109
122
  }
110
- return { projectName, framework, shell, sources, dir, install, yes, help };
123
+ return {
124
+ projectName,
125
+ description,
126
+ template,
127
+ framework,
128
+ shell,
129
+ sources,
130
+ dir,
131
+ install,
132
+ existing,
133
+ yes,
134
+ help,
135
+ };
111
136
  }
112
137
 
113
138
  /** 拆出 `--flag=value`;无等号时第二项为 undefined */
@@ -148,196 +173,70 @@ export async function runCreateCli(argv: readonly string[]): Promise<number> {
148
173
  }
149
174
  const meta = readTemplateMeta(templateRoot);
150
175
 
151
- const io = args.yes ? undefined : createTerminalIo();
152
- const promptTexts = texts;
176
+ // 只有「允许提问且确实能问」时才建问答面:`--yes` 承诺一个问题都不问;
177
+ // 非交互终端(管道、CI、被重定向的 stdin)上没有回车可打,建出来只会挂在
178
+ // 等一个永远不来的输入上。没有问答面时由 `answers.ts` 判定并给出可用参数。
179
+ const interactive = !args.yes && process.stdin.isTTY === true;
180
+ const io = interactive ? createTerminalIo() : undefined;
153
181
  try {
154
- const projectName = await resolveName(args, promptTexts, io);
155
- if (projectName === undefined) return 1;
156
- const framework = await resolveOption(
157
- "framework",
158
- args.framework,
159
- meta,
160
- promptTexts,
161
- io,
162
- );
163
- if (framework === undefined) return 1;
164
- const shell = await resolveOption(
165
- "shell",
166
- args.shell,
167
- meta,
168
- promptTexts,
169
- io,
170
- );
171
- if (shell === undefined) return 1;
172
- const sources = await resolveOption(
173
- "sources",
174
- args.sources,
182
+ const answers = await resolveAnswers({
183
+ args,
175
184
  meta,
176
- promptTexts,
185
+ templateRoot,
177
186
  io,
178
- );
179
- if (sources === undefined) return 1;
180
- return await generate({
187
+ texts,
188
+ });
189
+ if (answers === undefined) return 1;
190
+ const existing = await resolveExisting({
181
191
  args,
192
+ projectName: answers.projectName,
193
+ io,
194
+ texts,
195
+ });
196
+ if (existing === undefined) return 1;
197
+ return await runStage({
182
198
  meta,
183
199
  templateRoot,
184
- projectName,
185
- framework,
186
- shell,
187
- sources,
200
+ dir: args.dir,
201
+ projectName: answers.projectName,
202
+ description: answers.description,
203
+ framework: answers.framework,
204
+ shell: answers.shell,
205
+ sources: answers.sources,
206
+ existing,
207
+ install: args.install,
188
208
  });
189
209
  } finally {
190
210
  io?.close();
191
211
  }
192
212
  }
193
213
 
194
- /** 生成一步:查版本 → 写文件 → 可选安装 → 打印后续步骤 */
195
- async function generate(input: {
214
+ /**
215
+ * 决定目标目录非空时怎么处置。
216
+ *
217
+ * 缺省是取消:覆盖别人的工程无法撤销,`--yes` 也不该替人做这个决定。
218
+ * `--overwrite` 是显式表态,交互模式下也会问一次。
219
+ */
220
+ async function resolveExisting(input: {
196
221
  readonly args: CreateArgs;
197
- readonly meta: TemplateMeta;
198
- readonly templateRoot: string;
199
222
  readonly projectName: string;
200
- readonly framework: string;
201
- readonly shell: string;
202
- readonly sources: string;
203
- }): Promise<number> {
204
- const texts = defaultCreateTexts;
205
- const targetBase = resolvePath(input.args.dir ?? process.cwd());
206
- const layout = {
207
- templateRoot: input.templateRoot,
208
- targetBase,
223
+ readonly io: Parameters<typeof askExisting>[0] | undefined;
224
+ readonly texts: PromptTexts & { readonly targetExists: string };
225
+ }): Promise<ExistingTarget | undefined> {
226
+ const projectDir = projectDirOf({
227
+ targetBase: resolvePath(input.args.dir ?? process.cwd()),
209
228
  projectName: input.projectName,
210
- framework: input.framework,
211
- shell: input.shell,
212
- sources: input.sources,
213
- resolved: new Map<string, string>(),
214
- };
215
-
216
- process.stderr.write(`${texts.stageResolve}\n`);
217
- const names = collectDependencyNames(layerBases(layout));
218
- const resolved = await resolveVersions(names);
219
- if (resolved.size === 0 && names.length > 0) {
220
- process.stderr.write(`${texts.warnVersionOffline}\n`);
221
- }
222
- // 版本判定在写盘之前:模板要求的引擎还没发布时,生成出来的工程装不上,
223
- // 让它失败在「什么都没写」的时刻,而不是在别人的工程里第一次 pnpm dev 时。
224
- const incompatible = checkEngineCompatibility(
225
- input.meta.minEngineVersion,
226
- resolved,
227
- );
228
- if (incompatible !== undefined) {
229
- process.stderr.write(`${incompatible}\n`);
230
- return 1;
231
- }
232
-
233
- process.stderr.write(`${texts.stageCopy}\n`);
234
- let result;
235
- try {
236
- result = generateProject({ ...layout, resolved });
237
- } catch (error) {
238
- process.stderr.write(`${describe(error)}\n`);
239
- return 1;
240
- }
241
-
242
- let installed = false;
243
- if (input.args.install) {
244
- process.stderr.write(`${texts.stageInstall}\n`);
245
- installed = await install(result.projectDir);
246
- if (!installed) process.stderr.write(`${texts.warnInstallFailed}\n`);
247
- }
248
-
249
- process.stdout.write(
250
- [
251
- "",
252
- `${texts.doneTitle}:${result.projectDir}`,
253
- `共 ${result.fileCount} 个文件,框架 ${result.framework},壳 ${result.shell},故事源 ${result.sources}`,
254
- "",
255
- installed ? "依赖已安装。" : "依赖未安装。",
256
- texts.nextSteps.split("<name>").join(result.projectDir),
257
- "",
258
- ].join("\n"),
259
- );
260
- return 0;
261
- }
262
-
263
- /** 解析工程名:参数优先,参数非法即报错;交互模式打错会重问 */
264
- async function resolveName(
265
- args: CreateArgs,
266
- texts: PromptTexts & { readonly nameInvalid: string },
267
- io: Parameters<typeof askProjectName>[0] | undefined,
268
- ): Promise<string | undefined> {
269
- if (args.projectName !== undefined) {
270
- if (isValidProjectName(args.projectName)) return args.projectName;
271
- process.stderr.write(`${texts.nameInvalid}:${args.projectName}\n`);
229
+ });
230
+ if (!isOccupied(projectDir)) return "cancel";
231
+ if (input.args.existing !== undefined) return input.args.existing;
232
+ if (input.io === undefined) {
233
+ process.stderr.write(`${input.texts.targetExists}:${projectDir}\n`);
272
234
  return undefined;
273
235
  }
274
- if (io === undefined) {
275
- return suggestProjectName("");
276
- }
277
- return await askProjectName(io, texts, undefined);
278
- }
279
-
280
- /**
281
- * 解析框架/壳/故事源:必须真实存在对应的叠加层,否则报错。
282
- *
283
- * 只校验「模板声明过」而不校验「目录存在」会放过一种情况:元数据写了 `vue`
284
- * 但仓库里没有 `overlays/vue`,生成出来的工程静默缺少该框架的文件。
285
- */
286
- async function resolveOption(
287
- kind: "framework" | "shell" | "sources",
288
- value: string | undefined,
289
- meta: TemplateMeta,
290
- texts: PromptTexts & { readonly overlayMissing: string },
291
- io: Parameters<typeof askOption>[0] | undefined,
292
- ): Promise<string | undefined> {
293
- const options =
294
- kind === "framework"
295
- ? meta.frameworks
296
- : kind === "shell"
297
- ? meta.shells
298
- : meta.sources;
299
- const invalidText =
300
- kind === "framework"
301
- ? texts.frameworkUnknown
302
- : kind === "shell"
303
- ? texts.shellUnknown
304
- : texts.sourcesUnknown;
305
- const label =
306
- kind === "framework"
307
- ? texts.promptFramework
308
- : kind === "shell"
309
- ? texts.promptShell
310
- : texts.promptSources;
311
- const picked =
312
- value !== undefined || io === undefined
313
- ? (value ?? options[0]?.id)
314
- : await askOption(io, label, options, undefined, invalidText);
315
- if (picked === undefined || !options.some((item) => item.id === picked)) {
316
- process.stderr.write(`${invalidText}:${String(picked)}\n`);
236
+ const picked = await askExisting(input.io, input.texts);
237
+ if (picked === "cancel") {
238
+ process.stderr.write(`${input.texts.targetExists}:${projectDir}\n`);
317
239
  return undefined;
318
240
  }
319
241
  return picked;
320
242
  }
321
-
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
- /** 把异常折成一行提示:只暴露信息,不暴露堆栈 */
341
- function describe(error: unknown): string {
342
- return error instanceof Error ? error.message : String(error);
343
- }