dsh-plugin-remote-connect-beta 0.1.0-beta.1

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,360 @@
1
+ /**
2
+ * 租户实例看护:为每个租户拉起一个**独立** Harness 进程(独立 DSH_HOME、独立端口、独立令牌)。
3
+ *
4
+ * 隔离靠进程边界 + home 边界,不靠约定:
5
+ * - `DSH_HOME=<租户目录>`:会话、凭据、设置、storages 全在租户自己的目录里;
6
+ * - `--host 127.0.0.1 --port <p>`:只监听回环,公网/局域网只能经网关进来;
7
+ * - 启动令牌由子进程 **stdout** 打印(`dsh web: http://127.0.0.1:<port>/?token=…`),
8
+ * 每个实例只认自己的令牌 —— 实测拿 A 的令牌访问 B 的端口是 401。
9
+ *
10
+ * 启动方式与 DSH Desktop 自己拉起 Harness 的方式保持一致(见桌面应用 main 进程):
11
+ *
12
+ * <runtime> --expose-internals <dsh 入口> --profile <id> [--from-default-profile web] \
13
+ * --no-open --host 127.0.0.1 --port <p>
14
+ *
15
+ * `--from-default-profile web` 只在租户 home 里还没有该 profile 时加(首次启动时初始化)。
16
+ *
17
+ * @module dsh-plugin-remote-connect-beta/core/instance
18
+ */
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+ import { spawn } from 'node:child_process'
23
+ import { translator } from './messages.js'
24
+
25
+ const tEn = translator('en')
26
+
27
+ /** 子进程 stdout 里的就绪行:`dsh web: http://127.0.0.1:8791/?token=…` */
28
+ const READY_PATTERN = /dsh web:\s*(http:\/\/[^\s]*[?&]token=([A-Za-z0-9_-]+))/
29
+
30
+ /**
31
+ * 找 DSH 的 CLI 入口(`@deepseek-ai/dsh/lib/bin.js`)。
32
+ *
33
+ * 顺序:显式配置 → 环境变量 → 桌面版自带 → 常见全局安装位置。
34
+ * 找不到就返回 null 并给出候选,让面板/CLI 能说人话,而不是抛一个 ENOENT。
35
+ *
36
+ * @param {object} [options]
37
+ * @param {string} [options.configured] 配置里显式给的路径
38
+ * @param {string} [options.env=process.env.DSH_HARNESS_BIN]
39
+ * @param {string} [options.appPath] DSH Desktop 安装路径(可覆盖,便于测试)
40
+ * @returns {{ bin: string, source: string } | null}
41
+ */
42
+ export function discoverHarnessBin(options = {}) {
43
+ const candidates = []
44
+ const configured = typeof options.configured === 'string' ? options.configured.trim() : ''
45
+ if (configured !== '') candidates.push({ bin: configured, source: 'config' })
46
+ const fromEnv = typeof options.env === 'string' ? options.env.trim() : ''
47
+ if (fromEnv !== '') candidates.push({ bin: fromEnv, source: 'env' })
48
+ const appPath = options.appPath ?? '/Applications/DSH Desktop.app/Contents/Resources/app'
49
+ candidates.push({
50
+ bin: path.join(appPath, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
51
+ source: 'desktop-app',
52
+ })
53
+ candidates.push({ bin: '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js', source: 'npm-global' })
54
+ candidates.push({ bin: '/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/lib/bin.js', source: 'npm-global' })
55
+
56
+ for (const candidate of candidates) {
57
+ try {
58
+ if (fs.existsSync(candidate.bin)) return candidate
59
+ } catch {
60
+ /* 继续找下一个 */
61
+ }
62
+ }
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * 找真正能跑 Harness 的 runtime。
68
+ *
69
+ * ⚠️ 关键坑:DSH Desktop 会把自己的 `node` shim(`ELECTRON_RUN_AS_NODE=1` + Electron Helper)
70
+ * 放进 PATH。它是 Electron,会**拒绝** `NODE_OPTIONS` 环境变量;用它跑 Harness 必须把
71
+ * `--expose-internals` 当**命令行参数**传(桌面应用自己就是这么做的)。所以这里优先找真 node,
72
+ * shim 只作为最后的兜底。
73
+ *
74
+ * @param {object} [options]
75
+ * @param {string} [options.configured]
76
+ * @param {NodeJS.ProcessEnv} [options.env=process.env]
77
+ * @returns {{ node: string, source: string, electron: boolean }}
78
+ */
79
+ export function discoverRuntime(options = {}) {
80
+ const env = options.env ?? process.env
81
+ const configured = typeof options.configured === 'string' ? options.configured.trim() : ''
82
+ const pick = (value, source) => ({ node: value, source, electron: /Helper|Electron/i.test(value) })
83
+ if (configured !== '') return pick(configured, 'config')
84
+ if (typeof env.DSH_TENANT_NODE === 'string' && env.DSH_TENANT_NODE.trim() !== '') {
85
+ return pick(env.DSH_TENANT_NODE.trim(), 'env')
86
+ }
87
+ for (const candidate of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) {
88
+ try {
89
+ if (fs.existsSync(candidate)) return pick(candidate, 'path')
90
+ } catch {
91
+ /* 继续 */
92
+ }
93
+ }
94
+ // 兜底:本进程自己(若宿主是 Electron,则要用命令行参数传 --expose-internals)
95
+ const self = process.execPath
96
+ const shim = path.join(env.HOME ?? os.homedir(), 'Library', 'Application Support', 'dsh-desktop', 'harness', '.desktop-bin', 'node')
97
+ if (/Helper|Electron/i.test(self)) return pick(self, 'self')
98
+ try {
99
+ if (fs.existsSync(shim)) return pick(shim, 'desktop-shim')
100
+ } catch {
101
+ /* 忽略 */
102
+ }
103
+ return pick(self, 'self')
104
+ }
105
+
106
+ /**
107
+ * 组装启动参数(纯函数,便于测试)。
108
+ * @param {object} options
109
+ * @param {string} options.bin dsh 入口
110
+ * @param {string} options.profile profile 名(= 租户 id)
111
+ * @param {number} options.port 0 = 让系统分配
112
+ * @param {boolean} options.initialize 是否加 `--from-default-profile web`
113
+ * @param {boolean} [options.electron] runtime 是不是 Electron Helper
114
+ * @param {string[]} [options.extraArgs]
115
+ */
116
+ export function buildInstanceArgv(options) {
117
+ const argv = []
118
+ // Electron Helper 必须拿到这个参数(NODE_OPTIONS 会被它拒绝);真 node 给上也无害
119
+ if (options.electron !== false) argv.push('--expose-internals')
120
+ argv.push(options.bin)
121
+ argv.push('--profile', options.profile)
122
+ if (options.initialize) argv.push('--from-default-profile', 'web')
123
+ argv.push('--no-open', '--host', '127.0.0.1', '--port', String(options.port))
124
+ for (const extra of options.extraArgs ?? []) argv.push(extra)
125
+ return argv
126
+ }
127
+
128
+ /** 从一行 stdout 里取就绪地址与令牌。 */
129
+ export function parseReadyLine(text) {
130
+ const match = READY_PATTERN.exec(String(text ?? ''))
131
+ if (match === null) return null
132
+ let port = 0
133
+ try {
134
+ port = Number(new URL(match[1]).port)
135
+ } catch {
136
+ port = 0
137
+ }
138
+ return { url: match[1], token: match[2], port: Number.isSafeInteger(port) ? port : 0 }
139
+ }
140
+
141
+ /**
142
+ * 一个租户实例。
143
+ *
144
+ * @param {object} options
145
+ * @param {object} options.tenant 归一化后的租户定义
146
+ * @param {{ bin: string, node: string, electron?: boolean, extraArgs?: string[] }} options.harness
147
+ * @param {number} [options.restartBaseMs=1000] 退避基数
148
+ * @param {number} [options.maxRestarts=5] 连续崩溃上限,超过则停手(等人处理)
149
+ * @param {(state: object) => void} [options.onState]
150
+ * @param {(line: string) => void} [options.log]
151
+ * @param {string} [options.logFile] 实例输出日志(默认 `<home>/instance.log`)。
152
+ * 写它的原因:租户起不来时运维需要一个能看的地方,e2e 也要能从里面取启动令牌。
153
+ * @param {typeof spawn} [options.spawnImpl] 测试用
154
+ */
155
+ export function createInstance(options) {
156
+ const tenant = options.tenant
157
+ const harness = options.harness
158
+ const spawnImpl = options.spawnImpl ?? spawn
159
+ const log = options.log ?? (() => {})
160
+ const onState = options.onState ?? (() => {})
161
+ const restartBaseMs = options.restartBaseMs ?? 1000
162
+ const maxRestarts = options.maxRestarts ?? 5
163
+ const logFile = options.logFile ?? path.join(tenant.home, 'instance.log')
164
+ const logLimitBytes = options.logLimitBytes ?? 5 * 1024 * 1024
165
+
166
+ let child = null
167
+ let token = ''
168
+ let port = tenant.port
169
+ let stopped = false
170
+ let restarts = 0
171
+ let restartTimer = null
172
+ let state = { id: tenant.id, phase: 'idle', code: 'tenant.idle', params: {}, detail: tEn('tenant.idle'), port, hasToken: false, restarts: 0, pid: null }
173
+
174
+ function setState(patch) {
175
+ const next = { ...state, ...patch }
176
+ if (patch.code !== undefined && patch.detail === undefined) next.detail = tEn(patch.code, patch.params)
177
+ state = next
178
+ onState(state)
179
+ }
180
+
181
+ function profileExists() {
182
+ try {
183
+ return fs.existsSync(path.join(tenant.home, 'profiles', tenant.profile))
184
+ } catch {
185
+ return false
186
+ }
187
+ }
188
+
189
+ /** 追加一行到实例日志;超过上限就滚动一次,避免无限长大。 */
190
+ function appendLog(text) {
191
+ try {
192
+ fs.mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 })
193
+ try {
194
+ if (fs.statSync(logFile).size > logLimitBytes) fs.renameSync(logFile, logFile + '.1')
195
+ } catch {
196
+ /* 不存在就无所谓 */
197
+ }
198
+ fs.appendFileSync(logFile, text, { mode: 0o600 })
199
+ } catch {
200
+ /* 日志写不进去不影响实例本身 */
201
+ }
202
+ }
203
+
204
+ function launch() {
205
+ if (stopped) return
206
+ fs.mkdirSync(tenant.home, { recursive: true, mode: 0o700 })
207
+ const initialize = !profileExists()
208
+ const argv = buildInstanceArgv({
209
+ bin: harness.bin,
210
+ profile: tenant.profile,
211
+ port: tenant.port,
212
+ initialize,
213
+ electron: harness.electron !== false,
214
+ extraArgs: harness.extraArgs,
215
+ })
216
+ setState({
217
+ phase: 'starting',
218
+ code: 'tenant.starting',
219
+ params: { id: tenant.id, port: String(tenant.port) },
220
+ pid: null,
221
+ hasToken: false,
222
+ })
223
+ appendLog('# launch ' + new Date().toISOString() + ' ' + JSON.stringify(argv) + '\n')
224
+ let spawned
225
+ try {
226
+ spawned = spawnImpl(harness.node, argv, {
227
+ cwd: tenant.home,
228
+ env: { ...process.env, DSH_HOME: tenant.home, NO_COLOR: '1' },
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ })
231
+ } catch (error) {
232
+ scheduleRestart(tEn('tenant.spawnFailed', { message: String(error?.message ?? error) }))
233
+ return
234
+ }
235
+ child = spawned
236
+ setState({ pid: spawned.pid ?? null })
237
+ let stderr = ''
238
+ const onText = (text, fromStderr) => {
239
+ const ready = parseReadyLine(text)
240
+ if (ready === null) return
241
+ token = ready.token
242
+ if (ready.port > 0) port = ready.port
243
+ setState({
244
+ phase: 'up',
245
+ code: 'tenant.up',
246
+ params: { id: tenant.id, port: String(port) },
247
+ port,
248
+ hasToken: true,
249
+ pid: spawned.pid ?? null,
250
+ })
251
+ log(tEn('tenant.ready', { id: tenant.id, url: 'http://127.0.0.1:' + String(port) + '/' }))
252
+ void fromStderr
253
+ }
254
+ spawned.stdout.on('data', (chunk) => {
255
+ appendLog(chunk.toString('utf8'))
256
+ onText(chunk.toString('utf8'), false)
257
+ })
258
+ spawned.stderr.on('data', (chunk) => {
259
+ const text = chunk.toString('utf8')
260
+ appendLog(text)
261
+ stderr = (stderr + text).slice(-4000)
262
+ onText(text, true)
263
+ })
264
+ spawned.on('error', (error) => {
265
+ scheduleRestart(tEn('tenant.spawnFailed', { message: String(error?.message ?? error) }))
266
+ })
267
+ spawned.on('exit', (code, signal) => {
268
+ if (child !== spawned) return
269
+ child = null
270
+ token = ''
271
+ if (stopped) {
272
+ setState({ phase: 'stopped', code: 'tenant.stopped', params: { id: tenant.id }, hasToken: false, pid: null })
273
+ return
274
+ }
275
+ const tail = stderr.trim().split('\n').slice(-2).join(' | ')
276
+ scheduleRestart(
277
+ 'exit ' + (signal ?? String(code)) + (tail ? ': ' + tail : ''),
278
+ )
279
+ })
280
+ }
281
+
282
+ function scheduleRestart(reason) {
283
+ if (stopped) return
284
+ restarts += 1
285
+ if (restarts > maxRestarts) {
286
+ setState({
287
+ phase: 'error',
288
+ code: 'tenant.crashed',
289
+ params: { id: tenant.id, reason },
290
+ hasToken: false,
291
+ restarts,
292
+ })
293
+ return
294
+ }
295
+ const delay = Math.min(restartBaseMs * 2 ** (restarts - 1), 30000)
296
+ setState({
297
+ phase: 'restarting',
298
+ code: 'tenant.restarting',
299
+ params: { id: tenant.id, reason, seconds: String(Math.round(delay / 1000)) },
300
+ hasToken: false,
301
+ restarts,
302
+ })
303
+ restartTimer = setTimeout(() => {
304
+ restartTimer = null
305
+ launch()
306
+ }, delay)
307
+ }
308
+
309
+ return {
310
+ tenantId: tenant.id,
311
+ /** 启动(首次会初始化该租户的 profile)。 */
312
+ start() {
313
+ stopped = false
314
+ restarts = 0
315
+ launch()
316
+ return state
317
+ },
318
+ /** 停止并等子进程退出。 */
319
+ async stop() {
320
+ stopped = true
321
+ if (restartTimer !== null) {
322
+ clearTimeout(restartTimer)
323
+ restartTimer = null
324
+ }
325
+ const current = child
326
+ child = null
327
+ token = ''
328
+ if (current !== null) {
329
+ current.kill('SIGTERM')
330
+ await new Promise((resolve) => {
331
+ const timer = setTimeout(() => {
332
+ try {
333
+ current.kill('SIGKILL')
334
+ } catch {
335
+ /* 已退出 */
336
+ }
337
+ resolve()
338
+ }, 4000)
339
+ current.once('exit', () => {
340
+ clearTimeout(timer)
341
+ resolve()
342
+ })
343
+ })
344
+ }
345
+ setState({ phase: 'stopped', code: 'tenant.stopped', params: { id: tenant.id }, hasToken: false, pid: null })
346
+ return state
347
+ },
348
+ /** 该租户当前的启动令牌(没起来时为空串)。 */
349
+ token() {
350
+ return token
351
+ },
352
+ /** 上游端口(`port: 0` 时以子进程实际打印的为准)。 */
353
+ upstreamPort() {
354
+ return port
355
+ },
356
+ state() {
357
+ return state
358
+ },
359
+ }
360
+ }