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.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +341 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/sakura-api.js +103 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/tunnels.js +212 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +317 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- 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
|
+
})
|