@lingfan-engine/create 0.3.2 → 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.
Files changed (40) hide show
  1. package/bin/create.mjs +2 -2
  2. package/package.json +1 -1
  3. package/src/cli/cli.ts +135 -55
  4. package/src/cli/index.ts +21 -0
  5. package/src/cli/install.ts +60 -0
  6. package/src/cli/prompts.ts +51 -4
  7. package/src/generate/index.ts +7 -2
  8. package/src/generate/project.ts +67 -20
  9. package/src/overlay/index.ts +1 -0
  10. package/src/overlay/merge.ts +27 -1
  11. package/src/shared/index.ts +8 -1
  12. package/src/shared/package-manager.ts +87 -0
  13. package/src/shared/texts.ts +21 -11
  14. package/template/README.md +34 -23
  15. package/template/__PROJECT__/package.json +6 -1
  16. package/template/__PROJECT__/package.patch.json +5 -0
  17. package/template/__PROJECT__/src/host/index.ts +1 -0
  18. package/template/__PROJECT__/src/host/native-ports.ts +56 -0
  19. package/template/__PROJECT__/src/main.ts +5 -2
  20. package/template/{overlays/ts → __PROJECT__}/tsconfig.stories.json +4 -1
  21. package/template/overlays/README.md +27 -13
  22. package/template/overlays/react/src/main.tsx +3 -0
  23. package/template/{__PROJECT__ → overlays/story}/Resources/Stories/title/title_main.story +1 -1
  24. package/template/overlays/story/package.patch.json +17 -0
  25. package/template/overlays/tauri/README.md +6 -5
  26. package/template/overlays/tauri/package.patch.json +1 -4
  27. package/template/overlays/vue/src/main.ts +3 -0
  28. package/template/template-meta.json +5 -5
  29. package/template/overlays/tauri/src/main.ts +0 -101
  30. package/template/overlays/ts/Resources/project.json +0 -8
  31. package/template/overlays/ts/package.patch.json +0 -13
  32. /package/template/{overlays/ts → __PROJECT__}/Resources/Stories/title/look_around.json +0 -0
  33. /package/template/{overlays/ts → __PROJECT__}/Resources/Stories/title/title_main.json +0 -0
  34. /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/config.ts +0 -0
  35. /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/title/look_around.ts +0 -0
  36. /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/title/title_main.ts +0 -0
  37. /package/template/{overlays/ts → __PROJECT__}/src/gen/font.gen.d.ts +0 -0
  38. /package/template/{overlays/ts → __PROJECT__}/src/gen/script.gen.d.ts +0 -0
  39. /package/template/{overlays/ts → __PROJECT__}/src/gen/state.gen.d.ts +0 -0
  40. /package/template/{overlays/ts → __PROJECT__}/src/gen/stories.gen.d.ts +0 -0
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 [工程名] [-- --framework vue --shell browser]`;
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/cli.ts", import.meta.url);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/create",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "工程生成器:从模板骨架生成新工程,可选框架与壳",
6
6
  "license": "Apache-2.0",
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 = true;
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 { projectName, framework, shell, sources, dir, install, yes, help };
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
- const sources = await resolveOption(
173
- "sources",
174
- args.sources,
175
- meta,
176
- promptTexts,
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
- if (sources === undefined) return 1;
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 install(result.projectDir);
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
- texts.nextSteps.split("<name>").join(result.projectDir),
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
- * 只校验「模板声明过」而不校验「目录存在」会放过一种情况:元数据写了 `vue`
284
- * 但仓库里没有 `overlays/vue`,生成出来的工程静默缺少该框架的文件。
393
+ * 表是唯一事实来源——元数据没声明的形态,叠加层目录未必存在,放过去会生成出
394
+ * 静默缺文件的工程。故事源不在这里判:它只剩「作者手写 `.story`」这一种偏离,
395
+ * 由调用方按同一张表查一次即可。
285
396
  */
286
397
  async function resolveOption(
287
- kind: "framework" | "shell" | "sources",
398
+ kind: "framework" | "shell",
288
399
  value: string | undefined,
289
400
  meta: TemplateMeta,
290
- texts: PromptTexts & { readonly overlayMissing: string },
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);
@@ -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
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * 交互问答。
3
3
  *
4
- * 生成器要问四件事:工程名、框架、壳、故事源形态。四项都能用命令行参数预先给定——CI、
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 promptSources: string;
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 sourcesUnknown: string;
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
  *
@@ -7,11 +7,16 @@ export {
7
7
  collectDependencyNames,
8
8
  DEFAULT_SOURCES,
9
9
  generateProject,
10
+ isOccupied,
10
11
  layerBases,
11
- pinnedNames,
12
+ projectDirOf,
12
13
  SKELETON_DIR,
13
14
  } from "./project.ts";
14
- export type { GenerateOptions, GenerateResult } from "./project.ts";
15
+ export type {
16
+ ExistingTarget,
17
+ GenerateOptions,
18
+ GenerateResult,
19
+ } from "./project.ts";
15
20
  export {
16
21
  isTextPath,
17
22
  PLACEHOLDERS,