minecraft-dev-starter 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * 启动服务:把识别出来的 Gradle 任务跑成"实例",并管住它的生命周期。
3
+ *
4
+ * 为什么不用 `ctx.subprocess` / `ctx.jobs`:两者都会随宿主作用域 dispose 被杀,
5
+ * 而游戏要能活过 DSH 会话(依据见 doc/调研-DSH插件机制.md §6)。
6
+ *
7
+ * 进程模型:
8
+ * - `child_process.spawn({ windowsHide: true })`,输出走管道收进环形缓冲并落盘;
9
+ * - Windows 上**不能** detached(会丢标准输出、还会多开一个控制台),
10
+ * POSIX 上保留 detached 以便按进程组整组终止;
11
+ * - 停止用 `taskkill /PID <pid> /T /F`(Windows)杀整棵树,不留 java 孤儿;
12
+ * - 宿主重启时按 `pid` 探活:探不到就说 `unknown`,不伪造结束原因。
13
+ * @module minecraft-dev-starter/lib/launches
14
+ */
15
+
16
+ import { spawn } from 'node:child_process'
17
+ import { createWriteStream } from 'node:fs'
18
+ import { mkdir, readFile } from 'node:fs/promises'
19
+ import { dirname } from 'node:path'
20
+ import { gradlewCommand, isAlive, TASK_PATH_PATTERN } from './argv.js'
21
+ import { loadState, logPath, saveState, stateDir } from './state.js'
22
+
23
+ /** 各目标"就绪"的日志特征。命中即把实例从 starting 置为 running。 */
24
+ const READY_MARKERS = {
25
+ client: [/Backend library: LWJGL/i, /OpenAL initialized/i, /\[Render thread\/INFO\]/],
26
+ server: [/Done \([0-9.]+s\)!/i, /For help, type "help"/i],
27
+ data: [/BUILD SUCCESSFUL/i],
28
+ gameTestServer: [/Done \([0-9.]+s\)!/i, /All required tests passed/i],
29
+ }
30
+
31
+ /** 这些特征说明是崩溃而不是正常退出。 */
32
+ const CRASH_MARKERS = [
33
+ /A fatal error has been detected/i,
34
+ /Mixin apply failed/i,
35
+ /Unsupported class file major version/i,
36
+ /No matching toolchains found/i,
37
+ /Could not resolve/i,
38
+ /BUILD FAILED/i,
39
+ ]
40
+
41
+ /**
42
+ * 崩溃归因规则。只做"看得懂的一句话",不做自动修复。
43
+ * 命中顺序有讲究:越具体的越靠前。
44
+ */
45
+ const DIAGNOSES = [
46
+ { pattern: /Could not resolve|Could not find [\w.:-]+:/, hint: '依赖解析失败:检查网络或 Gradle 离线缓存' },
47
+ { pattern: /Unsupported class file major version/, hint: 'JDK 版本不匹配:换成工程要求的 JDK' },
48
+ { pattern: /No matching toolchains found/, hint: '缺少所需版本的 JDK:装一个或用已有版本覆盖' },
49
+ { pattern: /Address already in use|FAILED TO BIND TO PORT/i, hint: '端口被占用:先关掉占用的进程' },
50
+ { pattern: /Mixin apply failed/i, hint: 'Mixin 注入失败:看日志里点名的 mixin 配置' },
51
+ { pattern: /OutOfMemoryError|Metaspace/i, hint: '内存不足:调 gradle.properties 的 org.gradle.jvmargs' },
52
+ { pattern: /crash-reports[\\/]/, hint: '游戏生成了 crash report:工程目录下的 crash-reports/' },
53
+ { pattern: /BUILD FAILED/, hint: 'Gradle 构建失败:往上翻第一条 error' },
54
+ ]
55
+
56
+ /**
57
+ * 从日志尾部找一条能给人看的结论。
58
+ * @param {string[]} lines
59
+ * @returns {{ hint: string, line: string } | undefined}
60
+ */
61
+ export function diagnose(lines) {
62
+ for (const rule of DIAGNOSES) {
63
+ const hit = lines.find((line) => rule.pattern.test(line))
64
+ if (hit !== undefined) return { hint: rule.hint, line: hit.trim().slice(0, 300) }
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ /**
70
+ * @typedef {object} LaunchRequest
71
+ * @property {string} root
72
+ * @property {string} taskPath
73
+ * @property {string} label
74
+ * @property {string} kind
75
+ * @property {string} [module]
76
+ * @property {string[]} [extraArgs]
77
+ */
78
+
79
+ /**
80
+ * @param {{ dir?: string, platform?: string, spawnImpl?: typeof spawn, maxLogLines?: number, readyTimeoutMs?: number, commandFor?: typeof gradlewCommand }} [options]
81
+ */
82
+ export function createLaunchService(options = {}) {
83
+ const dir = options.dir ?? stateDir()
84
+ const platform = options.platform ?? process.platform
85
+ const spawnImpl = options.spawnImpl ?? spawn
86
+ const commandFor = options.commandFor ?? gradlewCommand
87
+ const maxLogLines = options.maxLogLines ?? 2000
88
+ const readyTimeoutMs = options.readyTimeoutMs ?? 90_000
89
+
90
+ /**
91
+ * @typedef {object} Runtime
92
+ * @property {string[]} ring
93
+ * @property {boolean} ready
94
+ * @property {NodeJS.Timeout | undefined} readyTimer
95
+ * @property {import('node:child_process').ChildProcess | undefined} child
96
+ */
97
+ /** @type {Map<string, Runtime>} */
98
+ const runtimes = new Map()
99
+
100
+ /** 串行化状态写入,避免并发 start/stop 互相覆盖。 */
101
+ let chain = Promise.resolve()
102
+ /** @type {import('./state.js').PersistentState | undefined} */
103
+ let state
104
+
105
+ /**
106
+ * @template T
107
+ * @param {() => Promise<T>} fn
108
+ * @returns {Promise<T>}
109
+ */
110
+ function serialize(fn) {
111
+ const next = chain.then(fn, fn)
112
+ chain = next.then(
113
+ () => undefined,
114
+ () => undefined,
115
+ )
116
+ return next
117
+ }
118
+
119
+ async function ensureState() {
120
+ if (state !== undefined) return state
121
+ state = await loadState(dir)
122
+ for (const record of state.launches) {
123
+ if (record.status !== 'starting' && record.status !== 'running') continue
124
+ if (isAlive(record.pid)) continue
125
+ // 宿主重启后进程没了:不猜它怎么结束的
126
+ record.status = 'unknown'
127
+ record.endedAt = new Date().toISOString()
128
+ }
129
+ await saveState(dir, state)
130
+ return state
131
+ }
132
+
133
+ /**
134
+ * 找这个工程当前活跃的实例。
135
+ * @param {string} root
136
+ */
137
+ function activeFor(root) {
138
+ return (state?.launches ?? []).find(
139
+ (record) => record.root.toLowerCase() === root.toLowerCase() && (record.status === 'starting' || record.status === 'running'),
140
+ )
141
+ }
142
+
143
+ /**
144
+ * @param {LaunchRequest} request
145
+ */
146
+ async function start(request) {
147
+ if (!TASK_PATH_PATTERN.test(request.taskPath)) {
148
+ throw Object.assign(new Error(`不认识的 Gradle 任务:${request.taskPath}`), { code: 'BAD_TASK' })
149
+ }
150
+ const current = await ensureState()
151
+ return serialize(async () => {
152
+ const running = activeFor(request.root)
153
+ if (running !== undefined) {
154
+ throw Object.assign(new Error(`${request.root} 已有运行中的实例:${running.taskPath}`), {
155
+ code: 'ALREADY_RUNNING',
156
+ launch: running,
157
+ })
158
+ }
159
+
160
+ const id = `launch-${current.nextLaunch}`
161
+ current.nextLaunch += 1
162
+ const file = logPath(dir, id)
163
+ await mkdir(dirname(file), { recursive: true })
164
+
165
+ /** @type {import('./state.js').LaunchRecord} */
166
+ const record = {
167
+ id,
168
+ root: request.root,
169
+ taskPath: request.taskPath,
170
+ label: request.label,
171
+ kind: request.kind,
172
+ module: request.module ?? '.',
173
+ pid: null,
174
+ startedAt: new Date().toISOString(),
175
+ endedAt: null,
176
+ status: 'starting',
177
+ outcome: null,
178
+ exitCode: null,
179
+ signal: null,
180
+ logPath: file,
181
+ }
182
+
183
+ const command = commandFor({ platform, projectDir: request.root, taskPath: request.taskPath, extraArgs: request.extraArgs })
184
+ /** @type {Runtime} */
185
+ const runtime = { ring: [], ready: false, readyTimer: undefined, child: undefined }
186
+
187
+ try {
188
+ const child = spawnImpl(command.argv[0], command.argv.slice(1), {
189
+ cwd: command.cwd,
190
+ windowsHide: true,
191
+ windowsVerbatimArguments: command.windowsVerbatimArguments,
192
+ // Windows 上不能 detached:DETACHED_PROCESS 与 CREATE_NO_WINDOW 组合后
193
+ // 子进程的标准输出会整个丢掉(实测同一命令 detached 拿到 0 字节、
194
+ // attached 拿到完整输出),顺带还会让 java 自己新开一个可见控制台。
195
+ // POSIX 仍要 detached,否则 `kill(-pid)` 杀不到进程组。
196
+ detached: platform !== 'win32',
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ })
199
+ runtime.child = child
200
+ record.pid = child.pid ?? null
201
+ current.launches.unshift(record)
202
+ if (current.launches.length > 100) current.launches.length = 100
203
+ await saveState(dir, current)
204
+
205
+ const sink = createWriteStream(file, { flags: 'a' })
206
+ attachStream(runtime, record, child.stdout, sink)
207
+ attachStream(runtime, record, child.stderr, sink)
208
+
209
+ runtime.readyTimer = setTimeout(() => {
210
+ if (record.status === 'starting') record.readyTimedOut = true
211
+ }, readyTimeoutMs)
212
+ runtime.readyTimer.unref?.()
213
+
214
+ child.on('exit', (code, signal) => {
215
+ if (runtime.readyTimer !== undefined) clearTimeout(runtime.readyTimer)
216
+ record.pid = null
217
+ record.exitCode = code
218
+ record.signal = signal
219
+ const crashed = code !== 0 && code !== null
220
+ record.outcome = record.stoppedByUser === true ? 'killed' : crashed ? 'crashed' : 'normal'
221
+ record.status = 'exited'
222
+ record.endedAt = new Date().toISOString()
223
+ sink.end()
224
+ void serialize(async () => saveState(dir, current))
225
+ })
226
+
227
+ return { ...record, logPath: file }
228
+ } catch (error) {
229
+ record.status = 'exited'
230
+ record.outcome = 'failed'
231
+ record.endedAt = new Date().toISOString()
232
+ current.launches.unshift(record)
233
+ await saveState(dir, current)
234
+ throw Object.assign(new Error(`启动失败:${error instanceof Error ? error.message : String(error)}`), { code: 'SPAWN_FAILED' })
235
+ }
236
+ })
237
+ }
238
+
239
+ /**
240
+ * 把一路输出接到环形缓冲、就绪判定与日志文件。
241
+ *
242
+ * 按字节切行再逐行解码:中文 Windows 上 Gradle 与游戏可能输出 GBK,
243
+ * 逐块 toString('utf8') 会把多字节字符切坏,逐行才有机会回退到 GBK。
244
+ * @param {Runtime} runtime
245
+ * @param {import('./state.js').LaunchRecord} record
246
+ * @param {NodeJS.ReadableStream | null} stream
247
+ * @param {import('node:fs').WriteStream} sink
248
+ */
249
+ function attachStream(runtime, record, stream, sink) {
250
+ if (stream === null) return
251
+ /** @type {Buffer} */
252
+ let partial = Buffer.alloc(0)
253
+ stream.on('data', (chunk) => {
254
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
255
+ sink.write(buffer)
256
+ partial = partial.length === 0 ? buffer : Buffer.concat([partial, buffer])
257
+ let index = partial.indexOf(0x0a)
258
+ while (index >= 0) {
259
+ const line = partial.subarray(0, index)
260
+ partial = partial.subarray(index + 1)
261
+ pushLine(runtime, record, decodeLine(line))
262
+ index = partial.indexOf(0x0a)
263
+ }
264
+ })
265
+ stream.on('end', () => {
266
+ if (partial.length > 0) pushLine(runtime, record, decodeLine(partial))
267
+ partial = Buffer.alloc(0)
268
+ })
269
+ }
270
+
271
+ /**
272
+ * @param {Runtime} runtime
273
+ * @param {import('./state.js').LaunchRecord} record
274
+ * @param {string} line
275
+ */
276
+ function pushLine(runtime, record, line) {
277
+ runtime.ring.push(line)
278
+ if (runtime.ring.length > maxLogLines) runtime.ring.shift()
279
+ if (runtime.ready || record.status !== 'starting') return
280
+ const markers = READY_MARKERS[/** @type {keyof typeof READY_MARKERS} */ (record.kind)] ?? READY_MARKERS.server
281
+ if (markers.some((pattern) => pattern.test(line))) {
282
+ runtime.ready = true
283
+ record.status = 'running'
284
+ if (runtime.readyTimer !== undefined) clearTimeout(runtime.readyTimer)
285
+ void serialize(async () => saveState(dir, state))
286
+ }
287
+ }
288
+
289
+ /**
290
+ * @param {string} launchId
291
+ * @param {{ graceMs?: number }} [request]
292
+ */
293
+ async function stop(launchId, request = {}) {
294
+ const current = await ensureState()
295
+ const record = current.launches.find((entry) => entry.id === launchId)
296
+ if (record === undefined) throw Object.assign(new Error(`没有这个实例:${launchId}`), { code: 'UNKNOWN_LAUNCH' })
297
+
298
+ record.stoppedByUser = true
299
+ if (record.pid === null || !isAlive(record.pid)) {
300
+ if (record.status === 'starting' || record.status === 'running') {
301
+ record.status = 'unknown'
302
+ record.endedAt = new Date().toISOString()
303
+ }
304
+ await serialize(async () => saveState(dir, current))
305
+ return { ...record }
306
+ }
307
+
308
+ await killTree(record.pid, platform, request.graceMs ?? 8000)
309
+ return { ...record }
310
+ }
311
+
312
+ /** @returns {Promise<Array<Record<string, unknown>>>} */
313
+ async function list() {
314
+ const current = await ensureState()
315
+ return current.launches.map((record) => snapshot(record))
316
+ }
317
+
318
+ /**
319
+ * @param {string} root
320
+ * @returns {Promise<Record<string, unknown> | undefined>}
321
+ */
322
+ async function current(root) {
323
+ const currentState = await ensureState()
324
+ const record = currentState.launches.find(
325
+ (entry) => entry.root.toLowerCase() === root.toLowerCase() && (entry.status === 'starting' || entry.status === 'running'),
326
+ )
327
+ return record === undefined ? undefined : snapshot(record)
328
+ }
329
+
330
+ /**
331
+ * 读日志:文件是权威来源(宿主重启后内存缓冲就没了)。
332
+ * @param {string} launchId
333
+ * @param {number} offset 起始行号(0 起)
334
+ * @param {number} limit
335
+ */
336
+ async function logs(launchId, offset = 0, limit = 400) {
337
+ const currentState = await ensureState()
338
+ const record = currentState.launches.find((entry) => entry.id === launchId)
339
+ if (record === undefined) throw Object.assign(new Error(`没有这个实例:${launchId}`), { code: 'UNKNOWN_LAUNCH' })
340
+ let all = []
341
+ try {
342
+ all = splitBufferLines(await readFile(record.logPath))
343
+ } catch {
344
+ all = []
345
+ }
346
+ const lines = all.slice(Math.max(0, offset), Math.max(0, offset) + limit)
347
+ const abnormal = record.outcome !== null && record.outcome !== 'normal'
348
+ return {
349
+ launchId,
350
+ lines,
351
+ nextOffset: Math.max(0, offset) + lines.length,
352
+ total: all.length,
353
+ status: record.status,
354
+ outcome: record.outcome ?? null,
355
+ exitCode: record.exitCode ?? null,
356
+ readyTimedOut: record.readyTimedOut === true,
357
+ startedAt: record.startedAt,
358
+ endedAt: record.endedAt ?? null,
359
+ label: record.label,
360
+ taskPath: record.taskPath,
361
+ root: record.root,
362
+ diagnosis: abnormal ? diagnose(all.slice(-300)) : undefined,
363
+ }
364
+ }
365
+
366
+ /**
367
+ * @param {import('./state.js').LaunchRecord} record
368
+ */
369
+ function snapshot(record) {
370
+ const running = record.status === 'starting' || record.status === 'running'
371
+ const runtime = runtimes.get(record.id)
372
+ return {
373
+ id: record.id,
374
+ root: record.root,
375
+ taskPath: record.taskPath,
376
+ label: record.label,
377
+ kind: record.kind,
378
+ module: record.module,
379
+ pid: record.pid,
380
+ status: record.status,
381
+ outcome: record.outcome ?? null,
382
+ exitCode: record.exitCode ?? null,
383
+ startedAt: record.startedAt,
384
+ endedAt: record.endedAt ?? null,
385
+ logPath: record.logPath,
386
+ readyTimedOut: record.readyTimedOut === true,
387
+ tail: runtime?.ring.slice(-8) ?? (running ? [] : lastLinesFromMarker(record)),
388
+ }
389
+ }
390
+
391
+ /**
392
+ * 已结束的实例没有内存缓冲时,用日志尾部给界面一个摘要。
393
+ * @param {import('./state.js').LaunchRecord} record
394
+ * @returns {string[]}
395
+ */
396
+ function lastLinesFromMarker(record) {
397
+ return crashSummary(record) === undefined ? [] : [crashSummary(record) ?? '']
398
+ }
399
+
400
+ /**
401
+ * 粗粒度归因:M3 会做成规则表,这里先给一条能看懂的话。
402
+ * @param {import('./state.js').LaunchRecord} record
403
+ * @returns {string | undefined}
404
+ */
405
+ function crashSummary(record) {
406
+ if (record.outcome === 'normal') return undefined
407
+ if (record.exitCode === null) return undefined
408
+ return `退出码 ${record.exitCode}`
409
+ }
410
+
411
+ return { start, stop, list, current, logs, ensureState, stateDir: dir }
412
+ }
413
+
414
+ /**
415
+ * 杀整棵进程树。
416
+ * @param {number} pid
417
+ * @param {string} platform
418
+ * @param {number} graceMs
419
+ */
420
+ async function killTree(pid, platform, graceMs) {
421
+ if (platform === 'win32') {
422
+ await new Promise((resolve) => {
423
+ const child = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
424
+ child.on('exit', () => resolve(undefined))
425
+ child.on('error', () => resolve(undefined))
426
+ })
427
+ return
428
+ }
429
+ try {
430
+ process.kill(-pid, 'SIGTERM')
431
+ } catch {
432
+ try {
433
+ process.kill(pid, 'SIGTERM')
434
+ } catch {
435
+ return
436
+ }
437
+ }
438
+ await new Promise((resolve) => setTimeout(resolve, Math.min(graceMs, 3000)))
439
+ if (isAlive(pid)) {
440
+ try {
441
+ process.kill(-pid, 'SIGKILL')
442
+ } catch {
443
+ try {
444
+ process.kill(pid, 'SIGKILL')
445
+ } catch {
446
+ /* 已经没了 */
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ /** 供测试与调用方复用。 */
453
+ export { READY_MARKERS, CRASH_MARKERS, DIAGNOSES }
454
+
455
+ /** GBK 解码器;运行时不带完整 ICU 时为 undefined,此时只能退回 UTF-8。 */
456
+ const GBK_DECODER = (() => {
457
+ try {
458
+ return new TextDecoder('gbk')
459
+ } catch {
460
+ return undefined
461
+ }
462
+ })()
463
+
464
+ /**
465
+ * 一行输出 → 文本:先按 UTF-8,出现替换字符时回退 GBK(中文 Windows 的 Gradle 常见)。
466
+ * @param {Buffer} buffer
467
+ * @returns {string}
468
+ */
469
+ export function decodeLine(buffer) {
470
+ const text = buffer.toString('utf8').replace(/\r$/, '')
471
+ if (!text.includes('\uFFFD') || GBK_DECODER === undefined) return text
472
+ try {
473
+ return GBK_DECODER.decode(buffer).replace(/\r$/, '')
474
+ } catch {
475
+ return text
476
+ }
477
+ }
478
+
479
+ /**
480
+ * 整个文件的字节 → 按行解码(逐行才有机会对 GBK 回退)。
481
+ * @param {Buffer} buffer
482
+ * @returns {string[]}
483
+ */
484
+ export function splitBufferLines(buffer) {
485
+ /** @type {string[]} */
486
+ const lines = []
487
+ let start = 0
488
+ for (let index = 0; index < buffer.length; index += 1) {
489
+ if (buffer[index] !== 0x0a) continue
490
+ lines.push(decodeLine(buffer.subarray(start, index)))
491
+ start = index + 1
492
+ }
493
+ if (start < buffer.length) lines.push(decodeLine(buffer.subarray(start)))
494
+ return lines
495
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * 读模组/插件元数据文件并解析成统一形状。
3
+ *
4
+ * 查找路径覆盖三种真实形态(本机 17 个工程实测):
5
+ * - `src/main/resources/...`(最常见)
6
+ * - `src/main/templates/...`(NeoForge 的 processResources 模板,占位符还没展开)
7
+ * - 子模块里各自一套(Architectury)
8
+ *
9
+ * @module minecraft-dev-starter/lib/metadata
10
+ */
11
+
12
+ import { join } from 'node:path'
13
+ import { parseToml } from './toml.js'
14
+ import { readText } from './text.js'
15
+
16
+ /**
17
+ * @typedef {object} MetadataHit
18
+ * @property {'fabric' | 'forge' | 'neoforge' | 'mcmod' | 'bukkit' | 'paper'} kind
19
+ * @property {string} file 相对于模块目录的路径
20
+ * @property {Record<string, unknown>} data
21
+ */
22
+
23
+ const LOCATIONS = [
24
+ ['fabric', 'src/main/resources/fabric.mod.json'],
25
+ ['fabric', 'src/main/templates/fabric.mod.json'],
26
+ ['neoforge', 'src/main/resources/META-INF/neoforge.mods.toml'],
27
+ ['neoforge', 'src/main/templates/META-INF/neoforge.mods.toml'],
28
+ ['forge', 'src/main/resources/META-INF/mods.toml'],
29
+ ['forge', 'src/main/templates/META-INF/mods.toml'],
30
+ ['mcmod', 'src/main/resources/mcmod.info'],
31
+ ['mcmod', 'src/main/templates/mcmod.info'],
32
+ ['paper', 'src/main/resources/paper-plugin.yml'],
33
+ ['paper', 'src/main/templates/paper-plugin.yml'],
34
+ ['bukkit', 'src/main/resources/plugin.yml'],
35
+ ['bukkit', 'src/main/templates/plugin.yml'],
36
+ ]
37
+
38
+ /**
39
+ * 扫一个模块目录下所有元数据文件。
40
+ * @param {string} moduleDir
41
+ * @returns {Promise<MetadataHit[]>}
42
+ */
43
+ export async function locateMetadata(moduleDir) {
44
+ /** @type {MetadataHit[]} */
45
+ const hits = []
46
+ for (const [kind, rel] of LOCATIONS) {
47
+ const text = await readText(join(moduleDir, rel))
48
+ if (text === undefined) continue
49
+ const data = parseMetadataText(kind, text)
50
+ if (data !== undefined) hits.push({ kind, file: rel, data })
51
+ }
52
+ return hits
53
+ }
54
+
55
+ /**
56
+ * @param {MetadataHit['kind']} kind
57
+ * @param {string} text
58
+ * @returns {Record<string, unknown> | undefined}
59
+ */
60
+ function parseMetadataText(kind, text) {
61
+ try {
62
+ if (kind === 'fabric' || kind === 'mcmod') {
63
+ const parsed = JSON.parse(text)
64
+ if (kind === 'mcmod' && Array.isArray(parsed)) {
65
+ return /** @type {Record<string, unknown>} */ (parsed[0] ?? {})
66
+ }
67
+ return /** @type {Record<string, unknown>} */ (parsed)
68
+ }
69
+ if (kind === 'forge' || kind === 'neoforge') return parseModsToml(text)
70
+ return parseYamlSubset(text)
71
+ } catch {
72
+ return undefined
73
+ }
74
+ }
75
+
76
+ /**
77
+ * `mods.toml` / `neoforge.mods.toml`。
78
+ * @param {string} text
79
+ * @returns {Record<string, unknown>}
80
+ */
81
+ export function parseModsToml(text) {
82
+ const root = parseToml(text)
83
+ const mods = Array.isArray(root.mods) ? root.mods : []
84
+ const first = /** @type {Record<string, unknown>} */ (mods[0] ?? {})
85
+ /** @type {Array<{ modId?: string, versionRange?: string }>} */
86
+ const dependencies = []
87
+ const deps = root.dependencies
88
+ if (deps !== null && typeof deps === 'object' && !Array.isArray(deps)) {
89
+ for (const value of Object.values(/** @type {Record<string, unknown>} */ (deps))) {
90
+ if (Array.isArray(value)) {
91
+ for (const dep of value) {
92
+ if (dep !== null && typeof dep === 'object') {
93
+ dependencies.push(/** @type {{ modId?: string, versionRange?: string }} */ (dep))
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return {
100
+ modLoader: root.modLoader,
101
+ loaderVersion: root.loaderVersion,
102
+ license: root.license,
103
+ modId: first.modId,
104
+ version: first.version,
105
+ displayName: first.displayName,
106
+ description: first.description,
107
+ dependencies,
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 极简 YAML:只取顶层 `key: value`,够读 plugin.yml。
113
+ * @param {string} text
114
+ * @returns {Record<string, unknown>}
115
+ */
116
+ export function parseYamlSubset(text) {
117
+ /** @type {Record<string, unknown>} */
118
+ const out = {}
119
+ for (const raw of text.split(/\r?\n/)) {
120
+ if (/^\s/.test(raw)) continue // 嵌套结构不关心
121
+ const line = raw.trim()
122
+ if (line === '' || line.startsWith('#')) continue
123
+ const colon = line.indexOf(':')
124
+ if (colon <= 0) continue
125
+ const key = line.slice(0, colon).trim()
126
+ let value = line.slice(colon + 1).trim()
127
+ if (value.startsWith("'") || value.startsWith('"')) value = value.slice(1, -1)
128
+ value = value.replace(/\s+#.*$/, '')
129
+ out[key] = value
130
+ }
131
+ return out
132
+ }
133
+
134
+ /**
135
+ * 从一组命中里挑能代表工程的元数据(优先级:neoforge > forge > fabric > mcmod > paper > bukkit)。
136
+ * @param {MetadataHit[]} hits
137
+ * @returns {MetadataHit | undefined}
138
+ */
139
+ export function primaryMetadata(hits) {
140
+ const order = ['neoforge', 'forge', 'fabric', 'mcmod', 'paper', 'bukkit']
141
+ for (const kind of order) {
142
+ const hit = hits.find((entry) => entry.kind === kind)
143
+ if (hit) return hit
144
+ }
145
+ return undefined
146
+ }
147
+
148
+ /**
149
+ * 把元数据里的 `depends` / `dependencies` 拉平成 `modId → 版本范围`。
150
+ * @param {MetadataHit | undefined} hit
151
+ * @returns {Record<string, string>}
152
+ */
153
+ export function dependencyRanges(hit) {
154
+ /** @type {Record<string, string>} */
155
+ const out = {}
156
+ if (!hit) return out
157
+ const data = hit.data
158
+ const depends = data.depends
159
+ if (depends !== null && typeof depends === 'object' && !Array.isArray(depends)) {
160
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (depends))) {
161
+ out[key] = typeof value === 'string' ? value : String(value)
162
+ }
163
+ }
164
+ const deps = data.dependencies
165
+ if (Array.isArray(deps)) {
166
+ for (const dep of deps) {
167
+ if (dep === null || typeof dep !== 'object') continue
168
+ const record = /** @type {{ modId?: unknown, versionRange?: unknown }} */ (dep)
169
+ if (typeof record.modId === 'string' && typeof record.versionRange === 'string') {
170
+ out[record.modId] = record.versionRange
171
+ }
172
+ }
173
+ }
174
+ return out
175
+ }