@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.
- package/bin/create.mjs +7 -3
- package/package.json +1 -1
- package/src/cli/answers.ts +275 -0
- package/src/cli/cli.ts +93 -194
- package/src/cli/index.ts +29 -0
- package/src/cli/install.ts +60 -0
- package/src/cli/prompts.ts +230 -31
- package/src/cli/stage.ts +113 -0
- package/src/generate/index.ts +7 -2
- package/src/generate/project.ts +99 -34
- package/src/overlay/index.ts +1 -0
- package/src/overlay/merge.ts +27 -1
- 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 +10 -1
- package/src/shared/package-manager.ts +87 -0
- package/src/shared/texts.ts +45 -17
- package/template/README.md +59 -32
- package/template/__PROJECT__/gitignore +2 -0
- 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/README.md +6 -0
- package/template/overlays/react/src/main.tsx +3 -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/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 +16 -12
- package/template/overlays/tauri/src/main.ts +0 -101
package/src/generate/project.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
mkdirSync,
|
|
15
15
|
readdirSync,
|
|
16
16
|
readFileSync,
|
|
17
|
+
rmSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from "node:fs";
|
|
19
20
|
import { dirname, join } from "node:path";
|
|
@@ -34,6 +35,14 @@ import { restoreDotfile } from "./dotfiles.ts";
|
|
|
34
35
|
/** 骨架目录名:占位符本身,也是模板里唯一随框架无关的目录 */
|
|
35
36
|
export const SKELETON_DIR = "__PROJECT__";
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 目标目录已存在且非空时的处置方式。
|
|
40
|
+
*
|
|
41
|
+
* 三种都是使用者在生成前明确选过的,不可省略成「默认覆盖」:覆盖别人的工程
|
|
42
|
+
* 无法撤销,所以缺省仍是 `cancel`。
|
|
43
|
+
*/
|
|
44
|
+
export type ExistingTarget = "cancel" | "ignore" | "clear";
|
|
45
|
+
|
|
37
46
|
/** 生成参数 */
|
|
38
47
|
export interface GenerateOptions {
|
|
39
48
|
/** 模板根目录(含 `template-meta.json` 与骨架目录) */
|
|
@@ -52,6 +61,15 @@ export interface GenerateOptions {
|
|
|
52
61
|
* 可选是为了不破坏既有调用方——只关心框架与壳的代码不必为一个用不到的参数改签名。
|
|
53
62
|
*/
|
|
54
63
|
readonly sources?: string;
|
|
64
|
+
/**
|
|
65
|
+
* 工程描述;写进 `package.json` 与 `Resources/project.json` 两处。
|
|
66
|
+
*
|
|
67
|
+
* 缺省与空串都表示「没有描述」,两处清单里都不出现该键。这两种状态不做区分:
|
|
68
|
+
* 调用方要么知道有描述,要么什么都没说,第三种「有但是空」没有语义。
|
|
69
|
+
*/
|
|
70
|
+
readonly description?: string;
|
|
71
|
+
/** 目标目录非空时怎么办;缺省 `cancel`(报错退出,不碰磁盘) */
|
|
72
|
+
readonly existing?: ExistingTarget;
|
|
55
73
|
/** 引擎包的最新版本;缺席项沿用模板记录的值 */
|
|
56
74
|
readonly resolved: ReadonlyMap<string, string>;
|
|
57
75
|
}
|
|
@@ -81,22 +99,35 @@ export const DEFAULT_SOURCES = "ts";
|
|
|
81
99
|
/**
|
|
82
100
|
* 生成一个工程。
|
|
83
101
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
102
|
+
* 目标目录已存在且非空时按 `options.existing` 处置,缺省 `cancel`——覆盖别人的工程
|
|
103
|
+
* 无法撤销,所以静默覆盖不是可选项,必须由使用者先表态(见 `ExistingTarget`)。
|
|
86
104
|
*/
|
|
87
105
|
export function generateProject(options: GenerateOptions): GenerateResult {
|
|
88
106
|
const meta = readTemplateMeta(options.templateRoot);
|
|
89
|
-
const projectDir =
|
|
90
|
-
|
|
107
|
+
const projectDir = projectDirOf(options);
|
|
108
|
+
const occupied = isOccupied(projectDir);
|
|
109
|
+
const existing = options.existing ?? "cancel";
|
|
110
|
+
if (occupied && existing === "cancel") {
|
|
91
111
|
throw new Error(`目标目录已存在且非空:${projectDir}`);
|
|
92
112
|
}
|
|
113
|
+
if (occupied && existing === "clear") {
|
|
114
|
+
// 清空的是「目录里的内容」而不是目录本身:调用方可能已经把 shell 的 cwd
|
|
115
|
+
// 放在里面,删掉目录本身会让它指向一个不存在的 inode。
|
|
116
|
+
for (const entry of readdirSync(projectDir)) {
|
|
117
|
+
rmSync(join(projectDir, entry), { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
93
120
|
const merged = mergeLayers(layerBases(options));
|
|
94
|
-
const files =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
const files = applyDescription(
|
|
122
|
+
finalize(
|
|
123
|
+
merged.files,
|
|
124
|
+
merged.patch,
|
|
125
|
+
merged.pinned,
|
|
126
|
+
options.resolved,
|
|
127
|
+
meta.placeholder,
|
|
128
|
+
options.projectName,
|
|
129
|
+
),
|
|
130
|
+
options.description ?? "",
|
|
100
131
|
);
|
|
101
132
|
mkdirSync(projectDir, { recursive: true });
|
|
102
133
|
for (const file of files) {
|
|
@@ -139,8 +170,15 @@ export function layerBases(options: GenerateOptions): string[] {
|
|
|
139
170
|
return bases;
|
|
140
171
|
}
|
|
141
172
|
|
|
173
|
+
/** 产物目录:`<targetBase>/<projectName>`;单独抽出来供 CLI 在写盘前提示用 */
|
|
174
|
+
export function projectDirOf(
|
|
175
|
+
options: Pick<GenerateOptions, "targetBase" | "projectName">,
|
|
176
|
+
): string {
|
|
177
|
+
return join(options.targetBase, options.projectName);
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
/** 目标目录是否已被占用:存在且含有内容即算占用,空目录可以复用 */
|
|
143
|
-
function isOccupied(dir: string): boolean {
|
|
181
|
+
export function isOccupied(dir: string): boolean {
|
|
144
182
|
if (!existsSync(dir)) return false;
|
|
145
183
|
return readdirSync(dir).length > 0;
|
|
146
184
|
}
|
|
@@ -181,10 +219,14 @@ export function collectDependencyNames(bases: readonly string[]): string[] {
|
|
|
181
219
|
*
|
|
182
220
|
* 点文件还原必须在这里发生:模板里存的是躲开 npm 改名的普通名(见 `dotfiles.ts`),
|
|
183
221
|
* 产物路径要写回带点的形态,占位符替换才按产物路径判文本/二进制。
|
|
222
|
+
*
|
|
223
|
+
* `pinned` 由合并层给出而不是在这里从 `patch` 里再读一次:`pinned` 是数组,
|
|
224
|
+
* 深合并后只剩最后一层的名单,必须用逐层取并集的结果(见 `overlay` 域)。
|
|
184
225
|
*/
|
|
185
226
|
export function finalize(
|
|
186
227
|
files: readonly PendingFile[],
|
|
187
228
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
229
|
+
pinned: readonly string[],
|
|
188
230
|
resolved: ReadonlyMap<string, string>,
|
|
189
231
|
placeholder: string,
|
|
190
232
|
projectName: string,
|
|
@@ -193,7 +235,7 @@ export function finalize(
|
|
|
193
235
|
const path = restoreDotfile(file.path);
|
|
194
236
|
const content =
|
|
195
237
|
file.path === "package.json"
|
|
196
|
-
? renderManifest(file.content, patch, resolved)
|
|
238
|
+
? renderManifest(file.content, patch, pinned, resolved)
|
|
197
239
|
: file.content;
|
|
198
240
|
return {
|
|
199
241
|
path,
|
|
@@ -202,6 +244,47 @@ export function finalize(
|
|
|
202
244
|
});
|
|
203
245
|
}
|
|
204
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 把工程描述写进两份清单。
|
|
249
|
+
*
|
|
250
|
+
* 写两处是因为它们服务不同读者:`package.json` 是开发者与 npm 工具链的门面,
|
|
251
|
+
* `Resources/project.json` 跟资源一起走,运行时与构建期都读它。同源同值,
|
|
252
|
+
* 由同一个变量一次写入,不会出现两处说法不一致。
|
|
253
|
+
*
|
|
254
|
+
* 没有描述时**一个字节都不改**:模板里本就不写这个键,于是「没填描述」的产物
|
|
255
|
+
* 与引入描述之前逐字节一致,也不存在「空描述」这种中间状态。
|
|
256
|
+
*/
|
|
257
|
+
function applyDescription(
|
|
258
|
+
files: readonly PendingFile[],
|
|
259
|
+
description: string,
|
|
260
|
+
): PendingFile[] {
|
|
261
|
+
if (description === "") return [...files];
|
|
262
|
+
return files.map((file) => {
|
|
263
|
+
if (!MANIFEST_WITH_DESCRIPTION.has(file.path)) return file;
|
|
264
|
+
const manifest = JSON.parse(file.content.toString("utf8")) as Record<
|
|
265
|
+
string,
|
|
266
|
+
unknown
|
|
267
|
+
>;
|
|
268
|
+
return {
|
|
269
|
+
path: file.path,
|
|
270
|
+
content: Buffer.from(
|
|
271
|
+
`${JSON.stringify({ ...manifest, description }, null, 2)}\n`,
|
|
272
|
+
"utf8",
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 描述落点的产物路径。
|
|
280
|
+
*
|
|
281
|
+
* 用产物路径而不是模板路径判断:点文件还原已经发生,这里的取值就是最终写盘的名字。
|
|
282
|
+
*/
|
|
283
|
+
const MANIFEST_WITH_DESCRIPTION: ReadonlySet<string> = new Set([
|
|
284
|
+
"package.json",
|
|
285
|
+
"Resources/project.json",
|
|
286
|
+
]);
|
|
287
|
+
|
|
205
288
|
/**
|
|
206
289
|
* 把增量与版本号写进 `package.json`。
|
|
207
290
|
*
|
|
@@ -212,6 +295,7 @@ export function finalize(
|
|
|
212
295
|
function renderManifest(
|
|
213
296
|
content: Buffer,
|
|
214
297
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
298
|
+
pinned: readonly string[],
|
|
215
299
|
resolved: ReadonlyMap<string, string>,
|
|
216
300
|
): Buffer {
|
|
217
301
|
const manifest = JSON.parse(content.toString("utf8")) as Record<
|
|
@@ -222,13 +306,13 @@ function renderManifest(
|
|
|
222
306
|
patch === undefined ? manifest : mergeManifest(manifest, patch);
|
|
223
307
|
applyUnset(withPatch, patch);
|
|
224
308
|
delete withPatch[CREATE_FIELD];
|
|
225
|
-
const
|
|
309
|
+
const pinnedSet = new Set(pinned);
|
|
226
310
|
for (const field of ["dependencies", "devDependencies"] as const) {
|
|
227
311
|
const table = withPatch[field];
|
|
228
312
|
if (isStringMap(table)) {
|
|
229
313
|
withPatch[field] = applyResolvedVersions(
|
|
230
314
|
table,
|
|
231
|
-
withoutNames(resolved,
|
|
315
|
+
withoutNames(resolved, pinnedSet),
|
|
232
316
|
);
|
|
233
317
|
}
|
|
234
318
|
}
|
|
@@ -282,8 +366,7 @@ const CREATE_FIELD = "create";
|
|
|
282
366
|
*
|
|
283
367
|
* 形状与 `create.remove` 并列:外层是 `package.json` 的字段名,内层是要去掉的键名。
|
|
284
368
|
* 缺省项不必写它——缺省形态什么都不叠,本来就没有要减的东西。
|
|
285
|
-
*/
|
|
286
|
-
function applyUnset(
|
|
369
|
+
*/ function applyUnset(
|
|
287
370
|
manifest: Record<string, unknown>,
|
|
288
371
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
289
372
|
): void {
|
|
@@ -304,24 +387,6 @@ function applyUnset(
|
|
|
304
387
|
}
|
|
305
388
|
}
|
|
306
389
|
|
|
307
|
-
/**
|
|
308
|
-
* 读叠加层声明的「不要跟最新版」的依赖名。
|
|
309
|
-
*
|
|
310
|
-
* 存在的理由:模板希望依赖尽量新,但上游并非都兼容——`vue-tsc` 在 `typescript` 7 上
|
|
311
|
-
* 会因 `./lib/tsc` 不再导出而直接崩,这类约束只有模板作者知道。钉住的包保留模板
|
|
312
|
-
* 记录的版本区间,其余照常解析到最新。
|
|
313
|
-
*/
|
|
314
|
-
export function pinnedNames(
|
|
315
|
-
patch: Readonly<Record<string, unknown>> | undefined,
|
|
316
|
-
): string[] {
|
|
317
|
-
if (patch === undefined) return [];
|
|
318
|
-
const section = patch[CREATE_FIELD];
|
|
319
|
-
if (!isPlainRecord(section)) return [];
|
|
320
|
-
const pinned = section.pinned;
|
|
321
|
-
if (!Array.isArray(pinned)) return [];
|
|
322
|
-
return pinned.filter((item): item is string => typeof item === "string");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
390
|
/** 只认普通对象:数组与 null 不算 */
|
|
326
391
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
327
392
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/src/overlay/index.ts
CHANGED
package/src/overlay/merge.ts
CHANGED
|
@@ -132,10 +132,33 @@ function isRemoved(path: string, removes: readonly string[]): boolean {
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** 合并结果:文件表(后写覆盖先写)+ 合并后的工程清单增量 */
|
|
135
|
+
/** 合并结果:文件表(后写覆盖先写)+ 合并后的工程清单增量 + 各层钉住的依赖名并集 */
|
|
136
136
|
export interface MergedLayer {
|
|
137
137
|
readonly files: readonly PendingFile[];
|
|
138
138
|
readonly patch: Readonly<Record<string, unknown>> | undefined;
|
|
139
|
+
readonly pinned: readonly string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 读本层 `create.pinned` 点名的依赖名。
|
|
144
|
+
*
|
|
145
|
+
* 与 `removeList` 同理,必须**每层各读一次**:`pinned` 是数组,深合并时由后者整体
|
|
146
|
+
* 取代,两层各钉各的包,合并后只剩最后一层的名单——`vue` 钉了 `typescript`、
|
|
147
|
+
* `tauri` 钉了 `@tauri-apps/*`,后者在后就把前者的钉法抹掉,于是 `vue-tsc` 拿到
|
|
148
|
+
* 了它不兼容的 typescript 大版本,`typecheck` 直接崩。
|
|
149
|
+
*
|
|
150
|
+
* 与 `remove` 不同的是,钉住是**累加声明**(每个层都在说「这几个包别跟最新」),
|
|
151
|
+
* 所以合并语义是并集而不是逐层施加的动作。
|
|
152
|
+
*/
|
|
153
|
+
export function pinnedNames(
|
|
154
|
+
patch: Readonly<Record<string, unknown>> | undefined,
|
|
155
|
+
): readonly string[] {
|
|
156
|
+
if (patch === undefined) return [];
|
|
157
|
+
const section = patch[CREATE_FIELD];
|
|
158
|
+
if (!isPlainObject(section)) return [];
|
|
159
|
+
const raw = section.pinned;
|
|
160
|
+
if (!Array.isArray(raw)) return [];
|
|
161
|
+
return raw.filter((item): item is string => typeof item === "string");
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
/**
|
|
@@ -149,6 +172,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
149
172
|
const byPath = new Map<string, PendingFile>();
|
|
150
173
|
let patch: Record<string, unknown> = {};
|
|
151
174
|
let hasPatch = false;
|
|
175
|
+
const pinned = new Set<string>();
|
|
152
176
|
for (const base of bases) {
|
|
153
177
|
if (!existsSync(base)) continue;
|
|
154
178
|
const layer = readLayer(base);
|
|
@@ -158,6 +182,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
for (const file of layer.files) byPath.set(file.path, file);
|
|
185
|
+
for (const name of pinnedNames(layer.patch)) pinned.add(name);
|
|
161
186
|
if (layer.patch) {
|
|
162
187
|
patch = deepMerge(patch, layer.patch);
|
|
163
188
|
hasPatch = true;
|
|
@@ -166,6 +191,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
166
191
|
return {
|
|
167
192
|
files: [...byPath.values()],
|
|
168
193
|
patch: hasPatch ? patch : undefined,
|
|
194
|
+
pinned: [...pinned],
|
|
169
195
|
};
|
|
170
196
|
}
|
|
171
197
|
|
package/src/registry/index.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/** registry 域唯一出口:模板元数据定位 + npm 版本查询 + 引擎兼容判定。 */
|
|
2
2
|
export { checkEngineCompatibility, ENGINE_SCOPE } from "./compat.ts";
|
|
3
3
|
export {
|
|
4
|
+
existsLayerDirectory,
|
|
5
|
+
findConflicts,
|
|
4
6
|
overlayDirOf,
|
|
5
7
|
readTemplateMeta,
|
|
6
8
|
resolveTemplateRoot,
|
|
9
|
+
selectableLayers,
|
|
7
10
|
OVERLAY_DIR,
|
|
11
|
+
type LayerConflict,
|
|
8
12
|
type TemplateMeta,
|
|
9
13
|
type TemplateOption,
|
|
10
14
|
} from "./template-meta.ts";
|
|
@@ -41,6 +41,13 @@ export interface TemplateOption {
|
|
|
41
41
|
readonly label: string;
|
|
42
42
|
/** 一句话说明,交互问答里作为提示 */
|
|
43
43
|
readonly hint: string;
|
|
44
|
+
/**
|
|
45
|
+
* 不能与本层共存的其他层 id;缺省视为空数组。
|
|
46
|
+
*
|
|
47
|
+
* 元素是**跨维度**的层 id(框架可以排斥壳),不是同一维度内的兄弟项。
|
|
48
|
+
* 单边声明即生效:只要一对层里有一方点名了另一方,这一对就是互斥的。
|
|
49
|
+
*/
|
|
50
|
+
readonly excludes?: readonly string[];
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -115,3 +122,69 @@ export function overlayDirOf(
|
|
|
115
122
|
const dir = join(templateRoot, OVERLAY_DIR, optionId);
|
|
116
123
|
return existsSync(dir) ? dir : undefined;
|
|
117
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 这一层是否有自己的目录。
|
|
128
|
+
*
|
|
129
|
+
* 判据是磁盘事实而不是 id 白名单:新增一层只要建了目录就自动可选,删掉目录就自动
|
|
130
|
+
* 退出选项面,生成器不必跟着改。缺省项(`none`/`browser`/`ts`)没有目录,
|
|
131
|
+
* 因此永远不出现在「可以叠哪些层」的选项里。
|
|
132
|
+
*/
|
|
133
|
+
export function existsLayerDirectory(
|
|
134
|
+
templateRoot: string,
|
|
135
|
+
optionId: string,
|
|
136
|
+
): boolean {
|
|
137
|
+
return existsSync(join(templateRoot, OVERLAY_DIR, optionId));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 列出可以在形态问答里勾选的层:框架与壳里有目录的那些。
|
|
142
|
+
*
|
|
143
|
+
* 故事源不是可叠加的层:TS 源、手写 `.story`、手写 `.json` 是同一棵
|
|
144
|
+
* `Resources/Stories/` 树上的三种文件形态,本来就能共存于一个工程,勾选它没有
|
|
145
|
+
* 「叠一层」的含义,所以 `sources` 维度不参与问答,只在脚本参数面(`--sources`)保留。
|
|
146
|
+
*
|
|
147
|
+
* 顺序沿用元数据里的维度顺序「框架 → 壳」,与合并顺序(骨架 → 框架 → 故事源 →
|
|
148
|
+
* 壳)不同——选项面按使用者心里的粒度排,合并顺序按覆盖关系排;维度内保持声明顺序。
|
|
149
|
+
*/
|
|
150
|
+
export function selectableLayers(
|
|
151
|
+
templateRoot: string,
|
|
152
|
+
meta: TemplateMeta,
|
|
153
|
+
): TemplateOption[] {
|
|
154
|
+
return [...meta.frameworks, ...meta.shells].filter((option) =>
|
|
155
|
+
existsLayerDirectory(templateRoot, option.id),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 一对互斥的层 id,按选项面出现顺序排列 */
|
|
160
|
+
export type LayerConflict = readonly [string, string];
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从选中的层里找出互斥对。
|
|
164
|
+
*
|
|
165
|
+
* 只看「选中集合里同时出现了互斥的两方」,不关心是哪一方先声明的:单边声明即生效,
|
|
166
|
+
* 因此两边都声明也不会被算成两对。返回的每一对都按选项面顺序排列,便于报错文本稳定。
|
|
167
|
+
*/
|
|
168
|
+
export function findConflicts(
|
|
169
|
+
selected: readonly string[],
|
|
170
|
+
options: readonly TemplateOption[],
|
|
171
|
+
): LayerConflict[] {
|
|
172
|
+
const order = new Map(options.map((option, index) => [option.id, index]));
|
|
173
|
+
const chosen = new Set(selected);
|
|
174
|
+
const pairs: LayerConflict[] = [];
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
for (const option of options) {
|
|
177
|
+
if (!chosen.has(option.id)) continue;
|
|
178
|
+
for (const excluded of option.excludes ?? []) {
|
|
179
|
+
if (!chosen.has(excluded)) continue;
|
|
180
|
+
const key = [option.id, excluded].sort().join("\u0000");
|
|
181
|
+
if (seen.has(key)) continue;
|
|
182
|
+
seen.add(key);
|
|
183
|
+
const pair = [option.id, excluded].sort(
|
|
184
|
+
(left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0),
|
|
185
|
+
) as [string, string];
|
|
186
|
+
pairs.push(pair);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return pairs;
|
|
190
|
+
}
|
package/src/shared/guards.ts
CHANGED
|
@@ -66,6 +66,20 @@ export function suggestProjectName(input: string): string {
|
|
|
66
66
|
return collapsed.length > 0 ? collapsed : "lingfan-story";
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** 描述的字符上限:`package.json#description` 在 npm 生态里的通行上限 */
|
|
70
|
+
export const DESCRIPTION_MAX_LENGTH = 200;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把自由文本收敛成可写入清单的描述。
|
|
74
|
+
*
|
|
75
|
+
* 全空白视为空,返回空串——调用方据此决定「不写这个字段」,而不是写一个空串进去。
|
|
76
|
+
* 超长直接截断而不报错:描述不影响任何路径与命令,没有理由为一个输入框拦住生成。
|
|
77
|
+
* 不校验字符集:它是给人看的自由文本,不是标识符。
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeDescription(input: string): string {
|
|
80
|
+
return input.trim().slice(0, DESCRIPTION_MAX_LENGTH).trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
/**
|
|
70
84
|
* 把用户输入的版本号归一成 `^x.y.z`。
|
|
71
85
|
*
|
package/src/shared/index.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
/** shared
|
|
1
|
+
/** shared 域唯一出口:文案表、参数校验、包管理器识别与版本运算。 */
|
|
2
2
|
export { defaultCreateTexts, type CreateTexts } from "./texts.ts";
|
|
3
3
|
export {
|
|
4
4
|
caretRange,
|
|
5
|
+
DESCRIPTION_MAX_LENGTH,
|
|
5
6
|
isValidProjectName,
|
|
7
|
+
normalizeDescription,
|
|
6
8
|
suggestProjectName,
|
|
7
9
|
} from "./guards.ts";
|
|
10
|
+
export {
|
|
11
|
+
currentPackageManager,
|
|
12
|
+
detectPackageManager,
|
|
13
|
+
parsePackageManager,
|
|
14
|
+
type PackageManager,
|
|
15
|
+
type PackageManagerId,
|
|
16
|
+
} from "./package-manager.ts";
|
|
8
17
|
export {
|
|
9
18
|
compareSemver,
|
|
10
19
|
floorOfRange,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包管理器识别。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要识别:生成器是给**别人**用的工具,不能假定对方用 pnpm。安装依赖与
|
|
5
|
+
* 打印后续步骤都要跟随调用方的包管理器——`npm create` 就该给出 npm 的命令,
|
|
6
|
+
* `pnpm create` 给出 pnpm 的。本仓内部开发固定 pnpm 是仓库约定,不是产物约定。
|
|
7
|
+
*
|
|
8
|
+
* 识别依据是 npm 生态的既定事实 `npm_config_user_agent`(`npm create` /
|
|
9
|
+
* `pnpm create` / `yarn create` / `bun create` 都会设置它)。拿不到时退回
|
|
10
|
+
* `npm_execpath` 的文件名,再拿不到按 npm 处理——这是最不会误导人的缺省,
|
|
11
|
+
* 因为 npm 的命令形式(`npm run dev`)在 pnpm/yarn 下也基本能跑。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 生成器认得的包管理器;不认识的都按 npm 处理 */
|
|
15
|
+
export type PackageManagerId = "npm" | "pnpm" | "yarn" | "bun";
|
|
16
|
+
|
|
17
|
+
/** 一个包管理器的命令写法 */
|
|
18
|
+
export interface PackageManager {
|
|
19
|
+
readonly id: PackageManagerId;
|
|
20
|
+
/** 跑一个 `package.json` 脚本的写法,如 `dev` / `run dev` */
|
|
21
|
+
readonly script: string;
|
|
22
|
+
/** 安装依赖的写法(四家都是 `install`,保留字段是为了日后出现例外时有落点) */
|
|
23
|
+
readonly install: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MANAGERS: Readonly<Record<PackageManagerId, PackageManager>> = {
|
|
27
|
+
npm: { id: "npm", script: "run dev", install: "install" },
|
|
28
|
+
pnpm: { id: "pnpm", script: "dev", install: "install" },
|
|
29
|
+
yarn: { id: "yarn", script: "dev", install: "install" },
|
|
30
|
+
bun: { id: "bun", script: "run dev", install: "install" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const KNOWN = ["npm", "pnpm", "yarn", "bun"] as const;
|
|
34
|
+
|
|
35
|
+
/** 把一段文本折成包管理器名:去扩展名、去版本尾缀 */
|
|
36
|
+
function normalize(segment: string): PackageManagerId | undefined {
|
|
37
|
+
const name = segment
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/\.(?:c|m)?js$|\.exe$/u, "")
|
|
40
|
+
.split("@")[0];
|
|
41
|
+
return KNOWN.find((id) => id === name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 从 `user-agent` 或可执行路径里认出包管理器。
|
|
46
|
+
*
|
|
47
|
+
* 两种输入形态不同,故首段与末段都试:`user-agent` 是
|
|
48
|
+
* `pm/版本 平台/…`(首段有名),可执行路径是 `/usr/local/bin/pm.cjs`
|
|
49
|
+
* (末段有名,首段是空串或盘符)。取第一个认得出的。
|
|
50
|
+
*/
|
|
51
|
+
export function parsePackageManager(
|
|
52
|
+
hint: string | undefined,
|
|
53
|
+
): PackageManagerId | undefined {
|
|
54
|
+
if (hint === undefined) return undefined;
|
|
55
|
+
const segments = hint.split(/[/\\]/u).filter((part) => part !== "");
|
|
56
|
+
for (const segment of [segments[0], segments[segments.length - 1]]) {
|
|
57
|
+
if (segment === undefined) continue;
|
|
58
|
+
const found = normalize(segment);
|
|
59
|
+
if (found !== undefined) return found;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 当前进程由哪个包管理器发起。
|
|
66
|
+
*
|
|
67
|
+
* 优先级:`npm_config_user_agent`(最权威,四家都会设)→ `npm_execpath`
|
|
68
|
+
* (垫片被直接 `node` 调用时的退路)→ npm(缺省)。
|
|
69
|
+
*/
|
|
70
|
+
export function detectPackageManager(env: {
|
|
71
|
+
readonly userAgent?: string | undefined;
|
|
72
|
+
readonly execPath?: string | undefined;
|
|
73
|
+
}): PackageManager {
|
|
74
|
+
const id =
|
|
75
|
+
parsePackageManager(env.userAgent) ??
|
|
76
|
+
parsePackageManager(env.execPath) ??
|
|
77
|
+
"npm";
|
|
78
|
+
return MANAGERS[id];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 从真实进程环境里取一个 */
|
|
82
|
+
export function currentPackageManager(): PackageManager {
|
|
83
|
+
return detectPackageManager({
|
|
84
|
+
userAgent: process.env.npm_config_user_agent,
|
|
85
|
+
execPath: process.env.npm_execpath,
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/shared/texts.ts
CHANGED
|
@@ -11,49 +11,77 @@ export const defaultCreateTexts = {
|
|
|
11
11
|
banner: "灵泛叙事引擎 · 工程生成器",
|
|
12
12
|
help: [
|
|
13
13
|
"用法:",
|
|
14
|
-
" npm create @lingfan-engine [工程名]
|
|
15
|
-
" pnpm create @lingfan-engine [工程名]
|
|
14
|
+
" npm create @lingfan-engine [工程名]",
|
|
15
|
+
" pnpm create @lingfan-engine [工程名]",
|
|
16
|
+
" bun create @lingfan-engine [工程名]",
|
|
16
17
|
"",
|
|
18
|
+
"工程名之后按提示回答:工程名 → 描述 → 形态(可多选)。",
|
|
19
|
+
"yarn 需要 2 及以上版本(classic 没有 create 子命令);仍用 classic 时等价写法:",
|
|
20
|
+
" yarn dlx create-@lingfan-engine [工程名]",
|
|
21
|
+
"",
|
|
22
|
+
"包管理器不限:生成后的安装命令与后续步骤按调用方自己的包管理器给出。",
|
|
17
23
|
"选项:",
|
|
18
|
-
"
|
|
19
|
-
" -s, --shell <id> 壳(缺省取模板声明的默认值)",
|
|
20
|
-
" -t, --sources <id> 故事源形态(缺省取模板声明的默认值)",
|
|
24
|
+
" --description <文本> 工程描述(写进 package.json 与 Resources/project.json;可留空)",
|
|
21
25
|
" -d, --dir <路径> 生成到哪个目录下(缺省 = 当前目录)",
|
|
22
|
-
" --
|
|
26
|
+
" --install 生成后立即安装依赖",
|
|
27
|
+
" --overwrite 目标目录非空时清空后继续",
|
|
23
28
|
" -y, --yes 全部取缺省值,不进入交互问答",
|
|
24
29
|
" -h, --help 显示本说明",
|
|
25
30
|
"",
|
|
31
|
+
"脚本用法:CI 里可以用参数直接指定形态,跳过问答。",
|
|
32
|
+
" -f, --framework <id> 框架(none / vue / react)",
|
|
33
|
+
" -s, --shell <id> 壳(browser / tauri / miniprogram)",
|
|
34
|
+
" -t, --sources <id> 故事源形态(ts / story)",
|
|
35
|
+
" --template <id> 等价写法:框架[-壳[-故事源]],如 vue-tauri",
|
|
36
|
+
"",
|
|
26
37
|
"示例:",
|
|
27
|
-
" npm create @lingfan-engine my-story
|
|
28
|
-
" npm create @lingfan-engine my-story --
|
|
38
|
+
" npm create @lingfan-engine my-story",
|
|
39
|
+
" npm create @lingfan-engine my-story --description 试菜单的小工程",
|
|
40
|
+
" npm create @lingfan-engine my-story --yes --install",
|
|
29
41
|
].join("\n"),
|
|
30
42
|
/** 交互问答的提示语 */
|
|
31
43
|
promptProjectName: "工程名",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
promptDescription: "描述",
|
|
45
|
+
descriptionHint: "可留空;会写进 package.json 与 Resources/project.json",
|
|
46
|
+
promptLayers: "形态",
|
|
47
|
+
/** 非交互终端的处置 */
|
|
48
|
+
nonInteractive: "当前不是交互终端,还有未指定的项",
|
|
49
|
+
nonInteractiveHint:
|
|
50
|
+
"请补 --yes 取全部缺省,或用参数指定:--description --framework --shell --sources",
|
|
51
|
+
promptExisting: "目标目录已存在且有内容,怎么处理",
|
|
36
52
|
/** 参数校验失败 */
|
|
37
53
|
nameRequired: "工程名不能为空",
|
|
38
54
|
nameInvalid:
|
|
39
55
|
"工程名只能包含小写字母、数字、连字符与下划线,且不能以点或连字符开头",
|
|
40
|
-
dirMissing: "目标目录不存在",
|
|
41
56
|
frameworkUnknown: "未知框架",
|
|
42
57
|
shellUnknown: "未知壳",
|
|
43
58
|
sourcesUnknown: "未知故事源形态",
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
templateMalformed: "形态写法不对,应为 框架[-壳[-故事源]]",
|
|
60
|
+
/** 形态多选 */
|
|
61
|
+
layersEmptyHint: "回车 = 只用骨架(框架无关 + 浏览器)",
|
|
62
|
+
layersInvalid: "没听懂这组编号,请用空格或逗号分隔的序号",
|
|
63
|
+
layersConflict: "选中的层互相排斥",
|
|
64
|
+
layersConflictHint: "重新选择(回车 = 全部缺省)",
|
|
65
|
+
/** 目标目录已存在时的三种处置 */
|
|
66
|
+
existingIgnore: "保留现有文件,同名覆盖",
|
|
67
|
+
existingIgnoreHint: "只写本模板的文件,别的东西原地不动",
|
|
68
|
+
existingClear: "清空目录后生成",
|
|
69
|
+
existingClearHint: "删掉目录里的全部内容,不可撤销",
|
|
70
|
+
existingCancel: "取消",
|
|
71
|
+
existingCancelHint: "什么都不做,退出",
|
|
72
|
+
targetExists: "目标目录已存在且非空,未做任何改动",
|
|
46
73
|
templateMissing: "找不到模板目录:本包未随附模板内容,或开发态仓库结构不完整",
|
|
47
74
|
/** 生成过程 */
|
|
48
75
|
stageResolve: "解析模板与版本…",
|
|
49
76
|
stageCopy: "写入工程文件…",
|
|
50
77
|
stageInstall: "安装依赖…",
|
|
51
78
|
doneTitle: "工程已生成",
|
|
52
|
-
|
|
79
|
+
/** 生成后的三步提示;`<install>` / `<dev>` 由调用方按包管理器替换 */
|
|
80
|
+
nextSteps: ["cd <name>", "<install>", "<dev>"].join("\n"),
|
|
53
81
|
/** 非致命提示 */
|
|
54
82
|
warnVersionOffline:
|
|
55
83
|
"无法查询 npm registry 上的最新版本,已沿用模板记录的版本",
|
|
56
|
-
warnInstallFailed: "
|
|
84
|
+
warnInstallFailed: "依赖安装失败,请在工程目录内手动安装依赖",
|
|
57
85
|
} as const;
|
|
58
86
|
|
|
59
87
|
export type CreateTexts = typeof defaultCreateTexts;
|