@lingfan-engine/create 0.3.4 → 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.
- package/bin/create.mjs +6 -2
- package/package.json +1 -1
- package/src/cli/answers.ts +275 -0
- package/src/cli/cli.ts +37 -218
- package/src/cli/index.ts +11 -3
- package/src/cli/prompts.ts +184 -32
- package/src/cli/stage.ts +113 -0
- package/src/generate/project.ts +58 -7
- package/src/registry/index.ts +4 -0
- package/src/registry/template-meta.ts +73 -0
- package/src/shared/guards.ts +14 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/texts.ts +29 -11
- package/template/README.md +55 -32
- package/template/__PROJECT__/gitignore +2 -0
- package/template/overlays/README.md +6 -0
- package/template/overlays/story/Resources/Stories/{title/title_main.story → manual/manual_intro.story} +15 -10
- package/template/overlays/story/package.patch.json +1 -17
- package/template/template-meta.json +16 -12
package/bin/create.mjs
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* `src/cli/cli.ts`,也不该被迫装一整套构建链。垫片用本包自带依赖 `tsx` 装载真实 CLI,
|
|
9
9
|
* 呈现与编排逻辑仍然只有一份。
|
|
10
10
|
*
|
|
11
|
-
* 用法:`npm create @lingfan-engine [工程名] [
|
|
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
|
|
package/package.json
CHANGED
|
@@ -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,40 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 命令行入口。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
4
|
+
* 分工:参数解析与流程编排在这里,「答案怎么合成」在 `answers.ts`,「问答怎么问」
|
|
5
|
+
* 在 `prompts.ts`,「生成怎么做」在 `stage.ts`。本文件只负责把这四段串成一次运行,
|
|
6
6
|
* 并把失败转成人能读的提示与退出码——库代码一律抛异常,只有这里决定措辞。
|
|
7
|
+
*
|
|
8
|
+
* 三个问题的顺序(工程名 → 描述 → 形态)由 `answers.ts` 保证,本文件不介入。
|
|
7
9
|
*/
|
|
8
10
|
import { resolve as resolvePath } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
import { isValidProjectName, suggestProjectName } from "../shared/index.ts";
|
|
11
|
+
import { defaultCreateTexts } from "../shared/index.ts";
|
|
11
12
|
import {
|
|
12
|
-
layerBases,
|
|
13
|
-
generateProject,
|
|
14
|
-
collectDependencyNames,
|
|
15
13
|
isOccupied,
|
|
16
14
|
projectDirOf,
|
|
17
15
|
type ExistingTarget,
|
|
18
16
|
} from "../generate/index.ts";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
resolveVersions,
|
|
24
|
-
type TemplateMeta,
|
|
25
|
-
} from "../registry/index.ts";
|
|
26
|
-
import {
|
|
27
|
-
askExisting,
|
|
28
|
-
askOption,
|
|
29
|
-
askProjectName,
|
|
30
|
-
createTerminalIo,
|
|
31
|
-
type PromptTexts,
|
|
32
|
-
} from "./prompts.ts";
|
|
33
|
-
import { installProject, renderNextSteps } from "./install.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";
|
|
34
21
|
|
|
35
22
|
/** 解析后的命令行参数;缺席项为 undefined,交由问答或模板缺省值补 */
|
|
36
23
|
export interface CreateArgs {
|
|
37
24
|
readonly projectName?: string;
|
|
25
|
+
/** 工程描述;写进两份清单,空串或缺席表示不写该字段 */
|
|
26
|
+
readonly description?: string;
|
|
38
27
|
readonly template?: string;
|
|
39
28
|
readonly framework?: string;
|
|
40
29
|
readonly shell?: string;
|
|
@@ -60,6 +49,7 @@ class ArgError extends Error {}
|
|
|
60
49
|
*/
|
|
61
50
|
export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
62
51
|
let projectName: string | undefined;
|
|
52
|
+
let description: string | undefined;
|
|
63
53
|
let template: string | undefined;
|
|
64
54
|
let framework: string | undefined;
|
|
65
55
|
let shell: string | undefined;
|
|
@@ -83,6 +73,9 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
|
83
73
|
return next;
|
|
84
74
|
};
|
|
85
75
|
switch (flag) {
|
|
76
|
+
case "--description":
|
|
77
|
+
description = takeValue();
|
|
78
|
+
break;
|
|
86
79
|
case "--template":
|
|
87
80
|
template = takeValue();
|
|
88
81
|
break;
|
|
@@ -129,6 +122,7 @@ export function parseArgs(argv: readonly string[]): CreateArgs {
|
|
|
129
122
|
}
|
|
130
123
|
return {
|
|
131
124
|
projectName,
|
|
125
|
+
description,
|
|
132
126
|
template,
|
|
133
127
|
framework,
|
|
134
128
|
shell,
|
|
@@ -179,96 +173,44 @@ export async function runCreateCli(argv: readonly string[]): Promise<number> {
|
|
|
179
173
|
}
|
|
180
174
|
const meta = readTemplateMeta(templateRoot);
|
|
181
175
|
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
// 只有「允许提问且确实能问」时才建问答面:`--yes` 承诺一个问题都不问;
|
|
177
|
+
// 非交互终端(管道、CI、被重定向的 stdin)上没有回车可打,建出来只会挂在
|
|
178
|
+
// 等一个永远不来的输入上。没有问答面时由 `answers.ts` 判定并给出可用参数。
|
|
179
|
+
const interactive = !args.yes && process.stdin.isTTY === true;
|
|
180
|
+
const io = interactive ? createTerminalIo() : undefined;
|
|
184
181
|
try {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
const preset = resolvePreset(args.template, meta);
|
|
188
|
-
if (preset === undefined) return 1;
|
|
189
|
-
const framework = await resolveOption(
|
|
190
|
-
"framework",
|
|
191
|
-
args.framework ?? preset.framework,
|
|
192
|
-
meta,
|
|
193
|
-
promptTexts,
|
|
194
|
-
io,
|
|
195
|
-
);
|
|
196
|
-
if (framework === undefined) return 1;
|
|
197
|
-
const shell = await resolveOption(
|
|
198
|
-
"shell",
|
|
199
|
-
args.shell ?? preset.shell,
|
|
182
|
+
const answers = await resolveAnswers({
|
|
183
|
+
args,
|
|
200
184
|
meta,
|
|
201
|
-
|
|
185
|
+
templateRoot,
|
|
202
186
|
io,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
}
|
|
187
|
+
texts,
|
|
188
|
+
});
|
|
189
|
+
if (answers === undefined) return 1;
|
|
212
190
|
const existing = await resolveExisting({
|
|
213
191
|
args,
|
|
214
|
-
projectName,
|
|
192
|
+
projectName: answers.projectName,
|
|
215
193
|
io,
|
|
216
|
-
texts
|
|
194
|
+
texts,
|
|
217
195
|
});
|
|
218
196
|
if (existing === undefined) return 1;
|
|
219
|
-
return await
|
|
220
|
-
args,
|
|
197
|
+
return await runStage({
|
|
221
198
|
meta,
|
|
222
199
|
templateRoot,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
200
|
+
dir: args.dir,
|
|
201
|
+
projectName: answers.projectName,
|
|
202
|
+
description: answers.description,
|
|
203
|
+
framework: answers.framework,
|
|
204
|
+
shell: answers.shell,
|
|
205
|
+
sources: answers.sources,
|
|
227
206
|
existing,
|
|
207
|
+
install: args.install,
|
|
228
208
|
});
|
|
229
209
|
} finally {
|
|
230
210
|
io?.close();
|
|
231
211
|
}
|
|
232
212
|
}
|
|
233
213
|
|
|
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
214
|
/**
|
|
273
215
|
* 决定目标目录非空时怎么处置。
|
|
274
216
|
*
|
|
@@ -298,126 +240,3 @@ async function resolveExisting(input: {
|
|
|
298
240
|
}
|
|
299
241
|
return picked;
|
|
300
242
|
}
|
|
301
|
-
|
|
302
|
-
/** 生成一步:查版本 → 写文件 → 可选安装 → 打印后续步骤 */
|
|
303
|
-
async function generate(input: {
|
|
304
|
-
readonly args: CreateArgs;
|
|
305
|
-
readonly meta: TemplateMeta;
|
|
306
|
-
readonly templateRoot: string;
|
|
307
|
-
readonly projectName: string;
|
|
308
|
-
readonly framework: string;
|
|
309
|
-
readonly shell: string;
|
|
310
|
-
readonly sources: string;
|
|
311
|
-
readonly existing: ExistingTarget;
|
|
312
|
-
}): Promise<number> {
|
|
313
|
-
const texts = defaultCreateTexts;
|
|
314
|
-
const manager = currentPackageManager();
|
|
315
|
-
const targetBase = resolvePath(input.args.dir ?? process.cwd());
|
|
316
|
-
const layout = {
|
|
317
|
-
templateRoot: input.templateRoot,
|
|
318
|
-
targetBase,
|
|
319
|
-
projectName: input.projectName,
|
|
320
|
-
framework: input.framework,
|
|
321
|
-
shell: input.shell,
|
|
322
|
-
sources: input.sources,
|
|
323
|
-
resolved: new Map<string, string>(),
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
process.stderr.write(`${texts.stageResolve}\n`);
|
|
327
|
-
const names = collectDependencyNames(layerBases(layout));
|
|
328
|
-
const resolved = await resolveVersions(names);
|
|
329
|
-
if (resolved.size === 0 && names.length > 0) {
|
|
330
|
-
process.stderr.write(`${texts.warnVersionOffline}\n`);
|
|
331
|
-
}
|
|
332
|
-
// 版本判定在写盘之前:模板要求的引擎还没发布时,生成出来的工程装不上,
|
|
333
|
-
// 让它失败在「什么都没写」的时刻,而不是在别人的工程里第一次 pnpm dev 时。
|
|
334
|
-
const incompatible = checkEngineCompatibility(
|
|
335
|
-
input.meta.minEngineVersion,
|
|
336
|
-
resolved,
|
|
337
|
-
);
|
|
338
|
-
if (incompatible !== undefined) {
|
|
339
|
-
process.stderr.write(`${incompatible}\n`);
|
|
340
|
-
return 1;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
process.stderr.write(`${texts.stageCopy}\n`);
|
|
344
|
-
let result;
|
|
345
|
-
try {
|
|
346
|
-
result = generateProject({ ...layout, existing: input.existing, resolved });
|
|
347
|
-
} catch (error) {
|
|
348
|
-
process.stderr.write(`${describe(error)}\n`);
|
|
349
|
-
return 1;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
let installed = false;
|
|
353
|
-
if (input.args.install) {
|
|
354
|
-
process.stderr.write(`${texts.stageInstall}\n`);
|
|
355
|
-
installed = await installProject(result.projectDir, manager);
|
|
356
|
-
if (!installed) process.stderr.write(`${texts.warnInstallFailed}\n`);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
process.stdout.write(
|
|
360
|
-
[
|
|
361
|
-
"",
|
|
362
|
-
`${texts.doneTitle}:${result.projectDir}`,
|
|
363
|
-
`共 ${result.fileCount} 个文件,框架 ${result.framework},壳 ${result.shell},故事源 ${result.sources}`,
|
|
364
|
-
"",
|
|
365
|
-
installed ? "依赖已安装。" : "依赖未安装。",
|
|
366
|
-
renderNextSteps(result.projectDir, manager, texts),
|
|
367
|
-
"",
|
|
368
|
-
].join("\n"),
|
|
369
|
-
);
|
|
370
|
-
return 0;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** 解析工程名:参数优先,参数非法即报错;交互模式打错会重问 */
|
|
374
|
-
async function resolveName(
|
|
375
|
-
args: CreateArgs,
|
|
376
|
-
texts: PromptTexts & { readonly nameInvalid: string },
|
|
377
|
-
io: Parameters<typeof askProjectName>[0] | undefined,
|
|
378
|
-
): Promise<string | undefined> {
|
|
379
|
-
if (args.projectName !== undefined) {
|
|
380
|
-
if (isValidProjectName(args.projectName)) return args.projectName;
|
|
381
|
-
process.stderr.write(`${texts.nameInvalid}:${args.projectName}\n`);
|
|
382
|
-
return undefined;
|
|
383
|
-
}
|
|
384
|
-
if (io === undefined) {
|
|
385
|
-
return suggestProjectName("");
|
|
386
|
-
}
|
|
387
|
-
return await askProjectName(io, texts, undefined);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* 解析框架/壳:值必须在模板声明的表里。
|
|
392
|
-
*
|
|
393
|
-
* 表是唯一事实来源——元数据没声明的形态,叠加层目录未必存在,放过去会生成出
|
|
394
|
-
* 静默缺文件的工程。故事源不在这里判:它只剩「作者手写 `.story`」这一种偏离,
|
|
395
|
-
* 由调用方按同一张表查一次即可。
|
|
396
|
-
*/
|
|
397
|
-
async function resolveOption(
|
|
398
|
-
kind: "framework" | "shell",
|
|
399
|
-
value: string | undefined,
|
|
400
|
-
meta: TemplateMeta,
|
|
401
|
-
texts: PromptTexts,
|
|
402
|
-
io: Parameters<typeof askOption>[0] | undefined,
|
|
403
|
-
): Promise<string | undefined> {
|
|
404
|
-
const options = kind === "framework" ? meta.frameworks : meta.shells;
|
|
405
|
-
const invalidText =
|
|
406
|
-
kind === "framework" ? texts.frameworkUnknown : texts.shellUnknown;
|
|
407
|
-
const label =
|
|
408
|
-
kind === "framework" ? texts.promptFramework : texts.promptShell;
|
|
409
|
-
const picked =
|
|
410
|
-
value !== undefined || io === undefined
|
|
411
|
-
? (value ?? options[0]?.id)
|
|
412
|
-
: await askOption(io, label, options, undefined, invalidText);
|
|
413
|
-
if (picked === undefined || !options.some((item) => item.id === picked)) {
|
|
414
|
-
process.stderr.write(`${invalidText}:${String(picked)}\n`);
|
|
415
|
-
return undefined;
|
|
416
|
-
}
|
|
417
|
-
return picked;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** 把异常折成一行提示:只暴露信息,不暴露堆栈 */
|
|
421
|
-
function describe(error: unknown): string {
|
|
422
|
-
return error instanceof Error ? error.message : String(error);
|
|
423
|
-
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI 域唯一出口。
|
|
3
3
|
*
|
|
4
|
-
* 只做转发:参数解析(`cli.ts`)、交互问答(`prompts.ts
|
|
5
|
-
*
|
|
4
|
+
* 只做转发:参数解析(`cli.ts`)、答案合成(`answers.ts`)、交互问答(`prompts.ts`)、
|
|
5
|
+
* 生成编排(`stage.ts`)、依赖安装与后续命令渲染(`install.ts`)各自成文件,
|
|
6
|
+
* 调用方从这里进出,不穿过文件名。
|
|
6
7
|
*/
|
|
7
8
|
export { parseArgs, runCreateCli } from "./cli.ts";
|
|
8
9
|
export type { CreateArgs } from "./cli.ts";
|
|
10
|
+
export { resolveAnswers } from "./answers.ts";
|
|
11
|
+
export type { AnswerSources, AnswerTexts, CreateAnswers } from "./answers.ts";
|
|
9
12
|
export {
|
|
13
|
+
askDescription,
|
|
10
14
|
askExisting,
|
|
11
|
-
|
|
15
|
+
askLayers,
|
|
12
16
|
askProjectName,
|
|
13
17
|
createTerminalIo,
|
|
18
|
+
findLayerConflicts,
|
|
19
|
+
parseLayerAnswer,
|
|
14
20
|
pickOption,
|
|
15
21
|
} from "./prompts.ts";
|
|
16
22
|
export type { PromptIo, PromptTexts } from "./prompts.ts";
|
|
23
|
+
export { runStage } from "./stage.ts";
|
|
24
|
+
export type { StageOptions } from "./stage.ts";
|
|
17
25
|
export {
|
|
18
26
|
fillManagerCommands,
|
|
19
27
|
installProject,
|