minecraft-dev-starter 0.0.0-stage → 0.1.1

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/src/lib/api.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `/mcdev/api` 的方法表。宿主入口只负责挂路由,具体动作在这里。
3
+ *
4
+ * 方法名与请求体形状一旦定下就是客户端合同,改名前先改客户端与文档。
5
+ * @module minecraft-dev-starter/lib/api
6
+ */
7
+
8
+ import { detectProject } from './detect.js'
9
+ import { discoverJdks } from './java.js'
10
+ import { createLaunchService } from './launches.js'
11
+ import { scanWorkspace } from './scanner.js'
12
+
13
+ /** 扫描结果的短缓存:面板打开/切会话会连着问几次,没必要每次重扫。 */
14
+ const CACHE_TTL_MS = 5000
15
+
16
+ /** 客户端诊断上报的最近若干条(只在开发期用)。 */
17
+ const clientReports = []
18
+
19
+ /**
20
+ * 判断子路径是否落在父路径之下(Windows 下大小写不敏感)。
21
+ * @param {string} child
22
+ * @param {string} parent
23
+ * @returns {boolean}
24
+ */
25
+ function isInside(child, parent) {
26
+ const normalize = (value) => value.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
27
+ const a = normalize(child)
28
+ const b = normalize(parent)
29
+ return a === b || a.startsWith(`${b}\\`)
30
+ }
31
+
32
+ /**
33
+ * @param {{ resolveRoot: () => Promise<string | undefined>, resolveRoots?: () => Promise<string[]>, version?: string, config?: Record<string, unknown>, launchService?: ReturnType<typeof createLaunchService> }} options
34
+ */
35
+ export function createApi(options) {
36
+ /** @type {Map<string, { at: number, value: unknown }>} */
37
+ const cache = new Map()
38
+ const launches = options.launchService ?? createLaunchService()
39
+
40
+ /**
41
+ * @param {string | undefined} requested
42
+ * @returns {Promise<string>}
43
+ */
44
+ async function resolveRoot(requested) {
45
+ const root = requested ?? (await options.resolveRoot())
46
+ if (root === undefined || root === '') {
47
+ throw new Error('找不到工作区目录:请求里没带 root,宿主也没有活跃会话')
48
+ }
49
+ return root
50
+ }
51
+
52
+ return {
53
+ /**
54
+ * 扫工作区:`{ root?, depth?, refresh? }` → `{ root, projects, truncated, scannedAt }`。
55
+ * @param {unknown} payload
56
+ */
57
+ 'projects.scan': async (payload) => {
58
+ const input = /** @type {{ root?: string, depth?: number, refresh?: boolean }} */ (payload ?? {})
59
+ const root = await resolveRoot(input.root)
60
+ const key = `${root}|${input.depth ?? 3}`
61
+ const cached = cache.get(key)
62
+ if (input.refresh !== true && cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS) {
63
+ return cached.value
64
+ }
65
+ const jdks = await discoverJdks()
66
+ const result = await scanWorkspace(root, { depth: input.depth, jdks })
67
+ console.log(`[mcdev] projects.scan ${root} → ${result.projects.length} 个工程(${result.scannedDirs} 个目录)`)
68
+ const value = { ...result, scannedAt: new Date().toISOString() }
69
+ cache.set(key, { at: Date.now(), value })
70
+ return value
71
+ },
72
+
73
+ /**
74
+ * 单工程详情:`{ root }` → 完整画像。
75
+ * @param {unknown} payload
76
+ */
77
+ 'projects.detail': async (payload) => {
78
+ const input = /** @type {{ root?: string }} */ (payload ?? {})
79
+ const root = await resolveRoot(input.root)
80
+ return detectProject(root, { jdks: await discoverJdks() })
81
+ },
82
+
83
+ /** 宿主/插件版本、工作区根与部署配置,客户端启动时打一次。 */
84
+ 'state.info': async () => ({
85
+ version: options.version,
86
+ root: await options.resolveRoot(),
87
+ config: options.config ?? {},
88
+ }),
89
+
90
+ /**
91
+ * 启动实例:`{ root, taskPath, label?, kind?, module?, extraArgs? }`。
92
+ * 只允许启动"识别得出的目标",并且工程必须落在当前工作区之内。
93
+ * @param {unknown} payload
94
+ */
95
+ 'launch.start': async (payload) => {
96
+ const input = /** @type {{ root?: string, taskPath?: string, label?: string, kind?: string, module?: string, extraArgs?: string[] }} */ (payload ?? {})
97
+ const root = await resolveRoot(input.root)
98
+ // 工作区可能不止一个(每个会话一个 cwd):只要落在其中任意一个之内就放行,
99
+ // 否则切到另一个工程的会话后会被误判成"越界"。
100
+ const workspaces = options.resolveRoots === undefined ? [await resolveRoot(undefined)] : await options.resolveRoots()
101
+ if (!workspaces.some((workspace) => isInside(root, workspace))) {
102
+ throw Object.assign(new Error(`工程不在当前工作区内:${root}`), { code: 'OUTSIDE_WORKSPACE' })
103
+ }
104
+ if (typeof input.taskPath !== 'string' || input.taskPath === '') {
105
+ throw Object.assign(new Error('缺少 taskPath'), { code: 'BAD_REQUEST' })
106
+ }
107
+ const profile = await detectProject(root, { jdks: await discoverJdks() })
108
+ const targets = /** @type {Array<Record<string, any>>} */ (profile.runTargets ?? [])
109
+ const target = targets.find((entry) => entry.taskPath === input.taskPath)
110
+ if (target === undefined) {
111
+ throw Object.assign(new Error(`工程里没有这个可运行目标:${input.taskPath}`), { code: 'UNKNOWN_TARGET' })
112
+ }
113
+ const record = await launches.start({
114
+ root,
115
+ taskPath: target.taskPath,
116
+ label: input.label ?? target.label ?? target.taskPath,
117
+ kind: input.kind ?? target.kind ?? 'client',
118
+ module: input.module ?? target.module ?? '.',
119
+ extraArgs: Array.isArray(input.extraArgs) ? input.extraArgs.filter((entry) => typeof entry === 'string') : [],
120
+ })
121
+ console.log(`[mcdev] launch.start ${record.id} ${record.taskPath} @ ${root} (pid ${record.pid})`)
122
+ return record
123
+ },
124
+
125
+ /**
126
+ * 停止实例:`{ id }`。
127
+ * @param {unknown} payload
128
+ */
129
+ 'launch.stop': async (payload) => {
130
+ const input = /** @type {{ id?: string }} */ (payload ?? {})
131
+ if (typeof input.id !== 'string' || input.id === '') {
132
+ throw Object.assign(new Error('缺少实例 id'), { code: 'BAD_REQUEST' })
133
+ }
134
+ const record = await launches.stop(input.id)
135
+ console.log(`[mcdev] launch.stop ${input.id} → ${record.status}/${record.outcome ?? '-'}`)
136
+ return record
137
+ },
138
+
139
+ /**
140
+ * 列实例:`{ root? }`,最新的在前。
141
+ * @param {unknown} payload
142
+ */
143
+ 'launch.list': async (payload) => {
144
+ const input = /** @type {{ root?: string }} */ (payload ?? {})
145
+ const all = await launches.list()
146
+ if (typeof input.root !== 'string' || input.root === '') return all
147
+ const root = input.root.toLowerCase()
148
+ return all.filter((record) => String(record.root).toLowerCase() === root)
149
+ },
150
+
151
+ /**
152
+ * 这个工程当前活跃的实例:`{ root }`。
153
+ * @param {unknown} payload
154
+ */
155
+ 'launch.current': async (payload) => {
156
+ const input = /** @type {{ root?: string }} */ (payload ?? {})
157
+ const root = await resolveRoot(input.root)
158
+ return (await launches.current(root)) ?? null
159
+ },
160
+
161
+ /**
162
+ * 读日志:`{ id, offset?, limit? }`。
163
+ * @param {unknown} payload
164
+ */
165
+ 'launch.logs': async (payload) => {
166
+ const input = /** @type {{ id?: string, offset?: number, limit?: number }} */ (payload ?? {})
167
+ if (typeof input.id !== 'string' || input.id === '') {
168
+ throw Object.assign(new Error('缺少实例 id'), { code: 'BAD_REQUEST' })
169
+ }
170
+ return launches.logs(input.id, Number.isFinite(input.offset) ? Number(input.offset) : 0, Number.isFinite(input.limit) ? Number(input.limit) : 400)
171
+ },
172
+
173
+ /**
174
+ * 客户端诊断上报(开发期):`{ event, detail }` → `{ ok, count }`。
175
+ * 只在宿主日志里留一行,并保留最近 20 条供后续排查。
176
+ * @param {unknown} payload
177
+ */
178
+ 'client.report': async (payload) => {
179
+ const input = /** @type {{ event?: string, detail?: unknown }} */ (payload ?? {})
180
+ clientReports.push({ at: new Date().toISOString(), event: input.event, detail: input.detail })
181
+ if (clientReports.length > 20) clientReports.shift()
182
+ console.log(`[mcdev] client: ${input.event} ${JSON.stringify(input.detail ?? {})}`)
183
+ return { ok: true, count: clientReports.length }
184
+ },
185
+ }
186
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 启动命令行构造与进程存活探测。
3
+ *
4
+ * Windows 的关键点在引号:`cmd.exe /d /s /c` 需要把整条命令再包一层引号,
5
+ * 且必须让 node 原样传递(`windowsVerbatimArguments: true`),否则 node 会把
6
+ * `"` 转义成 `\"`,cmd 不认。这套写法在 minecraft-dev 的 mc_gradle 上验证过。
7
+ * @module minecraft-dev-starter/lib/argv
8
+ */
9
+
10
+ import { posix, resolve, win32 } from 'node:path'
11
+
12
+ /**
13
+ * Gradle 任务路径只允许 `run*`(可能带 `:module:` 前缀)。
14
+ * 界面上没有自由输入的入口,但 API 是公开的,仍然要挡一道。
15
+ */
16
+ export const TASK_PATH_PATTERN = /^(?::[A-Za-z0-9_.-]+)*:?run[A-Za-z0-9_]+$/
17
+
18
+ /**
19
+ * @param {{ platform?: string, projectDir: string, gradlew?: string, taskPath: string, extraArgs?: string[] }} options
20
+ * @returns {{ argv: string[], windowsVerbatimArguments: boolean, cwd: string }}
21
+ */
22
+ export function gradlewCommand(options) {
23
+ const platform = options.platform ?? process.platform
24
+ const extraArgs = options.extraArgs ?? []
25
+
26
+ if (platform === 'win32') {
27
+ const wrapper = win32.resolve(options.projectDir, options.gradlew ?? 'gradlew.bat')
28
+ const parts = [quoteCmdArg(wrapper), quoteCmdArg(options.taskPath), ...extraArgs.map(quoteCmdArg)]
29
+ return {
30
+ // `/s` 会剥掉最外层的一对引号,所以这里要包两层:cmd /c ""prog" "arg""
31
+ argv: ['cmd.exe', '/d', '/s', '/c', `"${parts.join(' ')}"`],
32
+ windowsVerbatimArguments: true,
33
+ cwd: options.projectDir,
34
+ }
35
+ }
36
+
37
+ return {
38
+ argv: [posix.join(options.projectDir, options.gradlew ?? 'gradlew'), options.taskPath, ...extraArgs],
39
+ windowsVerbatimArguments: false,
40
+ cwd: options.projectDir,
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {string} arg
46
+ * @returns {string}
47
+ */
48
+ function quoteCmdArg(arg) {
49
+ return `"${String(arg).replace(/"/g, '\\"')}"`
50
+ }
51
+
52
+ /**
53
+ * 进程是否还活着(配合持久化记录做对账)。
54
+ * @param {number | null | undefined} pid
55
+ * @returns {boolean}
56
+ */
57
+ export function isAlive(pid) {
58
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false
59
+ try {
60
+ process.kill(pid, 0)
61
+ return true
62
+ } catch (error) {
63
+ // EPERM 说明进程存在但没权限发信号;ESRCH 才是真的没了
64
+ return error?.code === 'EPERM'
65
+ }
66
+ }