dsh-remote-agent 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.
- package/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
package/lib/setup.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小白向导的纯逻辑层(零外部依赖,交互壳在 bin/dsh-remote-setup.js)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:把"配一台 hub / worker 需要想的所有事"收敛成四个纯函数 +
|
|
5
|
+
* 一个异步探活。设计原则:
|
|
6
|
+
* - **生成可粘贴的配置**,而不是让用户从零写 YAML —— 副机最大的坑
|
|
7
|
+
* (必须写 `inject: []`,否则该行永远 PENDING 且无任何报错)直接
|
|
8
|
+
* 在生成的片段里替用户填好。
|
|
9
|
+
* - **每条 doctor 检查都带"怎么修"**,只报问题不给修法等于没查。
|
|
10
|
+
* - 纯函数便于测试;所有 fs/网络副作用留给调用方。
|
|
11
|
+
*/
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import net from 'node:net'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
import { expandPairConfig } from './pairing.js'
|
|
18
|
+
|
|
19
|
+
/** 与 hub 侧 newNodeToken 完全同构(nt_ 前缀 + 24 字节 base64url)。 */
|
|
20
|
+
export function newNodeToken() {
|
|
21
|
+
return `nt_${randomBytes(24).toString('base64url')}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 与 protocol.validateHello 的 nodeId 规则一致。 */
|
|
25
|
+
export const NODE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 枚举本机局域网 IPv4(排除 loopback / 链路本地 / 虚拟接口)。
|
|
29
|
+
* 返回 [{ name, address, private }],private = RFC1918。
|
|
30
|
+
*/
|
|
31
|
+
export function detectLanIps() {
|
|
32
|
+
const out = []
|
|
33
|
+
const ifaces = os.networkInterfaces()
|
|
34
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
35
|
+
for (const a of addrs ?? []) {
|
|
36
|
+
if (a.family !== 'IPv4' || a.internal) continue
|
|
37
|
+
if (a.address.startsWith('169.254.')) continue // 链路本地:没拿到 DHCP 的假地址
|
|
38
|
+
const [o1, o2] = a.address.split('.').map(Number)
|
|
39
|
+
const priv = o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)
|
|
40
|
+
out.push({ name, address: a.address, private: priv })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 私网优先,稳定的地址排前面
|
|
44
|
+
out.sort((x, y) => (x.private === y.private ? 0 : x.private ? -1 : 1))
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 归一化 roots:绝对路径化、去重、去掉尾分隔符;标出不存在的目录。
|
|
50
|
+
* @returns {{clean: string[], missing: string[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeRoots(roots) {
|
|
53
|
+
const seen = new Set()
|
|
54
|
+
const clean = []
|
|
55
|
+
for (const raw of roots ?? []) {
|
|
56
|
+
if (typeof raw !== 'string' || raw.trim() === '') continue
|
|
57
|
+
let p = raw.trim()
|
|
58
|
+
// Windows 用户常把 "D:\work\" 带尾斜杠粘进来
|
|
59
|
+
p = p.replace(/[\\/]+$/, '')
|
|
60
|
+
// 相对路径按 home 解析(~/work、work 都比"悄悄变成无效配置"好)
|
|
61
|
+
if (p.startsWith('~')) p = path.join(os.homedir(), p.slice(1))
|
|
62
|
+
if (!path.isAbsolute(p)) p = path.join(os.homedir(), p)
|
|
63
|
+
const norm = path.normalize(p)
|
|
64
|
+
if (seen.has(norm.toLowerCase())) continue
|
|
65
|
+
seen.add(norm.toLowerCase())
|
|
66
|
+
clean.push(norm)
|
|
67
|
+
}
|
|
68
|
+
const missing = clean.filter((p) => !fs.existsSync(p))
|
|
69
|
+
return { clean, missing }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 生成 hub 侧要粘进 cordis 配置的 YAML 行。
|
|
74
|
+
*
|
|
75
|
+
* hub 依赖包内 cordis.patch.yml 自动插入的那一行(含 inject),这里生成
|
|
76
|
+
* 等价的显式配置 —— 两条路径任选其一,写出来的这份覆盖 patch 的默认值
|
|
77
|
+
* (nodeToken / uiPullRoot 是 patch 默认里没有的)。
|
|
78
|
+
*/
|
|
79
|
+
export function buildHubYaml({ token = '', roots = [] } = {}) {
|
|
80
|
+
const lines = [
|
|
81
|
+
'- id: remote-agent',
|
|
82
|
+
' name: dsh-remote-agent',
|
|
83
|
+
" inject: ['webServer', 'tools', 'systemPrompt']",
|
|
84
|
+
' config:',
|
|
85
|
+
' role: hub',
|
|
86
|
+
]
|
|
87
|
+
if (token !== '') lines.push(` nodeToken: ${yq(token)}`)
|
|
88
|
+
if (roots.length > 0) {
|
|
89
|
+
lines.push(' uiPullRoot: ' + yq(roots[0])) // 面板拉取产物落地的目录(第一根)
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 生成 worker 侧的完整 profile YAML。
|
|
96
|
+
*
|
|
97
|
+
* ⚠ `inject: []` 是小白部署失败的头号原因:包内 patch 给该行声明了
|
|
98
|
+
* hub 需要的三个服务,副机没有这些服务 —— 不清空的话这一行永远停在
|
|
99
|
+
* PENDING,副机永远不拨入,且**没有任何报错**。生成的片段替用户填好。
|
|
100
|
+
*/
|
|
101
|
+
export function buildWorkerYaml({ hubUrl = '', token = '', nodeId = '', roots = [], pair = '', pinnedHubFingerprint = '' } = {}) {
|
|
102
|
+
const lines = [
|
|
103
|
+
'- id: remote-agent',
|
|
104
|
+
' name: dsh-remote-agent',
|
|
105
|
+
' inject: [] # ← 副机必须清空,否则永远 PENDING(生成器已替你填好)',
|
|
106
|
+
' config:',
|
|
107
|
+
' role: worker',
|
|
108
|
+
]
|
|
109
|
+
if (nodeId !== '') lines.push(` nodeId: ${yq(nodeId)}`)
|
|
110
|
+
if (pair !== '') {
|
|
111
|
+
// 配对码模式:一行替代 transportMode/hubUrl/token(运行时展开,见 lib/pairing.js)。
|
|
112
|
+
// ⚠ 配对码含 token,等同凭证 —— 提醒用户别贴公开渠道。
|
|
113
|
+
lines.push(` pair: ${yq(pair)}`)
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(' transportMode: direct')
|
|
116
|
+
lines.push(` hubUrl: ${yq(hubUrl)}`)
|
|
117
|
+
if (token !== '') lines.push(` token: ${yq(token)}`)
|
|
118
|
+
}
|
|
119
|
+
// P2-11:显式钉住 hub 指纹(配对码模式不需要这行 —— 指纹在码里,运行时展开)。
|
|
120
|
+
if (pinnedHubFingerprint !== '') lines.push(` pinnedHubFingerprint: ${yq(pinnedHubFingerprint)}`)
|
|
121
|
+
if (roots.length > 0) {
|
|
122
|
+
lines.push(' roots:')
|
|
123
|
+
for (const r of roots) lines.push(` - ${yq(r)}`)
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** YAML 标量:含特殊字符时加单引号(单引号翻倍转义)。 */
|
|
129
|
+
function yq(v) {
|
|
130
|
+
const s = String(v)
|
|
131
|
+
return /^[\w./:\\ -]+$/.test(s) && s.trim() === s && s !== '' ? s : `'${s.replace(/'/g, "''")}'`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 拼一个 direct 模式的 hubUrl。
|
|
136
|
+
* host 不带协议时补 ws://;路径缺省补 /api/remote-agent/node。
|
|
137
|
+
*/
|
|
138
|
+
export function buildHubUrl(host, port = 19387) {
|
|
139
|
+
let s = String(host ?? '').trim()
|
|
140
|
+
if (s === '') return ''
|
|
141
|
+
if (!/^wss?:\/\//i.test(s)) {
|
|
142
|
+
if (/^https:\/\//i.test(s)) s = `wss://${s.slice(8)}`
|
|
143
|
+
else if (/^http:\/\//i.test(s)) s = `ws://${s.slice(7)}`
|
|
144
|
+
else s = `ws://${s}`
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const u = new URL(s)
|
|
148
|
+
if (u.port === '') u.port = String(port)
|
|
149
|
+
if (u.pathname === '/' || u.pathname === '') u.pathname = '/api/remote-agent/node'
|
|
150
|
+
return u.toString()
|
|
151
|
+
} catch {
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* TCP 探活(不完成 WebSocket 握手,只确认端口有人听)。
|
|
158
|
+
* @returns {Promise<{ok:boolean, ms:number, error:string|undefined}>}
|
|
159
|
+
*/
|
|
160
|
+
export function probeTcp(host, port, { timeoutMs = 3000 } = {}) {
|
|
161
|
+
const startedAt = Date.now()
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const socket = net.connect({ host, port })
|
|
164
|
+
const done = (r) => {
|
|
165
|
+
socket.destroy()
|
|
166
|
+
resolve({ ok: r.ok, ms: Date.now() - startedAt, error: r.error })
|
|
167
|
+
}
|
|
168
|
+
socket.setTimeout(timeoutMs, () => done({ ok: false, error: `超时(${timeoutMs}ms 内无人应答)` }))
|
|
169
|
+
socket.once('connect', () => done({ ok: true }))
|
|
170
|
+
socket.once('error', (err) => done({ ok: false, error: friendlyConnectError(err, { host, port }) }))
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 把底层连接错误翻译成"哪里没对 + 怎么修"。
|
|
176
|
+
* 从 worker 的 error 事件到 doctor 都用这一份,口径一致。
|
|
177
|
+
*/
|
|
178
|
+
export function friendlyConnectError(err, { host = '', port = '' } = {}) {
|
|
179
|
+
const code = err?.code ?? ''
|
|
180
|
+
const where = host !== '' ? `${host}${port ? `:${port}` : ''}` : '主机'
|
|
181
|
+
switch (code) {
|
|
182
|
+
case 'ECONNREFUSED':
|
|
183
|
+
return `${where} 端口没人听 —— 检查:① 主机的 DSH 是否在跑;② IP/端口是否写对;③ 主机防火墙是否放行该端口`
|
|
184
|
+
case 'ENOTFOUND':
|
|
185
|
+
case 'EAI_AGAIN':
|
|
186
|
+
return `${where} 的主机名解析不了 —— hubUrl 里应写 IP(如 192.168.x.x)或能解析的主机名`
|
|
187
|
+
case 'ETIMEDOUT':
|
|
188
|
+
case 'EHOSTUNREACH':
|
|
189
|
+
case 'ENETUNREACH':
|
|
190
|
+
return `${where} 连不上(超时/不可达)—— 检查两台机器是否在同一网段、IP 是否写对、路由器是否开了 AP 隔离`
|
|
191
|
+
case 'EACCES':
|
|
192
|
+
case 'EPERM':
|
|
193
|
+
return `${where} 连接被系统拒绝 —— 端口可能被策略占用,换个端口试试`
|
|
194
|
+
default:
|
|
195
|
+
return err?.message ?? String(err ?? '未知错误')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 一个检查结果:ok + 人话 + 怎么修。 */
|
|
200
|
+
function chk(ok, label, fix) {
|
|
201
|
+
return { ok: Boolean(ok), label, fix: ok ? '' : fix }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* doctor 的静态检查(不碰网络;网络探活由调用方在结果后追加)。
|
|
206
|
+
* @param {object} o
|
|
207
|
+
* @param {'hub'|'worker'} o.role
|
|
208
|
+
* @param {object} o.config 用户写的 config(原始对象,容错解析)
|
|
209
|
+
* @param {string[]} [o.lanIps] 本机 IP(用于"公网暴露"判定)
|
|
210
|
+
*/
|
|
211
|
+
export function doctorChecks({ role, config, lanIps = [] } = {}) {
|
|
212
|
+
const raw = config && typeof config === 'object' ? config : {}
|
|
213
|
+
const out = []
|
|
214
|
+
const isWorker = role === 'worker'
|
|
215
|
+
|
|
216
|
+
// ── 配对码:有 pair 就先展开,后续检查全部基于展开后的配置 ──
|
|
217
|
+
const hasPair = typeof raw.pair === 'string' && raw.pair.trim() !== ''
|
|
218
|
+
const expansion = hasPair ? expandPairConfig(raw) : { config: raw, error: '' }
|
|
219
|
+
const c = expansion.config
|
|
220
|
+
if (hasPair) {
|
|
221
|
+
out.push(expansion.error === ''
|
|
222
|
+
? chk(true, '配对码(pair)解析成功')
|
|
223
|
+
: chk(false, '配对码(pair)解析失败', expansion.error))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Node 版本 ────────────────────────────────────────────────
|
|
227
|
+
const [maj, min, pat] = process.versions.node.split('.').map(Number)
|
|
228
|
+
const nodeOk = (maj === 22 && (min > 19 || (min === 19 && pat >= 0))) || maj >= 24
|
|
229
|
+
out.push(chk(nodeOk, `Node 版本 ${process.versions.node}`, '需要 ^22.19.0 或 >=24.0.0,请升级 Node'))
|
|
230
|
+
|
|
231
|
+
if (isWorker) {
|
|
232
|
+
// ── hubUrl ─────────────────────────────────────────────────
|
|
233
|
+
const urlRaw = typeof c.hubUrl === 'string' ? c.hubUrl.trim() : ''
|
|
234
|
+
out.push(chk(urlRaw !== '', '已配置 hubUrl(副机拨入地址)', 'config.hubUrl 必填,形如 ws://192.168.x.x:19387/api/remote-agent/node —— 用向导生成'))
|
|
235
|
+
let u = null
|
|
236
|
+
if (urlRaw !== '') {
|
|
237
|
+
try { u = new URL(/^wss?:\/\//i.test(urlRaw) ? urlRaw : `ws://${urlRaw}`) } catch { /* 保持 null */ }
|
|
238
|
+
out.push(chk(u !== null, 'hubUrl 是合法的 ws:// 地址', `当前值 "${urlRaw}" 解析不了;正确示例 ws://192.168.1.10:19387/api/remote-agent/node`))
|
|
239
|
+
// token 与公网暴露
|
|
240
|
+
const token = typeof c.token === 'string' ? c.token.trim() : ''
|
|
241
|
+
const hostIsPrivate = (() => {
|
|
242
|
+
const h = u?.hostname ?? ''
|
|
243
|
+
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h)
|
|
244
|
+
if (!m) return true // 主机名/回环按私网处理
|
|
245
|
+
const [o1, o2] = m.slice(1).map(Number)
|
|
246
|
+
return o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168) || o1 === 127
|
|
247
|
+
})()
|
|
248
|
+
if (token === '') {
|
|
249
|
+
out.push(chk(
|
|
250
|
+
hostIsPrivate,
|
|
251
|
+
'token 未配置(目标地址是私网/回环,暂可接受)',
|
|
252
|
+
'主机与副机的 token 必须一致;目标不是私网时,不配 token 等于把副机门打开 —— 用向导生成 nt_ 开头的强 token',
|
|
253
|
+
))
|
|
254
|
+
} else {
|
|
255
|
+
out.push(chk(true, 'token 已配置'))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ── roots(与 hubUrl 无关,单独检查 —— 缺 hubUrl 时 roots 问题也要报)──
|
|
259
|
+
const { clean, missing } = normalizeRoots(Array.isArray(c.roots) ? c.roots : [])
|
|
260
|
+
out.push(chk(clean.length > 0, 'roots 非空', 'config.roots 必须至少给一个真实存在的绝对路径(副机的安全边界),例如 D:\\work'))
|
|
261
|
+
for (const m of missing) {
|
|
262
|
+
out.push(chk(false, `roots 里的目录存在:${m}`, '目录不存在 —— 先创建它,或改成一个真实存在的路径'))
|
|
263
|
+
}
|
|
264
|
+
// ── nodeId ───────────────────────────────────────────────
|
|
265
|
+
const nid = typeof c.nodeId === 'string' ? c.nodeId.trim() : ''
|
|
266
|
+
if (nid !== '') {
|
|
267
|
+
out.push(chk(NODE_ID_RE.test(nid), `nodeId "${nid}" 合法`, '只能字母数字 . _ -,以字母或数字开头,≤64 字符(留空则自动取主机名)'))
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
// hub 侧
|
|
271
|
+
const token = typeof c.nodeToken === 'string' ? c.nodeToken.trim() : ''
|
|
272
|
+
const hasPublic = lanIps.some((i) => !i.private)
|
|
273
|
+
out.push(chk(token !== '', 'nodeToken 已配置(副机入网口令)', '不配置时直连端点不校验任何凭证 —— 仅限 Tailscale/同机等完全可信网络;公网部署必须配。用向导生成 nt_ 开头的强 token'))
|
|
274
|
+
if (token === '' && hasPublic) {
|
|
275
|
+
out.push(chk(false, '本机有公网地址但 token 为空', '立即配置 nodeToken,或确认本机只暴露在可信内网'))
|
|
276
|
+
}
|
|
277
|
+
const pullRoot = typeof c.uiPullRoot === 'string' ? c.uiPullRoot.trim() : ''
|
|
278
|
+
out.push(chk(pullRoot !== '', 'uiPullRoot 已配置(面板拉取产物落地的目录)', '不配置时 Web 面板的"拉取"按钮是禁用的 —— 建议给一个专门目录,如 D:\\remote-works'))
|
|
279
|
+
if (pullRoot !== '' && !fs.existsSync(pullRoot)) {
|
|
280
|
+
out.push(chk(false, `uiPullRoot 目录存在:${pullRoot}`, '目录不存在 —— 先创建它'))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 渲染 doctor 结果为可打印文本(✓/✗ + 修法)。 */
|
|
288
|
+
export function renderDoctor(checks, { title = 'doctor 体检结果' } = {}) {
|
|
289
|
+
const lines = [title, '']
|
|
290
|
+
let bad = 0
|
|
291
|
+
for (const c of checks) {
|
|
292
|
+
if (!c.ok) bad += 1
|
|
293
|
+
lines.push(`${c.ok ? '✓' : '✗'} ${c.label}`)
|
|
294
|
+
if (!c.ok && c.fix) lines.push(` ↳ 怎么修:${c.fix}`)
|
|
295
|
+
}
|
|
296
|
+
lines.push('')
|
|
297
|
+
lines.push(bad === 0 ? '全部通过。' : `共 ${bad} 项需要处理。`)
|
|
298
|
+
return { text: lines.join('\n'), failed: bad }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ════════════════════════════════════════════════════════════════
|
|
302
|
+
* 写盘(cordis.patch.yml 托管块)——零终端小白的"最后一公里"。
|
|
303
|
+
*
|
|
304
|
+
* 侦查结论(2026-10-15,对照本机 DSH 0.2.0-rc.2 实测):
|
|
305
|
+
* - 真正的配置文件是 ~/.dsh/profiles/<profile>/cordis.patch.yml
|
|
306
|
+
* (cordis.yml 是空壳,头注释明说 "Edit cordis.patch.yml, not this file")
|
|
307
|
+
* - dsh 自己改这个文件时留 .bak-<后缀>-<时间戳> 备份 → 照抄该惯例
|
|
308
|
+
* - 托管块先例:remote-web-ui 用 "# --- ... (managed - do not edit) ---"
|
|
309
|
+
* 标记实现幂等更新 → 照抄该模式
|
|
310
|
+
*
|
|
311
|
+
* 铁律:
|
|
312
|
+
* 1. 只动标记之间的内容,用户的其它配置一个字节不碰。
|
|
313
|
+
* 2. 写前必备份,写失败必回滚。
|
|
314
|
+
* 3. 已有**无标记**的 remote-agent 条目(手写的)绝不静默重复追加 ——
|
|
315
|
+
* 同 id 两行 = 插件挂载两次 = 路由重复注册 = 启动失败。
|
|
316
|
+
* ════════════════════════════════════════════════════════════════ */
|
|
317
|
+
|
|
318
|
+
export const MANAGED_BEGIN = '# --- dsh-remote-agent (managed by dsh-remote-setup - do not edit) ---'
|
|
319
|
+
export const MANAGED_END = '# --- end dsh-remote-agent managed block ---'
|
|
320
|
+
/** 手写 remote-agent 条目的检测(排除托管块内部)。 */
|
|
321
|
+
const RA_ENTRY_RE = /^\s*-\s+id:\s*remote-agent\b/m
|
|
322
|
+
|
|
323
|
+
/** 默认 profile 的 cordis.patch.yml 绝对路径。 */
|
|
324
|
+
export function patchFilePath(profile = 'desktop') {
|
|
325
|
+
const p = String(profile ?? 'desktop').trim() || 'desktop'
|
|
326
|
+
// profile 名做一次防路径穿越清洗(只允许字母数字 - _)
|
|
327
|
+
const safe = p.replace(/[^A-Za-z0-9_-]/g, '') || 'desktop'
|
|
328
|
+
return path.join(os.homedir(), '.dsh', 'profiles', safe, 'cordis.patch.yml')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 列出本机已有的 dsh profiles(含 cordis.patch.yml 的才算)。root 参数供测试注入。 */
|
|
332
|
+
export function listDshProfiles(root = path.join(os.homedir(), '.dsh', 'profiles')) {
|
|
333
|
+
try {
|
|
334
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
335
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
336
|
+
.map((d) => d.name)
|
|
337
|
+
.filter((name) => fs.existsSync(path.join(root, name, 'cordis.patch.yml')))
|
|
338
|
+
} catch {
|
|
339
|
+
return []
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 把生成的 YAML 块合并进 patch 文本(纯函数,幂等)。
|
|
345
|
+
*
|
|
346
|
+
* @param {string} fileText
|
|
347
|
+
* @param {string} blockYaml
|
|
348
|
+
* @param {boolean} [replaceHandWritten] 是否**先移除**托管块之外的手写
|
|
349
|
+
* remote-agent 条目(连同它上方的注释块),再写托管块。
|
|
350
|
+
*
|
|
351
|
+
* ⚠ 这个参数是 2026-10-15 修 bug 时加的。原 `force` 只跳过冲突检测却
|
|
352
|
+
* 仍走"追加"路径 —— 结果是**手写条目留着 + 托管块又写一条**,
|
|
353
|
+
* 两条同 id → 插件挂载两次 → 启动失败。**正是 force 声称要防的事。**
|
|
354
|
+
* 而且写完之后文件里永久同时存在两者,下一次写入会被冲突检测挡住,
|
|
355
|
+
* **再也改不了**(实测:force 一次之后就死锁)。
|
|
356
|
+
*
|
|
357
|
+
* 正确语义:force = "我确认要用托管块接管,请把手写那条替换掉"。
|
|
358
|
+
* 所以必须真的移除手写条目,而不是在旁边追加。
|
|
359
|
+
*
|
|
360
|
+
* @returns {{text:string, mode:'created'|'replaced'|'replaced-handwritten'}}
|
|
361
|
+
*/
|
|
362
|
+
export function upsertManagedBlock(fileText, blockYaml, replaceHandWritten = false) {
|
|
363
|
+
const src = typeof fileText === 'string' ? fileText : ''
|
|
364
|
+
const block = `${MANAGED_BEGIN}\n${String(blockYaml ?? '').trim()}\n${MANAGED_END}`
|
|
365
|
+
|
|
366
|
+
// 需要移除手写条目时:先把"托管块之外"的部分做一次剔除。
|
|
367
|
+
let base = src
|
|
368
|
+
if (replaceHandWritten === true) {
|
|
369
|
+
const i0 = src.indexOf(MANAGED_BEGIN)
|
|
370
|
+
const j0 = src.indexOf(MANAGED_END)
|
|
371
|
+
if (i0 === -1 || j0 === -1 || j0 < i0) {
|
|
372
|
+
base = stripHandWrittenEntries(src)
|
|
373
|
+
} else {
|
|
374
|
+
// 分别处理块前与块后,避免碰到托管块自身
|
|
375
|
+
const before = stripHandWrittenEntries(src.slice(0, i0))
|
|
376
|
+
const after = stripHandWrittenEntries(src.slice(j0 + MANAGED_END.length))
|
|
377
|
+
base = `${before}${src.slice(i0, j0 + MANAGED_END.length)}${after}`
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const i = base.indexOf(MANAGED_BEGIN)
|
|
382
|
+
const j = base.indexOf(MANAGED_END)
|
|
383
|
+
if (i !== -1 && j !== -1 && j > i) {
|
|
384
|
+
// 替换标记之间的内容(保留标记前后的原文)
|
|
385
|
+
const before = base.slice(0, i)
|
|
386
|
+
const after = base.slice(j + MANAGED_END.length)
|
|
387
|
+
return {
|
|
388
|
+
text: `${before}${block}${after}`,
|
|
389
|
+
mode: replaceHandWritten === true && base !== src ? 'replaced-handwritten' : 'replaced',
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// 追加到文件尾(前面隔一个空行,保证不粘连上一条目)
|
|
393
|
+
const sep = base === '' ? '' : (base.endsWith('\n') ? '\n' : '\n\n')
|
|
394
|
+
return {
|
|
395
|
+
text: `${base}${sep}${block}\n`,
|
|
396
|
+
mode: replaceHandWritten === true && base !== src ? 'replaced-handwritten' : 'created',
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 移除**托管块之外**的手写 remote-agent 条目(含它上方连续的注释块)。
|
|
402
|
+
*
|
|
403
|
+
* 条目边界怎么定:从 `- id: remote-agent` 那一行开始,向下吃到
|
|
404
|
+
* **下一个顶层 `- ` 开头的行**(那是下一条 loader 条目)或文件末尾。
|
|
405
|
+
* 中间的缩进行(` name:` / ` config:` / 更深缩进)都算这一条的。
|
|
406
|
+
*
|
|
407
|
+
* 为什么连上方注释一起删:手写条目通常带着用户自己的说明注释
|
|
408
|
+
* ("本机是主机(派活方)"之类),只删条目会留下孤立的注释,读起来莫名其妙。
|
|
409
|
+
*
|
|
410
|
+
* @param {string} text 不含托管块的文本片段
|
|
411
|
+
* @returns {string} 剔除后的文本
|
|
412
|
+
*/
|
|
413
|
+
function stripHandWrittenEntries(text) {
|
|
414
|
+
const src = typeof text === 'string' ? text : ''
|
|
415
|
+
const lines = src.split('\n')
|
|
416
|
+
const out = []
|
|
417
|
+
let i = 0
|
|
418
|
+
while (i < lines.length) {
|
|
419
|
+
const line = lines[i]
|
|
420
|
+
if (!/^\s*-\s+id:\s*remote-agent\b/.test(line)) {
|
|
421
|
+
out.push(line)
|
|
422
|
+
i += 1
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
// 命中条目头:先回退掉上方紧邻的注释行(含空注释与纯空行前的注释)
|
|
426
|
+
while (out.length > 0) {
|
|
427
|
+
const prev = out[out.length - 1]
|
|
428
|
+
if (/^\s*#/.test(prev) || prev.trim() === '') out.pop()
|
|
429
|
+
else break
|
|
430
|
+
}
|
|
431
|
+
// 吃掉条目本身:直到下一个顶层 `- ` 或末尾
|
|
432
|
+
i += 1
|
|
433
|
+
while (i < lines.length) {
|
|
434
|
+
const cur = lines[i]
|
|
435
|
+
if (/^-\s/.test(cur) || /^-\s*$/.test(cur)) break
|
|
436
|
+
i += 1
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// 折叠连续空行(剔除后容易留一串)
|
|
440
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 检测写盘冲突:托管块之外是否已有手写的 remote-agent 条目。
|
|
445
|
+
* 有 → 写盘会产生同 id 重复条目(插件挂载两次 → 启动失败),必须先拦下。
|
|
446
|
+
* @returns {boolean} true = 有冲突(不能直接写)
|
|
447
|
+
*/
|
|
448
|
+
export function hasUnmanagedRemoteAgent(fileText) {
|
|
449
|
+
const src = typeof fileText === 'string' ? fileText : ''
|
|
450
|
+
const i = src.indexOf(MANAGED_BEGIN)
|
|
451
|
+
if (i === -1) return RA_ENTRY_RE.test(src)
|
|
452
|
+
// 托管块内部自身含 remote-agent 条目,先挖掉块体再检测
|
|
453
|
+
const j = src.indexOf(MANAGED_END)
|
|
454
|
+
if (j === -1) return RA_ENTRY_RE.test(src)
|
|
455
|
+
return RA_ENTRY_RE.test(src.slice(0, i) + src.slice(j + MANAGED_END.length))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 写盘主流程:备份 → 合并 → 写入 → 校验,失败自动回滚。
|
|
460
|
+
*
|
|
461
|
+
* @param {object} o
|
|
462
|
+
* @param {string} o.file patch 文件绝对路径
|
|
463
|
+
* @param {string} o.blockYaml 生成的配置块(buildHubYaml/buildWorkerYaml 输出)
|
|
464
|
+
* @param {boolean} [o.force] 有手写条目冲突时,**用它替换**手写条目(默认拒绝)。
|
|
465
|
+
*
|
|
466
|
+
* ⚠ 语义修订(2026-10-15):原实现里 `force` 只跳过冲突检测,却仍走"追加"
|
|
467
|
+
* 路径 —— 结果是手写条目留着、托管块又写一条,**两条同 id**(插件挂载两次
|
|
468
|
+
* → 启动失败),正是它声称要防的事。而且写完之后文件里永久同时存在两者,
|
|
469
|
+
* 下一次写入会被冲突检测挡住 —— **force 一次之后就再也改不了**(实测死锁)。
|
|
470
|
+
*
|
|
471
|
+
* 现在 force = "确认用托管块接管,请把手写那条替换掉",真的会移除手写条目。
|
|
472
|
+
*
|
|
473
|
+
* @returns {{ok:boolean, file:string, backup:string, mode:string, error:string}}
|
|
474
|
+
*/
|
|
475
|
+
export function writePatchBlock({ file, blockYaml, force = false } = {}) {
|
|
476
|
+
const fail = (error) => ({ ok: false, file: String(file ?? ''), backup: '', mode: '', error })
|
|
477
|
+
if (typeof file !== 'string' || file === '') return fail('缺少目标文件路径')
|
|
478
|
+
let original
|
|
479
|
+
try {
|
|
480
|
+
original = fs.readFileSync(file, 'utf8')
|
|
481
|
+
} catch (err) {
|
|
482
|
+
return fail(`读不了 ${file}:${err?.code === 'ENOENT' ? '文件不存在(DSH 未安装或 profile 名不对)' : msg(err)}`)
|
|
483
|
+
}
|
|
484
|
+
const conflicted = hasUnmanagedRemoteAgent(original)
|
|
485
|
+
if (conflicted && force !== true) {
|
|
486
|
+
return fail(
|
|
487
|
+
'配置里已有一段手写的 remote-agent 条目。直接写会产生同 id 重复(插件挂载两次,启动失败)。'
|
|
488
|
+
+ '勾选"替换手写配置"后重试(会自动备份原文件),或手动合并。',
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
492
|
+
const backup = `${file}.bak-dshra-${stamp}`
|
|
493
|
+
try {
|
|
494
|
+
fs.writeFileSync(backup, original, 'utf8')
|
|
495
|
+
// force 且真有手写条目时才走"替换手写"路径 —— 没有冲突时保持原行为,
|
|
496
|
+
// 免得无谓地动用户文件(例如托管块已在、只是改个 role)。
|
|
497
|
+
const replaceHandWritten = force === true && conflicted
|
|
498
|
+
const { text, mode } = upsertManagedBlock(original, blockYaml, replaceHandWritten)
|
|
499
|
+
// 写前校验:结果必须仍非空且标记配对
|
|
500
|
+
if (text.trim() === '') throw new Error('合并结果为空')
|
|
501
|
+
// ⚠ 关键校验:写完之后**不得**再存在托管块之外的手写条目 ——
|
|
502
|
+
// 这正是原 bug 的形态(两条同 id)。不校验的话它会静默写出去。
|
|
503
|
+
if (hasUnmanagedRemoteAgent(text)) {
|
|
504
|
+
throw new Error('合并后仍存在托管块之外的手写 remote-agent 条目(会产生同 id 重复)')
|
|
505
|
+
}
|
|
506
|
+
fs.writeFileSync(file, text, 'utf8')
|
|
507
|
+
const verify = fs.readFileSync(file, 'utf8')
|
|
508
|
+
if (!verify.includes(MANAGED_BEGIN) || !verify.includes(MANAGED_END)) throw new Error('写入后标记丢失')
|
|
509
|
+
if (hasUnmanagedRemoteAgent(verify)) throw new Error('落盘后校验发现重复条目')
|
|
510
|
+
return { ok: true, file, backup, mode, error: '' }
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// 回滚:任何一步失败都把原文件还回去
|
|
513
|
+
try {
|
|
514
|
+
if (typeof original === 'string') fs.writeFileSync(file, original, 'utf8')
|
|
515
|
+
} catch { /* 回滚失败也如实上报 */ }
|
|
516
|
+
return fail(`写入失败(已回滚):${msg(err)}`)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function msg(err) {
|
|
521
|
+
return err instanceof Error ? err.message : String(err)
|
|
522
|
+
}
|