@shendeguize/remote-dsh-center 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
package/src/lib/ssh.js ADDED
@@ -0,0 +1,647 @@
1
+ /**
2
+ * 一次性 ssh/scp/本机执行器 + 每主机串行队列(规格 = 12 文档 §0、§6)。
3
+ *
4
+ * 二进制路径可经 DSHC_SSH_BIN / DSHC_SCP_BIN 覆盖——假远端测试装置(14 §2)据此把
5
+ * ssh/scp 换成本机垫片,无需真机即可跑通全部协议路径。
6
+ */
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { randomUUID } from 'node:crypto';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { REMOTE_DIR } from '../defaults.js';
13
+ import { createTailCapture } from './capture.js';
14
+ import { DshError } from './errors.js';
15
+ import { assertSafeHost, shq } from './shq.js';
16
+ import { PROTO_TIMING } from './proto.js';
17
+
18
+ export const COMMON_SSH_OPTS = Object.freeze([
19
+ '-o', 'BatchMode=yes',
20
+ '-o', 'ConnectTimeout=6',
21
+ '-o', 'StrictHostKeyChecking=accept-new',
22
+ ]);
23
+
24
+ export const TUNNEL_SSH_OPTS = Object.freeze([
25
+ ...COMMON_SSH_OPTS,
26
+ '-o', 'ServerAliveInterval=15',
27
+ '-o', 'ServerAliveCountMax=3',
28
+ '-o', 'ExitOnForwardFailure=yes',
29
+ ]);
30
+
31
+ const KILL_ESCALATE_MS = 2_000;
32
+
33
+ /**
34
+ * 每条流各自收上来的上限(issue #92)。
35
+ *
36
+ * 2MB 的选法:协议输出(POLL/VERIFY/STOP 的 KEY=VALUE)都在几百字节量级,永远碰不到;
37
+ * 日志抓取是 `tail -n ≤10000`,正常文本行一万行约 1MB 上下,也在里面。
38
+ * 真能撞上这条线的只有非正常输出——带 `\r` 的进度条压成的超长单行、刷屏的 .bashrc。
39
+ * 时间维度另有 onceTimeoutMs 兜着,所以撞线之后照常排空到命令自然结束,不额外掐连接。
40
+ */
41
+ export const SSH_OUTPUT_CAP_BYTES = 2 * 1024 * 1024;
42
+
43
+ /** settings 等敏感内容经 stdin 传输时的 lib 层硬上限(设计 §4.3)。 */
44
+ export const SSH_INPUT_CAP_BYTES = 512 * 1024;
45
+
46
+ /**
47
+ * 在飞的一次性运输操作。manager 退出时要把它们一并收走:不收就是把 ssh/本机 shell
48
+ * 交给 init 当孤儿,`dshc restart` 之后新老两批命令还会同时操作同一台主机(issue #73)。
49
+ * 隧道那条常驻 ssh 不在此列——它由 tunnel.closeAll() 自己关。
50
+ * @type {Set<() => void>} 子进程元素是强杀链,文件复制元素是取消函数
51
+ */
52
+ const inFlight = new Set();
53
+
54
+ /** 关停闩:落下之后不许再起新的一次性 ssh/scp/本机操作。 */
55
+ let closed = false;
56
+
57
+ /**
58
+ * 关停用:收走在飞的一次性运输操作,并且从此不再起新的。
59
+ *
60
+ * 闩是必须的:每主机队列里往往还压着后续任务(比如页面刚点过一次「全部探测」),
61
+ * 只杀在飞的那批,队列里下一个立刻就顶上来——退出过程会一直有新 ssh 冒出来,
62
+ * 最后照样留一批孤儿。本机 shell 复用 TERM → 2s → KILL;copy 的取消函数不提交正式文件。
63
+ */
64
+ export function shutdownSsh() {
65
+ closed = true;
66
+ for (const kill of [...inFlight]) kill();
67
+ }
68
+
69
+ /** 判据用:现在挂着几条。收场后必须归零,否则这张账本就是内存泄漏。 */
70
+ export function liveChildCount() {
71
+ return inFlight.size;
72
+ }
73
+
74
+ /**
75
+ * 抬闩。`server.main()` 开头调一次——同一个进程里关停后又起来的场合(用例装置、
76
+ * 前台自我重启)不能带着上一轮的闩,否则新 manager 一条远端命令都发不出去。
77
+ */
78
+ export function reopenSsh() {
79
+ closed = false;
80
+ }
81
+
82
+ /**
83
+ * 解析可执行覆盖。允许带前导参数(空格分隔),如
84
+ * DSHC_SSH_BIN="/usr/local/bin/node /path/fake-ssh.js" —— 假远端装置据此让 node 成为
85
+ * 直接子进程,信号(TERM/KILL)才能准确落到垫片上(shebang / sh 包装会丢信号)。
86
+ * @returns {{bin:string, prefixArgs:string[]}}
87
+ */
88
+ function resolveBin(envValue, fallback) {
89
+ const raw = (envValue || '').trim();
90
+ if (raw === '') return { bin: fallback, prefixArgs: [] };
91
+ const parts = raw.split(/\s+/);
92
+ return { bin: parts[0], prefixArgs: parts.slice(1) };
93
+ }
94
+
95
+ export function sshBin() {
96
+ return resolveBin(process.env.DSHC_SSH_BIN, 'ssh');
97
+ }
98
+
99
+ export function scpBin() {
100
+ return resolveBin(process.env.DSHC_SCP_BIN, 'scp');
101
+ }
102
+
103
+ export function localShBin() {
104
+ return resolveBin(process.env.DSHC_LOCAL_SH_BIN, 'sh');
105
+ }
106
+
107
+ /**
108
+ * 拉起浏览器用的命令(mac 上是 `open`)。同样允许覆盖——`dshc open` 在测试里必须
109
+ * 能验「到底有没有真去开浏览器」,而不是每跑一次用例就弹一个窗口。
110
+ */
111
+ export function openerBin() {
112
+ return resolveBin(process.env.DSHC_OPEN_BIN, 'open');
113
+ }
114
+
115
+ /**
116
+ * @typedef {{code:number|null, signal:string|null, stdout:string, stderr:string,
117
+ * stdoutDropped:number, stderrDropped:number, timedOut:boolean, aborted:boolean}} ExecResult
118
+ * `*Dropped` 是封顶时从**头部**丢掉的字符数(issue #92),0 表示这份是全的。
119
+ */
120
+
121
+ /** 压根没跑起来的那几条早退路径,输出字段一律取这份,省得各处漏填。 */
122
+ const EMPTY_OUTPUT = Object.freeze({ stdout: '', stderr: '', stdoutDropped: 0, stderrDropped: 0 });
123
+
124
+ /**
125
+ * ExecResult 的运输来源不属于公开结果契约,故用不可枚举 symbol 携带:
126
+ * 既让 execFailure 能区分本机/SSH,也不破坏调用方按既有字段 deepEqual/序列化。
127
+ */
128
+ const EXEC_ORIGIN = Symbol('execOrigin');
129
+
130
+ function markExecOrigin(result, origin) {
131
+ Object.defineProperty(result, EXEC_ORIGIN, { value: origin });
132
+ return result;
133
+ }
134
+
135
+ /**
136
+ * input 只接受明确的二进制类型,并在最靠近 spawn 的 lib 边界再次限长。
137
+ * 返回 Buffer view 供 stdin.end 使用;不转字符串,避免内容进入诊断文本。
138
+ */
139
+ function normalizeChildInput(input) {
140
+ if (input === undefined) return null;
141
+ if (!Buffer.isBuffer(input) && !(input instanceof Uint8Array)) {
142
+ throw new TypeError('input 必须是 Buffer 或 Uint8Array');
143
+ }
144
+ if (input.byteLength > SSH_INPUT_CAP_BYTES) {
145
+ throw new DshError('VALIDATION', 'input 不得超过 512 KiB');
146
+ }
147
+ return Buffer.isBuffer(input)
148
+ ? input
149
+ : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
150
+ }
151
+
152
+ /**
153
+ * 收集子进程输出并管理超时/中止的强杀链:TERM → 2s → KILL。
154
+ * @returns {Promise<ExecResult>} 不 reject;调用方看 code/timedOut 分类
155
+ */
156
+ function runChild(bin, args, {
157
+ timeoutMs,
158
+ signal,
159
+ closedMessage = 'manager 正在退出,这次远端命令没有发出',
160
+ origin = 'ssh',
161
+ input,
162
+ }) {
163
+ const childInput = normalizeChildInput(input);
164
+ return new Promise((resolve) => {
165
+ if (closed) {
166
+ resolve(markExecOrigin({
167
+ ...EMPTY_OUTPUT,
168
+ code: null,
169
+ signal: null,
170
+ stderr: closedMessage,
171
+ timedOut: false,
172
+ aborted: true,
173
+ }, origin));
174
+ return;
175
+ }
176
+ let child;
177
+ try {
178
+ child = spawn(bin, args, { stdio: [childInput === null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
179
+ } catch (err) {
180
+ resolve(markExecOrigin({
181
+ ...EMPTY_OUTPUT, code: null, signal: null, stderr: String(err.message ?? err), timedOut: false, aborted: false,
182
+ }, origin));
183
+ return;
184
+ }
185
+
186
+ const stdout = createTailCapture(SSH_OUTPUT_CAP_BYTES);
187
+ const stderr = createTailCapture(SSH_OUTPUT_CAP_BYTES);
188
+ let timedOut = false;
189
+ let aborted = false;
190
+ let escalate = null;
191
+ let settled = false;
192
+
193
+ child.stdout.setEncoding('utf8');
194
+ child.stderr.setEncoding('utf8');
195
+ child.stdout.on('data', (d) => stdout.push(d));
196
+ child.stderr.on('data', (d) => stderr.push(d));
197
+
198
+ const killChain = () => {
199
+ if (child.exitCode !== null || child.signalCode !== null) return;
200
+ child.kill('SIGTERM');
201
+ escalate = setTimeout(() => child.kill('SIGKILL'), KILL_ESCALATE_MS);
202
+ };
203
+
204
+ // 这两个定时器不 unref:它们必须真的能触发(子进程可能挂死且不产生 IO),
205
+ // 由 finish() 的 clearTimeout 负责不拖住退出。
206
+ const timer = timeoutMs > 0
207
+ ? setTimeout(() => { timedOut = true; killChain(); }, timeoutMs)
208
+ : null;
209
+
210
+ const onAbort = () => { aborted = true; killChain(); };
211
+ if (signal) {
212
+ if (signal.aborted) onAbort();
213
+ else signal.addEventListener('abort', onAbort, { once: true });
214
+ }
215
+ inFlight.add(killChain);
216
+
217
+ const finish = (code, sig) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ if (timer) clearTimeout(timer);
221
+ if (escalate) clearTimeout(escalate);
222
+ inFlight.delete(killChain);
223
+ signal?.removeEventListener('abort', onAbort);
224
+ resolve(markExecOrigin({
225
+ code,
226
+ signal: sig,
227
+ stdout: stdout.text(),
228
+ stderr: stderr.text(),
229
+ stdoutDropped: stdout.dropped(),
230
+ stderrDropped: stderr.dropped(),
231
+ timedOut,
232
+ aborted,
233
+ }, origin));
234
+ };
235
+
236
+ child.on('error', (err) => {
237
+ stderr.push((stderr.text() ? '\n' : '') + String(err.message ?? err));
238
+ finish(null, null);
239
+ });
240
+ child.on('close', (code, sig) => finish(code, sig));
241
+
242
+ if (childInput !== null) {
243
+ // 对端可能在 input 尚未写完前退出;pipe 的 EPIPE/ERR_STREAM_DESTROYED 不能成为
244
+ // manager 的未处理异常。命令成败仍由 close/code、timeout/abort 和协议输出判定。
245
+ child.stdin.on('error', () => {});
246
+ try {
247
+ child.stdin.end(childInput);
248
+ } catch {
249
+ // 极早退出也可能让 end 同步拒绝;不得把 input 或 stream 异常抛出执行器边界。
250
+ }
251
+ }
252
+ });
253
+ }
254
+
255
+ /**
256
+ * 一次性远端命令。body 由 lib/proto 产出,此处统一包 `sh -c <shq(body)>`(12 §0):
257
+ * 远端登录 shell 只负责剥一层引号并交给 sh,保证 POSIX 语义与登录 shell 种类无关。
258
+ * @param {string} host 经 assertSafeHost 校验(防 ssh 参数位注入,12 §2.4)
259
+ * @param {string} remoteCmd
260
+ * @param {{timeoutMs?:number, signal?:AbortSignal, extraOpts?:string[],
261
+ * input?:Buffer|Uint8Array}} [opts]
262
+ * @returns {Promise<ExecResult>}
263
+ */
264
+ export async function sshExec(
265
+ host,
266
+ remoteCmd,
267
+ {
268
+ timeoutMs = PROTO_TIMING.onceTimeoutMs,
269
+ signal,
270
+ extraOpts = [],
271
+ input,
272
+ } = {},
273
+ ) {
274
+ assertSafeHost(host);
275
+ const { bin, prefixArgs } = sshBin();
276
+ const args = [...prefixArgs, ...COMMON_SSH_OPTS, ...extraOpts, host, `sh -c ${shq(remoteCmd)}`];
277
+ return runChild(bin, args, { timeoutMs, signal, input });
278
+ }
279
+
280
+ /**
281
+ * 本机一次性命令。command 是 lib/proto 产出的原始模板文本;spawn 的 argv 边界已经
282
+ * 保住整段文本,因此这里只加一层 `-c`,不能再套远端运输使用的 `sh -c <quoted>`。
283
+ * @param {string} command
284
+ * @param {{timeoutMs?:number, signal?:AbortSignal, input?:Buffer|Uint8Array}} [opts]
285
+ * @returns {Promise<ExecResult>}
286
+ */
287
+ export async function localExec(
288
+ command,
289
+ { timeoutMs = PROTO_TIMING.onceTimeoutMs, signal, input } = {},
290
+ ) {
291
+ const { bin, prefixArgs } = localShBin();
292
+ return runChild(bin, [...prefixArgs, '-c', command], {
293
+ timeoutMs,
294
+ signal,
295
+ input,
296
+ closedMessage: 'manager 正在退出,这次本机命令没有执行',
297
+ origin: 'local-exec',
298
+ });
299
+ }
300
+
301
+ /**
302
+ * scp 单文件上载(12 §4)。remoteRelPath 相对远端 $HOME。
303
+ * @returns {Promise<ExecResult>}
304
+ */
305
+ export async function scpTo(host, localPath, remoteRelPath, { timeoutMs = PROTO_TIMING.scpTimeoutMs, signal } = {}) {
306
+ assertSafeHost(host);
307
+ const { bin, prefixArgs } = scpBin();
308
+ const args = [...prefixArgs, ...COMMON_SSH_OPTS, '--', localPath, `${host}:${remoteRelPath}`];
309
+ return runChild(bin, args, { timeoutMs, signal });
310
+ }
311
+
312
+ /** @returns {ExecResult} */
313
+ function copyResult({
314
+ code = null,
315
+ stderr = '',
316
+ timedOut = false,
317
+ aborted = false,
318
+ } = {}) {
319
+ return markExecOrigin({
320
+ ...EMPTY_OUTPUT,
321
+ code,
322
+ signal: null,
323
+ stderr,
324
+ timedOut,
325
+ aborted,
326
+ }, 'local-copy');
327
+ }
328
+
329
+ /**
330
+ * 把远端 HOME 相对路径收紧到本机 HOME/.dsh_center_remote/ 内。
331
+ * 这里只做词法判定;后续 prepareLocalCopyTarget 负责逐段核对真实文件系统。
332
+ * @returns {{home:string, target:string}}
333
+ */
334
+ function localCopyTarget(remoteRelPath) {
335
+ if (typeof remoteRelPath !== 'string' || remoteRelPath.includes('\0')) {
336
+ throw new DshError('VALIDATION', '本机复制目标路径不合法');
337
+ }
338
+ if (path.isAbsolute(remoteRelPath) || remoteRelPath.split(/[\\/]+/u).includes('..')) {
339
+ throw new DshError('VALIDATION', '本机复制目标必须位于 ~/.dsh_center_remote/ 内');
340
+ }
341
+
342
+ const home = process.env.HOME;
343
+ if (!home) {
344
+ throw new DshError('LOCAL_COPY_FAILED', '本机复制失败:HOME 未设置');
345
+ }
346
+ const root = path.resolve(home, REMOTE_DIR);
347
+ const target = path.resolve(home, remoteRelPath);
348
+ const relative = path.relative(root, target);
349
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
350
+ throw new DshError('VALIDATION', '本机复制目标必须严格位于 ~/.dsh_center_remote/ 内');
351
+ }
352
+ return { home: path.resolve(home), target };
353
+ }
354
+
355
+ function isWithin(root, candidate) {
356
+ const relative = path.relative(root, candidate);
357
+ return relative === ''
358
+ || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
359
+ }
360
+
361
+ function localPathError(message, filePath, cause = null) {
362
+ return new DshError('LOCAL_COPY_FAILED', message, {
363
+ detail: [filePath ? `路径:${filePath}` : null, cause ? String(cause.message ?? cause) : null]
364
+ .filter(Boolean)
365
+ .join('\n') || null,
366
+ cause: cause instanceof Error ? cause : undefined,
367
+ });
368
+ }
369
+
370
+ /**
371
+ * 从真实 HOME 下的 REMOTE_DIR 起逐段 lstat。缺目录逐个 mkdir,绝不让 recursive mkdir
372
+ * 帮我们悄悄穿过 symlink;每段再 realpath 回读,保证最终仍是刚核过的物理路径。
373
+ */
374
+ async function checkLocalDirectoryChain(root, parent, { createMissing }) {
375
+ if (!isWithin(root, parent)) {
376
+ throw localPathError('本机复制目标目录越过了 ~/.dsh_center_remote', parent);
377
+ }
378
+
379
+ const base = path.dirname(root);
380
+ const segments = path.relative(base, parent).split(path.sep).filter(Boolean);
381
+ let current = base;
382
+ for (const segment of segments) {
383
+ current = path.join(current, segment);
384
+ let stat;
385
+ try {
386
+ // eslint-disable-next-line no-await-in-loop -- 必须按父→子顺序核验,不能并发越级
387
+ stat = await fs.lstat(current);
388
+ } catch (err) {
389
+ if (err?.code !== 'ENOENT' || !createMissing) {
390
+ throw localPathError('本机复制目标目录不可访问', current, err);
391
+ }
392
+ try {
393
+ // eslint-disable-next-line no-await-in-loop -- 只建当前这一段,随后立即 lstat/realpath 回读
394
+ await fs.mkdir(current, { mode: 0o700 });
395
+ } catch (mkdirErr) {
396
+ if (mkdirErr?.code !== 'EEXIST') {
397
+ throw localPathError('本机复制目标目录创建失败', current, mkdirErr);
398
+ }
399
+ }
400
+ // mkdir 与 EEXIST 都必须重新读取,EEXIST 可能正是竞争者塞进来的 symlink。
401
+ // eslint-disable-next-line no-await-in-loop -- 同上,逐段安全检查
402
+ stat = await fs.lstat(current);
403
+ }
404
+
405
+ if (stat.isSymbolicLink()) {
406
+ throw localPathError('本机复制目标目录包含符号链接,已拒绝写入', current);
407
+ }
408
+ if (!stat.isDirectory()) {
409
+ throw localPathError('本机复制目标路径中的中间项不是目录', current);
410
+ }
411
+ // eslint-disable-next-line no-await-in-loop -- lstat 后逐段 realpath 回读是同一项安全检查
412
+ const actual = await fs.realpath(current);
413
+ if (actual !== current || !isWithin(root, actual)) {
414
+ throw localPathError('本机复制目标目录的真实路径越界,已拒绝写入', current);
415
+ }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * 供 localCopy 与本机 patch cleanup 共用:解析真实 HOME、安全创建父目录并回传物理路径。
421
+ * @returns {Promise<{root:string,target:string,parent:string}>}
422
+ */
423
+ export async function prepareLocalCopyTarget(remoteRelPath) {
424
+ const lexical = localCopyTarget(remoteRelPath);
425
+ let realHome;
426
+ try {
427
+ realHome = await fs.realpath(lexical.home);
428
+ } catch (err) {
429
+ throw localPathError('本机复制失败:HOME 不可访问', lexical.home, err);
430
+ }
431
+
432
+ const relativeFromHome = path.relative(lexical.home, lexical.target);
433
+ const root = path.resolve(realHome, REMOTE_DIR);
434
+ const target = path.resolve(realHome, relativeFromHome);
435
+ const parent = path.dirname(target);
436
+ if (!isWithin(root, target) || target === root) {
437
+ throw new DshError('VALIDATION', '本机复制目标必须严格位于 ~/.dsh_center_remote/ 内');
438
+ }
439
+ await checkLocalDirectoryChain(root, parent, { createMissing: true });
440
+ return { root, target, parent };
441
+ }
442
+
443
+ /**
444
+ * 本机单文件复制。先写同目录临时文件,再原子 rename;rename 成功即提交点,之后抵达的
445
+ * 中止/超时只能视为迟到,必须返回成功且不得删除正式目标。提交前取消只清临时文件。
446
+ * 文件系统 API 本身不能强杀,因此 cancel 会落状态,待当前内核操作返回后收敛。
447
+ * @param {string} localPath
448
+ * @param {string} remoteRelPath 相对本机 HOME,且必须位于 REMOTE_DIR 内
449
+ * @param {{timeoutMs?:number, signal?:AbortSignal}} [opts]
450
+ * @returns {Promise<ExecResult>}
451
+ */
452
+ export async function localCopy(
453
+ localPath,
454
+ remoteRelPath,
455
+ { timeoutMs = PROTO_TIMING.scpTimeoutMs, signal } = {},
456
+ ) {
457
+ if (closed) {
458
+ return copyResult({
459
+ stderr: 'manager 正在退出,这次本机文件复制没有执行',
460
+ aborted: true,
461
+ });
462
+ }
463
+ let prepared;
464
+ try {
465
+ prepared = await prepareLocalCopyTarget(remoteRelPath);
466
+ } catch (err) {
467
+ if (err?.code === 'VALIDATION') throw err;
468
+ return copyResult({
469
+ stderr: [err?.message, err?.detail].filter(Boolean).join('\n') || String(err),
470
+ });
471
+ }
472
+ const { root, target, parent } = prepared;
473
+ if (signal?.aborted) {
474
+ return copyResult({ stderr: '本机文件复制被中止', aborted: true });
475
+ }
476
+
477
+ const temporary = `${target}.dshc-copy-${process.pid}-${randomUUID()}`;
478
+ let timedOut = false;
479
+ let aborted = false;
480
+ let cancelled = false;
481
+ const cancel = () => {
482
+ aborted = true;
483
+ cancelled = true;
484
+ };
485
+ const onAbort = () => cancel();
486
+ const timer = timeoutMs > 0
487
+ ? setTimeout(() => {
488
+ timedOut = true;
489
+ cancelled = true;
490
+ }, timeoutMs)
491
+ : null;
492
+ signal?.addEventListener('abort', onAbort, { once: true });
493
+ inFlight.add(cancel);
494
+
495
+ try {
496
+ if (cancelled) {
497
+ return copyResult({
498
+ stderr: timedOut ? '本机文件复制超时' : '本机文件复制被中止',
499
+ timedOut,
500
+ aborted,
501
+ });
502
+ }
503
+ await checkLocalDirectoryChain(root, parent, { createMissing: false });
504
+ await fs.copyFile(localPath, temporary, fs.constants.COPYFILE_EXCL);
505
+ if (cancelled) {
506
+ await fs.rm(temporary, { force: true });
507
+ return copyResult({
508
+ stderr: timedOut ? '本机文件复制超时' : '本机文件复制被中止',
509
+ timedOut,
510
+ aborted,
511
+ });
512
+ }
513
+ await checkLocalDirectoryChain(root, parent, { createMissing: false });
514
+ if (cancelled) {
515
+ await fs.rm(temporary, { force: true });
516
+ return copyResult({
517
+ stderr: timedOut ? '本机文件复制超时' : '本机文件复制被中止',
518
+ timedOut,
519
+ aborted,
520
+ });
521
+ }
522
+ await fs.rename(temporary, target);
523
+ // commit point:rename 已原子替换正式目标;迟到的 abort/timeout 不得回滚。
524
+ return copyResult({ code: 0 });
525
+ } catch (err) {
526
+ await fs.rm(temporary, { force: true }).catch(() => {});
527
+ return copyResult({
528
+ stderr: String(err?.message ?? err),
529
+ timedOut,
530
+ aborted,
531
+ });
532
+ } finally {
533
+ if (timer) clearTimeout(timer);
534
+ signal?.removeEventListener('abort', onAbort);
535
+ inFlight.delete(cancel);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * 截断告知(issue #92)。detail 会原样进错误框和日志,被截过还不说,看的人会以为
541
+ * 对端就只说了这么多——而真正的头几行(往往正是原因)已经被丢掉了。
542
+ * @returns {string|null}
543
+ */
544
+ export function noteTruncation(text, dropped, scope = '远端') {
545
+ const body = text || '';
546
+ if (!dropped) return body || null;
547
+ return `(${scope}输出过大,已丢弃开头 ${dropped} 字符,以下是末尾部分)\n${body}`;
548
+ }
549
+
550
+ /** 把 ExecResult 的失败面转成 DshError(调用方决定是否抛)。 */
551
+ export function execFailure(host, label, res) {
552
+ const origin = res?.[EXEC_ORIGIN] ?? 'ssh';
553
+ const local = origin === 'local-exec' || origin === 'local-copy';
554
+ const readableLabel = local ? String(label).replace(/远端/gu, '本机') : label;
555
+ const detail = noteTruncation(res.stderr, res.stderrDropped, local ? '本机' : '远端');
556
+ if (local) {
557
+ if (res.timedOut) {
558
+ return new DshError('LOCAL_TIMEOUT', `${readableLabel}超时(本机)`, { host, detail });
559
+ }
560
+ if (res.aborted) {
561
+ return new DshError('LOCAL_TIMEOUT', `${readableLabel}被中止(本机)`, { host, detail });
562
+ }
563
+ if (res.code !== 0) {
564
+ const code = origin === 'local-copy' ? 'LOCAL_COPY_FAILED' : 'LOCAL_EXEC_FAILED';
565
+ const kind = origin === 'local-copy' ? '文件复制' : '命令执行';
566
+ return new DshError(
567
+ code,
568
+ `${readableLabel}失败(本机${kind},退出码 ${res.code ?? res.signal})`,
569
+ { host, detail },
570
+ );
571
+ }
572
+ return null;
573
+ }
574
+
575
+ if (res.timedOut) {
576
+ return new DshError('SSH_TIMEOUT', `${label} 超时(${host})`, { host, detail });
577
+ }
578
+ if (res.aborted) {
579
+ return new DshError('SSH_TIMEOUT', `${label} 被中止(${host})`, { host, detail });
580
+ }
581
+ if (res.code !== 0) {
582
+ return new DshError('SSH_UNREACHABLE', `${label} 失败(${host},退出码 ${res.code ?? res.signal})`, {
583
+ host,
584
+ detail,
585
+ });
586
+ }
587
+ return null;
588
+ }
589
+
590
+ // ── §6 每台主机操作串行队列 ──────────────────────────────────────────────
591
+
592
+ /** @type {Map<string, HostQueue>} */
593
+ const queues = new Map();
594
+
595
+ class HostQueue {
596
+ #tail = Promise.resolve();
597
+
598
+ #host;
599
+
600
+ constructor(host) {
601
+ this.#host = host;
602
+ }
603
+
604
+ get host() {
605
+ return this.#host;
606
+ }
607
+
608
+ /**
609
+ * @template T
610
+ * @param {string} label 事件/诊断用('probe'|'start'|'stop'|'verify'|…)
611
+ * @param {(signal:AbortSignal)=>Promise<T>} fn 在队首执行
612
+ * @param {{timeoutMs?:number}} [opts] 默认 30s;start 类传 90s(12 §3 预算)
613
+ * @returns {Promise<T>}
614
+ */
615
+ run(label, fn, { timeoutMs = 30_000 } = {}) {
616
+ const exec = async () => {
617
+ const ac = new AbortController();
618
+ const timeoutErr = new DshError('SSH_TIMEOUT', `${label} 超时 ${timeoutMs}ms`, { host: this.#host });
619
+ const t = setTimeout(() => ac.abort(timeoutErr), timeoutMs);
620
+ try {
621
+ return await fn(ac.signal);
622
+ } finally {
623
+ clearTimeout(t);
624
+ }
625
+ };
626
+ // 前序失败不阻断后续任务
627
+ const p = this.#tail.then(exec, exec);
628
+ // 吞掉尾部 rejection,防 unhandled
629
+ this.#tail = p.then(() => {}, () => {});
630
+ return p;
631
+ }
632
+ }
633
+
634
+ /** 同 host 返回同一实例。 */
635
+ export function hostQueue(host) {
636
+ let q = queues.get(host);
637
+ if (!q) {
638
+ q = new HostQueue(host);
639
+ queues.set(host, q);
640
+ }
641
+ return q;
642
+ }
643
+
644
+ /** 测试用。 */
645
+ export function _resetQueues() {
646
+ queues.clear();
647
+ }