dsh-multi-chat 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-multi-chat gateway — authenticated reverse proxy for one loopback DSH
4
+ * instance.
5
+ *
6
+ * The official `dsh web` CLI deliberately forbids `--host 0.0.0.0` ("it would
7
+ * expose remote code execution to the network"), so the safe way to reach a
8
+ * DSH instance from a phone / another machine is a small auth-gated gateway
9
+ * in front of the loopback instance:
10
+ *
11
+ * node scripts/gateway.mjs \
12
+ * --target 127.0.0.1:3080 \
13
+ * --listen 0.0.0.0:8443 \
14
+ * --token <secret>
15
+ *
16
+ * The phone opens http://<lan-ip>:8443/ , enters the token once, and gets the
17
+ * full DSH UI. Every proxied request has its Host/Origin rewritten to the
18
+ * loopback target, so the official /api browser-trust fence (DNS-rebinding /
19
+ * cross-site defense) treats it as a local request — no --trusted-host
20
+ * restart needed. WebSocket upgrades (the /api mux and host-events channels)
21
+ * and SSE streams pass through untouched.
22
+ *
23
+ * Security model:
24
+ * - Token authentication: an HMAC-signed session cookie (HttpOnly,
25
+ * SameSite=Strict, 12h default) after a successful login; the token is
26
+ * also accepted as `Authorization: Bearer <token>` or `?token=<token>`
27
+ * for scripts. Failed login attempts are rate-limited per IP.
28
+ * - Encryption: optional TLS via --tls-cert/--tls-key. On a trusted LAN the
29
+ * token over plain HTTP is acceptable; across the internet always use
30
+ * TLS (or run inside a VPN) — the DSH UI can execute commands as this
31
+ * machine's user.
32
+ * - The DSH instance itself stays on 127.0.0.1; only this gateway listens
33
+ * on the network.
34
+ *
35
+ * Zero dependencies: raw TCP head rewriting over node:net, node:crypto HMAC,
36
+ * node:tls for the optional certificate.
37
+ */
38
+
39
+ import { createServer as createNetServer, connect as netConnect } from 'node:net'
40
+ import { createServer as createTlsServer } from 'node:tls'
41
+ import { createHmac, timingSafeEqual } from 'node:crypto'
42
+ import { readFileSync } from 'node:fs'
43
+ import { networkInterfaces } from 'node:os'
44
+ import { URL } from 'node:url'
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Argument parsing (no deps). */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ const HELP = `dsh-multi-chat gateway — authenticated proxy for one loopback DSH instance
51
+
52
+ Usage:
53
+ node scripts/gateway.mjs --target <host:port> [options]
54
+
55
+ Required:
56
+ --target <host:port> the loopback DSH instance, e.g. 127.0.0.1:3080
57
+ --token <secret> the login token (required unless --no-auth)
58
+
59
+ Options:
60
+ --listen <host:port> listen address, default 0.0.0.0:<target-port + 5000>
61
+ --tls-cert <file.pem> TLS certificate (PEM) to enable HTTPS
62
+ --tls-key <file.pem> TLS private key (PEM)
63
+ --max-age <hours> session cookie lifetime, default 12
64
+ --name <label> instance label shown on the login page
65
+ --no-auth disable authentication (trusted/VPN networks only)
66
+ --quiet log only errors
67
+ --help show this help
68
+
69
+ The token is also accepted as "Authorization: Bearer <token>" or
70
+ "?token=<token>" on any request, which proceeds without a cookie (handy for
71
+ curl / scripts).
72
+
73
+ Examples:
74
+ node scripts/gateway.mjs --target 127.0.0.1:3080 --listen 0.0.0.0:8443 --token hunter2
75
+ node scripts/gateway.mjs --target 127.0.0.1:3080 --token hunter2 --tls-cert cert.pem --tls-key key.pem
76
+ `
77
+
78
+ function parseArgs(argv) {
79
+ const opts = { listen: null, target: null, token: null, tlsCert: null, tlsKey: null, maxAgeHours: 12, name: 'DSH', noAuth: false, quiet: false }
80
+ for (let i = 0; i < argv.length; i++) {
81
+ const arg = argv[i]
82
+ const next = () => argv[++i]
83
+ switch (arg) {
84
+ case '--target': opts.target = next(); break
85
+ case '--listen': opts.listen = next(); break
86
+ case '--token': opts.token = next(); break
87
+ case '--tls-cert': opts.tlsCert = next(); break
88
+ case '--tls-key': opts.tlsKey = next(); break
89
+ case '--max-age': opts.maxAgeHours = Number(next()); break
90
+ case '--name': opts.name = next(); break
91
+ case '--no-auth': opts.noAuth = true; break
92
+ case '--quiet': opts.quiet = true; break
93
+ case '--help':
94
+ process.stdout.write(HELP)
95
+ process.exit(0)
96
+ break
97
+ default:
98
+ process.stderr.write(`unknown option: ${arg}\n\n${HELP}`)
99
+ process.exit(2)
100
+ }
101
+ }
102
+ if (opts.target === undefined || !/^[^:/]+:\d+$/.test(opts.target)) {
103
+ process.stderr.write('missing or malformed --target <host:port>\n\n' + HELP)
104
+ process.exit(2)
105
+ }
106
+ if (!opts.noAuth && (opts.token === undefined || opts.token === '')) {
107
+ process.stderr.write('missing --token <secret> (or pass --no-auth on a trusted network)\n\n' + HELP)
108
+ process.exit(2)
109
+ }
110
+ if ((opts.tlsCert === null) !== (opts.tlsKey === null)) {
111
+ process.stderr.write('--tls-cert and --tls-key must be provided together\n')
112
+ process.exit(2)
113
+ }
114
+ return opts
115
+ }
116
+
117
+ /* ------------------------------------------------------------------ */
118
+ /* Helpers. */
119
+ /* ------------------------------------------------------------------ */
120
+
121
+ function lanAddresses() {
122
+ const out = []
123
+ for (const ifaces of Object.values(networkInterfaces())) {
124
+ for (const iface of ifaces ?? []) {
125
+ if (iface.family === 'IPv4' && !iface.internal) out.push(iface.address)
126
+ }
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** Constant-time hex string comparison (length-guarded). */
132
+ function safeEqualHex(left, right) {
133
+ if (typeof left !== 'string' || typeof right !== 'string') return false
134
+ const a = Buffer.from(left, 'hex')
135
+ const b = Buffer.from(right, 'hex')
136
+ return a.length !== 0 && a.length === b.length && timingSafeEqual(a, b)
137
+ }
138
+
139
+ const hmac = (data) => createHmac('sha256', opts.token).update(data).digest('hex')
140
+
141
+ /** Session cookie: base64url(payload).hmac where payload = { exp } — the HMAC
142
+ * key is the shared token, so only a token holder can mint a session. */
143
+ function signSession(exp) {
144
+ const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url')
145
+ return `${payload}.${hmac(payload)}`
146
+ }
147
+
148
+ function verifySession(value) {
149
+ const dot = typeof value === 'string' ? value.lastIndexOf('.') : -1
150
+ if (dot <= 0) return false
151
+ const payload = value.slice(0, dot)
152
+ const sig = value.slice(dot + 1)
153
+ if (!safeEqualHex(sig, hmac(payload))) return false
154
+ try {
155
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString())
156
+ return typeof parsed.exp === 'number' && parsed.exp > Date.now()
157
+ } catch {
158
+ return false
159
+ }
160
+ }
161
+
162
+ /** Minimal HTTP/1.1 head parse; null until the head is complete. */
163
+ function parseHead(buffer) {
164
+ const idx = buffer.indexOf('\r\n\r\n')
165
+ if (idx === -1) return null
166
+ const head = buffer.subarray(0, idx + 4).toString('latin1')
167
+ const lines = head.split('\r\n')
168
+ const requestLine = lines[0].match(/^(\S+)\s+(\S+)\s+(HTTP\/\d\.\d)$/)
169
+ const headers = []
170
+ const map = {}
171
+ for (const line of lines.slice(1)) {
172
+ const ci = line.indexOf(':')
173
+ if (ci === -1) continue
174
+ const name = line.slice(0, ci).trim().toLowerCase()
175
+ const value = line.slice(ci + 1).trim()
176
+ map[name] = value
177
+ headers.push([name, value])
178
+ }
179
+ return {
180
+ malformed: requestLine === null,
181
+ headBytes: idx + 4,
182
+ method: requestLine?.[1] ?? '',
183
+ target: requestLine?.[2] ?? '',
184
+ version: requestLine?.[3] ?? '',
185
+ headers,
186
+ map,
187
+ }
188
+ }
189
+
190
+ function targetPath(target) {
191
+ try {
192
+ return new URL(target, 'http://gateway.local').pathname
193
+ } catch {
194
+ return '/'
195
+ }
196
+ }
197
+
198
+ function log(message) {
199
+ if (!opts.quiet) process.stdout.write(`[gateway] ${message}\n`)
200
+ }
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* Local responses (login page + errors). */
204
+ /* ------------------------------------------------------------------ */
205
+
206
+ function loginPage(name, error, next) {
207
+ const err = error === '' ? '' : `<p class="err">${error}</p>`
208
+ const nxt = (next ?? '').replace(/"/g, '&quot;')
209
+ return `<!doctype html>
210
+ <html lang="zh-CN">
211
+ <head>
212
+ <meta charset="utf-8">
213
+ <meta name="viewport" content="width=device-width, initial-scale=1">
214
+ <title>${name} · 访问认证</title>
215
+ <style>
216
+ body { font-family: system-ui, sans-serif; background:#0d1117; color:#e6edf3;
217
+ display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
218
+ form { background:#161b22; border:1px solid #30363d; border-radius:12px;
219
+ padding:32px 36px; width:min(320px, 90vw); box-shadow:0 8px 30px #0006; }
220
+ h1 { font-size:16px; margin:0 0 18px; font-weight:600; }
221
+ .err { color:#f85149; font-size:12px; margin:0 0 12px; min-height:16px; }
222
+ label { font-size:13px; color:#8b949e; }
223
+ input { width:100%; box-sizing:border-box; margin:6px 0 16px; padding:9px 10px;
224
+ border-radius:8px; border:1px solid #30363d; background:#0d1117; color:#e6edf3; font-size:14px; }
225
+ button { width:100%; padding:10px; border:none; border-radius:8px; background:#2f81f7;
226
+ color:#fff; font-size:14px; font-weight:600; cursor:pointer; }
227
+ button:hover { background:#388bfd; }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <form method="post" action="/__gw__/login">
232
+ <h1>${name} · 需要访问口令</h1>
233
+ ${err}
234
+ <label for="token">访问口令</label>
235
+ <input id="token" name="token" type="password" autofocus autocomplete="current-password">
236
+ <input type="hidden" name="next" value="${nxt}">
237
+ <button type="submit">进入</button>
238
+ </form>
239
+ </body>
240
+ </html>`
241
+ }
242
+
243
+ function respondLogin(socket, name, next) {
244
+ const html = loginPage(name, '', next)
245
+ const body = Buffer.from(html, 'utf8')
246
+ socket.write(
247
+ 'HTTP/1.1 401 Unauthorized\r\n'
248
+ + 'content-type: text/html; charset=utf-8\r\n'
249
+ + `content-length: ${body.length}\r\n`
250
+ + 'connection: close\r\n'
251
+ + 'cache-control: no-store\r\n\r\n',
252
+ )
253
+ socket.write(body)
254
+ socket.end()
255
+ }
256
+
257
+ function respondPlain(socket, status, text) {
258
+ const body = Buffer.from(text, 'utf8')
259
+ socket.write(
260
+ `HTTP/1.1 ${status}\r\n`
261
+ + 'content-type: text/plain; charset=utf-8\r\n'
262
+ + `content-length: ${body.length}\r\n`
263
+ + 'connection: close\r\n'
264
+ + 'cache-control: no-store\r\n\r\n',
265
+ )
266
+ socket.write(body)
267
+ socket.end()
268
+ }
269
+
270
+ /* ------------------------------------------------------------------ */
271
+ /* Request head rewriting + transparent proxy. */
272
+ /* ------------------------------------------------------------------ */
273
+
274
+ /**
275
+ * Rebuild the request head for the loopback target: Host and Origin point at
276
+ * 127.0.0.1:<target-port> so the DSH /api browser-trust fence sees a local
277
+ * request. Non-upgrade requests get `connection: close`, which forces a fresh
278
+ * gateway connection (and therefore a fresh rewrite) per request — keep-alive
279
+ * multiplexing would smuggle unrewritten Host headers past the fence.
280
+ */
281
+ function rewriteHead(parsed, targetPort) {
282
+ const upgrade = parsed.map.upgrade !== undefined
283
+ const lines = [`${parsed.method} ${parsed.target} ${parsed.version}`]
284
+ for (const [name, value] of parsed.headers) {
285
+ if (name === 'host') {
286
+ lines.push(`host: 127.0.0.1:${targetPort}`)
287
+ } else if (name === 'origin') {
288
+ lines.push(`origin: http://127.0.0.1:${targetPort}`)
289
+ } else if (name === 'connection' || name === 'proxy-connection') {
290
+ // dropped; re-added below with the right semantics
291
+ } else {
292
+ lines.push(`${name}: ${value}`)
293
+ }
294
+ }
295
+ lines.push(upgrade ? 'connection: Upgrade' : 'connection: close')
296
+ return lines.join('\r\n') + '\r\n\r\n'
297
+ }
298
+
299
+ /* ------------------------------------------------------------------ */
300
+ /* Rate limiting (per source IP, login POST only). */
301
+ /* ------------------------------------------------------------------ */
302
+
303
+ const attempts = new Map() // ip -> { fails, blockedUntil }
304
+
305
+ function isBlocked(ip) {
306
+ const entry = attempts.get(ip)
307
+ return entry !== undefined && entry.blockedUntil !== undefined && entry.blockedUntil > Date.now()
308
+ }
309
+
310
+ function registerFailure(ip) {
311
+ const now = Date.now()
312
+ const entry = attempts.get(ip) ?? { fails: 0, blockedUntil: undefined }
313
+ if (entry.blockedUntil !== undefined && entry.blockedUntil > now) return
314
+ entry.fails += 1
315
+ if (entry.fails >= 5) {
316
+ entry.fails = 0
317
+ entry.blockedUntil = now + 60_000
318
+ log(`rate-limited ${ip} for 60s`)
319
+ }
320
+ attempts.set(ip, entry)
321
+ if (attempts.size > 512) {
322
+ for (const [key, value] of attempts) {
323
+ if (value.fails === 0 && value.blockedUntil === undefined) attempts.delete(key)
324
+ }
325
+ }
326
+ }
327
+
328
+ /* ------------------------------------------------------------------ */
329
+ /* Per-socket flow. */
330
+ /* ------------------------------------------------------------------ */
331
+
332
+ /** Handle one accepted client socket: read the head, gate it, then either
333
+ * answer locally (login flow / errors) or tunnel to the target with a
334
+ * rewritten head. Works identically for plain TCP and TLS sockets, and for
335
+ * WebSocket upgrades (after the head the pipes are raw bytes either way). */
336
+ function handleSocket(socket) {
337
+ let buffer = Buffer.alloc(0)
338
+ let headDone = false
339
+ let proxying = false
340
+
341
+ const onData = (chunk) => {
342
+ if (headDone) return
343
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk])
344
+ if (buffer.length > 65536) {
345
+ headDone = true
346
+ respondPlain(socket, 431, 'request head too large')
347
+ return
348
+ }
349
+ const parsed = parseHead(buffer)
350
+ if (parsed === null) return // head not complete yet
351
+
352
+ headDone = true
353
+ if (parsed.malformed) {
354
+ respondPlain(socket, 400, 'malformed request')
355
+ return
356
+ }
357
+
358
+ const path = targetPath(parsed.target)
359
+ if (path === '/__gw__/login') {
360
+ handleLogin(socket, parsed, buffer.subarray(parsed.headBytes))
361
+ return
362
+ }
363
+
364
+ // Token gate (--no-auth bypasses entirely).
365
+ if (!opts.noAuth && !isAuthed(parsed)) {
366
+ respondLogin(socket, opts.name, path === '/' ? '/' : path)
367
+ return
368
+ }
369
+
370
+ // Proxy: rewrite the head, connect to the loopback target, stream both ways.
371
+ const [targetHost, targetPortRaw] = opts.target.split(':')
372
+ const targetPort = Number(targetPortRaw)
373
+ const target = netConnect(targetPort, targetHost)
374
+ const rest = buffer.subarray(parsed.headBytes)
375
+ let connected = false
376
+ target.on('connect', () => {
377
+ connected = true
378
+ target.write(rewriteHead(parsed, targetPort))
379
+ if (rest.length > 0) target.write(rest)
380
+ proxying = true
381
+ socket.pipe(target)
382
+ target.pipe(socket)
383
+ })
384
+ target.on('error', (error) => {
385
+ if (!connected) {
386
+ respondPlain(socket, 502, `cannot reach target ${targetHost}:${targetPort}: ${error.message}`)
387
+ }
388
+ socket.destroy()
389
+ })
390
+ socket.on('error', () => target.destroy())
391
+ socket.on('close', () => { if (proxying) target.destroy() })
392
+ }
393
+
394
+ socket.on('data', onData)
395
+ socket.on('error', () => { socket.destroy() })
396
+ }
397
+
398
+ /** Whether the request carries a valid token: session cookie, Bearer header,
399
+ * or ?token= query parameter. */
400
+ function isAuthed(parsed) {
401
+ const cookie = parsed.map.cookie ?? ''
402
+ const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie)
403
+ if (session !== null && verifySession(session[1])) return true
404
+ const bearer = parsed.map.authorization ?? ''
405
+ if (bearer.startsWith('Bearer ') && bearer.slice(7) === opts.token) return true
406
+ try {
407
+ return new URL(parsed.target, 'http://gateway.local').searchParams.get('token') === opts.token
408
+ } catch {
409
+ return false
410
+ }
411
+ }
412
+
413
+ /** Login POST: verify the token (rate-limited), mint the session cookie. */
414
+ function handleLogin(socket, parsed, initialBody) {
415
+ const path = targetPath(parsed.target)
416
+ let next = '/'
417
+ try {
418
+ next = new URL(parsed.target, 'http://gateway.local').searchParams.get('next') ?? '/'
419
+ } catch { /* keep default */ }
420
+ if (!/^\/(?!\/)/.test(next)) next = '/'
421
+
422
+ if (parsed.method !== 'POST') {
423
+ respondLogin(socket, opts.name, next)
424
+ return
425
+ }
426
+
427
+ const ip = socket.remoteAddress ?? 'unknown'
428
+ if (isBlocked(ip)) {
429
+ respondPlain(socket, 429, 'too many login attempts, try again later')
430
+ return
431
+ }
432
+
433
+ const contentLength = Number(parsed.map['content-length'] ?? 0)
434
+ if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 16384) {
435
+ respondPlain(socket, 413, 'login body too large')
436
+ return
437
+ }
438
+
439
+ let collected = Buffer.from(initialBody ?? [])
440
+ const done = () => {
441
+ socket.off('data', onBody)
442
+ const token = new URLSearchParams(collected.toString('utf8')).get('token') ?? ''
443
+ if (token === opts.token) {
444
+ const cookie = `dsh_gw_session=${signSession(Date.now() + opts.maxAgeHours * 3600_000)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${opts.maxAgeHours * 3600}`
445
+ socket.write(
446
+ 'HTTP/1.1 302 Found\r\n'
447
+ + `location: ${next}\r\n`
448
+ + `set-cookie: ${cookie}\r\n`
449
+ + 'content-length: 0\r\n'
450
+ + 'connection: close\r\n\r\n',
451
+ )
452
+ socket.end()
453
+ log(`login ok from ${ip}`)
454
+ } else {
455
+ registerFailure(ip)
456
+ log(`login FAILED from ${ip}`)
457
+ respondLogin(socket, opts.name, next)
458
+ }
459
+ }
460
+ const onBody = (chunk) => {
461
+ collected = Buffer.concat([collected, chunk])
462
+ if (collected.length >= contentLength) done()
463
+ }
464
+ if (collected.length >= contentLength) {
465
+ done()
466
+ } else {
467
+ socket.on('data', onBody)
468
+ socket.once('end', done)
469
+ }
470
+ }
471
+
472
+ /* ------------------------------------------------------------------ */
473
+ /* Entry. */
474
+ /* ------------------------------------------------------------------ */
475
+
476
+ const opts = parseArgs(process.argv.slice(2))
477
+
478
+ const [targetHost, targetPortRaw] = opts.target.split(':')
479
+ const defaultListenPort = Number(targetPortRaw) + 5000
480
+ const [listenHostRaw, listenPortRaw] = (opts.listen ?? `0.0.0.0:${defaultListenPort}`).split(':')
481
+ const listenHost = listenHostRaw ?? '0.0.0.0'
482
+ const listenPort = Number(listenPortRaw)
483
+
484
+ const server = opts.tlsCert !== null
485
+ ? createTlsServer({ cert: readFileSync(opts.tlsCert), key: readFileSync(opts.tlsKey) }, handleSocket)
486
+ : createNetServer(handleSocket)
487
+
488
+ server.on('error', (error) => {
489
+ process.stderr.write(`[gateway] cannot listen on ${listenHost}:${listenPort}: ${error.message}\n`)
490
+ if (error.code === 'EACCES' || error.code === 'EADDRINUSE') {
491
+ process.stderr.write('[gateway] hint: the port may fall in a Windows excluded range (check `netsh interface ipv4 show excludedportrange protocol=tcp`) or is already in use — pick another --listen port.\n')
492
+ }
493
+ process.exit(1)
494
+ })
495
+
496
+ server.listen(listenPort, listenHost, () => {
497
+ const address = server.address()
498
+ const port = typeof address === 'object' && address !== null ? address.port : listenPort
499
+ const scheme = opts.tlsCert !== null ? 'https' : 'http'
500
+ log(`target ${opts.target} (${opts.name})`)
501
+ log(`listening ${scheme}://${listenHost}:${port} — auth ${opts.noAuth ? 'DISABLED (trusted network only)' : `token required (cookie ${opts.maxAgeHours}h)`}`)
502
+ if (!opts.noAuth) {
503
+ const hosts = lanAddresses()
504
+ const urls = hosts.length > 0 ? hosts.map(ip => `${scheme}://${ip}:${port}/`) : [`${scheme}://localhost:${port}/`]
505
+ log(`phone / remote access:\n ${urls.join('\n ')}`)
506
+ log(`token: ${opts.token}`)
507
+ }
508
+ })
509
+
510
+ process.on('SIGINT', () => { server.close(() => process.exit(0)) })
511
+ process.on('SIGTERM', () => { server.close(() => process.exit(0)) })
@@ -0,0 +1,65 @@
1
+ #requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ 把多窗口墙插件装进 DSH web profile 并启用。
5
+
6
+ .DESCRIPTION
7
+ 1) 将 plugin/dsh-client-ui-multi-wall 打包为 tarball;
8
+ 2) 用 `dsh plugin --profile web add` 装进 profile;
9
+ 3) 把 patches/multi-wall.yml 的 insert 行追加到
10
+ $DSH_HOME/profiles/web/cordis.patch.yml;
11
+ 4) 提示重启 `dsh web` 生效(打开 http://127.0.0.1:<port> 后,
12
+ 侧边栏底部会出现「多窗口墙」按钮)。
13
+
14
+ .PARAMETER Profile
15
+ profile 名,默认 web。
16
+
17
+ .EXAMPLE
18
+ .\scripts\install-plugin.ps1
19
+ #>
20
+ param(
21
+ [string]$Profile = "web"
22
+ )
23
+
24
+ $ErrorActionPreference = "Stop"
25
+ $root = Split-Path -Parent $PSScriptRoot
26
+ $pluginDir = Join-Path $root "plugin\dsh-client-ui-multi-wall"
27
+ $patchFile = Join-Path $root "patches\multi-wall.yml"
28
+
29
+ if (-not (Test-Path $pluginDir)) { throw "未找到插件目录:$pluginDir" }
30
+ if (-not (Test-Path $patchFile)) { throw "未找到 patch 文件:$patchFile" }
31
+
32
+ # 1) pack tarball
33
+ $tarball = Join-Path $root "dsh-client-ui-multi-wall.tgz"
34
+ Push-Location $pluginDir
35
+ npm pack --pack-destination $root | Out-Null
36
+ Pop-Location
37
+ if (-not (Test-Path $tarball)) { throw "npm pack 失败,未生成 $tarball" }
38
+ Write-Host "已打包:$tarball"
39
+
40
+ # 2) install into profile
41
+ Write-Host "安装到 profile '$Profile'(等价于 pnpm add <tarball>)…"
42
+ dsh plugin --profile $Profile add $tarball
43
+ if ($LASTEXITCODE -ne 0) { throw "dsh plugin add 失败(exit $LASTEXITCODE)" }
44
+
45
+ # 3) append the insert row to the profile patch (idempotent)
46
+ $home = $env:DSH_HOME
47
+ if ([string]::IsNullOrEmpty($home)) { $home = Join-Path $env:USERPROFILE ".dsh" }
48
+ $profilePatch = Join-Path $home "profiles\$Profile\cordis.patch.yml"
49
+ if (-not (Test-Path $profilePatch)) { throw "未找到 profile patch:$profilePatch" }
50
+ $content = Get-Content $profilePatch -Raw
51
+ if ($content -notmatch "ui-multi-wall") {
52
+ $insert = Get-Content $patchFile -Raw
53
+ # 把 patch 文件的 insert 块合并进 profile patch(去掉头部注释,保留 YAML 列表语义)
54
+ $insertBlock = ($insert -split "`n" | Where-Object { $_ -notmatch "^\s*#" -and $_.Trim() -ne "" }) -join "`n"
55
+ $newContent = $content.TrimEnd() + "`n" + $insertBlock + "`n"
56
+ Set-Content -Path $profilePatch -Value $newContent -Encoding UTF8
57
+ Write-Host "已追加到 $profilePatch"
58
+ } else {
59
+ Write-Host "profile patch 已包含 ui-multi-wall,跳过追加。"
60
+ }
61
+
62
+ Write-Host ""
63
+ Write-Host "完成。重启 dsh web 生效:"
64
+ Write-Host " dsh web --port <n>"
65
+ Write-Host "打开后侧边栏底部应出现「多窗口墙」按钮。"