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,131 @@
1
+ /**
2
+ * 工作区扫描:找出哪些目录是 MC 工程,再交给 detectProject 出画像。
3
+ *
4
+ * 规则(对应策划 §6.1):
5
+ * - 从工作区根 BFS,深度默认 3;
6
+ * - 命中构建文件的目录即视为工程根,**不再往下走**(子模块由 detect 的 include 展开);
7
+ * - 剪掉 build/out/bin/run/.gradle 这类产物与依赖目录——不剪会把
8
+ * `reference/` 里别人家的 mods.toml、`bin/` 里旧的 Eclipse 产物当成工程。
9
+ * @module minecraft-dev-starter/lib/scanner
10
+ */
11
+
12
+ import { readdir } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import { detectProject } from './detect.js'
15
+ import { discoverJdks } from './java.js'
16
+
17
+ /** 扫描时直接跳过的目录名。 */
18
+ export const PRUNE_DIRS = new Set([
19
+ '.git',
20
+ '.gradle',
21
+ '.idea',
22
+ '.vscode',
23
+ '.dsh',
24
+ 'build',
25
+ 'out',
26
+ 'bin',
27
+ 'run',
28
+ 'node_modules',
29
+ 'libs',
30
+ 'reference',
31
+ 'logs',
32
+ ])
33
+
34
+ /** 构建脚本名;命中任一即认为这是一个 Gradle 工程。 */
35
+ const BUILD_FILES = new Set(['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'])
36
+
37
+ /**
38
+ * @typedef {object} ScanResult
39
+ * @property {string} root
40
+ * @property {Array<Record<string, unknown>>} projects
41
+ * @property {number} scannedDirs
42
+ * @property {boolean} truncated
43
+ */
44
+
45
+ /**
46
+ * 扫一个工作区。
47
+ * @param {string} root
48
+ * @param {{ depth?: number, maxDirs?: number, maxProjects?: number, jdks?: import('./java.js').JdkInfo[], signal?: AbortSignal }} [options]
49
+ * @returns {Promise<ScanResult>}
50
+ */
51
+ export async function scanWorkspace(root, options = {}) {
52
+ const depth = options.depth ?? 3
53
+ const maxDirs = options.maxDirs ?? 2000
54
+ const maxProjects = options.maxProjects ?? 120
55
+ const jdks = options.jdks ?? (await discoverJdks())
56
+
57
+ /** @type {Array<{ dir: string, depth: number }>} */
58
+ const queue = [{ dir: root, depth: 0 }]
59
+ /** @type {string[]} */
60
+ const candidates = []
61
+ let scannedDirs = 0
62
+ let truncated = false
63
+
64
+ while (queue.length > 0) {
65
+ if (options.signal?.aborted === true) break
66
+ const item = queue.shift()
67
+ if (item === undefined) break
68
+ scannedDirs += 1
69
+ if (scannedDirs > maxDirs) {
70
+ truncated = true
71
+ break
72
+ }
73
+ if (candidates.length >= maxProjects) {
74
+ truncated = true
75
+ break
76
+ }
77
+
78
+ const entries = await safeReaddir(item.dir)
79
+ if (entries === undefined) continue
80
+
81
+ if (item.depth > 0 && entries.files.some((name) => BUILD_FILES.has(name))) {
82
+ candidates.push(item.dir)
83
+ continue // 工程内部不再展开,模块交给 detect
84
+ }
85
+
86
+ if (item.depth >= depth) continue
87
+ for (const child of entries.dirs) {
88
+ if (PRUNE_DIRS.has(child)) continue
89
+ queue.push({ dir: join(item.dir, child), depth: item.depth + 1 })
90
+ }
91
+ }
92
+
93
+ // 工作区根本身就是一个工程的情况
94
+ const rootEntries = await safeReaddir(root)
95
+ if (rootEntries !== undefined && !candidates.includes(root)) {
96
+ const isProject = rootEntries.files.some((name) => BUILD_FILES.has(name))
97
+ if (isProject) candidates.unshift(root)
98
+ }
99
+
100
+ /** @type {Array<Record<string, unknown>>} */
101
+ const projects = []
102
+ for (const dir of candidates) {
103
+ if (options.signal?.aborted === true) break
104
+ try {
105
+ projects.push(await detectProject(dir, { jdks }))
106
+ } catch (error) {
107
+ projects.push({ root: dir, loader: 'unknown', error: error instanceof Error ? error.message : String(error) })
108
+ }
109
+ }
110
+
111
+ return { root, projects, scannedDirs, truncated }
112
+ }
113
+
114
+ /**
115
+ * @param {string} dir
116
+ * @returns {Promise<{ files: string[], dirs: string[] } | undefined>}
117
+ */
118
+ async function safeReaddir(dir) {
119
+ try {
120
+ const entries = await readdir(dir, { withFileTypes: true })
121
+ const files = []
122
+ const dirs = []
123
+ for (const entry of entries) {
124
+ if (entry.isDirectory()) dirs.push(entry.name)
125
+ else if (entry.isFile()) files.push(entry.name)
126
+ }
127
+ return { files, dirs }
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 状态落盘:`$DSH_HOME/mc-dev-starter/state.json` 与同目录下的日志。
3
+ *
4
+ * 写入用「临时文件 + rename」:宿主被强杀时不会留下半个 JSON。
5
+ * @module minecraft-dev-starter/lib/state
6
+ */
7
+
8
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ /**
13
+ * 状态目录。`DSH_HOME` 优先,其次 `~/.dsh`。
14
+ * @param {NodeJS.ProcessEnv} [env]
15
+ * @returns {string}
16
+ */
17
+ export function stateDir(env = process.env) {
18
+ const home = env.DSH_HOME ?? join(env.USERPROFILE ?? homedir(), '.dsh')
19
+ return join(home, 'mc-dev-starter')
20
+ }
21
+
22
+ /**
23
+ * @param {string} dir
24
+ * @param {string} launchId
25
+ * @returns {string}
26
+ */
27
+ export function logPath(dir, launchId) {
28
+ return join(dir, 'logs', `${launchId}.log`)
29
+ }
30
+
31
+ /**
32
+ * @typedef {object} LaunchRecord
33
+ * @property {string} id
34
+ * @property {string} root
35
+ * @property {string} taskPath
36
+ * @property {string} label
37
+ * @property {string} kind client | server | data | gameTestServer
38
+ * @property {string} module
39
+ * @property {number | null} pid
40
+ * @property {string} startedAt
41
+ * @property {string | null} endedAt
42
+ * @property {'starting' | 'running' | 'exited' | 'unknown'} status
43
+ * @property {'normal' | 'crashed' | 'killed' | 'failed' | null} outcome
44
+ * @property {number | null} exitCode
45
+ * @property {string | null} signal
46
+ * @property {string} logPath
47
+ */
48
+
49
+ /**
50
+ * @typedef {object} PersistentState
51
+ * @property {number} version
52
+ * @property {number} nextLaunch
53
+ * @property {LaunchRecord[]} launches
54
+ */
55
+
56
+ /** @returns {PersistentState} */
57
+ export function emptyState() {
58
+ return { version: 1, nextLaunch: 1, launches: [] }
59
+ }
60
+
61
+ /**
62
+ * @param {string} dir
63
+ * @returns {Promise<PersistentState>}
64
+ */
65
+ export async function loadState(dir) {
66
+ try {
67
+ const raw = await readFile(join(dir, 'state.json'), 'utf8')
68
+ const parsed = JSON.parse(raw)
69
+ if (parsed === null || typeof parsed !== 'object') return emptyState()
70
+ return {
71
+ version: Number(parsed.version ?? 1),
72
+ nextLaunch: Number(parsed.nextLaunch ?? 1),
73
+ launches: Array.isArray(parsed.launches) ? parsed.launches : [],
74
+ }
75
+ } catch {
76
+ return emptyState()
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @param {string} dir
82
+ * @param {PersistentState} state
83
+ * @returns {Promise<void>}
84
+ */
85
+ export async function saveState(dir, state) {
86
+ await mkdir(dir, { recursive: true })
87
+ const target = join(dir, 'state.json')
88
+ const temp = `${target}.tmp`
89
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
90
+ await rename(temp, target)
91
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 文本小工具:读文件、解析 .properties、解析 ${...} 占位符。
3
+ * 识别引擎的每个规则都靠这几样东西,单独放一处方便测试。
4
+ * @module minecraft-dev-starter/lib/text
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises'
8
+
9
+ /**
10
+ * 读文本文件;不存在、没权限、是目录都返回 undefined(识别过程不该因为个别文件中断)。
11
+ * @param {string} path
12
+ * @returns {Promise<string | undefined>}
13
+ */
14
+ export async function readText(path) {
15
+ try {
16
+ return await readFile(path, 'utf8')
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 解析 Java 风格的 .properties(`key=value`,`#`/`!` 注释)。
24
+ * 不处理续行与转义——Gradle 工程里几乎用不到。
25
+ * @param {string | undefined} text
26
+ * @returns {Record<string, string>}
27
+ */
28
+ export function parseProperties(text) {
29
+ /** @type {Record<string, string>} */
30
+ const out = {}
31
+ if (!text) return out
32
+ for (const raw of text.split(/\r?\n/)) {
33
+ const line = raw.trim()
34
+ if (line === '' || line.startsWith('#') || line.startsWith('!')) continue
35
+ const eq = line.indexOf('=')
36
+ if (eq <= 0) continue
37
+ const key = line.slice(0, eq).trim()
38
+ if (key === '') continue
39
+ out[key] = line.slice(eq + 1).trim()
40
+ }
41
+ return out
42
+ }
43
+
44
+ const PLACEHOLDER = /\$\{([^}]+)\}/g
45
+
46
+ /**
47
+ * 把 `${key}` 替换成属性表里的值,替换不掉的保留原文并记下来。
48
+ * 元数据文件(fabric.mod.json / mods.toml)普遍写占位符,不解析就只能拿到字面量。
49
+ * @param {string | undefined} value
50
+ * @param {Record<string, string>} properties
51
+ * @returns {{ value: string | undefined, unresolved: string[] }}
52
+ */
53
+ export function resolvePlaceholders(value, properties) {
54
+ if (typeof value !== 'string' || !value.includes('${')) {
55
+ return { value, unresolved: [] }
56
+ }
57
+ /** @type {string[]} */
58
+ const unresolved = []
59
+ const resolved = value.replace(PLACEHOLDER, (match, rawName) => {
60
+ const key = String(rawName).trim()
61
+ if (Object.hasOwn(properties, key) && properties[key] !== '') return String(properties[key])
62
+ unresolved.push(key)
63
+ return match
64
+ })
65
+ return { value: resolved, unresolved }
66
+ }
67
+
68
+ /**
69
+ * 取第一个命中的捕获组。
70
+ * @param {string | undefined} text
71
+ * @param {RegExp} pattern
72
+ * @returns {string | undefined}
73
+ */
74
+ export function firstMatch(text, pattern) {
75
+ if (!text) return undefined
76
+ const match = pattern.exec(text)
77
+ return match?.[1]
78
+ }
79
+
80
+ /**
81
+ * 逐条尝试一组正则,返回第一个命中。
82
+ * @param {string | undefined} text
83
+ * @param {RegExp[]} patterns
84
+ * @returns {string | undefined}
85
+ */
86
+ export function firstMatchOf(text, patterns) {
87
+ for (const pattern of patterns) {
88
+ const hit = firstMatch(text, pattern)
89
+ if (hit !== undefined) return hit
90
+ }
91
+ return undefined
92
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * TOML 子集解析器:只覆盖 `mods.toml` / `neoforge.mods.toml` 实际用到的语法。
3
+ *
4
+ * 支持:注释、`key = value`、基本字符串(含 `"""` / `'''` 多行)、布尔、数字、
5
+ * 简单数组、`[table]`、`[[array.of.tables]]`。
6
+ * 不支持:内联表、日期、十六进制、转义 unicode——模组元数据里没有。
7
+ *
8
+ * @module minecraft-dev-starter/lib/toml
9
+ */
10
+
11
+ /**
12
+ * @typedef {Record<string, unknown>} TomlTable
13
+ */
14
+
15
+ /**
16
+ * @param {string} source
17
+ * @returns {TomlTable}
18
+ */
19
+ export function parseToml(source) {
20
+ /** @type {TomlTable} */
21
+ const root = {}
22
+ /** @type {TomlTable} */
23
+ let current = root
24
+ const lines = source.split(/\r?\n/)
25
+
26
+ let i = 0
27
+ while (i < lines.length) {
28
+ const line = lines[i]
29
+ const trimmed = line.trim()
30
+
31
+ if (trimmed === '' || trimmed.startsWith('#')) {
32
+ i += 1
33
+ continue
34
+ }
35
+
36
+ if (trimmed.startsWith('[[')) {
37
+ const close = trimmed.indexOf(']]')
38
+ if (close > 0) {
39
+ const path = trimmed.slice(2, close).trim()
40
+ const list = ensureArray(root, splitKey(path))
41
+ /** @type {TomlTable} */
42
+ const table = {}
43
+ list.push(table)
44
+ current = table
45
+ }
46
+ i += 1
47
+ continue
48
+ }
49
+
50
+ if (trimmed.startsWith('[')) {
51
+ const close = trimmed.indexOf(']')
52
+ if (close > 0) {
53
+ const path = trimmed.slice(1, close).trim()
54
+ current = ensureTable(root, splitKey(path))
55
+ }
56
+ i += 1
57
+ continue
58
+ }
59
+
60
+ const eq = indexOfEquals(trimmed)
61
+ if (eq <= 0) {
62
+ i += 1
63
+ continue
64
+ }
65
+
66
+ const key = unquote(trimmed.slice(0, eq).trim())
67
+ let rest = trimmed.slice(eq + 1).trim()
68
+
69
+ // 多行字符串:值跨行,直到出现同一对三引号
70
+ const triple = rest.startsWith("'''") ? "'''" : rest.startsWith('"""') ? '"""' : undefined
71
+ if (triple !== undefined) {
72
+ let body = rest.slice(3)
73
+ let closed = body.includes(triple)
74
+ while (!closed && i + 1 < lines.length) {
75
+ i += 1
76
+ body += `\n${lines[i]}`
77
+ closed = body.includes(triple)
78
+ }
79
+ const end = body.indexOf(triple)
80
+ current[key] = end >= 0 ? body.slice(0, end) : body
81
+ i += 1
82
+ continue
83
+ }
84
+
85
+ // 普通字符串也可能带行尾注释
86
+ rest = stripTrailingComment(rest)
87
+ current[key] = parseValue(rest)
88
+ i += 1
89
+ }
90
+
91
+ return root
92
+ }
93
+
94
+ /**
95
+ * 找 `=`,跳过引号内的内容。
96
+ * @param {string} text
97
+ * @returns {number}
98
+ */
99
+ function indexOfEquals(text) {
100
+ let quote
101
+ for (let i = 0; i < text.length; i += 1) {
102
+ const ch = text[i]
103
+ if (quote !== undefined) {
104
+ if (ch === '\\') i += 1
105
+ else if (ch === quote) quote = undefined
106
+ continue
107
+ }
108
+ if (ch === '"' || ch === "'") quote = ch
109
+ else if (ch === '=') return i
110
+ }
111
+ return -1
112
+ }
113
+
114
+ /**
115
+ * @param {string} text
116
+ * @returns {string}
117
+ */
118
+ function stripTrailingComment(text) {
119
+ let quote
120
+ for (let i = 0; i < text.length; i += 1) {
121
+ const ch = text[i]
122
+ if (quote !== undefined) {
123
+ if (ch === '\\') i += 1
124
+ else if (ch === quote) quote = undefined
125
+ continue
126
+ }
127
+ if (ch === '"' || ch === "'") quote = ch
128
+ else if (ch === '#') return text.slice(0, i).trimEnd()
129
+ }
130
+ return text
131
+ }
132
+
133
+ /**
134
+ * @param {string} text
135
+ * @returns {string[]}
136
+ */
137
+ function splitKey(text) {
138
+ return text
139
+ .split('.')
140
+ .map((part) => unquote(part.trim()))
141
+ .filter((part) => part !== '')
142
+ }
143
+
144
+ /**
145
+ * @param {string} text
146
+ * @returns {string}
147
+ */
148
+ function unquote(text) {
149
+ if (text.length >= 2 && (text.startsWith('"') || text.startsWith("'"))) {
150
+ return text.slice(1, -1)
151
+ }
152
+ return text
153
+ }
154
+
155
+ /**
156
+ * @param {string} raw
157
+ * @returns {unknown}
158
+ */
159
+ function parseValue(raw) {
160
+ const text = raw.trim()
161
+ if (text === '') return ''
162
+ if (text.startsWith('"') || text.startsWith("'") || text.startsWith('"""') || text.startsWith("'''")) {
163
+ return parseString(text)
164
+ }
165
+ if (text.startsWith('[')) return parseArray(text)
166
+ if (text === 'true') return true
167
+ if (text === 'false') return false
168
+ const number = Number(text)
169
+ return Number.isNaN(number) ? text : number
170
+ }
171
+
172
+ /**
173
+ * @param {string} text
174
+ * @returns {string}
175
+ */
176
+ function parseString(text) {
177
+ const triple = text.startsWith('"""') ? '"""' : text.startsWith("'''") ? "'''" : undefined
178
+ if (triple !== undefined) {
179
+ const body = text.slice(3)
180
+ const end = body.indexOf(triple)
181
+ return end >= 0 ? body.slice(0, end) : body
182
+ }
183
+ const quote = text[0]
184
+ let out = ''
185
+ for (let i = 1; i < text.length; i += 1) {
186
+ const ch = text[i]
187
+ if (ch === '\\' && i + 1 < text.length) {
188
+ const next = text[i + 1]
189
+ out += next === 'n' ? '\n' : next === 't' ? '\t' : next
190
+ i += 1
191
+ continue
192
+ }
193
+ if (ch === quote) break
194
+ out += ch
195
+ }
196
+ return out
197
+ }
198
+
199
+ /**
200
+ * @param {string} text
201
+ * @returns {Array<unknown>}
202
+ */
203
+ function parseArray(text) {
204
+ const inner = text.slice(1, text.lastIndexOf(']'))
205
+ /** @type {Array<unknown>} */
206
+ const out = []
207
+ let buffer = ''
208
+ let quote
209
+ for (let i = 0; i < inner.length; i += 1) {
210
+ const ch = inner[i]
211
+ if (quote !== undefined) {
212
+ buffer += ch
213
+ if (ch === '\\') {
214
+ buffer += inner[i + 1] ?? ''
215
+ i += 1
216
+ } else if (ch === quote) quote = undefined
217
+ continue
218
+ }
219
+ if (ch === '"' || ch === "'") {
220
+ quote = ch
221
+ buffer += ch
222
+ continue
223
+ }
224
+ if (ch === ',') {
225
+ pushItem(out, buffer)
226
+ buffer = ''
227
+ continue
228
+ }
229
+ buffer += ch
230
+ }
231
+ pushItem(out, buffer)
232
+ return out
233
+ }
234
+
235
+ /**
236
+ * @param {Array<unknown>} out
237
+ * @param {string} raw
238
+ */
239
+ function pushItem(out, raw) {
240
+ const text = raw.trim()
241
+ if (text === '') return
242
+ out.push(parseValue(text))
243
+ }
244
+
245
+ /**
246
+ * @param {TomlTable} root
247
+ * @param {string[]} path
248
+ * @returns {TomlTable}
249
+ */
250
+ function ensureTable(root, path) {
251
+ /** @type {TomlTable} */
252
+ let node = root
253
+ for (const key of path) {
254
+ const next = node[key]
255
+ if (next !== null && typeof next === 'object' && !Array.isArray(next)) {
256
+ node = /** @type {TomlTable} */ (next)
257
+ continue
258
+ }
259
+ /** @type {TomlTable} */
260
+ const created = {}
261
+ node[key] = created
262
+ node = created
263
+ }
264
+ return node
265
+ }
266
+
267
+ /**
268
+ * @param {TomlTable} root
269
+ * @param {string[]} path
270
+ * @returns {Array<TomlTable>}
271
+ */
272
+ function ensureArray(root, path) {
273
+ const parent = ensureTable(root, path.slice(0, -1))
274
+ const key = path.at(-1) ?? ''
275
+ const existing = parent[key]
276
+ if (Array.isArray(existing)) return /** @type {Array<TomlTable>} */ (existing)
277
+ /** @type {Array<TomlTable>} */
278
+ const list = []
279
+ parent[key] = list
280
+ return list
281
+ }