dsh-remote-agent 0.9.4 → 0.9.6
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/CHANGELOG.md +190 -0
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/lib/client.js +162 -10
- package/lib/explain-error.js +472 -0
- package/lib/hub/index.js +75 -26
- package/lib/hub/node-registry.js +78 -0
- package/lib/hub/registry-handshake.js +44 -0
- package/lib/hub/ui-routes.js +28 -0
- package/lib/index.js +89 -0
- package/lib/node/index.js +40 -7
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +66 -0
|
@@ -242,6 +242,18 @@ export function withHandshake(Base) {
|
|
|
242
242
|
|
|
243
243
|
if (transport === 'direct') this.sendWelcome(rec)
|
|
244
244
|
else this.sendIdentityFrame(rec) // relay:hub 在握手期对副机静默,身份帧必须在上线后追发
|
|
245
|
+
// 记一次成功的连接尝试(2026-10-20,体验提升 ①)——
|
|
246
|
+
// 面板据此区分"从没连上过"与"连上又断了",这是排查方向的分水岭。
|
|
247
|
+
try {
|
|
248
|
+
this.noteConnectionAttempt({
|
|
249
|
+
result: 'ok',
|
|
250
|
+
nodeId: hello.nodeId,
|
|
251
|
+
remote: remoteOf(conn),
|
|
252
|
+
transport,
|
|
253
|
+
})
|
|
254
|
+
} catch {
|
|
255
|
+
/* 诊断记录绝不能影响上线流程 */
|
|
256
|
+
}
|
|
245
257
|
this.log('info', 'node online', { nodeId: hello.nodeId, os: hello.os, transport })
|
|
246
258
|
return rec
|
|
247
259
|
}
|
|
@@ -388,6 +400,20 @@ export function withHandshake(Base) {
|
|
|
388
400
|
|
|
389
401
|
/** 在指定连接上回 error 帧然后关闭。 */
|
|
390
402
|
rejectConn(conn, code, message, nodeId) {
|
|
403
|
+
// 记一次失败的连接尝试(2026-10-20,体验提升 ①)——
|
|
404
|
+
// 副机连不上时,面板要能说清"为什么",而不是只显示 0 在线。
|
|
405
|
+
try {
|
|
406
|
+
this.noteConnectionAttempt({
|
|
407
|
+
result: 'fail',
|
|
408
|
+
code: typeof code === 'string' ? code : '',
|
|
409
|
+
message: typeof message === 'string' ? message : '',
|
|
410
|
+
nodeId: typeof nodeId === 'string' ? nodeId : '',
|
|
411
|
+
remote: remoteOf(conn),
|
|
412
|
+
transport: 'direct',
|
|
413
|
+
})
|
|
414
|
+
} catch {
|
|
415
|
+
/* 诊断记录绝不能影响拒绝流程本身 */
|
|
416
|
+
}
|
|
391
417
|
try {
|
|
392
418
|
conn.send(encodeFrame(envelope(HUB_TO_NODE.ERROR, { code, message, nodeId })))
|
|
393
419
|
} catch {
|
|
@@ -401,3 +427,21 @@ export function withHandshake(Base) {
|
|
|
401
427
|
}
|
|
402
428
|
}
|
|
403
429
|
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 从连接对象上尽力取远端地址(用于诊断记录)。
|
|
433
|
+
* WSConnection 不一定暴露 remote,取不到就返回空串。
|
|
434
|
+
*/
|
|
435
|
+
function remoteOf(conn) {
|
|
436
|
+
try {
|
|
437
|
+
const s = conn?.socket
|
|
438
|
+
const addr = s?.remoteAddress
|
|
439
|
+
const port = s?.remotePort
|
|
440
|
+
if (typeof addr === 'string' && addr !== '') {
|
|
441
|
+
return typeof port === 'number' ? `${addr}:${port}` : addr
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
/* 取不到就算了 */
|
|
445
|
+
}
|
|
446
|
+
return ''
|
|
447
|
+
}
|
package/lib/hub/ui-routes.js
CHANGED
|
@@ -441,9 +441,37 @@ export function registerUiRoutes({
|
|
|
441
441
|
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
442
442
|
const nodes = registry.list().map(nodeView)
|
|
443
443
|
const tasks = registry.listTasks().map((t) => registry.taskView(t))
|
|
444
|
+
// 连接尝试历史(2026-10-20,体验提升 ①):副机连不上时面板要能说清原因。
|
|
445
|
+
// 逐字段投影(不 spread)—— 与其他 UI 响应同一条硬规则。
|
|
446
|
+
const connReport = (() => {
|
|
447
|
+
try {
|
|
448
|
+
const r = registry.connectionReport(20)
|
|
449
|
+
const mapEntry = (e) => ({
|
|
450
|
+
at: Number.isFinite(e.at) ? e.at : 0,
|
|
451
|
+
result: e.result === 'ok' ? 'ok' : 'fail',
|
|
452
|
+
code: typeof e.code === 'string' ? e.code : '',
|
|
453
|
+
nodeId: typeof e.nodeId === 'string' ? e.nodeId : '',
|
|
454
|
+
remote: typeof e.remote === 'string' ? e.remote : '',
|
|
455
|
+
title: typeof e.title === 'string' ? e.title : '',
|
|
456
|
+
detail: typeof e.detail === 'string' ? e.detail : '',
|
|
457
|
+
hint: typeof e.hint === 'string' ? e.hint : '',
|
|
458
|
+
raw: typeof e.message === 'string' ? e.message : '',
|
|
459
|
+
})
|
|
460
|
+
return {
|
|
461
|
+
everConnected: r.everConnected === true,
|
|
462
|
+
byNode: Array.isArray(r.byNode) ? r.byNode.slice(0, 20).map(mapEntry) : [],
|
|
463
|
+
recent: Array.isArray(r.recent) ? r.recent.slice(0, 20).map(mapEntry) : [],
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
// 诊断数据取不到不该让整个面板挂掉
|
|
467
|
+
return { everConnected: false, byNode: [], recent: [] }
|
|
468
|
+
}
|
|
469
|
+
})()
|
|
444
470
|
writeJson(res, 200, {
|
|
445
471
|
ok: true,
|
|
446
472
|
nodes,
|
|
473
|
+
// 连接尝试历史:面板据此显示"最后尝试 2 分钟前 —— 握手超时 → …"
|
|
474
|
+
connection: connReport,
|
|
447
475
|
counts: {
|
|
448
476
|
nodes: nodes.length,
|
|
449
477
|
online: nodes.filter((n) => n.online).length,
|
package/lib/index.js
CHANGED
|
@@ -138,6 +138,31 @@ async function applyWorkerBranch(ctx, config, log) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* 每个"注册宿主"上当前这一代的清理函数。
|
|
143
|
+
*
|
|
144
|
+
* 键选 **`ctx.webServer` 服务实例**,不是 `ctx`:
|
|
145
|
+
* 撞车的是**宿主的路由表**(`webServer.upgrades` 这张 Map)。同一个 ctx 当然
|
|
146
|
+
* 共用同一张表,但 DSH 热重载时也可能给每代一个新的子 ctx —— 那时按 ctx 记
|
|
147
|
+
* 就会漏掉,照样撞车。按服务实例记才对应"真正会冲突的那个东西"。
|
|
148
|
+
*
|
|
149
|
+
* 读不到 `ctx.webServer`(副机 profile 的 `inject: []` 一读就抛)时退回 ctx,
|
|
150
|
+
* 这条兜底只是让字典有键,不影响正确性。
|
|
151
|
+
*
|
|
152
|
+
* 用 WeakMap:既不阻止 GC,也不会把多个 profile / 多个 host 实例串在一起。
|
|
153
|
+
*/
|
|
154
|
+
const GENERATIONS = new WeakMap()
|
|
155
|
+
|
|
156
|
+
/** 取"注册宿主"作为字典键。读服务可能抛(无 inject),所以必须 try。 */
|
|
157
|
+
function generationKey(ctx) {
|
|
158
|
+
try {
|
|
159
|
+
const ws = ctx && ctx.webServer
|
|
160
|
+
return ws !== undefined && ws !== null ? ws : ctx
|
|
161
|
+
} catch {
|
|
162
|
+
return ctx
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
/**
|
|
142
167
|
* DSH 插件入口。
|
|
143
168
|
* @param {object} ctx Cordis 上下文
|
|
@@ -188,6 +213,64 @@ export function apply(ctx, config = {}) {
|
|
|
188
213
|
}
|
|
189
214
|
const handles = { role, hub: undefined, worker: undefined, unconfigured: unconfigured === true }
|
|
190
215
|
|
|
216
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
217
|
+
// 可重入:同一个 ctx 上只允许存在**一代**注册(0.9.5 修 duplicate 激活失败)
|
|
218
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
219
|
+
//
|
|
220
|
+
// 0.9.4 装上后 DSH 报:
|
|
221
|
+
//
|
|
222
|
+
// webserver: duplicate upgrade route "/api/remote-agent/node"
|
|
223
|
+
// 1 entry did not activate remote-agent (dsh-remote-agent)
|
|
224
|
+
//
|
|
225
|
+
// 根因不是"注册了两次"写错,而是**我们的注册不可回收**:
|
|
226
|
+
//
|
|
227
|
+
// · DSH 的 `webServer.registerUpgrade(path)` 是**按 path 查表**的,
|
|
228
|
+
// 同路径第二次注册直接抛(这是它的组合级契约,合理):
|
|
229
|
+
// if (this.upgrades.has(route.path)) throw new Error('webserver: duplicate upgrade route ...')
|
|
230
|
+
// · 我们的注册全部收集在 `__disposers` 里、由 `applyHub()` 的返回值
|
|
231
|
+
// `disposeAll` 交给调用方保管 —— 但 **DSH 不认识这个返回值**,
|
|
232
|
+
// 热重载/安装后 reload 时它只会重新 `apply`,不会回头调我们的 disposer。
|
|
233
|
+
// · 于是第二代在第一代的残留上注册 → 撞车 → 整个 entry 激活失败。
|
|
234
|
+
//
|
|
235
|
+
// 为什么以前没暴露:只有"同一个 ctx 上重新 apply"才触发。DSH 在
|
|
236
|
+
// 插件安装/升级后会热重载,这正是用户装 0.9.4 时踩到的那一步。
|
|
237
|
+
//
|
|
238
|
+
// 修法:**不指望宿主帮我们清理**。按 ctx 记住上一代的 dispose,重新
|
|
239
|
+
// apply 时先自己摘干净再装。这样无论宿主是否调用 disposer,都不会重复注册。
|
|
240
|
+
const genKey = generationKey(ctx)
|
|
241
|
+
const prevDispose = GENERATIONS.get(genKey)
|
|
242
|
+
if (typeof prevDispose === 'function') {
|
|
243
|
+
try {
|
|
244
|
+
prevDispose()
|
|
245
|
+
log('info', 're-apply on the same host: previous generation disposed first')
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log('warn', 'previous generation dispose failed (continuing)', {
|
|
248
|
+
error: String(err?.message ?? err),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let released = false
|
|
254
|
+
const dispose = () => {
|
|
255
|
+
if (released) return
|
|
256
|
+
released = true
|
|
257
|
+
// 只摘自己:新一代可能已经顶替了这一格,别把它的注册也拆了
|
|
258
|
+
if (GENERATIONS.get(genKey) === dispose) GENERATIONS.delete(genKey)
|
|
259
|
+
// 顺序:hub 的路由/监听 → setup 路由 → worker 拨号。
|
|
260
|
+
// 每一步独立兜底:一个失败不能让后面的漏摘(否则又变成"摘一半")。
|
|
261
|
+
try { handles.hub?.disposeAll?.() } catch (err) {
|
|
262
|
+
log('warn', 'hub dispose failed', { error: String(err?.message ?? err) })
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(handles.setup)) {
|
|
265
|
+
for (const d of handles.setup) {
|
|
266
|
+
try { if (typeof d === 'function') d() } catch { /* 已摘 */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try { if (typeof handles.worker === 'function') handles.worker() } catch { /* 已摘 */ }
|
|
270
|
+
}
|
|
271
|
+
GENERATIONS.set(genKey, dispose)
|
|
272
|
+
handles.dispose = dispose
|
|
273
|
+
|
|
191
274
|
// ── 未配置:什么都不注册 ────────────────────────────────────────────────
|
|
192
275
|
//
|
|
193
276
|
// 为什么要有这个分支:首次安装时**插件无法知道这台机器是主机还是副机** ——
|
|
@@ -252,6 +335,12 @@ export function apply(ctx, config = {}) {
|
|
|
252
335
|
// 动态 import 是异步的;不 await —— apply 本身是同步契约。
|
|
253
336
|
// 失败已在 applyWorkerBranch 内部记日志,不让 host 因副机缺件而起不来。
|
|
254
337
|
void applyWorkerBranch(ctx, config, log).then((h) => {
|
|
338
|
+
// 竞态:这一代已经被 dispose(热重载/卸载)了,把刚挂上的 worker 立刻摘掉。
|
|
339
|
+
// 否则它会留在后台继续拨号 —— 一份回收不掉的僵尸连接。
|
|
340
|
+
if (released) {
|
|
341
|
+
try { if (typeof h === 'function') h() } catch { /* 已摘 */ }
|
|
342
|
+
return
|
|
343
|
+
}
|
|
255
344
|
handles.worker = h
|
|
256
345
|
})
|
|
257
346
|
}
|
package/lib/node/index.js
CHANGED
|
@@ -45,9 +45,12 @@ import { expandPairConfig } from '../pairing.js'
|
|
|
45
45
|
import { selectRunner } from './runner.js'
|
|
46
46
|
// 资源遥测:CPU(增量)/内存/负载采样,经 node/resource 帧上报 hub。
|
|
47
47
|
import { RESOURCE_INTERVAL_MS, createResourceSampler } from './resource.js'
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
48
|
+
// 统一错误翻译层(2026-10-20,体验提升 ④):覆盖协议错误码(17 个)+ HTTP
|
|
49
|
+
// 状态 + errno,每条都给"下一步做什么"。面板与日志共用同一份口径。
|
|
50
|
+
//
|
|
51
|
+
// 注:原先这里还 import 了 setup.js 的 friendlyConnectError,2026-10-20 起
|
|
52
|
+
// 运行时错误统一走 explain-error.js(覆盖面更广),那个 import 已无用。
|
|
53
|
+
import { explainError, formatExplained } from '../explain-error.js'
|
|
51
54
|
import { scanRepos } from './repos.js'
|
|
52
55
|
import { TaskEngine } from './task-engine.js'
|
|
53
56
|
import { TaskStore } from './task-store.js'
|
|
@@ -655,6 +658,18 @@ function startWorker(ctx, config) {
|
|
|
655
658
|
|
|
656
659
|
/* 6) 发送助手:永不抛、失败即丢帧(断连期间任务照跑) ---------------- */
|
|
657
660
|
let lastError = ''
|
|
661
|
+
/**
|
|
662
|
+
* 最近一次连接失败的结构化详情(2026-10-20,体验提升 ①)。
|
|
663
|
+
*
|
|
664
|
+
* `lastError` 只是原始字符串,面板没法据此给用户"下一步做什么"。
|
|
665
|
+
* 这里存翻译后的三件套:{ at, title, detail, hint, raw, code }。
|
|
666
|
+
*
|
|
667
|
+
* 面板据此显示:
|
|
668
|
+
* ✗ 最后尝试 2 分钟前 —— 主机上没有服务在监听
|
|
669
|
+
* → 检查:① 主机的 DSH 是否在跑;② 端口是否写对…
|
|
670
|
+
* 原始错误:connect ECONNREFUSED 100.86.48.54:19387
|
|
671
|
+
*/
|
|
672
|
+
let lastErrorDetail = null
|
|
658
673
|
const send = (type, data, opts) => {
|
|
659
674
|
try {
|
|
660
675
|
return transport.send(type, data, opts) === true
|
|
@@ -1104,10 +1119,25 @@ function startWorker(ctx, config) {
|
|
|
1104
1119
|
})
|
|
1105
1120
|
on('error', (err) => {
|
|
1106
1121
|
lastError = msgOf(err)
|
|
1107
|
-
// 底层错误码(ECONNREFUSED
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1122
|
+
// 底层错误码(ECONNREFUSED 等)对小白是天书。这里用**统一翻译层**
|
|
1123
|
+
// (lib/explain-error.js)给出"哪里没对 + 下一步做什么";原始错误照记
|
|
1124
|
+
// (排查仍需要)。
|
|
1125
|
+
//
|
|
1126
|
+
// 2026-10-20 改:从 setup.js 的 friendlyConnectError 升级到 explain-error.js
|
|
1127
|
+
// —— 后者覆盖协议错误码(17 个)+ HTTP 状态 + 握手超时等,不只是 5 类网络错误。
|
|
1128
|
+
//
|
|
1129
|
+
// ⚠ 结构化保存:面板要显示"最后尝试时间 + 人话 + 原始错误"三件套,
|
|
1130
|
+
// 只存字符串的话面板只能原样打印。
|
|
1131
|
+
const explained = explainError(err, { isHub: false, context: '连接主机' })
|
|
1132
|
+
lastErrorDetail = {
|
|
1133
|
+
at: Date.now(),
|
|
1134
|
+
title: explained.title,
|
|
1135
|
+
detail: explained.detail,
|
|
1136
|
+
hint: explained.hint,
|
|
1137
|
+
raw: explained.raw,
|
|
1138
|
+
code: typeof err?.code === 'string' ? err.code : '',
|
|
1139
|
+
}
|
|
1140
|
+
logger.warn(`连接问题:${formatExplained(explained)}`, { nodeId, error: lastError })
|
|
1111
1141
|
})
|
|
1112
1142
|
// P2-11:hub 身份验证状态。'pending' 期间(relay 宽限窗口内)task/start 会被
|
|
1113
1143
|
// 上面的 guard 拒绝;direct 模式在 welcome 时就已决出,宽限窗口实际只作用于
|
|
@@ -1197,6 +1227,9 @@ function startWorker(ctx, config) {
|
|
|
1197
1227
|
},
|
|
1198
1228
|
reconcile,
|
|
1199
1229
|
lastError: lastError || null,
|
|
1230
|
+
// 结构化的人话错误(面板据此显示"最后尝试 + 原因 + 下一步")。
|
|
1231
|
+
// 旧版副机没有这个字段 → 面板按 null 处理,退回只显示 lastError。
|
|
1232
|
+
lastErrorDetail: lastErrorDetail || null,
|
|
1200
1233
|
updatedAt: Date.now(),
|
|
1201
1234
|
}
|
|
1202
1235
|
}
|
package/package.json
CHANGED
|
@@ -1177,6 +1177,72 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1177
1177
|
'lib/listen-server.js(拒 0.0.0.0 + Tailscale 优先)+ hub/index.js(测试环境跳过)')
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
/* ── 88~90. 用户体验五项(2026-10-20)──
|
|
1181
|
+
*
|
|
1182
|
+
* 实测用户反馈:"出了问题不知道原因"。五个断层:
|
|
1183
|
+
* ① 副机连不上时主机只显示「0 在线」,没有原因
|
|
1184
|
+
* ② 体检能查出来,但修法要手抄 YAML
|
|
1185
|
+
* ③ 首次使用不知道从哪开始
|
|
1186
|
+
* ④ 原始错误(ECONNREFUSED / 401 / timeout after 40000ms)直接抛给用户
|
|
1187
|
+
* ⑤ 长任务只能看到"运行中",不知道在干什么
|
|
1188
|
+
*
|
|
1189
|
+
* 这三条断言钉住"每条都有对应实现",防止后续重构把它们悄悄拿掉。
|
|
1190
|
+
*/
|
|
1191
|
+
{
|
|
1192
|
+
const explainSrc = read('lib/explain-error.js')
|
|
1193
|
+
const regSrc = read('lib/hub/node-registry.js')
|
|
1194
|
+
const clientSrc6 = read('lib/client.js')
|
|
1195
|
+
const uiSrc5 = read('lib/hub/ui-routes.js')
|
|
1196
|
+
|
|
1197
|
+
check('88', '体验④:统一错误翻译层覆盖 errno + 协议码 + HTTP + 文案特征',
|
|
1198
|
+
/export function explainError/.test(explainSrc)
|
|
1199
|
+
// errno 分支
|
|
1200
|
+
&& /ECONNREFUSED/.test(explainSrc)
|
|
1201
|
+
&& /ETIMEDOUT/.test(explainSrc)
|
|
1202
|
+
&& /EADDRINUSE/.test(explainSrc)
|
|
1203
|
+
// 协议码(用 ERROR_CODE 常量,不写死字符串)
|
|
1204
|
+
&& /ERROR_CODE\.UNAUTHORIZED/.test(explainSrc)
|
|
1205
|
+
&& /ERROR_CODE\.PROTOCOL_MISMATCH/.test(explainSrc)
|
|
1206
|
+
// HTTP 状态
|
|
1207
|
+
&& /case 401:/.test(explainSrc)
|
|
1208
|
+
&& /case 404:/.test(explainSrc)
|
|
1209
|
+
// 文案特征
|
|
1210
|
+
&& /handshake timeout/.test(explainSrc)
|
|
1211
|
+
// 每条都有 hint(下一步做什么)
|
|
1212
|
+
&& /hint:/.test(explainSrc),
|
|
1213
|
+
'lib/explain-error.js(errno + 17 个协议码 + HTTP + 文案特征,每条带 hint)')
|
|
1214
|
+
|
|
1215
|
+
check('89', '体验①:连接尝试历史(记录 + 面板空态诊断)',
|
|
1216
|
+
// 注册表记录每次尝试
|
|
1217
|
+
/noteConnectionAttempt/.test(regSrc)
|
|
1218
|
+
&& /connectionLog/.test(regSrc)
|
|
1219
|
+
&& /connectionReport/.test(regSrc)
|
|
1220
|
+
// 拒绝点与成功点都要记
|
|
1221
|
+
&& /noteConnectionAttempt/.test(read('lib/hub/registry-handshake.js'))
|
|
1222
|
+
// 路由透出
|
|
1223
|
+
&& /connection: connReport/.test(uiSrc5)
|
|
1224
|
+
// 面板渲染诊断(区分"从没连过"与"连过又断")
|
|
1225
|
+
&& /connDiag/.test(clientSrc6)
|
|
1226
|
+
&& /everConnected/.test(clientSrc6),
|
|
1227
|
+
'node-registry.js + registry-handshake.js + ui-routes.js + client.js')
|
|
1228
|
+
|
|
1229
|
+
check('90', '体验②③⑤:复制修法按钮 + 编号步骤 + 任务进度摘要',
|
|
1230
|
+
// ② 复制修法
|
|
1231
|
+
/复制修法/.test(clientSrc6)
|
|
1232
|
+
&& /copyText\(it\.fix\)/.test(clientSrc6)
|
|
1233
|
+
// ③ 编号步骤(hub 侧 1-4 步)
|
|
1234
|
+
&& /'重启 DSH'/.test(clientSrc6)
|
|
1235
|
+
&& /'生成配对码'/.test(clientSrc6)
|
|
1236
|
+
&& /'在副机上粘贴'/.test(clientSrc6)
|
|
1237
|
+
&& /'等它连上'/.test(clientSrc6)
|
|
1238
|
+
// ③ 角色选择提示
|
|
1239
|
+
&& /你现在用的这台/.test(clientSrc6)
|
|
1240
|
+
// ⑤ 任务行进度摘要
|
|
1241
|
+
&& /progressLine/.test(clientSrc6)
|
|
1242
|
+
&& /最近:/.test(clientSrc6),
|
|
1243
|
+
'lib/client.js(复制按钮 + 编号步骤 + 角色提示 + 任务进度摘要)')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1180
1246
|
/* ── 输出 ── */
|
|
1181
1247
|
const failed = results.filter((r) => !r.ok)
|
|
1182
1248
|
if (process.argv.includes('--json')) {
|