@windsland52/maafw-live 0.0.0-stage → 0.4.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +121 -0
  5. package/docs/keyframe-retention-contract-v0.md +225 -0
  6. package/docs/session-unify-v0.md +115 -0
  7. package/lib/client/daemon.js +276 -0
  8. package/lib/commands/annotate.js +179 -0
  9. package/lib/commands/calibrate.js +61 -0
  10. package/lib/commands/color.js +73 -0
  11. package/lib/commands/crop.js +228 -0
  12. package/lib/commands/dispatch.js +121 -0
  13. package/lib/commands/env.js +409 -0
  14. package/lib/commands/index.js +70 -0
  15. package/lib/commands/input.js +257 -0
  16. package/lib/commands/kf.js +167 -0
  17. package/lib/commands/probe.js +70 -0
  18. package/lib/commands/reco.js +440 -0
  19. package/lib/commands/repl.js +1501 -0
  20. package/lib/commands/roi-check.js +273 -0
  21. package/lib/commands/roi-compare.js +388 -0
  22. package/lib/commands/runtime.js +718 -0
  23. package/lib/commands/skill.js +169 -0
  24. package/lib/commands/timing.js +247 -0
  25. package/lib/commands/version.js +66 -0
  26. package/lib/daemon/framed.mjs +3680 -0
  27. package/lib/daemon/reco_child.mjs +287 -0
  28. package/lib/exec.js +96 -0
  29. package/lib/flags.js +44 -0
  30. package/lib/image.js +90 -0
  31. package/lib/index.js +143 -0
  32. package/lib/interface/hotkey.js +142 -0
  33. package/lib/interface/index.js +12 -0
  34. package/lib/interface/jsonc.js +95 -0
  35. package/lib/interface/load.js +397 -0
  36. package/lib/interface/override.js +287 -0
  37. package/lib/interface/plan.js +279 -0
  38. package/lib/option-args.js +29 -0
  39. package/lib/pkg.js +54 -0
  40. package/lib/protocol.js +94 -0
  41. package/lib/runtime/actions.js +225 -0
  42. package/lib/runtime/keyframes.js +73 -0
  43. package/lib/runtime/log-watch.js +160 -0
  44. package/lib/runtime/node-source.js +100 -0
  45. package/lib/runtime/ocr-model.js +45 -0
  46. package/lib/runtime/pi-env.js +34 -0
  47. package/lib/runtime/pretask.js +217 -0
  48. package/lib/runtime/prune.js +28 -0
  49. package/lib/runtime/receipts.js +57 -0
  50. package/lib/runtime/reco-args.js +38 -0
  51. package/lib/runtime/reco-verdict.js +82 -0
  52. package/lib/runtime/roi-compare.js +198 -0
  53. package/lib/runtime/session-forward.js +79 -0
  54. package/lib/runtime/session-ipc.js +471 -0
  55. package/lib/runtime/session.js +135 -0
  56. package/lib/runtime/signal-guard.js +35 -0
  57. package/lib/types/client/daemon.d.ts +86 -0
  58. package/lib/types/commands/annotate.d.ts +3 -0
  59. package/lib/types/commands/calibrate.d.ts +7 -0
  60. package/lib/types/commands/color.d.ts +9 -0
  61. package/lib/types/commands/crop.d.ts +3 -0
  62. package/lib/types/commands/dispatch.d.ts +22 -0
  63. package/lib/types/commands/env.d.ts +21 -0
  64. package/lib/types/commands/index.d.ts +13 -0
  65. package/lib/types/commands/input.d.ts +17 -0
  66. package/lib/types/commands/kf.d.ts +10 -0
  67. package/lib/types/commands/probe.d.ts +15 -0
  68. package/lib/types/commands/reco.d.ts +27 -0
  69. package/lib/types/commands/repl.d.ts +10 -0
  70. package/lib/types/commands/roi-check.d.ts +3 -0
  71. package/lib/types/commands/roi-compare.d.ts +3 -0
  72. package/lib/types/commands/runtime.d.ts +66 -0
  73. package/lib/types/commands/skill.d.ts +17 -0
  74. package/lib/types/commands/timing.d.ts +64 -0
  75. package/lib/types/commands/version.d.ts +3 -0
  76. package/lib/types/exec.d.ts +31 -0
  77. package/lib/types/flags.d.ts +62 -0
  78. package/lib/types/image.d.ts +27 -0
  79. package/lib/types/index.d.ts +1 -0
  80. package/lib/types/interface/hotkey.d.ts +36 -0
  81. package/lib/types/interface/index.d.ts +11 -0
  82. package/lib/types/interface/jsonc.d.ts +9 -0
  83. package/lib/types/interface/load.d.ts +190 -0
  84. package/lib/types/interface/override.d.ts +78 -0
  85. package/lib/types/interface/plan.d.ts +68 -0
  86. package/lib/types/option-args.d.ts +19 -0
  87. package/lib/types/pkg.d.ts +14 -0
  88. package/lib/types/protocol.d.ts +124 -0
  89. package/lib/types/runtime/actions.d.ts +248 -0
  90. package/lib/types/runtime/keyframes.d.ts +43 -0
  91. package/lib/types/runtime/log-watch.d.ts +28 -0
  92. package/lib/types/runtime/node-source.d.ts +25 -0
  93. package/lib/types/runtime/ocr-model.d.ts +8 -0
  94. package/lib/types/runtime/pi-env.d.ts +24 -0
  95. package/lib/types/runtime/pretask.d.ts +53 -0
  96. package/lib/types/runtime/prune.d.ts +4 -0
  97. package/lib/types/runtime/receipts.d.ts +7 -0
  98. package/lib/types/runtime/reco-args.d.ts +18 -0
  99. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  100. package/lib/types/runtime/roi-compare.d.ts +117 -0
  101. package/lib/types/runtime/session-forward.d.ts +10 -0
  102. package/lib/types/runtime/session-ipc.d.ts +117 -0
  103. package/lib/types/runtime/session.d.ts +77 -0
  104. package/lib/types/runtime/signal-guard.d.ts +19 -0
  105. package/lib/types/update-check.d.ts +20 -0
  106. package/lib/types/usage.d.ts +3 -0
  107. package/lib/update-check.js +97 -0
  108. package/lib/usage.js +50 -0
  109. package/package.json +94 -4
  110. package/skills/README.md +51 -0
  111. package/skills/maafw-live/SKILL.md +293 -0
  112. package/skills/maafw-live/references/examples.md +238 -0
  113. package/skills/maafw-live/references/pitfalls.md +211 -0
  114. package/skills/maafw-live/references/workflows.md +164 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * reco_child —— 识别单测的一次性子进程(崩溃隔离)。
3
+ * 用法:node reco_child.mjs --cfg <json> --out <result.json>
4
+ * cfg = { resourceDirs? | resourceDir, type, image, cases?, node?, nodeName?, templateImage? }
5
+ * cases: 父进程(framed.mjs)已折叠好的参数用例列表,逐例执行
6
+ * node : 整节点 JSON 原样透传(V1 扁平 / V2 嵌套均支持,不转换)
7
+ * nodeName: 按资源里的原名跑(不 dump 注入——dump 是缺省填满的 V2,与手写稀疏 V1 不是同一份)
8
+ * param/sweep: 直接调用(调试)时的单例路径,与 cases 二选一
9
+ *
10
+ * 为什么要造一个"图像假控制器":裸 post_recognition 不产出 RecoId,于是 recognition_detail 拿不到
11
+ * 详情(也就是命中框)。上游 maa-support 的做法是:用给定图像造一个 CustomController,让框架以为自己
12
+ * 在截图,再在自定义 action 里调 context.run_recognition —— 详情直接由那次调用返回。这里沿用同一条路径。
13
+ */
14
+ import { createRequire } from "node:module"
15
+ import fs from "node:fs"
16
+
17
+ const require = createRequire(import.meta.url)
18
+ const cfgArg = process.argv.indexOf("--cfg")
19
+ const outArg = process.argv.indexOf("--out")
20
+ if (cfgArg < 0 || outArg < 0) { console.error("usage: reco_child --cfg <json> --out <json>"); process.exit(2) }
21
+ const cfg = JSON.parse(fs.readFileSync(process.argv[cfgArg + 1], "utf8"))
22
+ const out = process.argv[outArg + 1]
23
+
24
+ function toArrayBuffer(buf) {
25
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
26
+ }
27
+
28
+ /** 参数扫描折叠成用例列表(与 CLI 的 --sweep 语义一致;step 带方向)。 */
29
+ function casesOf(c) {
30
+ /* 父进程路径:cases 已折好,逐例透传——子进程不再自行折叠,避免两边语义分叉 */
31
+ if (Array.isArray(c.cases) && c.cases.length) {
32
+ return c.cases.filter((p) => p && typeof p === "object")
33
+ }
34
+ const base = c.param && typeof c.param === "object" ? c.param : {}
35
+ const sweep = c.sweep && typeof c.sweep === "object" ? c.sweep : null
36
+ const cases = []
37
+ if (sweep && typeof sweep.min === "number" && typeof sweep.max === "number") {
38
+ const step = sweep.step !== undefined && Number(sweep.step) !== 0 ? Number(sweep.step) : 1
39
+ const up = step > 0
40
+ for (let v = sweep.min; up ? v <= sweep.max + 1e-9 : v >= sweep.max - 1e-9; v += step) {
41
+ cases.push({ ...base, [String(sweep.key || "threshold")]: Math.round(v * 1000) / 1000 })
42
+ }
43
+ } else {
44
+ cases.push(base)
45
+ }
46
+ return cases
47
+ }
48
+
49
+ /**
50
+ * --node 整节点透传的入口构造:不做 V1/V2 互转,框架的 pipeline 解析两种形态都认识。
51
+ * 只在"模板由调用方图像提供"(override_image)且节点自己没写模板时补一个模板引用,
52
+ * 补的位置按节点形态放(V1 顶层 / V2 recognition.param 内),其余字段一律原样。
53
+ */
54
+ function nodeEntry(node, templateImage) {
55
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
56
+ throw new Error("--node 必须是 JSON 对象(pipeline 节点)")
57
+ }
58
+ const rec = node.recognition
59
+ const v2 = rec && typeof rec === "object" && !Array.isArray(rec)
60
+ if (rec === undefined || (typeof rec !== "string" && !v2)) {
61
+ throw new Error("--node 缺少可用的 recognition 字段(V1 字符串或 V2 {type,param})")
62
+ }
63
+ if (!templateImage) return node
64
+ if (v2) {
65
+ const param = rec.param && typeof rec.param === "object" ? rec.param : {}
66
+ if (param.template !== undefined) return node
67
+ return { ...node, recognition: { ...rec, param: { ...param, template: "@reco_template" } } }
68
+ }
69
+ if (node.template !== undefined) return node
70
+ return { ...node, template: "@reco_template" }
71
+ }
72
+
73
+ /** 页面/工具只需要判定信息:像素类字段(draws / raw)一律不带出去。 */
74
+ function strip(detail) {
75
+ if (!detail || typeof detail !== "object") return null
76
+ const copy = { ...detail }
77
+ delete copy.draws
78
+ delete copy.raw
79
+ return copy
80
+ }
81
+
82
+ function writeOut(payload) {
83
+ fs.writeFileSync(out, JSON.stringify(payload), "utf8")
84
+ }
85
+
86
+ /** dump 里的识别类型:V2 是 `recognition.type`,个别版本/形态可能还是 V1 的字符串。 */
87
+ function recoTypeOf(dump) {
88
+ const rec = dump && dump.recognition
89
+ if (typeof rec === "string") return rec
90
+ if (rec && typeof rec === "object") return String(rec.type ?? "")
91
+ return ""
92
+ }
93
+
94
+ function editDistance(a, b) {
95
+ if (a === b) return 0
96
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
97
+ for (let i = 1; i <= a.length; i++) {
98
+ const cur = [i]
99
+ for (let j = 1; j <= b.length; j++) {
100
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
101
+ }
102
+ prev = cur
103
+ }
104
+ return prev[b.length]
105
+ }
106
+
107
+ /**
108
+ * 近邻节点名:命令拼错会给建议,节点名拼错同样要给——否则 `--node-name` 写错一个字只会得到
109
+ * "资源里没有这个节点",而正确名字就在资源里。距离阈值随名字长度放宽(长名的单个字符笔误
110
+ * 距离仍是 1;短名放宽到 2 就够,再宽会把无关节点列出来)。
111
+ */
112
+ function nearestNames(names, target, limit) {
113
+ const want = String(target)
114
+ const cap = Math.max(2, Math.min(4, Math.floor(want.length / 3)))
115
+ const scored = []
116
+ for (const n of Array.isArray(names) ? names : []) {
117
+ if (typeof n !== "string" || !n || n === want) continue
118
+ const d = editDistance(n.toLowerCase(), want.toLowerCase())
119
+ if (d <= cap) scored.push({ n, d })
120
+ }
121
+ return scored.sort((x, y) => x.d - y.d || x.n.localeCompare(y.n)).slice(0, limit).map((x) => x.n)
122
+ }
123
+
124
+ /**
125
+ * dump 里的 Custom 识别:顶层 `type: "Custom"` / V1 的 `custom_recognition`,**以及 And / Or 里嵌的
126
+ * 子识别**。只看顶层会让"And 里挂了一个 custom 子识别"落回 miss 或"识别调用报错",看起来像没命中,
127
+ * 而它其实和顶层 custom 是同一条能力边界(reco_test 不桥接 agent 子进程)。
128
+ * 返回命中位置(便于在报错里说清是哪一层),深度与条数都封顶,避免异常 dump 撑爆回执。
129
+ */
130
+ function customHits(dump) {
131
+ const hits = []
132
+ /* key 要一起带下来:字符串 `"Custom"` 只有落在**识别类型位**上才算(V1 的 `recognition` / V2 的
133
+ * `type`)。整棵子树里任何字符串都认会把 `expected: "Custom"`(OCR 要认的文字)和名字恰好叫
134
+ * Custom 的引用节点误判成 Custom 子识别。 */
135
+ const walk = (v, path, key, depth) => {
136
+ if (hits.length >= 8 || depth > 6 || v === null || v === undefined) return
137
+ if (typeof v === "string") {
138
+ if (v === "Custom" && (key === "recognition" || key === "type")) hits.push(path)
139
+ return
140
+ }
141
+ if (typeof v !== "object") return
142
+ if (Array.isArray(v)) {
143
+ v.forEach((x, i) => walk(x, path + "[" + i + "]", null, depth + 1))
144
+ return
145
+ }
146
+ if (typeof v.type === "string" && v.type === "Custom") { hits.push(path + ".type"); return }
147
+ if (v.custom_recognition !== undefined) { hits.push(path + ".custom_recognition"); return }
148
+ for (const [k, x] of Object.entries(v)) walk(x, path === "" ? k : path + "." + k, k, depth + 1)
149
+ }
150
+ walk(dump && dump.recognition, "recognition", "recognition", 0)
151
+ return hits
152
+ }
153
+
154
+ async function main() {
155
+ const maa = require("@maaxyz/maa-node")
156
+ const image = toArrayBuffer(fs.readFileSync(cfg.image))
157
+
158
+ const ctrl = new maa.CustomController({
159
+ connect() { return true },
160
+ request_uuid() { return "0" },
161
+ screencap() { return image },
162
+ })
163
+ ctrl.screenshot_use_raw_size = true
164
+ await ctrl.post_connection().wait()
165
+ if (!ctrl.connected) throw new Error("假控制器连接失败")
166
+
167
+ /* 资源链:interface.json 的 resource.path[](+ 控制器 attach_resource_path)按声明顺序叠加,
168
+ * 后一段覆盖前一段——与 cmdRun 的 buildResource 同一语义。只加载单包时,overlay 包里的节点改写、
169
+ * 只存在于 overlay 的模板、被 And/Or 按名引用的子节点全部缺失,测出来的就不是项目里那个节点。 */
170
+ const dirs = Array.isArray(cfg.resourceDirs) && cfg.resourceDirs.length
171
+ ? cfg.resourceDirs.map(String)
172
+ : [String(cfg.resourceDir)]
173
+ const res = new maa.Resource()
174
+ /* 逐段点名失败:整链一起报只说"加载失败",看不出是哪一层读不进(原先是父进程
175
+ * probeResourceChain 先加载一遍做先验——探针挪进这里后只加载一次,失败定位不变)。 */
176
+ for (let i = 0; i < dirs.length; i++) {
177
+ const where = '第 ' + (i + 1) + '/' + dirs.length + ' 段(' + dirs[i] + ')'
178
+ let job = null
179
+ try {
180
+ job = res.post_bundle(dirs[i])
181
+ await job.wait()
182
+ } catch (e) {
183
+ writeOut({ ok: false, code: 'RESOURCE_LOAD', error: '资源链加载失败:' + where + ':' + String(e && e.message || e) })
184
+ return
185
+ }
186
+ const st = Number(job && job.status)
187
+ if (Number.isFinite(st) && st !== 3000) {
188
+ writeOut({ ok: false, code: 'RESOURCE_LOAD', error: '资源链加载失败:' + where + '返回 status=' + st + '(成功应为 3000)' })
189
+ return
190
+ }
191
+ }
192
+ /* 面板裁出来的模板走 override_image:不必把它塞进任何资源目录,也不写用户项目。 */
193
+ if (cfg.templateImage) res.override_image("@reco_template", toArrayBuffer(fs.readFileSync(cfg.templateImage)))
194
+
195
+ const tasker = new maa.Tasker()
196
+ tasker.controller = ctrl
197
+ tasker.resource = res
198
+
199
+ const nodeMode = cfg.node !== undefined
200
+ const nodeNameMode = typeof cfg.nodeName === "string" && cfg.nodeName !== ""
201
+ const node = nodeMode ? nodeEntry(cfg.node, cfg.templateImage) : null
202
+ /* --node-name:dump 只进回执给人看,**不注入识别**。dump 是 DumpTask(V2 嵌套 + 缺省值全部
203
+ * 填满),与手写稀疏 V1 JSON 不是同一份;dump 再注入会让"测的到底是不是真节点"多一层不确定性。 */
204
+ let nodeDump = null
205
+ if (nodeNameMode) {
206
+ nodeDump = res.get_node_data_parsed(cfg.nodeName)
207
+ if (!nodeDump) {
208
+ const candidates = nearestNames(res.node_list, cfg.nodeName, 3)
209
+ writeOut({
210
+ ok: false, code: "NODE_MISSING", nodeName: cfg.nodeName, resourceDirs: dirs,
211
+ ...(candidates.length ? { candidates } : {}),
212
+ error: "资源里没有名为 " + cfg.nodeName + " 的节点(只查资源合并结果,不含 interface 的 pipeline_override)",
213
+ })
214
+ return
215
+ }
216
+ /* custom / agent 节点:reco_test 不桥接 agent 子进程(run 才桥接),明确报能力边界而不是让它看起来像 miss。
217
+ * And / Or 里嵌的子识别同样算——它们会在 run_recognition 里被真的走到。 */
218
+ const customs = customHits(nodeDump)
219
+ if (customs.length > 0) {
220
+ const onlyTop = customs.length === 1
221
+ && (customs[0] === "recognition.type" || customs[0] === "recognition")
222
+ writeOut({
223
+ ok: false, code: "CUSTOM_NODE", nodeName: cfg.nodeName, nodeDump, resourceDirs: dirs, customPaths: customs,
224
+ error: onlyTop
225
+ ? "节点 " + cfg.nodeName + " 的识别是 Custom:reco_test 不桥接 custom/agent 识别(run --project 才桥接 agent 子进程)"
226
+ : "节点 " + cfg.nodeName + " 的识别含 Custom 子识别(" + customs.length + " 处:"
227
+ + customs.slice(0, 3).join(" / ") + (customs.length > 3 ? " …" : "")
228
+ + "):reco_test 不桥接 custom/agent 识别(run --project 才桥接 agent 子进程)",
229
+ })
230
+ return
231
+ }
232
+ }
233
+ const cases = nodeMode || nodeNameMode ? [node] : casesOf(cfg)
234
+ const results = []
235
+ let slot = null
236
+ let started = Date.now()
237
+
238
+ res.register_custom_action("@reco/run", async self => {
239
+ /* 入口三形态:--node-name 用资源原名(无 override);--node 原样透传;--type 用折叠好的一例参数。
240
+ * param 只用于回执展示"测的是哪个/组参数"。 */
241
+ let param = null
242
+ let detail = null
243
+ try {
244
+ if (nodeNameMode) {
245
+ param = { nodeName: cfg.nodeName }
246
+ detail = await self.context.run_recognition(cfg.nodeName, image)
247
+ } else {
248
+ const entry = nodeMode ? node : { recognition: cfg.type, ...(slot || {}) }
249
+ if (!nodeMode && cfg.templateImage && entry.template === undefined) entry.template = "@reco_template"
250
+ param = entry
251
+ detail = await self.context.run_recognition("@reco/node", image, { "@reco/node": entry })
252
+ }
253
+ } catch (e) {
254
+ results.push({ param, ms: Date.now() - started, ok: false, error: String(e && e.message || e), detail: null })
255
+ return true
256
+ }
257
+ /* ok 只认 hit===true:miss 时 run_recognition 仍返回 detail 对象(hit:false、box 全零),
258
+ * 旧判 !!detail 恒真——识别未中会标成命中,制造假观测证据。 */
259
+ results.push({ param, ms: Date.now() - started, ok: !!detail && detail.hit === true, detail: strip(detail) })
260
+ return true
261
+ })
262
+
263
+ try {
264
+ for (const param of cases) {
265
+ slot = param
266
+ started = Date.now()
267
+ await tasker.post_task("@reco/entry", { "@reco/entry": { action: "Custom", custom_action: "@reco/run" } }).wait()
268
+ }
269
+ } finally {
270
+ try { tasker.destroy() } catch (e) { /* ignore */ }
271
+ try { res.destroy() } catch (e) { /* ignore */ }
272
+ try { ctrl.destroy() } catch (e) { /* ignore */ }
273
+ }
274
+
275
+ writeOut({
276
+ ok: true,
277
+ ...(nodeNameMode ? { type: recoTypeOf(nodeDump), nodeName: cfg.nodeName, nodeDump } : { type: cfg.type }),
278
+ results,
279
+ })
280
+ }
281
+
282
+ main()
283
+ .then(() => process.exit(0))
284
+ .catch((e) => {
285
+ try { fs.writeFileSync(out, JSON.stringify({ ok: false, error: String(e && e.message || e) }), "utf8") } catch (e2) { /* ignore */ }
286
+ process.exit(1)
287
+ })
package/lib/exec.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 外部命令执行封装。
3
+ *
4
+ * 约定:**永不抛异常**,一律返回结果对象。原因是环境探针的本质就是「探测可能不存在的东西」——
5
+ * 缺 adb、缺 python、缺 git 都是正常结果,不是异常路径。
6
+ */
7
+ import { execFile } from 'node:child_process';
8
+ const DEFAULT_TIMEOUT = 8000;
9
+ function once(bin, args, opts) {
10
+ return new Promise((resolve) => {
11
+ let child;
12
+ try {
13
+ child = execFile(bin, args, {
14
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT,
15
+ cwd: opts.cwd,
16
+ windowsHide: true,
17
+ maxBuffer: 8 * 1024 * 1024,
18
+ encoding: 'utf8',
19
+ windowsVerbatimArguments: opts.verbatim === true,
20
+ }, (err, stdout, stderr) => {
21
+ const e = err;
22
+ if (!e) {
23
+ resolve({ ok: true, code: 0, stdout: String(stdout), stderr: String(stderr) });
24
+ return;
25
+ }
26
+ // 启动失败(ENOENT/EACCES)与「跑起来但退出码非 0」要分开报
27
+ const isSpawnFailure = typeof e.code === 'string';
28
+ resolve({
29
+ ok: false,
30
+ code: isSpawnFailure ? null : typeof e.code === 'number' ? e.code : null,
31
+ stdout: String(stdout ?? ''),
32
+ stderr: String(stderr ?? ''),
33
+ spawnError: isSpawnFailure ? e.code : undefined,
34
+ });
35
+ });
36
+ }
37
+ catch (e) {
38
+ /* execFile 在部分情况下是**同步**抛的(Windows 上直接跑 .cmd/.bat → EINVAL)。
39
+ * 本模块的契约是「永不抛异常」:环境探针的本质就是探测可能不存在的东西,
40
+ * 同步抛也必须收敛成结果对象,否则整个命令(如 env)会因为一个缺失的探测目标而失败。 */
41
+ const err = e;
42
+ resolve({
43
+ ok: false, code: null, stdout: '', stderr: String(err?.message ?? e),
44
+ spawnError: String(err?.code ?? 'EINVAL'),
45
+ });
46
+ return;
47
+ }
48
+ if (opts.input !== undefined) {
49
+ child.stdin?.end(opts.input);
50
+ }
51
+ });
52
+ }
53
+ /** Windows:`where` 解析目标(含 PATHEXT),拿到可执行的绝对路径用于经 shell 执行。找不到返回 null。 */
54
+ async function wherePath(bin) {
55
+ const r = await once('where', [bin], { timeout: 3000 });
56
+ if (!r.ok)
57
+ return null;
58
+ const all = String(r.stdout).split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
59
+ /* where 会同时列出无扩展名的 POSIX 壳(如 nodejs\npm,是给 sh 用的)与 npm.cmd;
60
+ * cmd 只能跑后者,所以优先取可执行扩展名,避免拿到壳脚本后"跑不起来"。 */
61
+ return all.find((p) => /\.(cmd|exe|bat)$/i.test(p)) ?? all[0] ?? null;
62
+ }
63
+ /** 极简 cmd 引号:只在含空白/引号/元字符时加引号并转义内部引号。 */
64
+ function quoteForCmd(arg) {
65
+ return /[\s"&|<>^]/.test(arg) ? '"' + arg.replace(/"/g, '""') + '"' : arg;
66
+ }
67
+ /**
68
+ * 执行外部命令。
69
+ *
70
+ * Windows 上 `.cmd` / `.bat`(npm、npx、pnpm)不是可执行文件:execFile 直接跑会**同步抛 EINVAL**
71
+ * (Node 对 CVE-2024-27980 的修复,本模块曾因此让 `env` 在只缺 adb 的机器上整条失败),
72
+ * 所以先 `where` 解析出可执行路径,再经 `ComSpec` 执行。而 cmd 的引号规则有讲究:`/s` 会剥掉最外层
73
+ * 引号,所以命令串要**整体再包一层引号**并配 `windowsVerbatimArguments`——实测
74
+ * `cmd /d /s /c ""C:\Program Files\nodejs\npm.cmd" --version"` 是唯一能跑通的拼法。
75
+ * 不直接用 `shell: true`:给 shell 传参会触发 `DEP0190` 弃用警告,且"命令不存在"会退化成
76
+ * "cmd 跑起来了但退出 1"(本地化报错,判不准)。
77
+ */
78
+ export async function run(bin, args = [], opts = {}) {
79
+ const first = await once(bin, args, opts);
80
+ if (first.spawnError === 'ENOENT' && process.platform === 'win32' && !/\.(exe|cmd|bat|ps1)$/i.test(bin)) {
81
+ const resolved = await wherePath(bin);
82
+ if (resolved) {
83
+ const comspec = process.env.ComSpec ?? 'cmd.exe';
84
+ const line = [resolved, ...args].map(quoteForCmd).join(' ');
85
+ const retry = await once(comspec, ['/d', '/s', '/c', '"' + line + '"'], { ...opts, verbatim: true });
86
+ if (retry.ok || retry.code !== null)
87
+ return retry;
88
+ }
89
+ }
90
+ return first;
91
+ }
92
+ /** 取首行有效输出,供「版本号探测」这类场景用 */
93
+ export function firstLine(s) {
94
+ return s.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== '') ?? '';
95
+ }
96
+ //# sourceMappingURL=exec.js.map
package/lib/flags.js ADDED
@@ -0,0 +1,44 @@
1
+ export const GLOBAL_OPTIONS = {
2
+ json: { type: 'boolean' },
3
+ 'dry-run': { type: 'boolean' },
4
+ yes: { type: 'boolean' },
5
+ 'no-interactive': { type: 'boolean' },
6
+ 'no-color': { type: 'boolean' },
7
+ cwd: { type: 'string' },
8
+ limit: { type: 'string' },
9
+ verbose: { type: 'boolean' },
10
+ help: { type: 'boolean', short: 'h' },
11
+ version: { type: 'boolean', short: 'V' },
12
+ /** 把命令转发到命名会话的常驻 daemon(`repl --session <name>` 起的那个) */
13
+ session: { type: 'string' },
14
+ };
15
+ const DEFAULT_LIMIT = 200;
16
+ /**
17
+ * 从 parseArgs 的 values 推导全局标志。
18
+ *
19
+ * `--no-color` 只关闭颜色;未指定时按 TTY 自动判断(管道里自动无色)。
20
+ * 注意不能用 NO_COLOR 环境变量以外的方式覆盖显式传入的值。
21
+ */
22
+ export function readGlobalFlags(values, cwd) {
23
+ const rawLimit = values.limit;
24
+ let limit = DEFAULT_LIMIT;
25
+ if (typeof rawLimit === 'string' && rawLimit !== '') {
26
+ const n = Number(rawLimit);
27
+ limit = Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_LIMIT;
28
+ }
29
+ const noColor = values['no-color'] === true;
30
+ const color = !noColor && process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
31
+ return {
32
+ json: values.json === true,
33
+ dryRun: values['dry-run'] === true,
34
+ yes: values.yes === true,
35
+ interactive: values['no-interactive'] !== true && process.env.MAAFW_RUN_NON_INTERACTIVE === undefined,
36
+ color,
37
+ cwd,
38
+ limit,
39
+ verbose: values.verbose === true,
40
+ help: values.help === true,
41
+ version: values.version === true,
42
+ };
43
+ }
44
+ //# sourceMappingURL=flags.js.map
package/lib/image.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 纯 JS 图像工具:复用 daemon 里那套无依赖 PNG 编解码(不做第二份实现)。
3
+ *
4
+ * daemon(daemon/framed.mjs)本来就带 PNG 解码 / 缩放 / 编码 —— 它要在没有 native 依赖的
5
+ * 前提下处理帧 —— 但只在子进程里用。这里用非字面量说明符动态 import 取它的 __test 导出:
6
+ * 避免为一个 .mjs 引入声明文件、把构建链复杂化,同时用下面的接口把契约钉死。
7
+ *
8
+ * 与 daemon 帧流共用同一条降采样路径,因此"面板预览看到的缩放"和"识别用的缩放"不会分叉。
9
+ */
10
+ let cached = null;
11
+ async function helpers() {
12
+ if (cached)
13
+ return cached;
14
+ const spec = "./daemon/framed.mjs";
15
+ const mod = (await import(spec));
16
+ cached = mod.__test;
17
+ return cached;
18
+ }
19
+ /** 解码结果 → 3 字节/像素的 RGB(daemon 的 toRgb 没导出,这里只补这一步)。 */
20
+ function toRgb(dec) {
21
+ if (dec.ch === 3)
22
+ return dec.data;
23
+ if (dec.ch === 4) {
24
+ const out = Buffer.alloc(dec.w * dec.h * 3);
25
+ for (let i = 0, o = 0; i < dec.data.length; i += 4, o += 3) {
26
+ out[o] = dec.data[i];
27
+ out[o + 1] = dec.data[i + 1];
28
+ out[o + 2] = dec.data[i + 2];
29
+ }
30
+ return out;
31
+ }
32
+ throw new Error("只支持 RGB/RGBA 的 PNG(该图为 " + dec.ch + " 通道)");
33
+ }
34
+ /** PNG Buffer 的尺寸。 */
35
+ export async function pngSize(buf) {
36
+ const t = await helpers();
37
+ const dec = t.pngDecode(buf);
38
+ return { w: dec.w, h: dec.h };
39
+ }
40
+ /** 按"长边不超过 maxSide"缩放 PNG(只缩不放,原图更小时原样返回)。 */
41
+ export async function scalePngLongSide(buf, maxSide) {
42
+ const t = await helpers();
43
+ const dec = t.pngDecode(buf);
44
+ const target = Math.max(16, Math.floor(maxSide));
45
+ if (dec.w <= target && dec.h <= target)
46
+ return buf;
47
+ const scaled = t.downscale(toRgb(dec), dec.w, dec.h, target);
48
+ return t.pngEncodeRGB(scaled.data, scaled.w, scaled.h);
49
+ }
50
+ /**
51
+ * 按矩形裁一块出来(控制器分辨率坐标系,越界自动夹紧)。
52
+ *
53
+ * 面板的"模板匹配即时校验"用它:把人框的这块裁成模板图,再交给识别去比对 —— 因此这一步
54
+ * 必须与帧流/识别走同一条解码路径,不能各自实现(否则裁出来的像素与识别看到的不是同一份)。
55
+ */
56
+ export async function cropPng(buf, rect) {
57
+ const t = await helpers();
58
+ const dec = t.pngDecode(buf);
59
+ const [rx, ry, rw, rh] = rect.map((n) => Math.round(Number(n) || 0));
60
+ const x = Math.max(0, Math.min(dec.w - 1, rx));
61
+ const y = Math.max(0, Math.min(dec.h - 1, ry));
62
+ const w = Math.max(1, Math.min(dec.w - x, rw));
63
+ const h = Math.max(1, Math.min(dec.h - y, rh));
64
+ const rgb = toRgb(dec);
65
+ const out = Buffer.alloc(w * h * 3);
66
+ for (let row = 0; row < h; row++) {
67
+ const from = ((y + row) * dec.w + x) * 3;
68
+ rgb.copy(out, row * w * 3, from, from + w * 3);
69
+ }
70
+ return t.pngEncodeRGB(out, w, h);
71
+ }
72
+ /** data URL → PNG Buffer;非图片 data URL 返回 null。 */
73
+ export function dataUrlToPng(dataUrl) {
74
+ if (!dataUrl.startsWith("data:image/"))
75
+ return null;
76
+ const comma = dataUrl.indexOf(",");
77
+ if (comma < 0)
78
+ return null;
79
+ try {
80
+ return Buffer.from(dataUrl.slice(comma + 1), "base64");
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /** PNG Buffer → data URL(面板侧统一 data:image/png)。 */
87
+ export function pngToDataUrl(buf) {
88
+ return "data:image/png;base64," + buf.toString("base64");
89
+ }
90
+ //# sourceMappingURL=image.js.map
package/lib/index.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * maafw-live CLI 入口:解析 → 分发 → 输出。
3
+ *
4
+ * 入口只做四件事,任何领域逻辑都不许放进来:
5
+ * 1. 处理无命令 / --help / --version 这类不进入命令的路径
6
+ * 2. 合并全局选项与命令选项并解析
7
+ * 3. 解析并校验 cwd
8
+ * 4. 执行命令、输出结果、返回退出码
9
+ *
10
+ * 未预期异常一律兜成 FAIL 信封——技能拿到的必须是结构化的错误,
11
+ * 而不是一段堆栈(堆栈会直接灌进模型上下文)。
12
+ */
13
+ import { statSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { parseArgs } from 'node:util';
17
+ import { findCommand, suggestCommand } from './commands/index.js';
18
+ import { dispatchArgv } from './commands/dispatch.js';
19
+ import { GLOBAL_OPTIONS, readGlobalFlags } from './flags.js';
20
+ import { cliPackage } from './pkg.js';
21
+ import { EXIT, emit, fail } from './protocol.js';
22
+ import { forwardToSession, stripSessionFlag } from './runtime/session-forward.js';
23
+ import { commandUsage, topLevelUsage } from './usage.js';
24
+ function print(lines, toStderr = false) {
25
+ const text = lines.join('\n') + '\n';
26
+ if (toStderr)
27
+ process.stderr.write(text);
28
+ else
29
+ process.stdout.write(text);
30
+ }
31
+ function message(e) {
32
+ return e instanceof Error ? e.message : String(e);
33
+ }
34
+ export async function main(argv) {
35
+ const head = argv[0];
36
+ // 不进入命令的路径
37
+ if (head === undefined || head === 'help') {
38
+ const topic = argv[1];
39
+ if (topic) {
40
+ const cmd = findCommand(topic);
41
+ if (cmd) {
42
+ print(commandUsage(cmd));
43
+ return EXIT.OK;
44
+ }
45
+ print([`未知命令:${topic}`], true);
46
+ print(topLevelUsage(), true);
47
+ return EXIT.USAGE;
48
+ }
49
+ print(topLevelUsage());
50
+ return head === undefined ? EXIT.USAGE : EXIT.OK;
51
+ }
52
+ if (head === '--version' || head === '-V') {
53
+ const pkg = cliPackage();
54
+ print([`${pkg.name} ${pkg.version}`]);
55
+ return EXIT.OK;
56
+ }
57
+ if (head === '--help' || head === '-h') {
58
+ print(topLevelUsage());
59
+ return EXIT.OK;
60
+ }
61
+ const cmd = findCommand(head);
62
+ if (!cmd) {
63
+ const suggestion = suggestCommand(head);
64
+ const result = {
65
+ exitCode: EXIT.USAGE,
66
+ error: {
67
+ code: 'UNKNOWN_COMMAND',
68
+ message: `未知命令:${head}`,
69
+ ...(suggestion ? { hint: `是否想执行 ${cliPackage().name} ${suggestion}?` } : {}),
70
+ },
71
+ };
72
+ emit(head, result, { json: argv.includes('--json'), color: false });
73
+ if (!argv.includes('--json'))
74
+ print(topLevelUsage(), true);
75
+ return EXIT.USAGE;
76
+ }
77
+ /* cwd 与 --json/--color 先粗解析(全局选项集即可,命令私有选项不在这层消费)。
78
+ * --session 转发发生在 dispatchArgv **之前**:会话侧跑的是同一份命令实现,
79
+ * 本地不该为"转发给会话的命令"先新起一个 daemon 再丢(requiresSession 闸门
80
+ * 在会话侧才有意义——ctx.client 已注入时不拦)。 */
81
+ let rough;
82
+ try {
83
+ rough = parseArgs({
84
+ args: argv.slice(1),
85
+ options: GLOBAL_OPTIONS,
86
+ allowPositionals: true,
87
+ strict: false,
88
+ });
89
+ }
90
+ catch {
91
+ /* 全局层解析不过就让 dispatchArgv 走完整错误路径(BAD_ARGUMENTS 信封) */
92
+ rough = { values: {}, positionals: argv.slice(1) };
93
+ }
94
+ let cwd = process.cwd();
95
+ if (typeof rough.values.cwd === 'string' && rough.values.cwd !== '') {
96
+ cwd = resolve(rough.values.cwd);
97
+ }
98
+ const flags = readGlobalFlags(rough.values, cwd);
99
+ if (flags.help) {
100
+ print(commandUsage(cmd));
101
+ return EXIT.OK;
102
+ }
103
+ if (!isDirectory(cwd)) {
104
+ const result = fail('CWD_NOT_FOUND', `工作目录不存在:${cwd}`, '用 --cwd 指定一个存在的目录', EXIT.ENV);
105
+ emit(cmd.name, result, { json: flags.json, color: flags.color });
106
+ return EXIT.ENV;
107
+ }
108
+ /* `--session <name>`:把整条 argv(剥掉 --session 本体)送进常驻会话执行。
109
+ * `repl` 自己的 --session 是"起/查/收会话"的参数,不是转发旗标,不能拦。 */
110
+ const sessionName = typeof rough.values.session === 'string' ? rough.values.session : undefined;
111
+ if (sessionName && cmd.name !== 'repl') {
112
+ const result = await forwardToSession(sessionName, stripSessionFlag(argv));
113
+ emit(cmd.name, result, { json: flags.json, color: flags.color });
114
+ return result.exitCode;
115
+ }
116
+ const { result } = await dispatchArgv(argv, { cwd });
117
+ emit(cmd.name, result, { json: flags.json, color: flags.color });
118
+ return result.exitCode;
119
+ }
120
+ function isDirectory(p) {
121
+ try {
122
+ return statSync(p).isDirectory();
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ /* 允许 `node lib/index.js` 直接运行(bin/maafw-live.mjs 是常规入口) */
129
+ const directRun = (() => {
130
+ const entry = process.argv[1];
131
+ if (!entry)
132
+ return false;
133
+ try {
134
+ return import.meta.url === pathToFileURL(entry).href;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ })();
140
+ if (directRun) {
141
+ process.exitCode = await main(process.argv.slice(2));
142
+ }
143
+ //# sourceMappingURL=index.js.map