dsh-clean-desktop-shell 0.1.11 → 0.1.12

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.
@@ -1,458 +1,521 @@
1
- /**
2
- * Local dsh web backend supervision.
3
- *
4
- * The shell owns the lifecycle of the dsh backend from the tray:
5
- * - detect(): probe configured paths + default port
6
- * - start() / stop() / restart()
7
- * - status: 'running' | 'stopped' | 'starting' | 'error'
8
- *
9
- * Shell/core decoupling: when a remote target URL is configured, no local
10
- * backend is ever touched — this module only manages the local dsh CLI.
11
- */
12
- import { spawn } from 'node:child_process'
13
- import { access } from 'node:fs'
14
- import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
16
- import { loadConfig, DEFAULT_TARGET_URL } from './config.js'
17
-
18
- // The local backend endpoint — derived from the shared default, never a
19
- // duplicated literal.
20
- const LOCAL_URL = DEFAULT_TARGET_URL
21
- const DEFAULT_PORT = Number(new URL(DEFAULT_TARGET_URL).port) || 80
22
-
23
- // Long-lived backend output must not accumulate without bound — keep the
24
- // tail only (enough for ready-line matching and diagnostics).
25
- const OUTPUT_CAP = 64 * 1024
26
-
27
- function cappedAppend(prev, chunk) {
28
- const s = prev + chunk
29
- return s.length > OUTPUT_CAP ? s.slice(-OUTPUT_CAP) : s
30
- }
31
-
32
- let child = null
33
- let currentStatus = 'stopped'
34
- let lastError = null
35
- let startResolver = null
36
-
37
- // Status-change listeners (tray menu auto-refresh, window auto-reload, ...).
38
- const listeners = new Set()
39
-
40
- /** Subscribe to backend status changes. Returns an unsubscribe function. */
41
- export function onStatusChange(cb) {
42
- listeners.add(cb)
43
- return () => listeners.delete(cb)
44
- }
45
-
46
- function setStatus(next, error = null) {
47
- if (currentStatus !== next || lastError !== error) {
48
- currentStatus = next
49
- lastError = error
50
- for (const cb of listeners) {
51
- try {
52
- cb(getStatus())
53
- } catch {
54
- // listener errors must not break the state machine
55
- }
56
- }
57
- }
58
- }
59
-
60
- export function getStatus() {
61
- return {
62
- status: currentStatus,
63
- port: DEFAULT_PORT,
64
- url: LOCAL_URL,
65
- error: lastError,
66
- pid: child?.pid ?? null,
67
- }
68
- }
69
-
70
- /** Simple HTTP probe — true when something responds on the port. */
71
- export async function probe(url, timeoutMs = 1500) {
72
- try {
73
- // AbortSignal.timeout is the standard self-cleaning timeout — no manual
74
- // controller/timer pair to leak when fetch rejects first.
75
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' })
76
- return res.status < 500
77
- } catch {
78
- return false
79
- }
80
- }
81
-
82
- /**
83
- * Detect a reachable local dsh web service.
84
- * Returns the URL when something already listens on the port,
85
- * or null when the backend is down.
86
- */
87
- export async function detect() {
88
- const up = await probe(LOCAL_URL)
89
- if (up) {
90
- setStatus('running')
91
- return LOCAL_URL
92
- }
93
- setStatus('stopped')
94
- return null
95
- }
96
-
97
- /**
98
- * Start the local dsh backend. Resolves once the service answers
99
- * (or a spawned CLI prints its ready line). Throws on failure.
100
- */
101
- export async function start({ backendPath } = {}) {
102
- // Already up?
103
- const up = await detect()
104
- if (up) return up
105
-
106
- // Backend path: explicit config → common locations → PATH.
107
- const resolved = await resolveDshCommand(backendPath)
108
- if (!resolved) {
109
- setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
110
- throw new Error(lastError)
111
- }
112
-
113
- setStatus('starting')
114
-
115
- // Windows: .cmd/.bat cannot be spawned directly — EINVAL. Route them
116
- // through the shell so `dsh.cmd web` behaves like a normal terminal.
117
- const isCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command)
118
- const command = isCmd ? `"${resolved.command}"` : resolved.command
119
-
120
- let spawned = null
121
- try {
122
- spawned = spawn(command, [...resolved.args, 'web'], {
123
- stdio: ['ignore', 'pipe', 'pipe'],
124
- windowsHide: true,
125
- shell: isCmd,
126
- env: { ...process.env, ...(resolved.env || {}) },
127
- })
128
- } catch (err) {
129
- // Synchronous spawn failures (e.g. EINVAL on Windows) must leave the
130
- // state machine in 'error' — otherwise the tray is stuck on 'starting'.
131
- setStatus('error', `后端启动失败:${err.message}`)
132
- throw new Error(lastError)
133
- }
134
- child = spawned
135
-
136
- child.on('error', (err) => {
137
- setStatus('error', err.message)
138
- if (startResolver) {
139
- const r = startResolver
140
- startResolver = null
141
- r.reject(new Error(lastError))
142
- }
143
- })
144
-
145
- child.on('exit', (code) => {
146
- child = null
147
- if (currentStatus === 'starting' && startResolver) {
148
- const r = startResolver
149
- startResolver = null
150
- setStatus('error', `dsh 后端异常退出 (code ${code})`)
151
- r.reject(new Error(lastError))
152
- } else if (currentStatus !== 'stopped') {
153
- setStatus('stopped')
154
- }
155
- })
156
-
157
- // Wait for readiness: ready line on stdout/stderr, or the port answering.
158
- return await new Promise((resolve, reject) => {
159
- startResolver = { resolve, reject }
160
- let stdout = ''
161
- let stderr = ''
162
- const timer = setTimeout(async () => {
163
- if (!startResolver) return
164
- const r = startResolver
165
- startResolver = null
166
- // Timeout does not mean failure — the port may already be up while
167
- // the CLI never printed a parseable URL. Probe before giving up.
168
- if (await probe(LOCAL_URL, 1500)) {
169
- setStatus('running')
170
- r.resolve(LOCAL_URL)
171
- return
172
- }
173
- terminate(child)
174
- setStatus('error', 'dsh 后端启动超时(45s)')
175
- r.reject(new Error(lastError))
176
- }, 45000)
177
-
178
- const onData = () => {
179
- const text = stdout + stderr
180
- const m = text.match(/http:\/\/127\.0\.0\.1:(\d+)/)
181
- if (m && startResolver) {
182
- clearTimeout(timer)
183
- const r = startResolver
184
- startResolver = null
185
- const url = `http://127.0.0.1:${m[1]}`
186
- setStatus('running')
187
- r.resolve(url)
188
- }
189
- }
190
- child.stdout.on('data', (d) => {
191
- stdout = cappedAppend(stdout, d.toString())
192
- onData()
193
- })
194
- child.stderr.on('data', (d) => {
195
- stderr = cappedAppend(stderr, d.toString())
196
- onData()
197
- })
198
- })
199
- }
200
-
201
- /**
202
- * Stop the backend.
203
- *
204
- * - If the backend was spawned by us, terminate our child (tree kill — a
205
- * shell-spawned .cmd can leave orphan node processes behind).
206
- * - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
207
- * find the process listening on the port and terminate it, but only when
208
- * it looks like a node-based backend, never an unrelated program.
209
- */
210
- export async function stop() {
211
- if (child) {
212
- const proc = child
213
- child = null
214
- setStatus('stopped')
215
- await terminate(proc)
216
- await ensurePortFree(DEFAULT_PORT)
217
- return
218
- }
219
-
220
- // External instance: locate it by port and kill (node-only guard).
221
- const pid = await findProcessOnPort(DEFAULT_PORT)
222
- if (pid) {
223
- const name = await processName(pid)
224
- if (name && /node/i.test(name)) {
225
- await killProcess(pid)
226
- await ensurePortFree(DEFAULT_PORT)
227
- }
228
- }
229
- setStatus('stopped')
230
- }
231
-
232
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
233
-
234
- /**
235
- * Terminate a spawned backend and wait for it to exit.
236
- *
237
- * On Windows `proc.kill()` only reaches the direct child — when the command
238
- * is a .cmd shim, the node process it launched survives and keeps holding
239
- * the port. `taskkill /T` (tree kill) is the recognized fix. POSIX: SIGTERM
240
- * first, SIGKILL after a short grace period.
241
- */
242
- async function terminate(proc) {
243
- if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
244
- const exited = new Promise((resolve) => proc.once('exit', resolve))
245
- if (process.platform === 'win32' && proc.pid) {
246
- await killProcess(proc.pid)
247
- } else {
248
- try {
249
- proc.kill('SIGTERM')
250
- } catch {
251
- // already gone
252
- }
253
- }
254
- const done = await Promise.race([exited.then(() => true), sleep(3000).then(() => false)])
255
- if (!done && process.platform !== 'win32') {
256
- try {
257
- proc.kill('SIGKILL')
258
- } catch {
259
- // already gone
260
- }
261
- await Promise.race([exited, sleep(1000)])
262
- }
263
- }
264
-
265
- /** Wait until nothing answers on the port (or give up after ~6s). */
266
- async function ensurePortFree(port, tries = 12) {
267
- const url = `http://127.0.0.1:${port}`
268
- for (let i = 0; i < tries; i++) {
269
- if (!(await probe(url, 500))) return true
270
- await sleep(500)
271
- }
272
- return !(await probe(url, 500))
273
- }
274
-
275
- /** Find the PID listening on a TCP port (netstat on Windows, lsof elsewhere). */
276
- function findProcessOnPort(port) {
277
- return new Promise((resolve) => {
278
- const isWin = process.platform === 'win32'
279
- const cmd = isWin ? 'netstat' : 'lsof'
280
- const args = isWin ? ['-ano'] : ['-ti', `:${port}`]
281
- const p = spawn(cmd, args, { windowsHide: true })
282
- let out = ''
283
- p.stdout.on('data', (d) => {
284
- out += d.toString()
285
- })
286
- p.on('error', () => resolve(null))
287
- p.on('exit', () => {
288
- if (isWin) {
289
- const re = new RegExp(`\\bTCP\\s+[^\\s]*:${port}\\s+[^\\s]*\\s+LISTENING\\s+(\\d+)`)
290
- const m = out.match(re)
291
- resolve(m ? m[1] : null)
292
- } else {
293
- const pid = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
294
- resolve(pid || null)
295
- }
296
- })
297
- })
298
- }
299
-
300
- /** Process image name for a PID (Windows tasklist; null elsewhere). */
301
- function processName(pid) {
302
- return new Promise((resolve) => {
303
- if (process.platform !== 'win32') {
304
- resolve(null)
305
- return
306
- }
307
- const p = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
308
- windowsHide: true,
309
- })
310
- let out = ''
311
- p.stdout.on('data', (d) => {
312
- out += d.toString()
313
- })
314
- p.on('error', () => resolve(null))
315
- p.on('exit', () => {
316
- // CSV row: "image name","pid","session name",...
317
- const m = out.match(/"([^"]+)","(\d+)"/)
318
- resolve(m ? m[1] : null)
319
- })
320
- })
321
- }
322
-
323
- /** Force-kill a process (taskkill on Windows, kill -9 elsewhere). */
324
- function killProcess(pid) {
325
- return new Promise((resolve) => {
326
- const isWin = process.platform === 'win32'
327
- const cmd = isWin ? 'taskkill' : 'kill'
328
- const args = isWin ? ['/PID', String(pid), '/F', '/T'] : ['-9', String(pid)]
329
- const p = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
330
- p.on('error', () => resolve(false))
331
- p.on('exit', () => resolve(true))
332
- })
333
- }
334
-
335
- /** Restart the local backend. */
336
- export async function restart(options) {
337
- await stop()
338
- return await start(options)
339
- }
340
-
341
- /**
342
- * Check whether a folder contains a usable dsh CLI.
343
- * Returns the resolved executable path, or null.
344
- */
345
- export async function findDshInFolder(folder) {
346
- if (!folder) return null
347
- const candidates = [
348
- joinCmd(folder, 'dsh.cmd'),
349
- joinCmd(folder, 'dsh'),
350
- joinCmd(folder, 'bin', 'dsh.cmd'),
351
- joinCmd(folder, 'node_modules', '.bin', 'dsh.cmd'),
352
- ]
353
- for (const c of candidates) {
354
- if (await exists(c)) return c
355
- }
356
- return null
357
- }
358
-
359
- /**
360
- * Candidate dsh CLI locations beyond the explicit config path:
361
- * the DSH_BACKEND_DIR env var (documented, machine-independent) and the
362
- * npm global bin dir. The previous list carried a developer-specific
363
- * absolute path — removed in favor of config (backendPath) and env.
364
- */
365
- function fallbackCandidates() {
366
- const list = []
367
- const envDir = process.env.DSH_BACKEND_DIR
368
- if (envDir) list.push(join(envDir, 'dsh.cmd'), join(envDir, 'dsh'))
369
- if (process.platform === 'win32') {
370
- list.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'dsh.cmd'))
371
- } else {
372
- list.push('/usr/local/bin/dsh', join(homedir(), '.local', 'bin', 'dsh'))
373
- }
374
- return list
375
- }
376
-
377
- /**
378
- * Auto-detect the dsh install folder (the folder that contains the dsh
379
- * CLI), following the same resolution order used to start the backend:
380
- * config → DSH_BACKEND_DIR → PATH → npm global. Returns the folder or null.
381
- */
382
- export async function detectInstallFolder() {
383
- // 1) Explicit backend folder from config.
384
- const cfgPath = getConfiguredBackendPath()
385
- if (cfgPath) {
386
- const found = await findDshInFolder(cfgPath)
387
- if (found) return dirOf(found)
388
- }
389
-
390
- // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
391
- for (const c of fallbackCandidates()) {
392
- if (await exists(c)) return dirOf(c)
393
- }
394
-
395
- // 3) `dsh` on PATH → resolve where it actually lives.
396
- const onPath = await commandPath('dsh')
397
- if (onPath) return dirOf(onPath)
398
- return null
399
- }
400
-
401
- /** Locate a usable dsh CLI. */
402
- async function resolveDshCommand(backendPath) {
403
- // 1) Explicit backend folder from config — look for dsh(.cmd/.exe) inside.
404
- if (backendPath) {
405
- const found = await findDshInFolder(backendPath)
406
- if (found) return { command: found, args: [] }
407
- }
408
-
409
- // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
410
- for (const c of fallbackCandidates()) {
411
- if (await exists(c)) return { command: c, args: [] }
412
- }
413
-
414
- // 3) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
415
- // shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
416
- const onPath = await commandPath('dsh')
417
- if (onPath) {
418
- return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
419
- }
420
- return null
421
- }
422
-
423
- function joinCmd(...parts) {
424
- return parts.join(process.platform === 'win32' ? '\\' : '/')
425
- }
426
-
427
- function dirOf(p) {
428
- return dirname(p)
429
- }
430
-
431
- function getConfiguredBackendPath() {
432
- try {
433
- return loadConfig().backendPath || null
434
- } catch {
435
- return null
436
- }
437
- }
438
-
439
- /** Resolve a command on PATH to its absolute path (Windows: where.exe). */
440
- function commandPath(cmd) {
441
- return new Promise((resolve) => {
442
- const finder = process.platform === 'win32' ? 'where' : 'which'
443
- const c = spawn(finder, [cmd], { windowsHide: true })
444
- let out = ''
445
- c.stdout.on('data', (d) => {
446
- out += d.toString()
447
- })
448
- c.on('error', () => resolve(null))
449
- c.on('exit', () => {
450
- const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
451
- resolve(line || null)
452
- })
453
- })
454
- }
455
-
456
- function exists(p) {
457
- return new Promise((resolve) => access(p, (err) => resolve(!err)))
458
- }
1
+ /**
2
+ * Local dsh web backend supervision.
3
+ *
4
+ * The shell owns the lifecycle of the dsh backend from the tray:
5
+ * - detect(): probe configured paths + default port
6
+ * - start() / stop() / restart()
7
+ * - status: 'running' | 'stopped' | 'starting' | 'error'
8
+ *
9
+ * Shell/core decoupling: when a remote target URL is configured, no local
10
+ * backend is ever touched — this module only manages the local dsh CLI.
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+ import { access } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { loadConfig, DEFAULT_TARGET_URL } from './config.js'
17
+
18
+ // The local backend endpoint — derived from the shared default, never a
19
+ // duplicated literal.
20
+ const LOCAL_URL = DEFAULT_TARGET_URL
21
+ const DEFAULT_PORT = Number(new URL(DEFAULT_TARGET_URL).port) || 80
22
+
23
+ // Long-lived backend output must not accumulate without bound — keep the
24
+ // tail only (enough for ready-line matching and diagnostics).
25
+ const OUTPUT_CAP = 64 * 1024
26
+
27
+ function cappedAppend(prev, chunk) {
28
+ const s = prev + chunk
29
+ return s.length > OUTPUT_CAP ? s.slice(-OUTPUT_CAP) : s
30
+ }
31
+
32
+ let child = null
33
+ let currentStatus = 'stopped'
34
+ let lastError = null
35
+ let startResolver = null
36
+
37
+ // dsh 0.1.2+ gates the web index behind a per-process launch token: the
38
+ // startup line reads `dsh web: http://127.0.0.1:3080/?token=…`, and a loopback
39
+ // GET without that token (or the session cookie it mints) is answered 401, so
40
+ // the window used to come up blank. Two independent sources feed this state:
41
+ //
42
+ // 1. DSH_WEB_LAUNCH_URL — set by the DSH host half when the shell is launched
43
+ // from inside `dsh web` (plugin mode). This is the only way to reach the
44
+ // token of a backend this shell did not start, and it is why the plugin
45
+ // form works at all.
46
+ // 2. the ready banner printed by a backend this shell started itself.
47
+ //
48
+ // In-memory only: the launch URL is a per-process bootstrap secret, it rotates
49
+ // on every backend start, and it must never be persisted to config or disk.
50
+ const envLaunchUrl = process.env.DSH_WEB_LAUNCH_URL
51
+ let currentLaunchUrl = envLaunchUrl && /^https?:\/\//i.test(envLaunchUrl) ? envLaunchUrl : null
52
+
53
+ /** The launch URL of the backend this shell is bound to, or null. */
54
+ export function getAuthenticatedUrl() {
55
+ return currentLaunchUrl
56
+ }
57
+
58
+ // Status-change listeners (tray menu auto-refresh, window auto-reload, ...).
59
+ const listeners = new Set()
60
+
61
+ /** Subscribe to backend status changes. Returns an unsubscribe function. */
62
+ export function onStatusChange(cb) {
63
+ listeners.add(cb)
64
+ return () => listeners.delete(cb)
65
+ }
66
+
67
+ function setStatus(next, error = null) {
68
+ if (currentStatus !== next || lastError !== error) {
69
+ currentStatus = next
70
+ lastError = error
71
+ for (const cb of listeners) {
72
+ try {
73
+ cb(getStatus())
74
+ } catch {
75
+ // listener errors must not break the state machine
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ export function getStatus() {
82
+ return {
83
+ status: currentStatus,
84
+ port: DEFAULT_PORT,
85
+ url: LOCAL_URL,
86
+ launchUrl: currentLaunchUrl,
87
+ error: lastError,
88
+ pid: child?.pid ?? null,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * HTTP probe that separates “something is listening” from “the web UI is
94
+ * authenticated/ready”. A bare 401 means the backend is alive but the shell
95
+ * still needs a launch-URL bootstrap, not that the real page is usable.
96
+ */
97
+ export async function probeState(url, timeoutMs = 1500) {
98
+ try {
99
+ // AbortSignal.timeout is the standard self-cleaning timeout — no manual
100
+ // controller/timer pair to leak when fetch rejects first.
101
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' })
102
+ return {
103
+ alive: res.status < 500,
104
+ authenticated: res.status !== 401,
105
+ status: res.status,
106
+ }
107
+ } catch {
108
+ return {
109
+ alive: false,
110
+ authenticated: false,
111
+ status: null,
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Simple HTTP probe — true when something responds on the port. */
117
+ export async function probe(url, timeoutMs = 1500) {
118
+ return (await probeState(url, timeoutMs)).alive
119
+ }
120
+
121
+ /**
122
+ * Detect a reachable local dsh web service.
123
+ * Returns the URL when something already listens on the port,
124
+ * or null when the backend is down.
125
+ */
126
+ export async function detect() {
127
+ const state = await probeState(LOCAL_URL)
128
+ if (state.alive) {
129
+ setStatus('running')
130
+ return LOCAL_URL
131
+ }
132
+ // The old process token (if any) belongs to a backend that is no longer
133
+ // listening. Keep it out of the next backend's bootstrap.
134
+ currentLaunchUrl = null
135
+ setStatus('stopped')
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Start the local dsh backend. Resolves once the service answers
141
+ * (or a spawned CLI prints its ready line). Throws on failure.
142
+ */
143
+ export async function start({ backendPath } = {}) {
144
+ // Already up? Two very different situations hide behind this:
145
+ // - plugin mode: the DSH host half started this very backend and handed us
146
+ // its launch URL through DSH_WEB_LAUNCH_URL — that credential is still
147
+ // valid, so it must survive here (only detect() clears it, and only when
148
+ // nothing answers on the port at all).
149
+ // - a backend started outside the shell: its banner went to someone else's
150
+ // terminal, so we simply have no token for it.
151
+ const up = await detect()
152
+ if (up) return up
153
+
154
+ // Backend path: explicit config → common locations → PATH.
155
+ const resolved = await resolveDshCommand(backendPath)
156
+ if (!resolved) {
157
+ setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
158
+ throw new Error(lastError)
159
+ }
160
+
161
+ setStatus('starting')
162
+
163
+ // Windows: .cmd/.bat cannot be spawned directly — EINVAL. Route them
164
+ // through the shell so `dsh.cmd web` behaves like a normal terminal.
165
+ const isCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command)
166
+ const command = isCmd ? `"${resolved.command}"` : resolved.command
167
+
168
+ let spawned = null
169
+ try {
170
+ spawned = spawn(command, [...resolved.args, 'web'], {
171
+ stdio: ['ignore', 'pipe', 'pipe'],
172
+ windowsHide: true,
173
+ shell: isCmd,
174
+ env: { ...process.env, ...(resolved.env || {}) },
175
+ })
176
+ } catch (err) {
177
+ // Synchronous spawn failures (e.g. EINVAL on Windows) must leave the
178
+ // state machine in 'error' — otherwise the tray is stuck on 'starting'.
179
+ setStatus('error', `后端启动失败:${err.message}`)
180
+ throw new Error(lastError)
181
+ }
182
+ child = spawned
183
+
184
+ child.on('error', (err) => {
185
+ setStatus('error', err.message)
186
+ if (startResolver) {
187
+ const r = startResolver
188
+ startResolver = null
189
+ r.reject(new Error(lastError))
190
+ }
191
+ })
192
+
193
+ child.on('exit', (code) => {
194
+ child = null
195
+ currentLaunchUrl = null
196
+ if (currentStatus === 'starting' && startResolver) {
197
+ const r = startResolver
198
+ startResolver = null
199
+ setStatus('error', `dsh 后端异常退出 (code ${code})`)
200
+ r.reject(new Error(lastError))
201
+ } else if (currentStatus !== 'stopped') {
202
+ setStatus('stopped')
203
+ }
204
+ })
205
+
206
+ // Wait for readiness: ready line on stdout/stderr, or the port answering.
207
+ return await new Promise((resolve, reject) => {
208
+ startResolver = { resolve, reject }
209
+ let stdout = ''
210
+ let stderr = ''
211
+ const timer = setTimeout(async () => {
212
+ if (!startResolver) return
213
+ const r = startResolver
214
+ startResolver = null
215
+ // Timeout does not mean failure — the port may already be up while
216
+ // the CLI never printed a parseable URL. Probe before giving up.
217
+ if (await probe(LOCAL_URL, 1500)) {
218
+ setStatus('running')
219
+ r.resolve(LOCAL_URL)
220
+ return
221
+ }
222
+ terminate(child)
223
+ setStatus('error', 'dsh 后端启动超时(45s)')
224
+ r.reject(new Error(lastError))
225
+ }, 45000)
226
+
227
+ const onData = () => {
228
+ const text = stdout + stderr
229
+ // Preferred: the exact `dsh web: http://127.0.0.1:<port>/?token=…`
230
+ // banner. Anchoring on the banner and requiring the token means we only
231
+ // ever adopt a credential the backend actually handed us, and only from
232
+ // loopback — a launch token is a local-process secret and must never be
233
+ // taken from a remote host or from an unrelated line in the buffer.
234
+ const tokened = text.match(/dsh web:\s+(https?:\/\/127\.0\.0\.1:\d+\/\?token=[^\s]+)/)
235
+ // Fallback for pre-0.1.2 backends, which print a bare loopback URL and
236
+ // need no credential at all: keep the full printed URL (query tail
237
+ // optional) so those still go ready the moment the line appears instead
238
+ // of waiting out the 45s startup timeout.
239
+ const bare = tokened ? null : text.match(/http:\/\/127\.0\.0\.1:\d+(?:\/[^\s"')]*[^\s"')]?)?/)
240
+ const url = tokened ? tokened[1] : bare && bare[0]
241
+ if (url && startResolver) {
242
+ clearTimeout(timer)
243
+ const r = startResolver
244
+ startResolver = null
245
+ currentLaunchUrl = url
246
+ setStatus('running')
247
+ r.resolve(url)
248
+ }
249
+ }
250
+ child.stdout.on('data', (d) => {
251
+ stdout = cappedAppend(stdout, d.toString())
252
+ onData()
253
+ })
254
+ child.stderr.on('data', (d) => {
255
+ stderr = cappedAppend(stderr, d.toString())
256
+ onData()
257
+ })
258
+ })
259
+ }
260
+
261
+ /**
262
+ * Stop the backend.
263
+ *
264
+ * - If the backend was spawned by us, terminate our child (tree kill — a
265
+ * shell-spawned .cmd can leave orphan node processes behind).
266
+ * - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
267
+ * find the process listening on the port and terminate it, but only when
268
+ * it looks like a node-based backend, never an unrelated program.
269
+ */
270
+ export async function stop() {
271
+ // Any process launch token dies with its backend. Do not carry it into a
272
+ // future process (DSH regenerates the token on every `dsh web` start).
273
+ currentLaunchUrl = null
274
+ if (child) {
275
+ const proc = child
276
+ child = null
277
+ setStatus('stopped')
278
+ await terminate(proc)
279
+ await ensurePortFree(DEFAULT_PORT)
280
+ return
281
+ }
282
+
283
+ // External instance: locate it by port and kill (node-only guard).
284
+ const pid = await findProcessOnPort(DEFAULT_PORT)
285
+ if (pid) {
286
+ const name = await processName(pid)
287
+ if (name && /node/i.test(name)) {
288
+ await killProcess(pid)
289
+ await ensurePortFree(DEFAULT_PORT)
290
+ }
291
+ }
292
+ setStatus('stopped')
293
+ }
294
+
295
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
296
+
297
+ /**
298
+ * Terminate a spawned backend and wait for it to exit.
299
+ *
300
+ * On Windows `proc.kill()` only reaches the direct child — when the command
301
+ * is a .cmd shim, the node process it launched survives and keeps holding
302
+ * the port. `taskkill /T` (tree kill) is the recognized fix. POSIX: SIGTERM
303
+ * first, SIGKILL after a short grace period.
304
+ */
305
+ async function terminate(proc) {
306
+ if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
307
+ const exited = new Promise((resolve) => proc.once('exit', resolve))
308
+ if (process.platform === 'win32' && proc.pid) {
309
+ await killProcess(proc.pid)
310
+ } else {
311
+ try {
312
+ proc.kill('SIGTERM')
313
+ } catch {
314
+ // already gone
315
+ }
316
+ }
317
+ const done = await Promise.race([exited.then(() => true), sleep(3000).then(() => false)])
318
+ if (!done && process.platform !== 'win32') {
319
+ try {
320
+ proc.kill('SIGKILL')
321
+ } catch {
322
+ // already gone
323
+ }
324
+ await Promise.race([exited, sleep(1000)])
325
+ }
326
+ }
327
+
328
+ /** Wait until nothing answers on the port (or give up after ~6s). */
329
+ async function ensurePortFree(port, tries = 12) {
330
+ const url = `http://127.0.0.1:${port}`
331
+ for (let i = 0; i < tries; i++) {
332
+ if (!(await probe(url, 500))) return true
333
+ await sleep(500)
334
+ }
335
+ return !(await probe(url, 500))
336
+ }
337
+
338
+ /** Find the PID listening on a TCP port (netstat on Windows, lsof elsewhere). */
339
+ function findProcessOnPort(port) {
340
+ return new Promise((resolve) => {
341
+ const isWin = process.platform === 'win32'
342
+ const cmd = isWin ? 'netstat' : 'lsof'
343
+ const args = isWin ? ['-ano'] : ['-ti', `:${port}`]
344
+ const p = spawn(cmd, args, { windowsHide: true })
345
+ let out = ''
346
+ p.stdout.on('data', (d) => {
347
+ out += d.toString()
348
+ })
349
+ p.on('error', () => resolve(null))
350
+ p.on('exit', () => {
351
+ if (isWin) {
352
+ const re = new RegExp(`\\bTCP\\s+[^\\s]*:${port}\\s+[^\\s]*\\s+LISTENING\\s+(\\d+)`)
353
+ const m = out.match(re)
354
+ resolve(m ? m[1] : null)
355
+ } else {
356
+ const pid = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
357
+ resolve(pid || null)
358
+ }
359
+ })
360
+ })
361
+ }
362
+
363
+ /** Process image name for a PID (Windows tasklist; null elsewhere). */
364
+ function processName(pid) {
365
+ return new Promise((resolve) => {
366
+ if (process.platform !== 'win32') {
367
+ resolve(null)
368
+ return
369
+ }
370
+ const p = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
371
+ windowsHide: true,
372
+ })
373
+ let out = ''
374
+ p.stdout.on('data', (d) => {
375
+ out += d.toString()
376
+ })
377
+ p.on('error', () => resolve(null))
378
+ p.on('exit', () => {
379
+ // CSV row: "image name","pid","session name",...
380
+ const m = out.match(/"([^"]+)","(\d+)"/)
381
+ resolve(m ? m[1] : null)
382
+ })
383
+ })
384
+ }
385
+
386
+ /** Force-kill a process (taskkill on Windows, kill -9 elsewhere). */
387
+ function killProcess(pid) {
388
+ return new Promise((resolve) => {
389
+ const isWin = process.platform === 'win32'
390
+ const cmd = isWin ? 'taskkill' : 'kill'
391
+ const args = isWin ? ['/PID', String(pid), '/F', '/T'] : ['-9', String(pid)]
392
+ const p = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
393
+ p.on('error', () => resolve(false))
394
+ p.on('exit', () => resolve(true))
395
+ })
396
+ }
397
+
398
+ /** Restart the local backend. */
399
+ export async function restart(options) {
400
+ await stop()
401
+ return await start(options)
402
+ }
403
+
404
+ /**
405
+ * Check whether a folder contains a usable dsh CLI.
406
+ * Returns the resolved executable path, or null.
407
+ */
408
+ export async function findDshInFolder(folder) {
409
+ if (!folder) return null
410
+ const candidates = [
411
+ joinCmd(folder, 'dsh.cmd'),
412
+ joinCmd(folder, 'dsh'),
413
+ joinCmd(folder, 'bin', 'dsh.cmd'),
414
+ joinCmd(folder, 'node_modules', '.bin', 'dsh.cmd'),
415
+ ]
416
+ for (const c of candidates) {
417
+ if (await exists(c)) return c
418
+ }
419
+ return null
420
+ }
421
+
422
+ /**
423
+ * Candidate dsh CLI locations beyond the explicit config path:
424
+ * the DSH_BACKEND_DIR env var (documented, machine-independent) and the
425
+ * npm global bin dir. The previous list carried a developer-specific
426
+ * absolute path — removed in favor of config (backendPath) and env.
427
+ */
428
+ function fallbackCandidates() {
429
+ const list = []
430
+ const envDir = process.env.DSH_BACKEND_DIR
431
+ if (envDir) list.push(join(envDir, 'dsh.cmd'), join(envDir, 'dsh'))
432
+ if (process.platform === 'win32') {
433
+ list.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'dsh.cmd'))
434
+ } else {
435
+ list.push('/usr/local/bin/dsh', join(homedir(), '.local', 'bin', 'dsh'))
436
+ }
437
+ return list
438
+ }
439
+
440
+ /**
441
+ * Auto-detect the dsh install folder (the folder that contains the dsh
442
+ * CLI), following the same resolution order used to start the backend:
443
+ * config → DSH_BACKEND_DIR → PATH → npm global. Returns the folder or null.
444
+ */
445
+ export async function detectInstallFolder() {
446
+ // 1) Explicit backend folder from config.
447
+ const cfgPath = getConfiguredBackendPath()
448
+ if (cfgPath) {
449
+ const found = await findDshInFolder(cfgPath)
450
+ if (found) return dirOf(found)
451
+ }
452
+
453
+ // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
454
+ for (const c of fallbackCandidates()) {
455
+ if (await exists(c)) return dirOf(c)
456
+ }
457
+
458
+ // 3) `dsh` on PATH → resolve where it actually lives.
459
+ const onPath = await commandPath('dsh')
460
+ if (onPath) return dirOf(onPath)
461
+ return null
462
+ }
463
+
464
+ /** Locate a usable dsh CLI. */
465
+ async function resolveDshCommand(backendPath) {
466
+ // 1) Explicit backend folder from config — look for dsh(.cmd/.exe) inside.
467
+ if (backendPath) {
468
+ const found = await findDshInFolder(backendPath)
469
+ if (found) return { command: found, args: [] }
470
+ }
471
+
472
+ // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
473
+ for (const c of fallbackCandidates()) {
474
+ if (await exists(c)) return { command: c, args: [] }
475
+ }
476
+
477
+ // 3) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
478
+ // shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
479
+ const onPath = await commandPath('dsh')
480
+ if (onPath) {
481
+ return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
482
+ }
483
+ return null
484
+ }
485
+
486
+ function joinCmd(...parts) {
487
+ return parts.join(process.platform === 'win32' ? '\\' : '/')
488
+ }
489
+
490
+ function dirOf(p) {
491
+ return dirname(p)
492
+ }
493
+
494
+ function getConfiguredBackendPath() {
495
+ try {
496
+ return loadConfig().backendPath || null
497
+ } catch {
498
+ return null
499
+ }
500
+ }
501
+
502
+ /** Resolve a command on PATH to its absolute path (Windows: where.exe). */
503
+ function commandPath(cmd) {
504
+ return new Promise((resolve) => {
505
+ const finder = process.platform === 'win32' ? 'where' : 'which'
506
+ const c = spawn(finder, [cmd], { windowsHide: true })
507
+ let out = ''
508
+ c.stdout.on('data', (d) => {
509
+ out += d.toString()
510
+ })
511
+ c.on('error', () => resolve(null))
512
+ c.on('exit', () => {
513
+ const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
514
+ resolve(line || null)
515
+ })
516
+ })
517
+ }
518
+
519
+ function exists(p) {
520
+ return new Promise((resolve) => access(p, (err) => resolve(!err)))
521
+ }