dsh-remote 0.8.65 → 0.8.66

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/lib/web-deploy.js DELETED
@@ -1,700 +0,0 @@
1
- // dsh-remote — remote DSH deployment probe & installer (issue #46 follow-up).
2
- //
3
- // Why this exists: connecting to a remote machine's DSH Web UI fails for
4
- // environmental reasons far more often than for plugin reasons. Measured on a
5
- // real host: its global dsh was 0.1.0-rc.6, whose node-pty@1.1.0 publishes
6
- // darwin/win32 prebuilds but **no linux-x64**, so `dsh web` could not boot at
7
- // all ("Failed to load native module: pty.node"). The user saw only "the remote
8
- // DSH did not report a startup token" and had no way to know why.
9
- //
10
- // Two design commitments, both deliberate:
11
- //
12
- // 1. **Detection and installation are deterministic, so they are code, not a
13
- // model call.** A probe that needs an LLM is slow, costs money and cannot be
14
- // unit-tested. Everything in this module is a pure function over shell
15
- // output: the caller supplies `exec`, tests supply strings.
16
- // 2. **The probe is strictly read-only.** It never writes, installs or mutates
17
- // remote state — that is what makes it safe to run before the user has
18
- // agreed to anything, and safe to run on a machine they are merely looking
19
- // at.
20
- //
21
- // The installer deliberately targets a PRIVATE PREFIX by default rather than a
22
- // global install: no write permission to the system prefix is required, the
23
- // user's PATH is untouched, and a dsh they are already using is never replaced.
24
- // That is the same shape used to get the E2E working by hand.
25
-
26
- /** Machine-readable markers the probe prints; parsing never guesses. */
27
- const MARK = {
28
- platform: 'P_OS',
29
- arch: 'P_ARCH',
30
- node: 'P_NODE',
31
- nodeVersion: 'P_NODE_V',
32
- npm: 'P_NPM',
33
- npmVersion: 'P_NPM_V',
34
- dsh: 'P_DSH',
35
- dshVersion: 'P_DSH_V',
36
- dshRoot: 'P_DSH_ROOT',
37
- noOpen: 'P_NOOPEN',
38
- ptyPrebuild: 'P_PTY',
39
- home: 'P_HOME',
40
- writePrefix: 'P_WRITABLE',
41
- prefix: 'P_PREFIX',
42
- installed: 'P_INSTALLED',
43
- installedVersion: 'P_INSTALLED_V',
44
- installedPty: 'P_INSTALLED_PTY',
45
- installedWeb: 'P_INSTALLED_WEB',
46
- gitBash: 'P_GITBASH',
47
- proxyVar: 'P_PROXY_NAME',
48
- registry: 'P_REGISTRY',
49
- }
50
-
51
- /**
52
- * Build the READ-ONLY environment probe.
53
- *
54
- * Multiline rather than '; '-joined on purpose: the launch builder once shipped a
55
- * `do;` POSIX syntax error that only a real shell caught, and newlines remove
56
- * that entire failure mode. Nothing here writes, installs or deletes.
57
- *
58
- * @param {object} [opts] - probe options.
59
- * @param {string} [opts.prefix] - directory the installer would use; reported
60
- * only to say whether it is writable (the probe never creates it).
61
- * @returns {string} a POSIX shell script.
62
- */
63
- export function buildProbeCommand(opts = {}) {
64
- // `prefix` may be:
65
- // • an explicit absolute path from configuration, checked verbatim;
66
- // • empty, meaning "use the default", which MUST be resolved ON THE REMOTE:
67
- // the caller cannot know $HOME before the probe runs, and guessing (the
68
- // caller's own /tmp) would report writability for a directory that has
69
- // nothing to do with the real install location.
70
- const prefix = String(opts.prefix || '')
71
- const useDefaultPrefix = !prefix && opts.defaultPrefixFor === 'home'
72
- // A dsh a previous deployment installed FOR THIS MACHINE. Probing only the
73
- // PATH dsh would keep reporting the old broken one after a successful install,
74
- // so the installed binary is probed explicitly and preferred when usable.
75
- const installed = String(opts.installedCommand || '')
76
- // Statements are one-per-line. `if ...; then` is legitimate shell, but mixing
77
- // `;`-joined statements with a `while`/`if` body is exactly how the launch
78
- // builder once produced a `do;` syntax error — so this stays newline-only and
79
- // a test asserts that.
80
- const lines = [
81
- 'echo "P_OS=$(uname -s 2>/dev/null || echo unknown)"',
82
- 'A=$(uname -m 2>/dev/null || echo unknown)',
83
- 'case "$A" in x86_64|amd64) A=x64;; aarch64|arm64) A=arm64;; esac',
84
- 'echo "P_ARCH=$A"',
85
- 'echo "P_HOME=$HOME"',
86
- 'N=$(command -v node 2>/dev/null)',
87
- 'echo "P_NODE=$N"',
88
- 'if [ -n "$N" ]',
89
- 'then',
90
- ' echo "P_NODE_V=$($N -v 2>/dev/null)"',
91
- 'fi',
92
- 'M=$(command -v npm 2>/dev/null)',
93
- 'echo "P_NPM=$M"',
94
- 'if [ -n "$M" ]',
95
- 'then',
96
- ' echo "P_NPM_V=$($M -v 2>/dev/null)"',
97
- 'fi',
98
- 'D=$(command -v dsh 2>/dev/null)',
99
- 'echo "P_DSH=$D"',
100
- 'if [ -n "$D" ]',
101
- 'then',
102
- ' echo "P_DSH_V=$($D --version 2>/dev/null | tail -1)"',
103
- 'fi',
104
- ]
105
- // A prefix the caller is considering. Reported, never created.
106
- if (useDefaultPrefix) {
107
- // Resolve the default on the REMOTE and report it back, so the caller learns
108
- // the real install path (and its writability) in the same round trip.
109
- lines.push(
110
- `DFLT="$HOME/${DEFAULT_PREFIX_SUFFIX}"`,
111
- 'echo "P_PREFIX=$DFLT"',
112
- 'if [ -w "$HOME" ]',
113
- 'then',
114
- ' echo "P_WRITABLE=parent:yes"',
115
- 'else',
116
- ' echo "P_WRITABLE=parent:no"',
117
- 'fi',
118
- )
119
- } else if (prefix) {
120
- lines.push(
121
- `if [ -d "${prefix}" ]`,
122
- 'then',
123
- ` if [ -w "${prefix}" ]`,
124
- ' then',
125
- ' echo "P_WRITABLE=yes"',
126
- ' else',
127
- ' echo "P_WRITABLE=no"',
128
- ' fi',
129
- `elif [ -w "$(dirname "${prefix}")" ]`,
130
- 'then',
131
- ' echo "P_WRITABLE=parent:yes"',
132
- 'else',
133
- ' echo "P_WRITABLE=parent:no"',
134
- 'fi',
135
- )
136
- } else {
137
- lines.push('echo "P_WRITABLE=unknown"')
138
- }
139
-
140
- // Probe whatever a previous deployment installed for THIS machine, and prefer
141
- // it when it is usable. Without this a successful install is invisible to the
142
- // next probe, because only the PATH dsh would be examined.
143
- if (installed) {
144
- lines.push(
145
- `INSTALLED="${installed}"`,
146
- 'if [ -x "$INSTALLED" ]',
147
- 'then',
148
- ' echo "P_INSTALLED=yes"',
149
- ' echo "P_INSTALLED_V=$("$INSTALLED" --version 2>/dev/null | tail -1)"',
150
- ...resolveRootLines('INSTALLED', 'RI'),
151
- ' OSI=$(echo "$(uname -s)" | tr "A-Z" "a-z")',
152
- ' PTYI="$RI/node_modules/node-pty/prebuilds/$OSI-$A/pty.node"',
153
- ' ALTI="$RI/node_modules/@deepseek-ai/dsh/node_modules/node-pty/prebuilds/$OSI-$A/pty.node"',
154
- ' if [ -f "$PTYI" ] || [ -f "$ALTI" ]',
155
- ' then',
156
- ' echo "P_INSTALLED_PTY=yes"',
157
- ' else',
158
- ' echo "P_INSTALLED_PTY=no"',
159
- ' fi',
160
- ' if "$INSTALLED" web --help 2>&1 | grep -q -- "--port"',
161
- ' then',
162
- ' echo "P_INSTALLED_WEB=yes"',
163
- ' else',
164
- ' echo "P_INSTALLED_WEB=no"',
165
- ' fi',
166
- 'else',
167
- ' echo "P_INSTALLED=no"',
168
- 'fi',
169
- )
170
- }
171
-
172
- // A POSIX shell on a Windows remote. The launch/install commands need one, and
173
- // this is the single fact that decides whether a Windows host is deployable.
174
- lines.push(
175
- 'GB=""',
176
- 'for c in /usr/bin/bash.exe /bin/bash.exe bash',
177
- 'do',
178
- ' if command -v "$c" >/dev/null 2>&1',
179
- ' then',
180
- ' GB=$(command -v "$c")',
181
- ' break',
182
- ' fi',
183
- 'done',
184
- 'echo "P_GITBASH=$GB"',
185
- )
186
-
187
- // Proxy configuration: REPORTED only. An install that inherits a broken proxy
188
- // is a plausible cause of a slow or failing npm, and the user is the one who
189
- // knows the right value. The probe never sets or changes it.
190
- lines.push(
191
- 'PX=""',
192
- 'for v in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy',
193
- 'do',
194
- ' EV=$(eval "echo \\$$v")',
195
- ' if [ -n "$EV" ]',
196
- ' then',
197
- ' PX="$v"',
198
- ' break',
199
- ' fi',
200
- 'done',
201
- 'echo "P_PROXY_NAME=$PX"',
202
- )
203
-
204
- // Whether npm already points at a non-default registry (an internal mirror).
205
- // Reported so an install can keep using it rather than overriding it.
206
- lines.push(
207
- 'if [ -n "$M" ]',
208
- 'then',
209
- ' RG=$($M config get registry 2>/dev/null | head -1)',
210
- ' echo "P_REGISTRY=$RG"',
211
- 'fi',
212
- )
213
-
214
- // Facts that need the dsh binary: where it lives and whether it can boot web.
215
- lines.push(
216
- 'if [ -n "$D" ]',
217
- 'then',
218
- ' if [ -n "$N" ]',
219
- ' then',
220
- ...resolveRootLines('D', 'R').map((l) => ' ' + l),
221
- ' echo "P_DSH_ROOT=$R"',
222
- ' OS=$(echo "$(uname -s)" | tr "A-Z" "a-z")',
223
- ' PTY="$R/node_modules/node-pty/prebuilds/$OS-$A/pty.node"',
224
- ' ALT="$R/node_modules/@deepseek-ai/dsh/node_modules/node-pty/prebuilds/$OS-$A/pty.node"',
225
- ' if [ -f "$PTY" ] || [ -f "$ALT" ]',
226
- ' then',
227
- ' echo "P_PTY=yes"',
228
- ' else',
229
- ' echo "P_PTY=no"',
230
- ' fi',
231
- ' fi',
232
- // `web --help` is cheap and reveals the flag family; it does NOT prove the
233
- // surface boots (a broken native module still prints help), which is why
234
- // P_PTY is probed separately.
235
- ' if $D web --help 2>&1 | grep -q -- "--no-open"',
236
- ' then',
237
- ' echo "P_NOOPEN=yes"',
238
- ' else',
239
- ' echo "P_NOOPEN=no"',
240
- ' fi',
241
- 'fi',
242
- )
243
- return lines.join('\n')
244
- }
245
-
246
- /** Read one `KEY=value` line out of probe output. */
247
- function valueOf(text, key) {
248
- const re = new RegExp('^' + key + '=(.*)$', 'm')
249
- const m = re.exec(text)
250
- return m ? m[1].trim() : ''
251
- }
252
-
253
- /**
254
- * Resolve a `dsh` executable to the PREFIX ROOT that owns its `node_modules`,
255
- * as a POSIX shell fragment (one statement per line).
256
- *
257
- * Not `dirname(dirname(bin))`. The bin is typically a symlink like
258
- * `node_modules/.bin/dsh -> ../@deepseek-ai/dsh/lib/bin.js`, so realpath puts us
259
- * at `<root>/node_modules/@deepseek-ai/dsh/lib/bin.js` and two dirnames land at
260
- * `<root>/node_modules/@deepseek-ai/dsh` — INSIDE the package, where a sibling
261
- * `node-pty` does not exist. Measured on a real host: that produced a false
262
- * "pty missing" verdict for a perfectly good install, and the same mistake would
263
- * hide a genuinely broken one.
264
- *
265
- * Walking up to the first ancestor that contains a `node_modules` DIRECTORY
266
- * resolves both layouts: a private prefix (`.../dsh/node_modules/.bin/dsh`) and a
267
- * global install (`.../lib/node_modules/@deepseek-ai/dsh/lib/bin.js`).
268
- *
269
- * @param {string} binVar - shell variable holding the executable path.
270
- * @param {string} outVar - shell variable to receive the resolved root.
271
- * @returns {string[]} shell lines.
272
- */
273
- export function resolveRootLines(binVar, outVar) {
274
- return [
275
- `${outVar}=""`,
276
- `RD="$(dirname "$${binVar}")"`,
277
- 'i=0',
278
- 'while [ $i -lt 8 ]',
279
- 'do',
280
- ` if [ -d "$RD/node_modules" ]`,
281
- ' then',
282
- ` ${outVar}="$RD"`,
283
- ' break',
284
- ' fi',
285
- ' NC="$(dirname "$RD")"',
286
- ' if [ "$NC" = "$RD" ]',
287
- ' then',
288
- ' break',
289
- ' fi',
290
- ' RD="$NC"',
291
- ' i=$((i+1))',
292
- 'done',
293
- ]
294
- }
295
-
296
- /**
297
- * Parse probe output into facts. Absent keys stay empty rather than defaulting,
298
- * so the judge can tell "not installed" from "probe could not tell".
299
- *
300
- * @param {string} output - raw stdout of {@link buildProbeCommand}.
301
- * @returns {object} parsed facts.
302
- */
303
- export function parseProbe(output) {
304
- const text = String(output || '')
305
- const platform = valueOf(text, MARK.platform)
306
- const lower = platform.toLowerCase()
307
- // Git Bash / MSYS / Cygwin report an emulated uname; treat them as Windows.
308
- const windows = /mingw|msys|cygwin|windows/i.test(platform)
309
- return {
310
- platform: lower === 'unknown' ? '' : platform,
311
- arch: valueOf(text, MARK.arch),
312
- windows,
313
- posix: !windows && lower !== 'unknown' && lower !== '',
314
- node: valueOf(text, MARK.node),
315
- nodeVersion: valueOf(text, MARK.nodeVersion),
316
- npm: valueOf(text, MARK.npm),
317
- npmVersion: valueOf(text, MARK.npmVersion),
318
- dsh: valueOf(text, MARK.dsh),
319
- dshVersion: valueOf(text, MARK.dshVersion),
320
- dshRoot: valueOf(text, MARK.dshRoot),
321
- noOpen: valueOf(text, MARK.noOpen),
322
- ptyPrebuild: valueOf(text, MARK.ptyPrebuild),
323
- installed: valueOf(text, MARK.installed),
324
- installedVersion: valueOf(text, MARK.installedVersion),
325
- installedPty: valueOf(text, MARK.installedPty),
326
- installedWeb: valueOf(text, MARK.installedWeb),
327
- gitBash: valueOf(text, MARK.gitBash),
328
- proxyVar: valueOf(text, MARK.proxyVar),
329
- registry: valueOf(text, MARK.registry),
330
- home: valueOf(text, MARK.home),
331
- writable: valueOf(text, MARK.writePrefix),
332
- // The prefix the REMOTE resolved for itself (only reported when the caller
333
- // asked for the default). Absent when an explicit prefix was probed.
334
- resolvedPrefix: valueOf(text, MARK.prefix),
335
- }
336
- }
337
-
338
- /** Compare dotted versions; returns true when `a` >= `b` (empty `a` is false). */
339
- export function versionAtLeast(a, b) {
340
- const parse = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((x) => Number(x) || 0)
341
- const [x, y] = [parse(a), parse(b)]
342
- if (!String(a || '').trim()) return false
343
- for (let i = 0; i < Math.max(x.length, y.length); i++) {
344
- const l = x[i] || 0
345
- const r = y[i] || 0
346
- if (l !== r) return l > r
347
- }
348
- return true
349
- }
350
-
351
- /** The first dsh release whose node-pty ships Linux prebuilds (measured). */
352
- export const PTY_FIXED_VERSION = '0.1.5-rc.2'
353
-
354
- /**
355
- * Minimum Node the remote must run, and the reason it is exactly this number.
356
- *
357
- * DSH's boot path imports `parseEnv` from `node:util`, and that export only
358
- * exists from **20.12.0** (Node 21.7.0 and later have it too). Measured on a
359
- * real host with two nodes installed:
360
- *
361
- * /usr/bin/node (v18.19.1) → parseEnv: undefined
362
- * ~/.nvm/versions/node/v24.22.0/bin/node (v24.22.0) → parseEnv: function
363
- *
364
- * The failure mode is what makes this worth a BLOCKER rather than a warning: npm
365
- * only prints EBADENGINE (it does not refuse), so all ~535 packages install
366
- * cleanly and the crash arrives afterwards as
367
- *
368
- * SyntaxError: The requested module 'node:util' does not provide an export
369
- * named 'parseEnv' at .../@deepseek-ai/dsh-app-boot/lib/index.js:4
370
- *
371
- * which does not read as "your node is too old". Checking the version BEFORE the
372
- * install is the only way to turn that into an actionable message.
373
- *
374
- * Reference: Node.js 20.12.0 release notes, "Loading and parsing environment
375
- * variables" — https://nodejs.org/en/blog/release/v20.12.0
376
- */
377
- export const MIN_NODE_VERSION = '20.12.0'
378
-
379
- /**
380
- * Turn facts into a verdict: what is wrong, why, and what to do.
381
- *
382
- * Severity is deliberately coarse — `blocker` means "connecting cannot work
383
- * until this changes", `warn` means "works, but with a caveat", `info` means
384
- * "worth knowing", `ok` means "nothing to do". The UI maps these directly.
385
- *
386
- * @param {object} facts - parsed probe facts.
387
- * @param {object} [opts] - judgement options.
388
- * @param {string} [opts.installedCommand] - command the installer already placed, if any.
389
- * @returns {{severity: string, ok: boolean, findings: object[], useCommand: string, canAutoInstall: boolean}}
390
- */
391
- export function judgeProbe(facts, opts = {}) {
392
- const f = facts || {}
393
- const findings = []
394
- const add = (code, severity, summary, detail, fix) => {
395
- findings.push({ code, severity, summary, detail: detail || '', ...(fix ? { fix } : {}) })
396
- }
397
-
398
- // ── platform / shell ──────────────────────────────────────────────────────
399
- if (!f.platform) {
400
- add('PLATFORM_UNKNOWN', 'warn', '无法识别远端操作系统',
401
- '探测没有返回可识别的 uname 结果。', '确认该主机是常规 Linux/macOS/Windows。')
402
- } else if (f.windows) {
403
- // The launch command is POSIX shell (setsid/nohup). pool.exec wraps commands
404
- // in Git Bash when it finds one on a Windows remote; without it there is no
405
- // POSIX shell to run them in, and an install would produce a dsh we could
406
- // never start.
407
- if (f.gitBash) {
408
- add('WINDOWS_REMOTE_GITBASH', 'info', '远端是 Windows 主机,但有 Git Bash',
409
- '启动命令会经 Git Bash 执行,自动部署与连接都可用。')
410
- } else {
411
- add('WINDOWS_REMOTE', 'warn', '远端是 Windows 主机且没有 Git Bash',
412
- '本功能的启动命令是 POSIX shell(依赖 setsid/nohup),没有 Git Bash 就无法执行。',
413
- '在该机器安装 Git Bash(随 Git for Windows 一起),或改用 Linux/macOS 远端。')
414
- }
415
- }
416
-
417
- // ── node / npm ────────────────────────────────────────────────────────────
418
- if (!f.node) {
419
- add('NO_NODE', 'blocker', '远端没有 node',
420
- '自动部署需要 node 与 npm。',
421
- '先在该机器安装 Node.js(nvm 或系统包管理器),再回来重试。')
422
- } else if (f.nodeVersion && !versionAtLeast(f.nodeVersion, MIN_NODE_VERSION)) {
423
- // The install itself SUCCEEDS on this node — npm only warns (EBADENGINE) —
424
- // and the failure surfaces later as a parseEnv import error that does not
425
- // read as a version problem. So the check has to happen before the install.
426
- add('NODE_TOO_OLD', 'blocker',
427
- `远端 node ${f.nodeVersion} 版本过低(需要 ≥ ${MIN_NODE_VERSION})`,
428
- `dsh 启动时要 import node:util 的 parseEnv,该导出从 Node ${MIN_NODE_VERSION} 起才有。`
429
- + '在这个版本上 npm 只会给 EBADENGINE 警告、仍然把包装完,'
430
- + '直到启动时才崩:SyntaxError: The requested module \'node:util\' does not provide an export named \'parseEnv\'。',
431
- `升级该机器的 node 到 ${MIN_NODE_VERSION} 以上(例如 nvm install 22),`
432
- + '并把 `webAttachCommand` 指向新 node 的绝对路径(如 ~/.nvm/versions/node/v22.x/bin/node <前缀>/node_modules/@deepseek-ai/dsh/lib/bin.js)。')
433
- }
434
- if (!f.npm) {
435
- add('NO_NPM', 'blocker', '远端没有 npm',
436
- '自动部署通过 npm 拉取 dsh。',
437
- '随 Node.js 一起安装 npm(或换用 nvm 安装的 node)。')
438
- }
439
-
440
- // ── existing dsh ──────────────────────────────────────────────────────────
441
- if (!f.dsh) {
442
- add('NO_DSH', 'info', '远端没有 dsh',
443
- '没有找到可执行的 dsh。',
444
- '可以自动装到私有前缀,不影响该机器上已装的任何东西。')
445
- } else {
446
- // The exact failure measured on a real host: node-pty has no prebuild for
447
- // this platform, so the web surface cannot boot at all.
448
- if (f.ptyPrebuild === 'no' && !f.windows) {
449
- add('PTY_MISSING', 'blocker',
450
- `远端 dsh ${f.dshVersion || '(版本未知)'} 无法启动 web 界面`,
451
- '它依赖的 node-pty 在当前平台没有预编译产物,'
452
- + '启动会直接失败(Failed to load native module: pty.node)。',
453
- `装一个 ${PTY_FIXED_VERSION} 或更新的 dsh 到私有前缀即可(其 node-pty 带该平台产物)。`)
454
- }
455
- if (f.noOpen === 'no') {
456
- // Handled automatically by WebAttach (it retries without the flag), so
457
- // this is informational rather than a problem.
458
- add('OLD_FLAG_SET', 'info', `远端 dsh ${f.dshVersion || ''} 不认识 --no-open`,
459
- '这是较新的 flag;插件会自动去掉它重试一次,无需处理。',
460
- '升级到较新版本可避免这条重试。')
461
- }
462
- if (f.noOpen === 'yes' && f.ptyPrebuild !== 'no') {
463
- add('DSH_OK', 'ok', `远端 dsh ${f.dshVersion || ''} 可以直接使用`,
464
- '已装且有当前平台的 node-pty 预编译产物。')
465
- }
466
- }
467
-
468
- // ── prefix writability (only relevant when we would install) ──────────────
469
- if ((!f.dsh || f.ptyPrebuild === 'no') && f.writable === 'no') {
470
- add('PREFIX_UNWRITABLE', 'warn', '默认安装位置不可写',
471
- '自动部署会改用 $HOME 下的私有目录;若 $HOME 也不可写则需要人工处理。',
472
- '通常无需处理,安装器会回退到 $HOME。')
473
- }
474
-
475
- // ── a dsh a previous deployment installed for this machine ─────────────────
476
- // This must be judged BEFORE the PATH dsh: an installed build is the whole
477
- // point of a deployment, and reporting the old broken PATH dsh after a
478
- // successful install would make the feature look like it did nothing.
479
- const installed = String(opts.installedCommand || '').trim()
480
- const installedUsable = f.installed === 'yes'
481
- && f.installedPty !== 'no'
482
- && f.installedWeb !== 'no'
483
- if (installed && installedUsable) {
484
- add('INSTALLED_OK', 'ok',
485
- `已部署的 dsh ${f.installedVersion || ''} 可用`,
486
- '该机器上之前自动部署的版本已通过校验(native 模块与 web 子命令都在)。')
487
- } else if (installed && f.installed === 'no') {
488
- add('INSTALLED_MISSING', 'info', '之前部署的 dsh 已不在',
489
- `${installed} 不存在(可能被清理或换了机器镜像)。`,
490
- '重新部署一次即可。')
491
- } else if (installed && f.installed === 'yes' && !installedUsable) {
492
- add('INSTALLED_UNUSABLE', 'warn', '已部署的 dsh 校验未通过',
493
- '它存在但 web 界面或 native 模块有问题。',
494
- '重新部署一次;仍失败时用「让 AI 排查」。')
495
- }
496
-
497
- // ── environment notes that change what an install will do ─────────────────
498
- // Reported, never "fixed": the user is the one who knows whether the proxy or
499
- // mirror is correct, and silently overriding either would be worse than slow.
500
- if (f.proxyVar) {
501
- add('PROXY_SET', 'info', `远端设置了代理(${f.proxyVar})`,
502
- '安装会沿用远端已有的代理设置。',
503
- '若 npm 下载很慢或失败,检查这个代理是否可达。')
504
- }
505
- if (f.registry && !/registry\.npmjs\.org/.test(f.registry)) {
506
- add('CUSTOM_REGISTRY', 'info', `远端 npm 使用了自定义源:${f.registry}`,
507
- '安装默认沿用远端自己的 npm 配置,不会覆盖它。')
508
- }
509
-
510
- const blockers = findings.filter((x) => x.severity === 'blocker')
511
- // A Windows remote with Git Bash can be installed into: pool.exec pipes every
512
- // command through `bash -s`, so the POSIX plan works there.
513
- //
514
- // A node older than MIN_NODE_VERSION makes an install POINTLESS as well as
515
- // failing: the packages land, then the binary cannot boot. Promising
516
- // "can auto-install: yes" there is what sent the reporter of issue #54 through
517
- // a 535-package install that ended in a parseEnv SyntaxError.
518
- const nodeUsable = !!f.node && (!f.nodeVersion || versionAtLeast(f.nodeVersion, MIN_NODE_VERSION))
519
- const canAutoInstall = nodeUsable && !!f.npm && (!f.windows || !!f.gitBash)
520
-
521
- // Which command should the attach flow use? A verified deployment wins, then a
522
- // usable dsh already on PATH.
523
- //
524
- // Note `noOpen` deliberately does NOT disqualify a PATH dsh: WebAttach already
525
- // retries without `--no-open`, so an old version is perfectly usable. Treating
526
- // it as unusable would tell users to reinstall for no reason.
527
- const usablePath = f.dsh && f.ptyPrebuild !== 'no' ? f.dsh : ''
528
- const useCommand = installedUsable ? installed : usablePath
529
-
530
- // If the installed build works, the PATH dsh's shortcomings are no longer
531
- // blockers — the user can connect right now.
532
- const effectiveBlockers = installedUsable
533
- ? blockers.filter((x) => x.code !== 'PTY_MISSING')
534
- : blockers
535
-
536
- return {
537
- ok: effectiveBlockers.length === 0,
538
- severity: effectiveBlockers.length ? 'blocker'
539
- : findings.some((x) => x.severity === 'warn') ? 'warn' : 'ok',
540
- findings,
541
- useCommand,
542
- canAutoInstall,
543
- }
544
- }
545
-
546
- // ── installation ────────────────────────────────────────────────────────────
547
-
548
- /** Where the installer puts dsh when the user does not choose. */
549
- export const DEFAULT_PREFIX_SUFFIX = '.dsh-remote/dsh'
550
- /** Default version: the first release whose node-pty ships Linux prebuilds. */
551
- export const DEFAULT_INSTALL_VERSION = PTY_FIXED_VERSION
552
-
553
- /**
554
- * Resolve the install prefix from probe facts and configuration.
555
- *
556
- * Preference order is deliberate: an explicit user setting, then `$HOME`, and
557
- * only then a temp directory. `$HOME` keeps the install across reboots, which
558
- * matters because the resulting `webAttachCommand` is written into the machine
559
- * record and would otherwise dangle after a reboot wipes `/tmp`.
560
- *
561
- * @param {object} facts - parsed probe facts.
562
- * @param {object} [opts] - overrides.
563
- * @param {string} [opts.prefix] - explicit prefix.
564
- * @returns {string} a remote directory path.
565
- */
566
- export function resolvePrefix(facts, opts = {}) {
567
- const explicit = String(opts.prefix || '').trim()
568
- if (explicit) return explicit
569
- const home = String((facts && facts.home) || '').trim()
570
- if (home) return `${home.replace(/\/+$/, '')}/${DEFAULT_PREFIX_SUFFIX}`
571
- return `/tmp/${DEFAULT_PREFIX_SUFFIX}`
572
- }
573
-
574
- /** Quote one shell argument. */
575
- function q(s) {
576
- return `'${String(s).replace(/'/g, `'\\''`)}'`
577
- }
578
-
579
- /**
580
- * Build the ordered install plan.
581
- *
582
- * Each step is a separate SSH exec rather than one long script, because the
583
- * caller wants per-step progress and a failure report that names the step. The
584
- * plan is pure data so a test can assert the exact commands without a shell.
585
- *
586
- * @param {object} spec - install parameters.
587
- * @param {object} spec.facts - parsed probe facts.
588
- * @param {string} spec.prefix - remote prefix directory.
589
- * @param {string} [spec.version] - dsh version to install.
590
- * @param {string} [spec.registry] - npm registry to use.
591
- * @param {object} [spec.env] - env vars for the install step (proxy, mirror…).
592
- * @param {boolean} [spec.forcePosix] - allow the plan for a Windows remote that
593
- * has Git Bash (pool.exec then pipes commands through `bash -s`).
594
- * @returns {{steps: object[], command: string}} the plan and the resulting dsh path.
595
- */
596
- export function buildInstallPlan(spec) {
597
- const facts = spec.facts || {}
598
- const prefix = String(spec.prefix || '').trim().replace(/\/+$/, '')
599
- // An empty prefix emits `mkdir -p ''` / `cd ''` / `--prefix ''`: commands that
600
- // either fail confusingly or (worse) touch the filesystem root. Refuse to build
601
- // such a plan at all — the caller must resolve a prefix first.
602
- if (!prefix || prefix === '/') {
603
- throw new Error('buildInstallPlan: refusing to build a plan without a private install prefix')
604
- }
605
- const version = String(spec.version || DEFAULT_INSTALL_VERSION)
606
- const registry = String(spec.registry || '')
607
- const env = spec.env && typeof spec.env === 'object' ? spec.env : undefined
608
- const bin = `${prefix}/node_modules/.bin/dsh`
609
-
610
- // `--no-audit --no-fund` keeps output small; `--loglevel=error` keeps the
611
- // captured stdout parseable. `--prefix` installs locally without touching any
612
- // system or user-global prefix.
613
- const npmArgs = [
614
- 'install', '--prefix', q(prefix),
615
- '--no-audit', '--no-fund', '--loglevel=error',
616
- // `--ignore-scripts=false` is npm's default and is stated explicitly here
617
- // because a restrictive global config would otherwise silently skip
618
- // node-pty's build/install step, producing an install that cannot boot web.
619
- ...(registry ? ['--registry', q(registry)] : []),
620
- q(`@deepseek-ai/dsh@${version}`),
621
- ]
622
-
623
- const steps = [
624
- {
625
- id: 'prepare',
626
- title: '准备安装目录',
627
- // A private package.json stops npm from walking up to an unrelated
628
- // project and mutating its dependency tree.
629
- command: `mkdir -p ${q(prefix)} && printf '%s\\n' `
630
- + `'{"name":"dsh-remote-install","private":true,"version":"0.0.0"}' > ${q(`${prefix}/package.json`)}`
631
- + ` && echo OK`,
632
- },
633
- {
634
- id: 'install',
635
- title: `安装 dsh@${version} 到私有目录`,
636
- command: `cd ${q(prefix)} && ${facts.npm || 'npm'} ${npmArgs.join(' ')}`,
637
- // npm can legitimately take minutes on a cold cache.
638
- timeoutMs: 600000,
639
- ...(env ? { env } : {}),
640
- },
641
- {
642
- id: 'verify-binary',
643
- title: '校验安装结果',
644
- command: `test -x ${q(bin)} && ${q(bin)} --version`,
645
- },
646
- ]
647
-
648
- // A native-module check is what actually distinguishes "installed" from
649
- // "installed and able to boot the web surface" — the exact gap that made the
650
- // original failure so opaque. It uses the walk-up rule from
651
- // `resolveRootLines` rather than dirname(dirname()), which lands inside the
652
- // package and yields a FALSE NEGATIVE on a good install (measured).
653
- steps.push({
654
- id: 'verify-pty',
655
- title: '校验 web 界面可启动(native 模块)',
656
- command: [
657
- `BIN=${q(bin)}`,
658
- ...resolveRootLines('BIN', 'ROOT'),
659
- `OS=$(echo "$(uname -s)" | tr 'A-Z' 'a-z')`,
660
- `A=$(uname -m)`,
661
- `case "$A" in x86_64|amd64) A=x64;; aarch64|arm64) A=arm64;; esac`,
662
- `P1="$ROOT/node_modules/node-pty/prebuilds/$OS-$A/pty.node"`,
663
- `P2="$ROOT/node_modules/@deepseek-ai/dsh/node_modules/node-pty/prebuilds/$OS-$A/pty.node"`,
664
- // One `if` rather than a `||` continuation: a line beginning with `||`
665
- // after a newline is a syntax error in POSIX sh (caught by the real-shell
666
- // gate, not by string assertions).
667
- `if [ -f "$P1" ] || [ -f "$P2" ]`,
668
- `then`,
669
- ` echo PTY_OK`,
670
- `else`,
671
- ` echo "PTY_MISSING: no prebuild at $P1 or $P2" >&2`,
672
- ` exit 1`,
673
- `fi`,
674
- ].join('\n'),
675
- })
676
-
677
- steps.push({
678
- id: 'verify-web',
679
- title: '校验 dsh web 可用',
680
- command: `${q(bin)} web --help 2>&1 | grep -q -- '--port'`,
681
- })
682
-
683
- return { steps, command: bin }
684
- }
685
-
686
- /** One step's outcome, as returned to the UI. */
687
- export function stepResult(step, res) {
688
- const out = String((res && res.stdout) || '')
689
- const err = String((res && res.stderr) || '')
690
- const code = res && res.code !== undefined ? res.code : 0
691
- return {
692
- id: step.id,
693
- title: step.title,
694
- ok: code === 0,
695
- code,
696
- // Keep a bounded tail: enough to diagnose, small enough to stream to a UI.
697
- output: (out + (err ? '\n' + err : '')).trim().slice(-1200),
698
- }
699
- }
700
-