dsh-mesh-node 0.0.0-stage → 0.0.2

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
package/src/cli.js ADDED
@@ -0,0 +1,1246 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { dirname as dirnameOf, join as joinPath, resolve as resolvePath } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ meshHome,
7
+ loadConfig,
8
+ loadSecrets,
9
+ saveSecrets,
10
+ configPath,
11
+ secretsPath,
12
+ peersPath,
13
+ MESH_VERSION,
14
+ defaultConfig,
15
+ saveConfig
16
+ } from './core/config.js'
17
+ import { auditPath, tailAudit } from './core/audit.js'
18
+ import { describeAcl, resolveAclUpdate } from './core/acl.js'
19
+ import { TASK_NAME, autostartCommand, autostartInstall, autostartRemove, autostartStatus } from './core/autostart.js'
20
+ import { startMeshServer } from './server.js'
21
+ import { runSelftest } from './selftest.js'
22
+ import { buildAll, isAllowed } from './tools/index.js'
23
+ import { postJson } from './core/peer-http.js'
24
+ import { DshWebClient } from './core/ds-web.js'
25
+
26
+ const HELP = `dsh-mesh v${MESH_VERSION} — expose this machine to a peer DSH over MCP
27
+
28
+ Usage:
29
+ node src/cli.js serve [--port N] [--bind ADDR] start the mesh endpoint (config hot-reloads)
30
+ node src/cli.js pair [--host H] [--port N] print the connection snippet + token for the peer
31
+ node src/cli.js pair --code [--ttl N] mint a one-time pairing code (default 30 min, max 24 h)
32
+ node src/cli.js peer [list] peers this machine can reach (client-side)
33
+ node src/cli.js peer import --file <name>.yml import a mesh-peer overlay token (for cluster exec)
34
+ node src/cli.js peer add --url URL --code CODE [--name NAME] [--ca cert.pem | --fingerprint SHA256]
35
+ redeem a pairing code and store the peer + overlay
36
+ node src/cli.js peer remove <id> forget a paired peer (client side only)
37
+ node src/cli.js peer patch [id] [--transport streamable-http|stdio]
38
+ rewrite the DSH overlay for a paired peer
39
+ node src/cli.js peer tools [--to NAME] list the tools a paired peer exposes (pinned TLS)
40
+ node src/cli.js peer call <tool> [--to NAME] [--args JSON | --args-file PATH]
41
+ call one tool on a paired peer and print its result
42
+ node src/cli.js peer doctor [--to NAME] diagnose pin/token/overlay/live-DSH-tools in one report
43
+ node src/cli.js peer bridge --to NAME stdio MCP bridge (spawned by DSH; pins TLS in-process)
44
+ node src/cli.js token list list inbound tokens (id / note / per-token ACL)
45
+ node src/cli.js token add [--id ID] [--allow a,b] [--fs-roots "C:\\a;C:\\b"] [--note TEXT] [--yes]
46
+ add a narrow inbound token (prints the token once; --yes required)
47
+ node src/cli.js revoke <token-id> delete an inbound token (peer loses access now)
48
+ node src/cli.js patch [--out FILE] [--url URL] write a DSH mcp-client overlay yaml
49
+ node src/cli.js tls init [--san A,B] [--days N] generate a self-signed cert (enables https)
50
+ node src/cli.js tls info show the current certificate
51
+ node src/cli.js rotate [--id ID] rotate an inbound token (old one stops working)
52
+ node src/cli.js channel [targets] list local DSH channel targets + cookie state
53
+ node src/cli.js channel login <launchUrl> [--target ID]
54
+ store a cookie from a "dsh web" launch URL
55
+ node src/cli.js channel sessions [target] list sessions of one target
56
+ node src/cli.js channel send <text> --target ID [--session ID] [--mode queue|steer] [--wait-ms N]
57
+ type into a DSH session and print its reply (admin path: bypasses MCP ACL)
58
+ node src/cli.js host status [--instance ID] ping every bridge (or one) and show its DSH instance
59
+ node src/cli.js host tools [--search TEXT] [--instance ID]
60
+ list tools registered inside that DSH instance
61
+ node src/cli.js host agents [--instance ID] list live sessions of that DSH instance
62
+ node src/cli.js host call <tool> [--args JSON | --args-file PATH] [--session ID] [--instance ID]
63
+ call a tool inside that DSH instance (PowerShell drops
64
+ embedded quotes from native args: prefer --args-file there)
65
+ node src/cli.js host wake <sessionId> <text> [--instance ID] [--target next-step|next-turn]
66
+ inject a message into a live session (in-process inbox)
67
+ node src/cli.js host install --profile DIR [--instance ID]
68
+ install dsh-mesh-host into a profile (ID picks port/token; default first hosts entry)
69
+ node src/cli.js cluster status [--no-peers] [--probe-self] [--timeout-ms N]
70
+ one snapshot: local DSH instances + paired peers + verdict
71
+ (--probe-self also checks this node's own serve endpoint)
72
+ node src/cli.js autostart status [--name N] is the endpoint registered to run at logon? (read-only)
73
+ node src/cli.js autostart install [--name N] --yes register a logon task running "serve" (user-level change)
74
+ node src/cli.js autostart remove [--name N] --yes delete that logon task
75
+ node src/cli.js selftest run the built-in verification suite
76
+ node src/cli.js acl show current allow/deny + per-tool flags
77
+ node src/cli.js acl allow <tool...> allow tools (upgrades do NOT widen an edited ACL)
78
+ node src/cli.js acl deny <tool...> deny tools
79
+ node src/cli.js acl reset reset allow to DEFAULT_ALLOW
80
+ node src/cli.js status effective config / secrets / health
81
+ node src/cli.js tools [--all] list tools and whether the ACL allows them
82
+ node src/cli.js audit [--limit N] [--tool NAME] tail the audit log
83
+ node src/cli.js init [--force] write a default config.json
84
+
85
+ Environment:
86
+ DSH_MESH_HOME override the state directory (default ~/.dsh-mesh)
87
+ `
88
+
89
+ function argValue(argv, flag, fallback) {
90
+ const i = argv.indexOf(flag)
91
+ if (i === -1) return fallback
92
+ const v = argv[i + 1]
93
+ return v === undefined || v.startsWith('--') ? fallback : v
94
+ }
95
+
96
+ /** http or https, following config.node.tls.enabled. */
97
+ function schemeOf(config) {
98
+ return config.node?.tls?.enabled === true ? 'https' : 'http'
99
+ }
100
+
101
+ /** Health probe that trusts the mesh's own self-signed cert when TLS is on. */
102
+ async function probeHealth(config, home, port) {
103
+ const tlsCfg = config.node?.tls
104
+ const bind = config.node?.bind || '127.0.0.1'
105
+ if (tlsCfg?.enabled !== true) {
106
+ const res = await fetch(`http://${bind}:${port}/health`, { signal: AbortSignal.timeout(1500) })
107
+ return { status: res.status, body: await res.json() }
108
+ }
109
+ const { get } = await import('node:https')
110
+ const { readFileSync } = await import('node:fs')
111
+ const { certPath } = await import('./core/tls.js')
112
+ const ca = readFileSync(tlsCfg.cert || certPath(home))
113
+ return await new Promise((resolvePromise, rejectPromise) => {
114
+ const req = get({ host: bind, port, path: '/health', ca, timeout: 1500 }, (res) => {
115
+ let text = ''
116
+ res.setEncoding('utf8')
117
+ res.on('data', (chunk) => { text += chunk })
118
+ res.on('end', () => {
119
+ try {
120
+ resolvePromise({ status: res.statusCode, body: JSON.parse(text) })
121
+ } catch {
122
+ resolvePromise({ status: res.statusCode, body: text })
123
+ }
124
+ })
125
+ })
126
+ req.on('timeout', () => req.destroy(new Error('timeout')))
127
+ req.on('error', rejectPromise)
128
+ })
129
+ }
130
+
131
+ /** Single-quote a YAML scalar (the only escaping YAML needs for these values). */
132
+ function quoteYaml(value) {
133
+ return `'${String(value).replaceAll("'", "''")}'`
134
+ }
135
+
136
+ /**
137
+ * Write a DSH mcp-client overlay for one paired peer (0600: it contains a token).
138
+ *
139
+ * `transport: 'stdio'` spawns `peer bridge` as a child process. The bridge does
140
+ * the pinned TLS handshake itself, so the DSH process needs no CA trust env and
141
+ * a later change of peer/token is picked up by restarting the bridge instead of
142
+ * the whole DSH instance.
143
+ */
144
+ function writePeerPatch({ home, peer, serverName, transport = 'streamable-http' }) {
145
+ const out = joinPath(home, 'patches', `${peer.id}.yml`)
146
+ const header = `# generated by dsh-mesh for peer "${peer.id}" — contains a bearer token, keep out of git
147
+ `
148
+ const config = transport === 'stdio'
149
+ ? ` transport: stdio
150
+ serverName: ${serverName}
151
+ command: ${quoteYaml(process.execPath)}
152
+ args:
153
+ ${[
154
+ joinPath(dirnameOf(fileURLToPath(import.meta.url)), 'cli.js'),
155
+ 'peer',
156
+ 'bridge',
157
+ '--to',
158
+ String(peer.name)
159
+ ].map((arg) => ` - ${quoteYaml(arg)}`).join('\n')}
160
+ env:
161
+ DSH_MESH_HOME: ${quoteYaml(home)}
162
+ toolCallTimeoutMs: 120000
163
+ failOnStartupError: false
164
+ `
165
+ : ` transport: streamable-http
166
+ serverName: ${serverName}
167
+ url: ${peer.url}/mcp
168
+ headers:
169
+ Authorization: "Bearer ${peer.token}"
170
+ failOnStartupError: true
171
+ `
172
+ const yml = `${header}- insert:
173
+ - id: ${serverName}
174
+ name: "@deepseek-ai/dsh-mcp-client"
175
+ config:
176
+ ${config}`
177
+ mkdirSync(dirnameOf(resolvePath(out)), { recursive: true })
178
+ writeFileSync(resolvePath(out), yml, { encoding: 'utf8', mode: 0o600 })
179
+ return resolvePath(out)
180
+ }
181
+
182
+ /** Print a peer failure with its machine-readable code plus the one command that explains it. */
183
+ function printPeerError(error, peer) {
184
+ const code = typeof error?.code === 'string' ? error.code : null
185
+ console.error(`peer "${peer?.name || '?'}" failed: ${code ? `[${code}] ` : ''}${error?.message || error}`)
186
+ console.error(`run \`node src/cli.js peer doctor${peer?.name ? ` --to ${peer.name}` : ''}\` for a full diagnosis`)
187
+ }
188
+
189
+ /** Minimal ctx for listing tools outside a running server. */
190
+ function offlineCtx(config, home) {
191
+ const ctx = {
192
+ home,
193
+ getConfig: () => config,
194
+ isAllowed,
195
+ toolNames: () => buildAll(ctx).map((s) => s.name),
196
+ secrets: { inbound: [] },
197
+ log: console,
198
+ audit: async () => {},
199
+ jobs: null,
200
+ dispatch: async () => {
201
+ throw new Error('offline ctx cannot dispatch')
202
+ }
203
+ }
204
+ return ctx
205
+ }
206
+
207
+ async function main(argv) {
208
+ const cmd = argv[0] || 'help'
209
+ const home = meshHome()
210
+
211
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
212
+ console.log(HELP)
213
+ return 0
214
+ }
215
+
216
+ if (cmd === 'init') {
217
+ const force = argv.includes('--force')
218
+ loadConfig(home)
219
+ if (force) {
220
+ saveConfig(defaultConfig(), home)
221
+ console.log(`wrote default config: ${configPath(home)}`)
222
+ } else {
223
+ console.log(`config ready: ${configPath(home)}`)
224
+ }
225
+ console.log(`secrets: ${secretsPath(home)}`)
226
+ console.log(`jobs dir: ${home}/jobs`)
227
+ console.log(`audit : ${auditPath(home)}`)
228
+ return 0
229
+ }
230
+
231
+ if (cmd === 'tls') {
232
+ const sub = argv[1] || 'info'
233
+ const { generateCert, describeCert, hasCert } = await import('./core/tls.js')
234
+ if (sub === 'init') {
235
+ const sans = String(argValue(argv, '--san', '')).split(',').map((s) => s.trim()).filter(Boolean)
236
+ const days = Number(argValue(argv, '--days', '825'))
237
+ const force = argv.includes('--force')
238
+ const result = await generateCert({ home, sans, days, force })
239
+ console.log(
240
+ result.reused
241
+ ? 'certificate already present (it is now the trust anchor for the peer; use --force to regenerate)'
242
+ : 'generated self-signed certificate (CA-and-leaf, SAN includes localhost/127.0.0.1/hostname + --san)'
243
+ )
244
+ console.log(JSON.stringify(describeCert(home), null, 2))
245
+ console.log('\nnext steps:')
246
+ console.log(' 1) set node.tls.enabled = true in ' + configPath(home) + ', then restart `serve`')
247
+ console.log(' 2) on the PEER side (the DSH process that connects) set:')
248
+ console.log(' NODE_EXTRA_CA_CERTS=' + result.certPath)
249
+ console.log(' 3) peer url becomes https://<host>:<port>/mcp')
250
+ return 0
251
+ }
252
+ console.log(JSON.stringify(describeCert(home), null, 2))
253
+ if (!hasCert(home)) console.log('(no certificate yet — run `node src/cli.js tls init`)')
254
+ return 0
255
+ }
256
+
257
+ if (cmd === 'rotate') {
258
+ const { rotateToken, secretsPath: spath } = await import('./core/config.js')
259
+ const id = argValue(argv, '--id', 'default')
260
+ const note = argValue(argv, '--note', undefined)
261
+ const rotated = rotateToken({ home, id, note })
262
+ console.log(`rotated inbound token "${rotated.id}" in ${spath(home)} (0600)`)
263
+ console.log(`new token: ${rotated.token}`)
264
+ console.log('if the server is running it picks this up automatically; otherwise restart `serve`.')
265
+ console.log('re-run `node src/cli.js patch --out ...` to refresh the peer overlay.')
266
+ return 0
267
+ }
268
+
269
+ if (cmd === 'peer') {
270
+ const sub = argv[1]
271
+ const { loadPeers, savePeers } = await import('./core/pairing.js')
272
+ const peers = loadPeers(home)
273
+
274
+ if (sub === undefined || sub === 'list') {
275
+ console.log(
276
+ JSON.stringify(
277
+ {
278
+ peers: peers.items.map((p) => ({
279
+ id: p.id,
280
+ name: p.name,
281
+ url: p.url,
282
+ certFingerprint: p.certFingerprint,
283
+ addedAt: p.addedAt
284
+ })),
285
+ file: peersPath(home)
286
+ },
287
+ null,
288
+ 2
289
+ )
290
+ )
291
+ return 0
292
+ }
293
+
294
+ if (sub === 'add') {
295
+ const url = argValue(argv, '--url', undefined)
296
+ const code = argValue(argv, '--code', undefined)
297
+ const name = argValue(argv, '--name', undefined) || 'peer'
298
+ const caPath = argValue(argv, '--ca', undefined)
299
+ const pin = argValue(argv, '--fingerprint', undefined)
300
+ if (!url || !code) {
301
+ console.error('usage: node src/cli.js peer add --url <https://host:port> --code XXXX-XXXX [--name NAME] [--ca cert.pem | --fingerprint SHA256]')
302
+ return 1
303
+ }
304
+ const base = url.replace(/\/+$/, '')
305
+ let res
306
+ try {
307
+ res = await postJson(`${base}/pair`, { code, peerName: name }, { caPath, fingerprint: pin })
308
+ } catch (error) {
309
+ console.error(`pairing request failed: ${error?.code ? `[${error.code}] ` : ''}${error?.message || error}`)
310
+ if (error?.code === 'E_PIN_MISMATCH') {
311
+ console.error('证书指纹与 --fingerprint 不一致:可能证书已轮换(重新 tls init 过)或被中间人替换。')
312
+ console.error('先在被访问端跑 `node src/cli.js pair --code` 看它打印的 certFingerprint,核对后再配。')
313
+ } else if (String(base).startsWith('https:')) {
314
+ console.error('self-signed cert: pass --ca <the node cert.pem>, or pin with --fingerprint <sha256 from `pair --code`>')
315
+ }
316
+ return 1
317
+ }
318
+ if (res.status !== 200 || !res.body?.ok) {
319
+ console.error(`pairing rejected: ${res.status} ${JSON.stringify(res.body)}`)
320
+ return res.status === 401 ? 2 : 1
321
+ }
322
+ const entry = {
323
+ id: res.body.tokenId,
324
+ name,
325
+ url: base,
326
+ token: res.body.token,
327
+ // 记「实际连到的证书指纹」优先于服务端自报值,两者都给时交叉核对
328
+ certFingerprint: res.fingerprint || res.body.certFingerprint || null,
329
+ version: res.body.version,
330
+ addedAt: new Date().toISOString()
331
+ }
332
+ if (res.fingerprint && res.body.certFingerprint) {
333
+ const { assertFingerprint } = await import('./core/tls.js')
334
+ assertFingerprint(res.body.certFingerprint, res.fingerprint)
335
+ }
336
+ peers.items = [...peers.items.filter((p) => p.id !== entry.id), entry]
337
+ savePeers(peers, home)
338
+ const patchFile = writePeerPatch({ home, peer: entry, serverName: `${name}-mesh` })
339
+ console.log(
340
+ JSON.stringify(
341
+ {
342
+ paired: entry.id,
343
+ url: entry.url,
344
+ certFingerprint: entry.certFingerprint,
345
+ peersFile: peersPath(home),
346
+ patchFile,
347
+ next: `dsh --profile headless --patch "${patchFile}" "call mcp__${name}-mesh__mesh_self"`
348
+ },
349
+ null,
350
+ 2
351
+ )
352
+ )
353
+ return 0
354
+ }
355
+
356
+ if (sub === 'import') {
357
+ const file = argValue(argv, '--file', argv[2])
358
+ if (!file) {
359
+ console.error('usage: node src/cli.js peer import --file <name>.yml [--name NAME] [--url URL] [--meta <name>.json] [--fingerprint SHA256]')
360
+ return 1
361
+ }
362
+ const { readFileSync: readFileSyncFn } = await import('node:fs')
363
+ const { parsePeerOverlay } = await import('./core/peer-client.js')
364
+ let imported
365
+ try {
366
+ const overlayText = readFileSyncFn(resolvePath(file), 'utf8')
367
+ const metaFile = argValue(argv, '--meta', undefined)
368
+ const meta = metaFile ? JSON.parse(readFileSyncFn(resolvePath(metaFile), 'utf8')) : null
369
+ imported = parsePeerOverlay(overlayText, { meta })
370
+ } catch (error) {
371
+ console.error(`peer import failed: ${error?.message || error}`)
372
+ return 1
373
+ }
374
+ const name = argValue(argv, '--name', imported.suggestedName)
375
+ const entry = {
376
+ id: `peer-${name}`,
377
+ name,
378
+ url: argValue(argv, '--url', imported.url),
379
+ token: imported.token,
380
+ certFingerprint: argValue(argv, '--fingerprint', imported.certFingerprint) || null,
381
+ tokenId: imported.tokenId,
382
+ source: 'import',
383
+ addedAt: new Date().toISOString()
384
+ }
385
+ peers.items = [...peers.items.filter((item) => item.id !== entry.id && item.name !== name), entry]
386
+ savePeers(peers, home)
387
+ console.log(
388
+ JSON.stringify(
389
+ {
390
+ imported: name,
391
+ url: entry.url,
392
+ tokenId: entry.tokenId,
393
+ hasToken: true,
394
+ certFingerprint: entry.certFingerprint,
395
+ peersFile: peersPath(home)
396
+ },
397
+ null,
398
+ 2
399
+ )
400
+ )
401
+ return 0
402
+ }
403
+
404
+ if (sub === 'remove') {
405
+ const id = argv[2]
406
+ if (!id) {
407
+ console.error('usage: node src/cli.js peer remove <id>')
408
+ return 1
409
+ }
410
+ const before = peers.items.length
411
+ peers.items = peers.items.filter((p) => p.id !== id)
412
+ savePeers(peers, home)
413
+ console.log(`removed ${before - peers.items.length} peer(s) from ${peersPath(home)}`)
414
+ console.log('note: this only forgets the client-side record; revoke the token on the node with `revoke <id>`')
415
+ return 0
416
+ }
417
+
418
+ if (sub === 'patch') {
419
+ const idArg = argv[2] && !argv[2].startsWith('--') ? argv[2] : undefined
420
+ const wanted = argValue(argv, '--to', argValue(argv, '--peer', idArg))
421
+ const peer = wanted ? peers.items.find((p) => p.id === wanted || p.name === wanted) : peers.items[0]
422
+ if (!peer) {
423
+ console.error(wanted ? `no paired peer "${wanted}"` : 'no peers yet; run `peer add` first')
424
+ return 1
425
+ }
426
+ const transport = argValue(argv, '--transport', 'streamable-http')
427
+ if (transport !== 'streamable-http' && transport !== 'stdio') {
428
+ console.error(`unknown --transport "${transport}" (streamable-http | stdio)`)
429
+ return 1
430
+ }
431
+ const out = writePeerPatch({
432
+ home,
433
+ peer,
434
+ serverName: argValue(argv, '--server-name', `${peer.name}-mesh`),
435
+ transport
436
+ })
437
+ console.log(`wrote ${out} (transport: ${transport})`)
438
+ if (transport === 'stdio') {
439
+ console.log('DSH hot-reloads profile patches (HMR): the instance that mounts this path picks it up within seconds; restart only if it does not')
440
+ }
441
+ return 0
442
+ }
443
+
444
+ if (sub === 'tools' || sub === 'call' || sub === 'bridge' || sub === 'doctor') {
445
+ if (sub === 'doctor') {
446
+ const { runPeerDoctor } = await import('./core/peer-doctor.js')
447
+ const report = await runPeerDoctor({
448
+ home,
449
+ target: argValue(argv, '--to', argValue(argv, '--peer', undefined)),
450
+ timeoutMs: Number(argValue(argv, '--timeout-ms', 8000)) || 8000,
451
+ env: process.env
452
+ })
453
+ console.log(JSON.stringify(report, null, 2))
454
+ return report.verdict === 'fail' ? 1 : 0
455
+ }
456
+
457
+ const { callPeerTool, listPeerTools } = await import('./core/peer-client.js')
458
+ const target = argValue(argv, '--to', argValue(argv, '--peer', undefined))
459
+ const peer = target
460
+ ? peers.items.find((p) => p.name === target || p.id === target)
461
+ : peers.items.length === 1
462
+ ? peers.items[0]
463
+ : null
464
+ if (!peer) {
465
+ const available = peers.items.map((p) => p.name).join(', ') || 'none'
466
+ console.error(target ? `unknown peer "${target}" — paired: ${available}` : `several peers are paired (${available}); pass --to <name>`)
467
+ return 1
468
+ }
469
+ const timeoutMs = Number(argValue(argv, '--timeout-ms', 30000)) || 30000
470
+
471
+ if (sub === 'bridge') {
472
+ const { runPeerBridge } = await import('./peer-bridge.js')
473
+ // Resolves once the stdio server is serving; the process stays alive on the pipe.
474
+ await runPeerBridge(peer, {
475
+ serverName: argValue(argv, '--server-name', peer.name),
476
+ timeoutMs,
477
+ log: console
478
+ })
479
+ return null
480
+ }
481
+
482
+ if (sub === 'tools') {
483
+ try {
484
+ const listed = await listPeerTools(peer, { timeoutMs })
485
+ console.log(JSON.stringify(listed, null, 2))
486
+ return 0
487
+ } catch (error) {
488
+ printPeerError(error, peer)
489
+ return 1
490
+ }
491
+ }
492
+
493
+ const tool = argv[2]
494
+ if (!tool || tool.startsWith('--')) {
495
+ console.error('usage: node src/cli.js peer call <tool> [--to NAME] [--args JSON | --args-file PATH] [--timeout-ms N]')
496
+ return 1
497
+ }
498
+ let args = {}
499
+ try {
500
+ const argsFile = argValue(argv, '--args-file', undefined)
501
+ const argsJson = argValue(argv, '--args', undefined)
502
+ if (argsFile) args = JSON.parse(readFileSync(resolvePath(argsFile), 'utf8'))
503
+ else if (argsJson) args = JSON.parse(argsJson)
504
+ } catch (error) {
505
+ console.error(`invalid --args/--args-file JSON: ${error?.message || error}`)
506
+ return 1
507
+ }
508
+ try {
509
+ const result = await callPeerTool(peer, { tool, args, timeoutMs })
510
+ console.log(JSON.stringify({ peer: peer.name, tool, ok: result.ok, ms: result.ms, result: result.result }, null, 2))
511
+ return result.ok ? 0 : 1
512
+ } catch (error) {
513
+ printPeerError(error, peer)
514
+ return 1
515
+ }
516
+ }
517
+
518
+ console.error('usage: node src/cli.js peer [list | add --url URL --code CODE [--name NAME] [--ca cert.pem] | import --file <name>.yml [--meta <name>.json] | remove <id> | patch [id] [--transport streamable-http|stdio] | tools [--to NAME] | call <tool> [--to NAME] [--args JSON] | doctor [--to NAME] | bridge --to NAME]')
519
+ return 1
520
+ }
521
+
522
+ if (cmd === 'token') {
523
+ const sub = argv[1] || 'list'
524
+ const { addInboundToken, loadSecrets } = await import('./core/config.js')
525
+ if (sub === 'list') {
526
+ const secrets = loadSecrets(home)
527
+ const rows = (secrets.inbound || []).map((entry) => ({ id: entry.id, note: entry.note || '', acl: entry.acl || null }))
528
+ console.log(JSON.stringify(rows, null, 2))
529
+ return 0
530
+ }
531
+ if (sub === 'add') {
532
+ const id = argValue(argv, '--id', undefined)
533
+ const note = argValue(argv, '--note', 'narrow token')
534
+ const allowRaw = argValue(argv, '--allow', '')
535
+ const fsRootsRaw = argValue(argv, '--fs-roots', '')
536
+ const yes = argv.includes('--yes')
537
+ const acl = {}
538
+ if (allowRaw) acl.allow = allowRaw.split(',').map((s) => s.trim()).filter(Boolean)
539
+ if (fsRootsRaw) acl.fsRoots = fsRootsRaw.split(';').map((s) => s.trim()).filter(Boolean)
540
+ const plan = { id: id || '(auto: token-<random>)', note, acl: Object.keys(acl).length ? acl : '(inherit node ACL)' }
541
+ if (!yes) {
542
+ console.log('(dry-run)将新增 inbound token:')
543
+ console.log(JSON.stringify(plan, null, 2))
544
+ console.log('加 --yes 才写入 secrets.json(热重载,无需重启)')
545
+ return 0
546
+ }
547
+ const finalId = id || `token-${Math.random().toString(16).slice(2, 10)}`
548
+ const created = addInboundToken({ home, id: finalId, note, acl: Object.keys(acl).length ? acl : undefined })
549
+ console.log(JSON.stringify({ id: created.id, token: created.token, acl: plan.acl, note }, null, 2))
550
+ console.log('⚠️ token 只显示这一次;只给对端配置用,禁止写进 git / 群聊 / 会分享的文档')
551
+ return 0
552
+ }
553
+ console.error('usage: node src/cli.js token list | token add [--id ID] [--allow tool1,tool2] [--fs-roots "C:\\a;C:\\b"] [--note TEXT] [--yes]')
554
+ return 1
555
+ }
556
+
557
+ if (cmd === 'revoke') {
558
+ const { removeInboundToken } = await import('./core/config.js')
559
+ const id = argv[1]
560
+ if (!id) {
561
+ console.error('usage: node src/cli.js revoke <token-id> (ids: see `node src/cli.js status`, secrets.json)')
562
+ return 1
563
+ }
564
+ const { removed } = removeInboundToken({ home, id })
565
+ console.log(`revoked ${removed} inbound token(s) with id "${id}"; live server picks it up immediately`)
566
+ return removed > 0 ? 0 : 1
567
+ }
568
+
569
+ if (cmd === 'serve') {
570
+ // 注意:这里**故意不传 secrets** —— 传了 startMeshServer 会把它当固定值,
571
+ // 配对/轮换/吊销就要重启才生效。让 server 自己用 createSecretsLoader 按 mtime 热重载。
572
+ const port = argValue(argv, '--port', undefined)
573
+ const bind = argValue(argv, '--bind', undefined)
574
+ const srv = await startMeshServer({
575
+ home,
576
+ port: port ? Number(port) : undefined,
577
+ bind: bind || undefined,
578
+ log: {
579
+ info: (m) => console.error(m),
580
+ warn: (m) => console.error(m),
581
+ debug: (m) => process.env.DSH_MESH_DEBUG && console.error(m),
582
+ error: (m) => console.error(m)
583
+ }
584
+ })
585
+ const config = loadConfig(home)
586
+ console.log(`dsh-mesh listening on ${srv.url}`)
587
+ console.log(` node : ${config.node?.name || 'local'}`)
588
+ console.log(` config : ${configPath(home)} (hot-reloaded on save)`)
589
+ console.log(` secrets: ${secretsPath(home)}`)
590
+ console.log(` audit : ${auditPath(home)}`)
591
+ console.log(` health : ${schemeOf(config)}://${config.node.bind || '127.0.0.1'}:${srv.port}/health`)
592
+ const shutdown = async (signal) => {
593
+ console.error(`\n[dsh-mesh] ${signal} received, shutting down`)
594
+ await srv.close()
595
+ process.exit(0)
596
+ }
597
+ process.on('SIGINT', () => shutdown('SIGINT'))
598
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
599
+
600
+ // Self-restart hook: the supervisor (scripts/mesh-supervisor.mjs) owns the
601
+ // process tree. Writing `~/.dsh-mesh/restart.flag` makes THIS process exit
602
+ // gracefully; the supervisor immediately starts the new code. No kill from
603
+ // an AI session, and no parent/child dependency on DSH.
604
+ const restartFlag = joinPath(home, 'restart.flag')
605
+ const restartWatcher = setInterval(() => {
606
+ if (!existsSync(restartFlag)) return
607
+ try {
608
+ rmSync(restartFlag, { force: true })
609
+ } catch {
610
+ /* flag removal is best-effort */
611
+ }
612
+ console.error('[dsh-mesh] restart.flag detected — exiting for supervisor')
613
+ shutdown('restart-flag')
614
+ }, 1000)
615
+ restartWatcher.unref?.()
616
+ return null // keep the process alive
617
+ }
618
+
619
+ if (cmd === 'pair') {
620
+ const config = loadConfig(home)
621
+ const secrets = loadSecrets(home)
622
+ const scheme = schemeOf(config)
623
+ const host = argValue(argv, '--host', `${config.node.bind || '127.0.0.1'}:${config.node.port || 8124}`)
624
+ const port = argValue(argv, '--port', String(config.node.port || 8124))
625
+ const endpoint = host.includes(':')
626
+ ? `${scheme}://${host}${config.node.path || '/mcp'}`
627
+ : `${scheme}://${host}:${port}${config.node.path || '/mcp'}`
628
+ if (argv.includes('--code')) {
629
+ const { createPairingCode } = await import('./core/pairing.js')
630
+ const { describeCert } = await import('./core/tls.js')
631
+ const rawTtl = Number(argValue(argv, '--ttl', String(config.pairing?.codeTtlSec || 1800)))
632
+ const ttlSec = Number.isFinite(rawTtl) ? Math.min(86400, Math.max(60, Math.round(rawTtl))) : 1800
633
+ const created = createPairingCode({ home, ttlSec })
634
+ const cert = scheme === 'https' ? describeCert(home) : null
635
+ console.log(
636
+ JSON.stringify(
637
+ {
638
+ pairingCode: created.code,
639
+ expiresAt: created.expiresAt,
640
+ ttlSec,
641
+ ttlHint: `码 ${Math.round(ttlSec / 60)} 分钟内有效、一次性;想留更久用 --ttl 86400(最长 24 h)`,
642
+ node: config.node.name,
643
+ endpoint,
644
+ certFingerprint: cert?.fingerprint256 ?? null,
645
+ nextSteps: [
646
+ `on the peer machine: node src/cli.js peer add --url ${endpoint.replace(/\/mcp$/, '')} --code ${created.code} --name ${config.node.name}`,
647
+ scheme === 'https'
648
+ ? `pass --fingerprint ${describeCert(home).fingerprint256} (or --ca <this node cert.pem>)`
649
+ : undefined
650
+ ].filter(Boolean)
651
+ },
652
+ null,
653
+ 2
654
+ )
655
+ )
656
+ return 0
657
+ }
658
+ const token = secrets.inbound[0]?.token
659
+ console.log(
660
+ JSON.stringify(
661
+ {
662
+ node: config.node.name,
663
+ endpoint,
664
+ tls: scheme === 'https',
665
+ headers: { Authorization: `Bearer ${token}` },
666
+ mcpServers: {
667
+ [`${config.node.name}-mesh`]: { url: endpoint, headers: { Authorization: `Bearer ${token}` } }
668
+ },
669
+ healthCheck: `curl -s -o /dev/null -w "%{http_code}" ${endpoint.replace(/\/mcp$/, '')}/health`,
670
+ peerEnv: scheme === 'https' ? { NODE_EXTRA_CA_CERTS: '~/.dsh-mesh/tls/cert.pem (copy from this node)' } : undefined
671
+ },
672
+ null,
673
+ 2
674
+ )
675
+ )
676
+ return 0
677
+ }
678
+
679
+ if (cmd === 'patch') {
680
+ const { writeFileSync, mkdirSync } = await import('node:fs')
681
+ const { dirname, resolve: resolvePath } = await import('node:path')
682
+ const config = loadConfig(home)
683
+ const secrets = loadSecrets(home)
684
+ const out = argValue(argv, '--out', `${home}/patches/peer-${config.node.name || 'local'}.yml`)
685
+ const serverName = argValue(argv, '--server-name', `${config.node.name || 'local'}-mesh`)
686
+ const scheme = schemeOf(config)
687
+ const url = argValue(
688
+ argv,
689
+ '--url',
690
+ `${scheme}://${config.node.bind || '127.0.0.1'}:${config.node.port || 8124}${config.node.path || '/mcp'}`
691
+ )
692
+ const token = secrets.inbound[0]?.token
693
+ const yml = `# generated by dsh-mesh — contains a bearer token, keep out of git
694
+ - insert:
695
+ - id: ${serverName}
696
+ name: "@deepseek-ai/dsh-mcp-client"
697
+ config:
698
+ transport: streamable-http
699
+ serverName: ${serverName}
700
+ url: ${url}
701
+ headers:
702
+ Authorization: "Bearer ${token}"
703
+ failOnStartupError: true
704
+ `
705
+ mkdirSync(dirname(resolvePath(out)), { recursive: true })
706
+ writeFileSync(resolvePath(out), yml, { encoding: 'utf8', mode: 0o600 })
707
+ console.log(`wrote DSH patch overlay: ${resolvePath(out)}`)
708
+ console.log(`boot with: dsh --profile <name> --patch "${resolvePath(out)}"`)
709
+ if (scheme === 'https') {
710
+ console.log('TLS is on: the connecting DSH process needs the peer certificate as a trust anchor, e.g.')
711
+ console.log(` $env:NODE_EXTRA_CA_CERTS = "<path to the server's cert.pem>"`)
712
+ }
713
+ return 0
714
+ }
715
+
716
+ if (cmd === 'host') {
717
+ const sub = argv[1] || 'status'
718
+ const config = loadConfig(home)
719
+ const { createHostClient, hostInstances, hostTokenPath } = await import('./core/host-agent.js')
720
+ const instanceArg = argValue(argv, '--instance')
721
+ const pickEntry = () => {
722
+ const list = hostInstances(config)
723
+ const entry = instanceArg ? list.find((item) => item.id === instanceArg) : list[0]
724
+ if (!entry) {
725
+ throw new Error(
726
+ instanceArg
727
+ ? `unknown host instance "${instanceArg}" — configured: ${list.map((item) => item.id).join(', ')}`
728
+ : 'no host bridge configured (config.hosts is empty)'
729
+ )
730
+ }
731
+ return entry
732
+ }
733
+
734
+ if (sub === 'install') {
735
+ const profile = argValue(argv, '--profile')
736
+ if (!profile) {
737
+ console.error('usage: node src/cli.js host install --profile <DSH profile dir> [--instance <id>]')
738
+ console.error(' e.g. node src/cli.js host install --profile "%USERPROFILE%\\.dsh0\\profiles\\headless"')
739
+ return 1
740
+ }
741
+ const profileDir = resolvePath(profile)
742
+ if (!existsSync(joinPath(profileDir, 'package.json'))) {
743
+ console.error(`not a DSH profile (no package.json): ${profileDir}`)
744
+ return 1
745
+ }
746
+ const pluginSrc = resolvePath(joinPath(dirnameOf(fileURLToPath(import.meta.url)), '..', 'plugin'))
747
+ if (!existsSync(joinPath(pluginSrc, 'lib', 'index.js'))) {
748
+ console.error(`plugin source missing: ${pluginSrc}`)
749
+ return 1
750
+ }
751
+ const pluginDst = joinPath(profileDir, 'node_modules', 'dsh-mesh-host')
752
+ mkdirSync(joinPath(pluginDst, 'lib'), { recursive: true })
753
+ copyFileSync(joinPath(pluginSrc, 'package.json'), joinPath(pluginDst, 'package.json'))
754
+ copyFileSync(joinPath(pluginSrc, 'lib', 'index.js'), joinPath(pluginDst, 'lib', 'index.js'))
755
+ const patchPath = joinPath(profileDir, 'cordis.patch.yml')
756
+ const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
757
+ if (current.includes('dsh-mesh-host')) {
758
+ console.log(`plugin already wired in ${patchPath}`)
759
+ } else {
760
+ const trimmed = current.trim()
761
+ if (trimmed && trimmed !== '[]') {
762
+ const backup = `${patchPath}.bak-mesh-${Date.now()}`
763
+ writeFileSync(backup, current, 'utf8')
764
+ console.log(`existing patch entries kept; backed up to ${backup}`)
765
+ }
766
+ const header = trimmed && trimmed !== '[]' ? `${current.trimEnd()}\n` : ''
767
+ const block = '- insert:\n - id: mesh-host\n name: dsh-mesh-host\n'
768
+ writeFileSync(patchPath, header + block, 'utf8')
769
+ console.log(`wired plugin into ${patchPath}`)
770
+ }
771
+ const entry = pickEntry()
772
+ console.log(`installed: ${pluginDst}`)
773
+ console.log(`instance: ${entry.id} -> ${entry.url}`)
774
+ console.log('restart that DSH instance to load it. If this is NOT dsh0, start it with:')
775
+ console.log(` $env:DSH_MESH_HOST_PORT="${new URL(entry.url).port || '9100'}"`)
776
+ console.log(` $env:DSH_MESH_HOST_TOKEN_FILE="${hostTokenPath(config, home, entry.id)}"`)
777
+ console.log('(the plugin creates the token file on first load, 0600)')
778
+ return 0
779
+ }
780
+
781
+ if (sub === 'status' && !instanceArg) {
782
+ const rows = []
783
+ for (const entry of hostInstances(config)) {
784
+ const client = createHostClient({ config, home, log: console, instance: entry.id })
785
+ const alive = await client.ping().catch(() => false)
786
+ if (!alive) {
787
+ rows.push({ id: entry.id, label: entry.label || entry.id, url: entry.url, alive: false })
788
+ continue
789
+ }
790
+ try {
791
+ const self = await client.self()
792
+ rows.push({
793
+ id: entry.id,
794
+ label: entry.label || entry.id,
795
+ url: entry.url,
796
+ alive: true,
797
+ plugin: self.plugin,
798
+ node: self.node,
799
+ tools: self.tools.length,
800
+ agents: self.agents
801
+ })
802
+ } catch (error) {
803
+ rows.push({ id: entry.id, url: entry.url, alive: true, error: error?.message || String(error) })
804
+ }
805
+ }
806
+ console.log(JSON.stringify({ instances: rows }, null, 2))
807
+ return rows.some((row) => row.alive) ? 0 : 1
808
+ }
809
+
810
+ const client = createHostClient({ config, home, log: console, instance: instanceArg })
811
+ if (sub === 'status') {
812
+ const alive = await client.ping()
813
+ if (!alive) {
814
+ console.log(JSON.stringify({ instance: client.id, bridge: client.url, alive: false, hint: 'start a DSH instance with dsh-mesh-host installed (node src/cli.js host install --profile <profile> --instance ' + client.id + ')' }, null, 2))
815
+ return 1
816
+ }
817
+ const self = await client.self()
818
+ console.log(JSON.stringify({ instance: client.id, bridge: client.url, alive: true, plugin: self.plugin, node: self.node, tools: self.tools.length, agents: self.agents }, null, 2))
819
+ return 0
820
+ }
821
+ if (sub === 'tools') {
822
+ const { tools } = await client.tools()
823
+ const search = (argValue(argv, '--search') || '').toLowerCase()
824
+ const rows = (tools || []).filter(
825
+ (t) => !search || String(t.name).toLowerCase().includes(search) || String(t.description || '').toLowerCase().includes(search)
826
+ )
827
+ console.log(JSON.stringify({ instance: client.id, count: rows.length, tools: rows }, null, 2))
828
+ return 0
829
+ }
830
+ if (sub === 'agents') {
831
+ const { agents } = await client.agents()
832
+ console.log(JSON.stringify({ instance: client.id, count: agents.length, agents }, null, 2))
833
+ return 0
834
+ }
835
+ if (sub === 'sessions') {
836
+ const { sessions } = await client.sessions({ limit: Number(argValue(argv, '--limit')) || 50 })
837
+ console.log(JSON.stringify({ instance: client.id, count: sessions.length, sessions }, null, 2))
838
+ return 0
839
+ }
840
+ if (sub === 'call') {
841
+ const tool = argv[2]
842
+ if (!tool) {
843
+ console.error('usage: node src/cli.js host call <tool> [--args JSON | --args-file PATH] [--session ID] [--instance ID]')
844
+ return 1
845
+ }
846
+ let args = {}
847
+ try {
848
+ const argsFile = argValue(argv, '--args-file')
849
+ args = JSON.parse((argsFile ? readFileSync(resolvePath(argsFile), 'utf8') : argValue(argv, '--args', '{}')) || '{}')
850
+ } catch (error) {
851
+ console.error(`--args must be JSON: ${error?.message || error}`)
852
+ return 1
853
+ }
854
+ const result = await client.callTool({ tool, args, sessionId: argValue(argv, '--session') })
855
+ console.log(JSON.stringify({ instance: client.id, result }, null, 2))
856
+ return 0
857
+ }
858
+ if (sub === 'wake') {
859
+ const sessionId = argv[2]
860
+ const text = argv.slice(3).filter((a) => !a.startsWith('--')).join(' ')
861
+ if (!sessionId || !text) {
862
+ console.error('usage: node src/cli.js host wake <sessionId> <text...> [--instance ID] [--target next-step|next-turn]')
863
+ return 1
864
+ }
865
+ console.log(JSON.stringify({ instance: client.id, result: await client.wake({ sessionId, text, target: argValue(argv, '--target') }) }, null, 2))
866
+ return 0
867
+ }
868
+ console.error(`unknown host subcommand: ${sub} (status|tools|agents|sessions|call|wake|install)`)
869
+ return 1
870
+ }
871
+
872
+ if (cmd === 'cluster') {
873
+ const sub = argv[1] || 'status'
874
+ const { loadPeers } = await import('./core/pairing.js')
875
+ const { clusterReport } = await import('./core/cluster.js')
876
+ if (sub === 'status') {
877
+ try {
878
+ const config = loadConfig(home)
879
+ const peers = argv.includes('--no-peers') ? [] : loadPeers(home).items || []
880
+ const report = await clusterReport({
881
+ config,
882
+ home,
883
+ log: console,
884
+ peers: peers.map((peer) => ({
885
+ id: peer.id,
886
+ name: peer.name,
887
+ url: peer.url,
888
+ certFingerprint: peer.certFingerprint,
889
+ token: peer.token
890
+ })),
891
+ timeoutMs: Number(argValue(argv, '--timeout-ms', 4000)),
892
+ // Run from outside the server, so "my own endpoint is dead" is a real
893
+ // failure mode that the host-bridge probes cannot see (2026-10-23).
894
+ probeSelf: argv.includes('--probe-self')
895
+ })
896
+ console.log(JSON.stringify(report, null, 2))
897
+ return 0
898
+ } catch (error) {
899
+ console.error(`cluster status failed: ${error?.message || error}`)
900
+ return 1
901
+ }
902
+ }
903
+ if (sub === 'exec') {
904
+ const peerName = argValue(argv, '--peer', undefined)
905
+ const tool = argValue(argv, '--tool', undefined)
906
+ if (!peerName || !tool) {
907
+ console.error('usage: node src/cli.js cluster exec --peer NAME --tool TOOL [--args JSON | --args-file FILE] [--timeout-ms N]')
908
+ return 1
909
+ }
910
+ const entry = loadPeers(home).items.find((item) => item.name === peerName || item.id === peerName)
911
+ if (!entry) {
912
+ console.error(`unknown peer "${peerName}" — run \`node src/cli.js peer list\``)
913
+ return 1
914
+ }
915
+ const { probePeer } = await import('./core/cluster.js')
916
+ const { callPeerTool } = await import('./core/peer-client.js')
917
+ const timeoutMs = Number(argValue(argv, '--timeout-ms', 30000))
918
+ const probe = await probePeer(
919
+ { id: entry.id, name: entry.name, url: entry.url, certFingerprint: entry.certFingerprint },
920
+ { timeoutMs: Math.min(timeoutMs, 8000) }
921
+ )
922
+ if (probe.up !== true || probe.fingerprintMatch !== true) {
923
+ console.error(`peer unreachable or pin mismatch: ${JSON.stringify(probe.error || { fingerprintMatch: probe.fingerprintMatch })}`)
924
+ return 1
925
+ }
926
+ const argsFile = argValue(argv, '--args-file', undefined)
927
+ let args = {}
928
+ try {
929
+ args = JSON.parse((argsFile ? readFileSync(resolvePath(argsFile), 'utf8') : argValue(argv, '--args', '{}')) || '{}')
930
+ } catch (error) {
931
+ console.error(`--args must be JSON: ${error.message}`)
932
+ return 1
933
+ }
934
+ try {
935
+ const out = await callPeerTool(entry, { tool, args, timeoutMs })
936
+ console.log(JSON.stringify({ peer: peerName, node: probe.node, version: probe.version, tool, ok: out.ok, ms: out.ms, result: out.result }, null, 2))
937
+ return out.ok ? 0 : 1
938
+ } catch (error) {
939
+ console.error(`cluster exec failed: ${error?.code ? `[${error.code}] ` : ''}${error?.message || error}`)
940
+ return 1
941
+ }
942
+ }
943
+ console.error('usage: node src/cli.js cluster status [--no-peers] [--probe-self] [--timeout-ms N]')
944
+ console.error(' node src/cli.js cluster exec --peer NAME --tool TOOL [--args JSON | --args-file FILE] [--timeout-ms N]')
945
+ return 1
946
+ }
947
+
948
+ if (cmd === 'autostart') {
949
+ const sub = argv[1] || 'status'
950
+ const name = argValue(argv, '--name', TASK_NAME)
951
+ const yes = argv.includes('--yes')
952
+ try {
953
+ if (sub === 'status') {
954
+ const config = loadConfig(home)
955
+ console.log(JSON.stringify({
956
+ task: await autostartStatus(name),
957
+ suggestedCommand: autostartCommand(name, config.node?.port),
958
+ configPath: configPath(home)
959
+ }, null, 2))
960
+ return 0
961
+ }
962
+ if (sub === 'install') {
963
+ const config = loadConfig(home)
964
+ const command = autostartCommand(name, config.node?.port)
965
+ if (!yes) {
966
+ console.error('dry run — nothing changed. Would run:')
967
+ console.error(` schtasks /Create /TN ${name} /SC ONLOGON /RL LIMITED /TR ${command} /F`)
968
+ console.error('pass --yes to register this logon task (user-level change)')
969
+ return 1
970
+ }
971
+ console.log(JSON.stringify(await autostartInstall({ name, command, yes }), null, 2))
972
+ return 0
973
+ }
974
+ if (sub === 'remove') {
975
+ console.log(JSON.stringify(await autostartRemove({ name, yes }), null, 2))
976
+ return 0
977
+ }
978
+ console.error(`unknown autostart subcommand: ${sub} (status|install|remove)`)
979
+ return 1
980
+ } catch (err) {
981
+ console.error(`autostart ${sub} failed: ${err.message}`)
982
+ return 1
983
+ }
984
+ }
985
+
986
+ if (cmd === 'channel') {
987
+ const sub = argv[1]
988
+ const config = loadConfig(home)
989
+ const secrets = loadSecrets(home)
990
+ const configured = (config.dsh?.targets || []).filter((t) => t?.id && t?.baseUrl)
991
+ const saved = secrets.dshWeb || {}
992
+
993
+ if (sub === undefined || sub === 'targets') {
994
+ const rows = configured.map((t) => ({
995
+ id: t.id,
996
+ baseUrl: t.baseUrl,
997
+ note: t.note,
998
+ hasCookie: Boolean(saved[t.id]?.cookie),
999
+ savedAt: saved[t.id]?.savedAt
1000
+ }))
1001
+ for (const id of Object.keys(saved)) {
1002
+ if (!rows.some((r) => r.id === id)) {
1003
+ rows.push({ id, baseUrl: saved[id].baseUrl, note: '(saved by channel login)', hasCookie: true, savedAt: saved[id].savedAt })
1004
+ }
1005
+ }
1006
+ console.log(JSON.stringify({ targets: rows, configPath: configPath(home), secretsPath: secretsPath(home) }, null, 2))
1007
+ return 0
1008
+ }
1009
+
1010
+ if (sub === 'login') {
1011
+ const launchUrl = argv[2]
1012
+ if (!launchUrl) {
1013
+ console.error('usage: node src/cli.js channel login <launchUrl> [--target ID]')
1014
+ return 1
1015
+ }
1016
+ const client = new DshWebClient({ log: console })
1017
+ const { baseUrl } = await client.login(launchUrl)
1018
+ const targetId = argValue(argv, '--target') || `dsh-${new URL(baseUrl).port || '80'}`
1019
+ secrets.dshWeb = { ...saved, [targetId]: { baseUrl, cookie: client.cookie, savedAt: new Date().toISOString() } }
1020
+ saveSecrets(secrets, home)
1021
+ const list = await client.listSessions().catch(() => ({ items: [] }))
1022
+ console.log(
1023
+ JSON.stringify(
1024
+ {
1025
+ target: targetId,
1026
+ baseUrl,
1027
+ sessions: (list.items || []).length,
1028
+ savedTo: secretsPath(home),
1029
+ note: 'cookie is signed by the DSH instance key and survives restarts of `dsh web`'
1030
+ },
1031
+ null,
1032
+ 2
1033
+ )
1034
+ )
1035
+ return 0
1036
+ }
1037
+
1038
+ if (sub === 'sessions') {
1039
+ const targetId = argv[2]
1040
+ const target = configured.find((t) => t.id === targetId) || (targetId ? undefined : configured[0])
1041
+ if (!target) {
1042
+ console.error('unknown target; run `node src/cli.js channel` to see configured targets')
1043
+ return 1
1044
+ }
1045
+ const client = new DshWebClient({ baseUrl: target.baseUrl, cookie: saved[target.id]?.cookie })
1046
+ const value = await client.listSessions()
1047
+ console.log(JSON.stringify({ target: target.id, sessions: value?.items || [] }, null, 2))
1048
+ return 0
1049
+ }
1050
+
1051
+ if (sub === 'send') {
1052
+ const text = argv[2]
1053
+ if (!text || text.startsWith('--')) {
1054
+ console.error('usage: node src/cli.js channel send <text> --target ID [--session ID] [--mode queue|steer] [--wait-ms N]')
1055
+ return 1
1056
+ }
1057
+ const targetId = argValue(argv, '--target')
1058
+ const target = configured.find((t) => t.id === targetId)
1059
+ const id = targetId || target?.id || Object.keys(saved)[0]
1060
+ const entry = id ? saved[id] : undefined
1061
+ if (!id || (!target && !entry)) {
1062
+ console.error('unknown target; run `node src/cli.js channel targets` to see configured targets and saved cookies')
1063
+ return 1
1064
+ }
1065
+ const baseUrl = target?.baseUrl || entry?.baseUrl
1066
+ if (!baseUrl) {
1067
+ console.error(`target ${id} has no baseUrl; run \`node src/cli.js channel login <launchUrl> --target ${id}\``)
1068
+ return 1
1069
+ }
1070
+ const mode = argValue(argv, '--mode', 'queue')
1071
+ if (mode !== 'queue' && mode !== 'steer') {
1072
+ console.error(`unknown --mode: ${mode} (queue|steer)`)
1073
+ return 1
1074
+ }
1075
+ const existing = argValue(argv, '--session')
1076
+ const waitMs = Number(argValue(argv, '--wait-ms', String(config.limits?.channelWaitMs ?? 180000)))
1077
+ const client = new DshWebClient({ baseUrl, cookie: entry?.cookie, log: console })
1078
+ try {
1079
+ const sessionId = existing || (await client.createSession())
1080
+ const result = await client.sendAndWait({
1081
+ sessionId,
1082
+ text,
1083
+ mode,
1084
+ waitMs,
1085
+ pollMs: config.limits?.channelPollMs ?? 800
1086
+ })
1087
+ console.log(
1088
+ JSON.stringify(
1089
+ {
1090
+ target: id,
1091
+ created: !existing,
1092
+ sessionId: result.sessionId,
1093
+ requestId: result.requestId,
1094
+ turnEnd: result.turnEnd,
1095
+ reply: result.reply,
1096
+ events: result.events,
1097
+ ms: result.ms
1098
+ },
1099
+ null,
1100
+ 2
1101
+ )
1102
+ )
1103
+ return 0
1104
+ } catch (error) {
1105
+ console.error(`channel send failed: ${error.message}`)
1106
+ return 1
1107
+ }
1108
+ }
1109
+
1110
+ console.error('usage: node src/cli.js channel [targets|login <launchUrl> [--target ID]|sessions [target]|send <text> --target ID [--session ID] [--mode queue|steer] [--wait-ms N]]')
1111
+ return 1
1112
+ }
1113
+
1114
+ if (cmd === 'acl') {
1115
+ const sub = argv[1] || 'show'
1116
+ const config = loadConfig(home)
1117
+ const known = buildAll(offlineCtx(config, home)).map((s) => s.name)
1118
+ if (sub === 'show') {
1119
+ console.log(JSON.stringify({ acl: describeAcl(config, known), config: configPath(home) }, null, 2))
1120
+ return 0
1121
+ }
1122
+ if (sub === 'allow' || sub === 'deny' || sub === 'reset') {
1123
+ const names = argv.slice(2).filter((a) => !a.startsWith('--'))
1124
+ if (sub !== 'reset' && names.length === 0) {
1125
+ console.error(`usage: node src/cli.js acl ${sub} <tool...>`)
1126
+ return 1
1127
+ }
1128
+ const change = sub === 'reset' ? { reset: true } : sub === 'allow' ? { allow: names } : { deny: names }
1129
+ let next
1130
+ try {
1131
+ next = resolveAclUpdate(config.acl, { ...change, known })
1132
+ } catch (error) {
1133
+ console.error(`acl ${sub} failed: ${error.message}`)
1134
+ return 1
1135
+ }
1136
+ if (!next.changed) {
1137
+ console.log('acl unchanged')
1138
+ return 0
1139
+ }
1140
+ saveConfig({ ...config, acl: { ...(config.acl || {}), allow: next.allow, deny: next.deny } }, home)
1141
+ console.log(JSON.stringify({
1142
+ ok: true,
1143
+ changes: next.notes,
1144
+ allow: next.allow,
1145
+ deny: next.deny,
1146
+ note: 'server hot-reloads config; no restart needed'
1147
+ }, null, 2))
1148
+ return 0
1149
+ }
1150
+ console.error(`unknown acl subcommand: ${sub} (show|allow|deny|reset)`)
1151
+ return 1
1152
+ }
1153
+
1154
+ if (cmd === 'tools') {
1155
+ const config = loadConfig(home)
1156
+ const ctx = offlineCtx(config, home)
1157
+ const specs = buildAll(ctx)
1158
+ const showAll = argv.includes('--all')
1159
+ console.log(`${specs.length} tools on node "${config.node?.name || 'local'}"`)
1160
+ for (const spec of specs) {
1161
+ const allowed = isAllowed(config, spec.name)
1162
+ if (!allowed && !showAll) continue
1163
+ console.log(` ${allowed ? 'ALLOW' : 'deny '} ${spec.name.padEnd(16)} ${spec.title}`)
1164
+ }
1165
+ if (!showAll && specs.some((s) => !isAllowed(config, s.name))) {
1166
+ console.log(' (run with --all to list denied tools too)')
1167
+ }
1168
+ return 0
1169
+ }
1170
+
1171
+ if (cmd === 'audit') {
1172
+ const limit = Number(argValue(argv, '--limit', '20'))
1173
+ const tool = argValue(argv, '--tool', undefined)
1174
+ const tail = tailAudit(home, { limit, tool })
1175
+ console.log(`audit: ${tail.path}`)
1176
+ for (const entry of tail.entries) {
1177
+ const parts = [entry.ts, entry.ok ? 'OK ' : 'FAIL', String(entry.tool || '').padEnd(16), String(entry.ms ?? '') + 'ms']
1178
+ if (entry.code) parts.push(entry.code)
1179
+ if (entry.jobId) parts.push(`job=${entry.jobId}`)
1180
+ console.log(' ' + parts.join(' '))
1181
+ }
1182
+ if (tail.entries.length === 0) console.log(' (no entries yet)')
1183
+ return 0
1184
+ }
1185
+
1186
+ if (cmd === 'status') {
1187
+ const config = loadConfig(home)
1188
+ const secrets = loadSecrets(home)
1189
+ const port = config.node.port || 8124
1190
+ let health = null
1191
+ try {
1192
+ health = await probeHealth(config, home, port)
1193
+ } catch (error) {
1194
+ health = { status: 'unreachable', error: error?.message || String(error) }
1195
+ }
1196
+ const { describeCert } = await import('./core/tls.js')
1197
+ const { readPairingState } = await import('./core/pairing.js')
1198
+ const ctx = offlineCtx(config, home)
1199
+ const allowed = buildAll(ctx)
1200
+ .map((s) => s.name)
1201
+ .filter((n) => isAllowed(config, n))
1202
+ console.log(
1203
+ JSON.stringify(
1204
+ {
1205
+ version: MESH_VERSION,
1206
+ home,
1207
+ config: configPath(home),
1208
+ secrets: secretsPath(home),
1209
+ audit: auditPath(home),
1210
+ pairing: { ...(config.pairing || {}), state: readPairingState(home) },
1211
+ node: config.node,
1212
+ tls: describeCert(home),
1213
+ acl: { ...config.acl, allowedTools: allowed },
1214
+ shell: config.shell,
1215
+ limits: config.limits,
1216
+ inboundTokens: (secrets.inbound || []).map((t) => ({
1217
+ id: t.id,
1218
+ tokenPreview: `${String(t.token).slice(0, 6)}…${String(t.token).slice(-4)}`
1219
+ })),
1220
+ peers: config.peers || [],
1221
+ health
1222
+ },
1223
+ null,
1224
+ 2
1225
+ )
1226
+ )
1227
+ return 0
1228
+ }
1229
+
1230
+ if (cmd === 'selftest') {
1231
+ return runSelftest()
1232
+ }
1233
+
1234
+ console.error(`unknown command: ${cmd}\n`)
1235
+ console.log(HELP)
1236
+ return 2
1237
+ }
1238
+
1239
+ main(process.argv.slice(2))
1240
+ .then((code) => {
1241
+ if (typeof code === 'number') process.exit(code)
1242
+ })
1243
+ .catch((error) => {
1244
+ console.error(error?.stack || error)
1245
+ process.exit(1)
1246
+ })