@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/cli.js ADDED
@@ -0,0 +1,1445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dshc —— 本机主入口(11 §6 / 02 §9–§10)。
4
+ *
5
+ * 纪律:主机操作一律走 manager 的 REST API,CLI 不直连 ssh、不读写 state;
6
+ * 唯一例外是 `dshc init` 第 3 步的 probeOnce(此时 server 还不存在,11 §1.3 例外条款)。
7
+ *
8
+ * 退出码:0 成功|1 操作失败|2 超时/通信失败|3 用法错误|130 等待被 Ctrl-C 打断(操作仍在继续)。
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import http from 'node:http';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { spawn } from 'node:child_process';
16
+
17
+ import * as daemon from './daemon.js';
18
+ import * as updater from './updater.js';
19
+ import { FACTORY_DEFAULTS, newFactoryConfig, resolvePaths } from './defaults.js';
20
+ import { isMainEntry } from './lib/entry.js';
21
+ import {
22
+ RELEASE_REPO, SUMS_FILE, assetUrl, releasesUrl,
23
+ } from './lib/bundle.js';
24
+ import { DshError } from './lib/errors.js';
25
+ import { openerBin } from './lib/ssh.js';
26
+ import { BINDABLE_PORT_RANGE, isBindablePort } from './lib/validate.js';
27
+ import { canonicalSetupLocalName } from './store.js';
28
+ import {
29
+ SETUP_STEPS, buildConfigFromAnswers, defaultAnswers, getByPath, normalizeHostCandidates, previewJson, setByPath,
30
+ } from './web/setup-schema.js';
31
+
32
+ // interrupted=130 是 shell 惯例(128+SIGINT):脚本里要能把「我自己按了 Ctrl-C」
33
+ // 和「这事真失败了」分开(issue #108)
34
+ export const EXIT = { ok: 0, failed: 1, comm: 2, usage: 3, interrupted: 130 };
35
+
36
+ // ── argv 解析(ENG-16) ──────────────────────────────────────────────────
37
+
38
+ const FLAG_SPEC = {
39
+ port: 'number',
40
+ foreground: 'boolean',
41
+ force: 'boolean',
42
+ 'no-wait': 'boolean',
43
+ json: 'boolean',
44
+ verbose: 'boolean',
45
+ n: 'number',
46
+ f: 'boolean',
47
+ pre: 'boolean',
48
+ ref: 'string',
49
+ restart: 'boolean',
50
+ };
51
+
52
+ export class UsageError extends Error {}
53
+
54
+ /**
55
+ * `--key value` / `--key=value` / `-f` / `-n 50`;`--` 之后全部当 positional。
56
+ * 未知旗标抛 UsageError(退出码 3)。
57
+ * @param {string[]} argv
58
+ * @param {{flags:Record<string,'number'|'boolean'|'string'>}} [spec]
59
+ * @returns {{positionals:string[], flags:Record<string,any>}}
60
+ */
61
+ export function parseArgv(argv, spec = { flags: FLAG_SPEC }) {
62
+ const flags = {};
63
+ const positionals = [];
64
+ const types = spec.flags ?? {};
65
+ let passthrough = false;
66
+
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ const arg = argv[i];
69
+ if (passthrough) {
70
+ positionals.push(arg);
71
+ continue;
72
+ }
73
+ if (arg === '--') {
74
+ passthrough = true;
75
+ continue;
76
+ }
77
+ if (!arg.startsWith('-') || arg === '-') {
78
+ positionals.push(arg);
79
+ continue;
80
+ }
81
+
82
+ const long = arg.startsWith('--');
83
+ const body = long ? arg.slice(2) : arg.slice(1);
84
+ const eq = body.indexOf('=');
85
+ const name = eq === -1 ? body : body.slice(0, eq);
86
+ const inlineValue = eq === -1 ? null : body.slice(eq + 1);
87
+
88
+ const type = types[name];
89
+ if (!type) throw new UsageError(`未知旗标 ${arg}`);
90
+
91
+ if (type === 'boolean') {
92
+ if (inlineValue !== null && !/^(true|false)$/.test(inlineValue)) {
93
+ throw new UsageError(`旗标 --${name} 不接受值`);
94
+ }
95
+ flags[name] = inlineValue === null ? true : inlineValue === 'true';
96
+ continue;
97
+ }
98
+
99
+ const raw = inlineValue ?? argv[i + 1];
100
+ if (raw === undefined || (inlineValue === null && String(raw).startsWith('-'))) {
101
+ throw new UsageError(`旗标 --${name} 缺少值`);
102
+ }
103
+ if (inlineValue === null) i += 1;
104
+
105
+ if (type === 'number') {
106
+ if (!/^\d+$/.test(String(raw))) throw new UsageError(`旗标 --${name} 需要整数,收到 ${raw}`);
107
+ flags[name] = Number(raw);
108
+ } else {
109
+ flags[name] = String(raw);
110
+ }
111
+ }
112
+
113
+ return { positionals, flags };
114
+ }
115
+
116
+ /**
117
+ * 主机名前缀匹配(02 §10):精确优先 → 唯一前缀 → 歧义报错列候选。
118
+ * @returns {{ok:true, name:string}|{ok:false, error:string, candidates:string[]}}
119
+ */
120
+ export function resolveHostArg(input, hosts) {
121
+ const list = [...hosts];
122
+ if (list.includes(input)) return { ok: true, name: input };
123
+
124
+ const hits = list.filter((h) => h.startsWith(input));
125
+ if (hits.length === 1) return { ok: true, name: hits[0] };
126
+ if (hits.length === 0) {
127
+ return { ok: false, error: `没有匹配 "${input}" 的主机`, candidates: list };
128
+ }
129
+ return { ok: false, error: `"${input}" 匹配到多台主机,请写全`, candidates: hits };
130
+ }
131
+
132
+ // ── SSE 行解析(纯函数,供 waitTerminal 与单测共用) ─────────────────────
133
+
134
+ /**
135
+ * 增量 SSE 分帧器:喂 chunk,吐出完整帧。
136
+ * @returns {{push:(chunk:string)=>{type:string,data:any}[]}}
137
+ */
138
+ export function createSseParser() {
139
+ let buffer = '';
140
+ return {
141
+ push(chunk) {
142
+ buffer += chunk;
143
+ const frames = [];
144
+ let idx = buffer.indexOf('\n\n');
145
+ while (idx !== -1) {
146
+ const frame = parseSseFrame(buffer.slice(0, idx));
147
+ if (frame) frames.push(frame);
148
+ buffer = buffer.slice(idx + 2);
149
+ idx = buffer.indexOf('\n\n');
150
+ }
151
+ return frames;
152
+ },
153
+ };
154
+ }
155
+
156
+ export function parseSseFrame(raw) {
157
+ let type = 'message';
158
+ const data = [];
159
+ for (const line of raw.split('\n')) {
160
+ if (line.startsWith(':')) continue; // 心跳注释
161
+ if (line.startsWith('event:')) type = line.slice(6).trim();
162
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
163
+ }
164
+ if (data.length === 0) return null;
165
+ try {
166
+ return { type, data: JSON.parse(data.join('\n')) };
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * 各操作的终态集(11 §6.2 表)。
174
+ *
175
+ * `afterStarting`:ready 是「拉起回滚」的信号,可它同样是拉起**开始之前**的常态——
176
+ * 先订阅后动作意味着我们会收到动作前的存量帧(上一次 stop 的收尾、探测落地、
177
+ * restart 自己 stop 完那一拍)。所以 start/restart 的 fail 集只在见过一次 starting
178
+ * 之后才生效:真机 IT-09/IT-13 都栽在这里,CLI 报失败退场而拉起还在后台跑,
179
+ * 比单纯误报更糟。兜底仍是 operation-done——它每个 202 动作有且仅有一条。
180
+ */
181
+ export const TERMINAL = Object.freeze({
182
+ start: { success: ['running'], fail: ['ready', 'crashed'], afterStarting: true },
183
+ restart: { success: ['running'], fail: ['ready', 'crashed'], afterStarting: true },
184
+ stop: { success: ['ready'], fail: [] },
185
+ reconnect: { success: ['running'], fail: ['crashed'] },
186
+ probe: { success: ['ready', 'no_dsh', 'unreachable'], fail: [] },
187
+ });
188
+
189
+ // ── API 客户端 ───────────────────────────────────────────────────────────
190
+
191
+ class ApiError extends Error {
192
+ constructor({ status, code, message, detail }) {
193
+ super(message);
194
+ this.status = status;
195
+ this.code = code ?? 'INTERNAL';
196
+ this.detail = detail ?? null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * config.json 到底怎么了。「没有」「坏了」「读不了」必须分开——把它们一律当成
202
+ * 「尚未初始化」,就会拿「请执行 dshc init」去回答一份只是被截断的配置,而 init
203
+ * 是整份替换:原文里的 localPort 分配、workdir、注入的环境变量与 patch 清单一起没了。
204
+ *
205
+ * @param {string} [file]
206
+ * @returns {{kind:'ok', config:any}|{kind:'missing'}|{kind:'damaged', reason:string}|{kind:'unreadable', reason:string}}
207
+ */
208
+ export function classifyConfigFile(file = resolvePaths().config) {
209
+ let text;
210
+ try {
211
+ text = fs.readFileSync(file, 'utf8');
212
+ } catch (err) {
213
+ if (err.code === 'ENOENT') return { kind: 'missing' };
214
+ // fs 的 message 本来就以错误码开头(`EACCES: permission denied, …`),
215
+ // 再拼一遍 err.code 会读成「EACCES EACCES: …」
216
+ const reason = err.message.startsWith(`${err.code}:`) ? err.message : `${err.code ?? ''} ${err.message}`.trim();
217
+ return { kind: 'unreadable', reason };
218
+ }
219
+ // 空文件不算「没有」:它承载不了配置,可覆盖它照样丢东西(比如上一次写了一半)
220
+ if (text.trim() === '') return { kind: 'damaged', reason: '文件是空的' };
221
+ let parsed;
222
+ try {
223
+ parsed = JSON.parse(text);
224
+ } catch (err) {
225
+ return { kind: 'damaged', reason: `JSON 解析失败:${err.message}` };
226
+ }
227
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
228
+ return { kind: 'damaged', reason: '顶层不是 JSON 对象' };
229
+ }
230
+ return { kind: 'ok', config: parsed };
231
+ }
232
+
233
+ /** config.manager.port 是 API 的落点;--port 可临时覆盖。 */
234
+ function readConfigFile() {
235
+ const v = classifyConfigFile();
236
+ return v.kind === 'ok' ? v.config : null;
237
+ }
238
+
239
+ /**
240
+ * 坏配置在被覆盖前挪到一边。用户手上那份可能还能捞出 localPort 与注入项,
241
+ * 这个动作是「不丢东西」的最后一道保障。
242
+ * @returns {string|null} 备份路径
243
+ */
244
+ function backupDamagedConfig(file = resolvePaths().config) {
245
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
246
+ const dest = `${file}.bad-${stamp}`;
247
+ try {
248
+ fs.copyFileSync(file, dest);
249
+ return dest;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /** 坏/读不了时的统一说法:说清是什么情形、在哪个文件、往哪儿走。 */
256
+ function reportBadConfig(verdict, file = resolvePaths().config) {
257
+ if (verdict.kind === 'unreadable') {
258
+ errOut(`读不了 ${file}(${verdict.reason})。检查文件权限后重试。`);
259
+ return EXIT.failed;
260
+ }
261
+ errOut(`${file} 已损坏,拒绝启动(${verdict.reason})。`);
262
+ errOut('里面可能还留着能用的东西(localPort 分配、workdir、注入项)。');
263
+ errOut('先手工修好这份 JSON;确实不要了,就备份后执行 dshc init --force 重来。');
264
+ return EXIT.failed;
265
+ }
266
+
267
+ function managerPort(flags) {
268
+ if (Number.isInteger(flags?.port)) return flags.port;
269
+ const cfg = readConfigFile();
270
+ return Number.isInteger(cfg?.manager?.port) ? cfg.manager.port : FACTORY_DEFAULTS.manager.port;
271
+ }
272
+
273
+ /**
274
+ * manager 不在时的唯一口径。
275
+ *
276
+ * 前置探活(needsServer)与请求半路撞墙是同一件事的两条路,用户不该因为敲的是
277
+ * `start` 还是 `config set` 就看到两套说法(issue #22)。这句话只许有一份。
278
+ */
279
+ export function managerDownMessage(port) {
280
+ return `manager 未在 127.0.0.1:${port} 运行。先执行 dshc up。`;
281
+ }
282
+
283
+ function apiRequest(port, method, p, body) {
284
+ const payload = body === undefined ? null : JSON.stringify(body);
285
+ return new Promise((resolve, reject) => {
286
+ const req = http.request({
287
+ host: '127.0.0.1',
288
+ port,
289
+ path: p,
290
+ method,
291
+ timeout: 30_000,
292
+ headers: payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {},
293
+ }, (res) => {
294
+ let text = '';
295
+ res.setEncoding('utf8');
296
+ res.on('data', (c) => { text += c; });
297
+ res.on('end', () => {
298
+ let json = null;
299
+ try {
300
+ json = JSON.parse(text);
301
+ } catch {
302
+ json = null;
303
+ }
304
+ if (res.statusCode >= 400) {
305
+ reject(new ApiError({
306
+ status: res.statusCode,
307
+ code: json?.code,
308
+ message: json?.error ?? `HTTP ${res.statusCode}`,
309
+ detail: json?.detail ?? (json === null ? text : null),
310
+ }));
311
+ return;
312
+ }
313
+ resolve({ status: res.statusCode, json, text });
314
+ });
315
+ });
316
+ req.on('timeout', () => {
317
+ req.destroy();
318
+ reject(new ApiError({ status: 0, code: 'SSH_TIMEOUT', message: 'manager 响应超时' }));
319
+ });
320
+ req.on('error', (err) => reject(new ApiError({
321
+ status: 0,
322
+ // 端口上没人监听 = manager 没起,这是最常见的一种,给人话而不是 errno
323
+ code: err.code === 'ECONNREFUSED' ? 'MANAGER_DOWN' : 'INTERNAL',
324
+ message: err.code === 'ECONNREFUSED'
325
+ ? managerDownMessage(port)
326
+ : `无法连接 manager(127.0.0.1:${port}):${err.message}`,
327
+ detail: '先执行 dshc up 启动 manager。',
328
+ })));
329
+ if (payload) req.write(payload);
330
+ req.end();
331
+ });
332
+ }
333
+
334
+ /**
335
+ * 先订阅后动作(11 §6.2):SSE 开着再发 POST,避免事件竞速。
336
+ * @returns {Promise<{status:'ok'|'failed'|'timeout'|'interrupted', phase:string|null, lastError:string|null}>}
337
+ */
338
+ function waitTerminal(port, host, action, { timeoutMs = 120_000, onLog = null, trigger }) {
339
+ const spec = TERMINAL[action];
340
+ return new Promise((resolve, reject) => {
341
+ const parser = createSseParser();
342
+ let settled = false;
343
+ let lastError = null;
344
+ let sawStarting = false;
345
+
346
+ const finish = (result) => {
347
+ if (settled) return;
348
+ settled = true;
349
+ clearTimeout(timer);
350
+ process.off('SIGINT', onSignal);
351
+ process.off('SIGTERM', onSignal);
352
+ req.destroy();
353
+ resolve(result);
354
+ };
355
+
356
+ // Ctrl-C 只是「不等了」,不是「取消」:远端那趟拉起在 manager 那边照常跑完。
357
+ // 接住信号是为了能说出这句实话——默认行为会把 CLI 直接掐掉,一个字都留不下。
358
+ // 不去尝试中止操作:中途掐断远端命令正是孤儿的来源。
359
+ const onSignal = () => finish({ status: 'interrupted', phase: null, lastError });
360
+ process.on('SIGINT', onSignal);
361
+ process.on('SIGTERM', onSignal);
362
+
363
+ const timer = setTimeout(() => finish({ status: 'timeout', phase: null, lastError }), timeoutMs);
364
+
365
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/events' }, async (res) => {
366
+ if (res.statusCode !== 200) {
367
+ clearTimeout(timer);
368
+ reject(new ApiError({ status: res.statusCode, code: 'INTERNAL', message: `事件流不可用(HTTP ${res.statusCode})` }));
369
+ return;
370
+ }
371
+ res.setEncoding('utf8');
372
+ res.on('data', (chunk) => {
373
+ for (const frame of parser.push(chunk)) {
374
+ if (frame.type === 'log-line' && frame.data.host === host) {
375
+ if (frame.data.level === 'error') lastError = frame.data.msg;
376
+ onLog?.(frame.data);
377
+ }
378
+ if (frame.type === 'operation-done' && frame.data.host === host && frame.data.action === action) {
379
+ if (frame.data.status === 'failed') lastError = frame.data.error ?? lastError;
380
+ finish({
381
+ status: frame.data.status === 'ok' ? 'ok' : 'failed',
382
+ phase: null,
383
+ lastError,
384
+ });
385
+ }
386
+ if (frame.type === 'host-changed' && frame.data.host?.name === host) {
387
+ const phase = frame.data.host.phase;
388
+ if (phase === 'starting') sawStarting = true;
389
+ const failArmed = !spec.afterStarting || sawStarting;
390
+ if (spec.success.includes(phase)) finish({ status: 'ok', phase, lastError });
391
+ else if (failArmed && spec.fail.includes(phase)) finish({ status: 'failed', phase, lastError });
392
+ }
393
+ }
394
+ });
395
+ res.on('end', () => finish({ status: 'timeout', phase: null, lastError }));
396
+
397
+ // 订阅已建立,现在才发动作
398
+ try {
399
+ await trigger();
400
+ } catch (err) {
401
+ clearTimeout(timer);
402
+ req.destroy();
403
+ settled = true;
404
+ reject(err);
405
+ }
406
+ });
407
+ req.on('error', (err) => {
408
+ clearTimeout(timer);
409
+ if (!settled) reject(new ApiError({ status: 0, code: 'INTERNAL', message: `事件流中断:${err.message}` }));
410
+ });
411
+ });
412
+ }
413
+
414
+ // ── 输出助手 ─────────────────────────────────────────────────────────────
415
+
416
+ const PHASE_LABEL = {
417
+ running: '运行中',
418
+ degraded: '重连中',
419
+ crashed: '已崩溃',
420
+ ready: '可拉起',
421
+ starting: '启动中',
422
+ no_dsh: '无 dsh',
423
+ unreachable: '不可达',
424
+ unknown: '未探测',
425
+ };
426
+
427
+ function out(line = '') {
428
+ process.stdout.write(`${line}\n`);
429
+ }
430
+
431
+ function errOut(line) {
432
+ process.stderr.write(`${line}\n`);
433
+ }
434
+
435
+ /** 等宽表格:中文按两格宽计,避免列错位。 */
436
+ function width(s) {
437
+ let w = 0;
438
+ for (const ch of String(s)) w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1;
439
+ return w;
440
+ }
441
+
442
+ export function formatTable(headers, rows) {
443
+ const all = [headers, ...rows];
444
+ const widths = headers.map((_, i) => Math.max(...all.map((r) => width(r[i] ?? ''))));
445
+ const line = (cells) => cells.map((c, i) => String(c ?? '') + ' '.repeat(widths[i] - width(c ?? ''))).join(' ').trimEnd();
446
+ return [line(headers), ...rows.map(line)].join('\n');
447
+ }
448
+
449
+ /**
450
+ * 超时或「没能跟对方说上话」的错误码——退出码 2 的判据。
451
+ * 其余(校验不过、相位冲突、拒杀、端口用尽、本机执行/复制失败)都是操作失败,算 1。
452
+ */
453
+ const COMM_CODES = new Set(['SSH_TIMEOUT', 'SSH_UNREACHABLE', 'LOCAL_TIMEOUT']);
454
+
455
+ /** @returns {0|1|2|3} */
456
+ export function exitCodeFor({ status, code }) {
457
+ if (status === 0) return EXIT.comm; // 连 manager 都没连上
458
+ if (COMM_CODES.has(code)) return EXIT.comm;
459
+ // 值不合法就是用法错误:命令行上敲错的东西,和 `up --port` 越界同一口径(issue #63)
460
+ return code === 'VALIDATION' ? EXIT.usage : EXIT.failed;
461
+ }
462
+
463
+ /**
464
+ * 这些错的 detail 装的正是用户此刻要的那一句——CONFIG_STALE 是「接下来怎么办」,
465
+ * VALIDATION 是「哪个字段、要什么」。藏在 --verbose 后面等于没说(issue #65、#63)。
466
+ */
467
+ const DETAIL_ALWAYS_CODES = new Set(['CONFIG_STALE', 'VALIDATION']);
468
+
469
+ function reportApiError(err, flags) {
470
+ if (err instanceof ApiError) {
471
+ // manager 没起:那句话本身就是完整交代,别再套「错误:…(MANAGER_DOWN)」
472
+ // 和「加 --verbose」这两层壳(issue #22)
473
+ if (err.code === 'MANAGER_DOWN') {
474
+ errOut(err.message);
475
+ return EXIT.comm;
476
+ }
477
+ errOut(`错误:${err.message}${err.code ? `(${err.code})` : ''}`);
478
+ if (err.detail) {
479
+ if (flags?.verbose || DETAIL_ALWAYS_CODES.has(err.code)) errOut(err.detail);
480
+ else errOut('加 --verbose 查看完整 detail。');
481
+ }
482
+ return exitCodeFor(err);
483
+ }
484
+ // 本机侧抛的 DshError:detail 装的是文件路径与常见成因,正是此刻要给的那几句
485
+ errOut(`错误:${err.message}`);
486
+ if (err.detail) errOut(err.detail);
487
+ return EXIT.failed;
488
+ }
489
+
490
+ // ── 生命周期命令(ENG-21) ──────────────────────────────────────────────
491
+
492
+ async function cmdUp({ flags }) {
493
+ // 端口写错是用法错误,在拉起之前就该判掉:否则会 spawn 一个必然绑不上的 manager,
494
+ // 等满 10s 健康检查,最后报「未确认健康」——把人往权限、launchd 的方向带(issue #21)
495
+ if (flags.port !== undefined && !isBindablePort(flags.port)) {
496
+ throw new UsageError(
497
+ `--port 需在 ${BINDABLE_PORT_RANGE.min}–${BINDABLE_PORT_RANGE.max} 之间,收到 ${flags.port}`,
498
+ );
499
+ }
500
+
501
+ const check = await daemon.aliveCheck();
502
+ if (check.alive) {
503
+ out(`manager 已在运行:pid ${check.info.pid},端口 ${check.info.port},模式 ${check.info.mode}`);
504
+ return EXIT.ok;
505
+ }
506
+ if (check.stale) out('清理了失效的 pidfile(上次进程已不在)。');
507
+
508
+ const verdict = classifyConfigFile();
509
+ // 坏了/读不了都不许往下走:往下是「当作没有配置」,交互终端上还会直接进向导
510
+ // 覆盖掉那份可能还能救的文件(issue #52)。
511
+ if (verdict.kind === 'damaged' || verdict.kind === 'unreadable') return reportBadConfig(verdict);
512
+
513
+ const cfg = verdict.kind === 'ok' ? verdict.config : null;
514
+ if (cfg?.setupCompleted !== true) {
515
+ if (!process.stdin.isTTY) {
516
+ errOut('尚未初始化配置,且当前不是交互终端。请先在终端执行 dshc init。');
517
+ return EXIT.usage;
518
+ }
519
+ out('尚未初始化配置,先走一遍向导:');
520
+ const code = await cmdInit({ flags: { force: true }, positionals: [] });
521
+ if (code !== EXIT.ok) return code;
522
+ }
523
+
524
+ if (flags.foreground) {
525
+ // 前台:直接把 server 跑在当前进程(Ctrl-C 即停),launchd 也走这条路
526
+ const { main } = await import('./server.js');
527
+ const booted = await main({ portOverride: flags.port ?? null });
528
+ out(`manager 前台运行中:http://127.0.0.1:${booted.port}`);
529
+ return EXIT.ok;
530
+ }
531
+
532
+ const res = await daemon.launchDetached({ port: flags.port ?? null });
533
+ if (!res.confirmed) {
534
+ // 报了失败就不许有进程留着:否则占端口的人一走,它自己就把端口接过去了(issue #77)
535
+ errOut(res.reaped
536
+ ? `已拉起 pid ${res.pid},但未在预算内确认健康,已把它收走。查看 ${resolvePaths().log}`
537
+ : `已拉起 pid ${res.pid},但它自己退了(未确认健康)。查看 ${resolvePaths().log}`);
538
+ return EXIT.comm;
539
+ }
540
+ out(`manager 已启动:pid ${res.pid},http://127.0.0.1:${res.port}`);
541
+ return EXIT.ok;
542
+ }
543
+
544
+ async function cmdDown() {
545
+ const res = await daemon.stopDaemon();
546
+ if (!res.stopped) {
547
+ out('manager 未在运行。');
548
+ return EXIT.ok;
549
+ }
550
+ out(`manager 已关停(模式 ${res.mode}${res.forced ? ',SIGTERM 超时后强杀' : ''})。`);
551
+ return EXIT.ok;
552
+ }
553
+
554
+ /** 无参 = manager 重启;带主机名 = 主机重启(11 §6.1 冲突消解)。 */
555
+ async function cmdRestart(parsed) {
556
+ if (parsed.positionals.length > 0) return cmdHostAction('restart', parsed);
557
+
558
+ const check = await daemon.aliveCheck();
559
+ if (!check.alive) {
560
+ out('manager 未在运行,直接启动。');
561
+ return cmdUp(parsed);
562
+ }
563
+ if (check.info.mode === 'launchd') {
564
+ // launchd 会按 KeepAlive 拉回,走 API 自我重启最稳
565
+ return withApi(parsed, async (port) => {
566
+ await apiRequest(port, 'POST', '/api/manager/restart');
567
+ out('已请求重启,launchd 会拉回新实例。');
568
+ return EXIT.ok;
569
+ });
570
+ }
571
+ await daemon.stopDaemon();
572
+ return cmdUp(parsed);
573
+ }
574
+
575
+ async function cmdStatus({ flags }) {
576
+ const check = await daemon.aliveCheck();
577
+ const service = await daemon.serviceStatus();
578
+ const port = check.info?.port ?? managerPort(flags);
579
+ const info = check.remote ?? await daemon.fetchInfo(port);
580
+
581
+ const report = {
582
+ running: Boolean(info),
583
+ mode: info?.mode ?? check.info?.mode ?? null,
584
+ pid: info?.pid ?? null,
585
+ port: info?.port ?? check.info?.port ?? null,
586
+ uptimeMs: info?.uptimeMs ?? null,
587
+ setupCompleted: info?.setupCompleted ?? (readConfigFile()?.setupCompleted ?? null),
588
+ hosts: info?.hostCounts ?? null,
589
+ pidfile: check.info ?? null,
590
+ pidfileStale: check.stale,
591
+ launchd: service,
592
+ };
593
+
594
+ if (flags.json) {
595
+ out(JSON.stringify(report, null, 2));
596
+ return report.running ? EXIT.ok : EXIT.failed;
597
+ }
598
+
599
+ if (!report.running) {
600
+ out('manager:未运行');
601
+ if (check.stale) out(` pidfile 残留:pid ${check.info.pid}(进程已不在或端口无响应)`);
602
+ if (service.installed) out(` launchd:已安装 plist${service.loaded ? `,状态 ${service.state}` : ',未加载'}`);
603
+ if (report.setupCompleted !== true) out(' 配置:未初始化(先跑 dshc init)');
604
+ return EXIT.failed;
605
+ }
606
+
607
+ out(`manager:运行中(${report.mode})`);
608
+ out(` pid ${report.pid} 端口 ${report.port} 已运行 ${fmtDuration(report.uptimeMs)}`);
609
+ if (report.hosts) {
610
+ out(` 主机 ${report.hosts.total} 台:运行 ${report.hosts.running} / 重连 ${report.hosts.degraded} / 异常 ${report.hosts.crashed}`);
611
+ }
612
+ // 三方核对:不一致时如实列出,不擅自“修正”
613
+ if (check.info && check.info.pid !== report.pid) {
614
+ out(` ⚠ pidfile 记录 pid ${check.info.pid},实际 ${report.pid}`);
615
+ }
616
+ if (report.mode === 'launchd' && !service.loaded) {
617
+ out(' ⚠ 自称 launchd 模式,但 launchctl 里查不到该服务');
618
+ }
619
+ if (service.loaded && service.pid && service.pid !== report.pid) {
620
+ out(` ⚠ launchctl 记录 pid ${service.pid},实际 ${report.pid}`);
621
+ }
622
+ return EXIT.ok;
623
+ }
624
+
625
+ function fmtDuration(ms) {
626
+ if (!Number.isFinite(ms)) return '—';
627
+ const s = Math.floor(ms / 1000);
628
+ if (s < 60) return `${s}秒`;
629
+ if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`;
630
+ if (s < 86_400) return `${Math.floor(s / 3600)}小时${Math.floor((s % 3600) / 60)}分`;
631
+ return `${Math.floor(s / 86_400)}天${Math.floor((s % 86_400) / 3600)}小时`;
632
+ }
633
+
634
+ async function cmdLogs({ flags }) {
635
+ const file = resolvePaths().log;
636
+ if (!fs.existsSync(file)) {
637
+ errOut(`日志还不存在:${file}`);
638
+ return EXIT.failed;
639
+ }
640
+ const lines = flags.n ?? 200;
641
+ out(tailFile(file, lines).trimEnd());
642
+
643
+ if (!flags.f) return EXIT.ok;
644
+
645
+ // -f:轮询追加(无依赖版 tail -f;fs.watch 在日志轮转时不可靠)
646
+ let offset = fs.statSync(file).size;
647
+ await new Promise(() => {
648
+ setInterval(() => {
649
+ let size;
650
+ try {
651
+ size = fs.statSync(file).size;
652
+ } catch {
653
+ return;
654
+ }
655
+ if (size < offset) offset = 0; // 被截断/轮转
656
+ if (size === offset) return;
657
+ const fd = fs.openSync(file, 'r');
658
+ const buf = Buffer.alloc(size - offset);
659
+ fs.readSync(fd, buf, 0, buf.length, offset);
660
+ fs.closeSync(fd);
661
+ offset = size;
662
+ process.stdout.write(buf.toString('utf8'));
663
+ }, 400);
664
+ });
665
+ return EXIT.ok;
666
+ }
667
+
668
+ /** 只读文件尾部(大日志不整读)。 */
669
+ export function tailFile(file, lines, { chunkSize = 64 * 1024 } = {}) {
670
+ const size = fs.statSync(file).size;
671
+ const fd = fs.openSync(file, 'r');
672
+ try {
673
+ let pos = size;
674
+ let text = '';
675
+ // 多读一行:从块中间切进来的首行可能是残行,按行数够了再丢掉它
676
+ while (pos > 0 && countLines(text) <= lines) {
677
+ const len = Math.min(chunkSize, pos);
678
+ pos -= len;
679
+ const buf = Buffer.alloc(len);
680
+ fs.readSync(fd, buf, 0, len, pos);
681
+ text = buf.toString('utf8') + text;
682
+ }
683
+ const all = text.replace(/\n$/, '').split('\n');
684
+ const tail = all.slice(Math.max(0, all.length - lines));
685
+ return tail.join('\n');
686
+ } finally {
687
+ fs.closeSync(fd);
688
+ }
689
+ }
690
+
691
+ function countLines(text) {
692
+ return text === '' ? 0 : text.replace(/\n$/, '').split('\n').length;
693
+ }
694
+
695
+ async function cmdService({ positionals }) {
696
+ const sub = positionals[0];
697
+ if (!['install', 'uninstall', 'status'].includes(sub)) {
698
+ throw new UsageError('dshc service install|uninstall|status');
699
+ }
700
+ if (sub === 'install') {
701
+ const res = await daemon.serviceInstall();
702
+ if (!res.ok) {
703
+ errOut(`launchd 安装失败:${res.stderr ?? '未知原因'}`);
704
+ return EXIT.failed;
705
+ }
706
+ out(`已安装并加载 ${res.plist}(崩溃会由 launchd 自动拉回)。`);
707
+ return EXIT.ok;
708
+ }
709
+ if (sub === 'uninstall') {
710
+ const res = await daemon.serviceUninstall();
711
+ if (!res.ok) {
712
+ errOut(`launchd 卸载失败:${res.stderr ?? '未知原因'}`);
713
+ return EXIT.failed;
714
+ }
715
+ out('已卸载 launchd 服务。');
716
+ return EXIT.ok;
717
+ }
718
+ const st = await daemon.serviceStatus();
719
+ out(`plist:${st.installed ? '已安装' : '未安装'}`);
720
+ out(`加载:${st.loaded ? `是(state=${st.state}${st.pid ? `, pid=${st.pid}` : ''})` : '否'}`);
721
+ return st.loaded ? EXIT.ok : EXIT.failed;
722
+ }
723
+
724
+ // ── 版本与更新 ───────────────────────────────────────────────────────────
725
+
726
+ async function cmdVersion({ flags }) {
727
+ const info = await updater.collectVersionInfo();
728
+ if (flags.json) {
729
+ out(JSON.stringify(info, null, 2));
730
+ return EXIT.ok;
731
+ }
732
+ out(`dsh-center ${info.version ?? '(版本号读不出来)'}`);
733
+ out(`安装通道:${info.channelDetail}`);
734
+ // 运行时路径是 bundle 安装的自证:指向 <bundle 根>/runtime/bin/node 才算真用上自带运行时
735
+ out(`Node 运行时:${info.node.version}(${info.node.execPath})`);
736
+ out(`安装位置:${info.root}`);
737
+ return info.channel === 'unknown' ? EXIT.failed : EXIT.ok;
738
+ }
739
+
740
+ /**
741
+ * 「无需更新」怎么说。跟着预发布的人在稳定口径下会一直停在旧 rc 上(正式版比 rc 旧,
742
+ * 只会看到「已是最新」),所以有更新的预发布时必须点名,否则这条路是个哑口。
743
+ * @returns {string[]}
744
+ */
745
+ export function upToDateLines({ from, pre = false, newerPrerelease = null }) {
746
+ const lines = [`已是最新:v${from}${pre ? '(含预发布口径)' : ''}。`];
747
+ if (newerPrerelease) {
748
+ lines.push(`有更新的预发布 v${newerPrerelease},要跟就 dshc update --pre。`);
749
+ }
750
+ return lines;
751
+ }
752
+
753
+ /** 更新完要不要重启:默认只提示——重启会瞬断所有隧道页签,时机该由人挑。 */
754
+ async function offerRestart(flags) {
755
+ const check = await daemon.aliveCheck();
756
+ if (!check.alive) return EXIT.ok;
757
+ if (!flags.restart) {
758
+ out('manager 还在跑旧代码,改动下次重启才生效:dshc restart');
759
+ return EXIT.ok;
760
+ }
761
+ out('正在重启 manager(隧道会瞬断,页签会自愈重连)…');
762
+ // 复用 restart 的既有分支(launchd 走 API 自我重启,普通模式停了再起)
763
+ return cmdRestart({ positionals: [], flags });
764
+ }
765
+
766
+ async function cmdUpdate({ flags }) {
767
+ const install = updater.resolveInstall();
768
+
769
+ if (install.channel === 'unknown') {
770
+ errOut(`认不出这是怎么装的,不敢动:${install.reason}`);
771
+ errOut('重装一次最省事:curl -fsSL https://raw.githubusercontent.com/'
772
+ + `${RELEASE_REPO}/main/install.sh | bash`);
773
+ return EXIT.failed;
774
+ }
775
+
776
+ // npm 装的包归 npm 管:dshc 代跑 npm i -g 会踩权限与多包管器的浑水,只指路
777
+ if (install.channel === 'npm') {
778
+ errOut('这是 npm 装的,更新请用:npm i -g @shendeguize/remote-dsh-center@latest'
779
+ + '(跟预发布用 npm i -g @shendeguize/remote-dsh-center@next)');
780
+ return EXIT.failed;
781
+ }
782
+
783
+ if (install.channel === 'git') {
784
+ const ref = flags.ref ?? updater.DEFAULT_GIT_REF;
785
+ const res = await updater.updateGit({ root: install.root, ref });
786
+ if (!res.ok) {
787
+ errOut(`更新失败:${res.problem}`);
788
+ return EXIT.failed;
789
+ }
790
+ if (res.action === 'up-to-date') {
791
+ out(`已是 origin/${ref} 的最新提交(${res.from.slice(0, 8)},版本 ${res.fromVersion})。`);
792
+ return EXIT.ok;
793
+ }
794
+ out(`已更新:${res.from.slice(0, 8)} → ${res.to.slice(0, 8)}`);
795
+ out(`版本:${res.fromVersion} → ${res.toVersion}(跟的是 origin/${ref})`);
796
+ return offerRestart(flags);
797
+ }
798
+
799
+ const res = await updater.updateBundle({
800
+ root: install.root,
801
+ bundleInfo: install.bundleInfo,
802
+ releasesUrl: releasesUrl(),
803
+ assetUrlFor: ({ tag, name }) => assetUrl({ tag, name }),
804
+ sumsUrlFor: ({ tag }) => assetUrl({ tag, name: SUMS_FILE }),
805
+ includePrerelease: Boolean(flags.pre),
806
+ pinned: flags.ref ?? null,
807
+ });
808
+
809
+ if (res.action === 'none') {
810
+ errOut(`没找到可装的版本:${res.reason}`);
811
+ return EXIT.failed;
812
+ }
813
+ if (res.action === 'up-to-date') {
814
+ for (const line of upToDateLines({ from: res.from, pre: Boolean(flags.pre), newerPrerelease: res.newerPrerelease })) {
815
+ out(line);
816
+ }
817
+ return EXIT.ok;
818
+ }
819
+ out(`已更新:v${res.from} → v${res.to}`);
820
+ out(`上一版留在 ${res.previous}(要回滚就把它换回来)。`);
821
+ return offerRestart(flags);
822
+ }
823
+
824
+ // ── 主机操作命令(ENG-22) ──────────────────────────────────────────────
825
+
826
+ /** 需要 manager 在跑的命令统一入口:拿端口 + 统一错误处理。 */
827
+ async function withApi(parsed, fn) {
828
+ const port = managerPort(parsed.flags);
829
+ try {
830
+ return await fn(port);
831
+ } catch (err) {
832
+ // 参数写错不是「操作失败」:原样抛给 main 那段统一处理(用法错误 + usage + 3)。
833
+ // 接住它就等于把 `dshc start`(漏主机名)报成退出码 1,脚本会拿去重试(issue #63)
834
+ if (err instanceof UsageError) throw err;
835
+ return reportApiError(err, parsed.flags);
836
+ }
837
+ }
838
+
839
+ async function fetchHosts(port) {
840
+ const res = await apiRequest(port, 'GET', '/api/hosts');
841
+ return res.json.hosts;
842
+ }
843
+
844
+ async function pickHost(port, input) {
845
+ const hosts = await fetchHosts(port);
846
+ const names = hosts.map((h) => h.name);
847
+ const hit = resolveHostArg(input, names);
848
+ if (!hit.ok) {
849
+ errOut(`错误:${hit.error}`);
850
+ if (hit.candidates.length > 0) errOut(`候选:${hit.candidates.join(', ')}`);
851
+ return { ok: false, code: EXIT.usage };
852
+ }
853
+ return { ok: true, name: hit.name, host: hosts.find((h) => h.name === hit.name) };
854
+ }
855
+
856
+ async function cmdLs(parsed) {
857
+ return withApi(parsed, async (port) => {
858
+ const hosts = await fetchHosts(port);
859
+ if (parsed.flags.json) {
860
+ out(JSON.stringify(hosts, null, 2));
861
+ return EXIT.ok;
862
+ }
863
+ if (hosts.length === 0) {
864
+ out('没有主机:检查 ~/.ssh/config 是否有可用 Host 条目。');
865
+ return EXIT.ok;
866
+ }
867
+ out(formatTable(
868
+ ['主机', '状态', '本机映射', 'PID', '版本', '自启'],
869
+ hosts.map((h) => [
870
+ h.name,
871
+ PHASE_LABEL[h.phase] ?? h.phase,
872
+ h.mappedUrl ? `127.0.0.1:${h.tunnel.localPort}` : '—',
873
+ h.web ? `${h.web.pid}${h.web.startedByUs ? '' : '(手动)'}` : '—',
874
+ h.probe?.version ?? '—',
875
+ h.config.autoStart ? '是' : '否',
876
+ ]),
877
+ ));
878
+ return EXIT.ok;
879
+ });
880
+ }
881
+
882
+ async function cmdProbe(parsed) {
883
+ return withApi(parsed, async (port) => {
884
+ if (parsed.positionals.length === 0) {
885
+ await apiRequest(port, 'POST', '/api/hosts/probe');
886
+ out('已触发全量探测(dshc ls 查看结果)。');
887
+ return EXIT.ok;
888
+ }
889
+ const picked = await pickHost(port, parsed.positionals[0]);
890
+ if (!picked.ok) return picked.code;
891
+ return runAction(port, picked.name, 'probe', parsed);
892
+ });
893
+ }
894
+
895
+ async function cmdHostAction(action, parsed) {
896
+ return withApi(parsed, async (port) => {
897
+ const input = parsed.positionals[0];
898
+ if (!input) throw new UsageError(`dshc ${action} <host>`);
899
+ const picked = await pickHost(port, input);
900
+ if (!picked.ok) return picked.code;
901
+ return runAction(port, picked.name, action, parsed);
902
+ });
903
+ }
904
+
905
+ /** 202 受理型操作:默认挂 SSE 等终态,--no-wait 立即返回。 */
906
+ async function runAction(port, name, action, parsed) {
907
+ const endpoint = `/api/hosts/${encodeURIComponent(name)}/${action}`;
908
+
909
+ if (parsed.flags['no-wait']) {
910
+ await apiRequest(port, 'POST', endpoint);
911
+ out(`已受理:${name} ${action}`);
912
+ return EXIT.ok;
913
+ }
914
+
915
+ const res = await waitTerminal(port, name, action, {
916
+ trigger: () => apiRequest(port, 'POST', endpoint),
917
+ onLog: (line) => errOut(` [${line.level}] ${line.msg}`),
918
+ });
919
+
920
+ if (res.status === 'ok') {
921
+ out(`${name} ${action} 成功${res.phase ? `(${PHASE_LABEL[res.phase] ?? res.phase})` : ''}`);
922
+ return EXIT.ok;
923
+ }
924
+ if (res.status === 'interrupted') {
925
+ errOut(`不等了(Ctrl-C)。${name} ${action} 仍在 manager 那边继续,dshc ls 看它落到哪。`);
926
+ return EXIT.interrupted;
927
+ }
928
+ if (res.status === 'timeout') {
929
+ errOut(`${name} ${action} 等待超时;manager 可能仍在执行,dshc ls 查看当前状态。`);
930
+ return EXIT.comm;
931
+ }
932
+ errOut(`${name} ${action} 失败${res.phase ? `(回到${PHASE_LABEL[res.phase] ?? res.phase})` : ''}${res.lastError ? `:${res.lastError}` : ''}`);
933
+ return EXIT.failed;
934
+ }
935
+
936
+ async function cmdLog(parsed) {
937
+ return withApi(parsed, async (port) => {
938
+ const input = parsed.positionals[0];
939
+ if (!input) throw new UsageError('dshc log <host> [-n N]');
940
+ const picked = await pickHost(port, input);
941
+ if (!picked.ok) return picked.code;
942
+ const lines = parsed.flags.n ?? 200;
943
+ const res = await apiRequest(port, 'GET', `/api/hosts/${encodeURIComponent(picked.name)}/log?lines=${lines}`);
944
+ process.stdout.write(res.text.endsWith('\n') || res.text === '' ? res.text : `${res.text}\n`);
945
+ return EXIT.ok;
946
+ });
947
+ }
948
+
949
+ async function cmdOpen(parsed) {
950
+ const port = managerPort(parsed.flags);
951
+ const input = parsed.positionals[0];
952
+ let url = `http://127.0.0.1:${port}/`;
953
+ if (input) {
954
+ try {
955
+ const hosts = await fetchHosts(port);
956
+ const hit = resolveHostArg(input, hosts.map((h) => h.name));
957
+ if (!hit.ok) {
958
+ errOut(`错误:${hit.error}`);
959
+ if (hit.candidates.length > 0) errOut(`候选:${hit.candidates.join(', ')}`);
960
+ return EXIT.usage;
961
+ }
962
+ url = `http://127.0.0.1:${port}/#/host/${encodeURIComponent(hit.name)}`;
963
+ } catch (err) {
964
+ return reportApiError(err, parsed.flags);
965
+ }
966
+ }
967
+ out(url);
968
+ const opener = openerBin();
969
+ const child = spawn(opener.bin, [...opener.prefixArgs, url], { stdio: 'ignore', detached: true });
970
+ child.unref();
971
+ return EXIT.ok;
972
+ }
973
+
974
+ /** dshc config get|set <点路径> [值]:走 API 让运行中的实例热生效。 */
975
+ async function cmdConfig(parsed) {
976
+ const [sub, key, value] = parsed.positionals;
977
+ if (!['get', 'set'].includes(sub)) throw new UsageError('dshc config get|set <key> [value]');
978
+
979
+ if (sub === 'get') {
980
+ const cfg = readConfigFile();
981
+ if (!cfg) {
982
+ errOut('尚未初始化 config.json,先跑 dshc init。');
983
+ return EXIT.failed;
984
+ }
985
+ const found = key ? getByPath(cfg, key) : cfg;
986
+ if (found === undefined) {
987
+ errOut(`config 里没有 ${key}`);
988
+ return EXIT.failed;
989
+ }
990
+ out(typeof found === 'object' ? JSON.stringify(found, null, 2) : String(found));
991
+ return EXIT.ok;
992
+ }
993
+
994
+ if (!key || value === undefined) throw new UsageError('dshc config set <key> <value>');
995
+
996
+ return withApi(parsed, async (port) => {
997
+ const parsedValue = coerceConfigValue(value);
998
+
999
+ // 主机级键走 PUT /api/hosts/:name/config(CLI 镜像全部主机操作,02 §10)
1000
+ const hostPatch = buildHostPatchFor(key, parsedValue);
1001
+ if (hostPatch) {
1002
+ const picked = await pickHost(port, hostPatch.name);
1003
+ if (!picked.ok) return picked.code;
1004
+ await apiRequest(port, 'PUT', `/api/hosts/${encodeURIComponent(picked.name)}/config`, hostPatch.body);
1005
+ const [field, written] = Object.entries(hostPatch.body)[0];
1006
+ out(`已写入 hosts.${picked.name}.${field} = ${JSON.stringify(written)}`);
1007
+ out('下次拉起时生效(正在跑的实例不受影响):dshc restart <host>');
1008
+ return EXIT.ok;
1009
+ }
1010
+
1011
+ const body = buildDefaultsPatchFor(key, parsedValue);
1012
+ if (!body) {
1013
+ errOut(`不支持直接 set ${key};可写:manager.port、defaults.remoteWebPort、defaults.localPortRange、hosts.<主机>.workdir`);
1014
+ return EXIT.usage;
1015
+ }
1016
+ const res = await apiRequest(port, 'PUT', '/api/config/defaults', body);
1017
+ out(`已写入 ${key} = ${JSON.stringify(parsedValue)}`);
1018
+ if (res.json?.restartRequired) out('manager 端口改动需重启后生效:dshc restart');
1019
+ return EXIT.ok;
1020
+ });
1021
+ }
1022
+
1023
+ export function coerceConfigValue(raw) {
1024
+ if (/^\d+$/.test(raw)) return Number(raw);
1025
+ if (raw === 'true' || raw === 'false') return raw === 'true';
1026
+ const range = /^(\d+)[-,\s]+(\d+)$/.exec(raw);
1027
+ if (range) return [Number(range[1]), Number(range[2])];
1028
+ return raw;
1029
+ }
1030
+
1031
+ /**
1032
+ * `hosts.<主机>.<字段>` 点路径 → PUT 主机配置的 {主机名, 请求体}。
1033
+ * 主机名本身可含点(ssh Host 名允许),故用贪婪匹配 + 已知字段后缀切分。
1034
+ * @returns {{name:string, body:object}|null} null = 不是主机级键
1035
+ */
1036
+ export function buildHostPatchFor(key, value) {
1037
+ const m = /^hosts\.(.+)\.workdir$/.exec(String(key ?? ''));
1038
+ if (!m || m[1] === '') return null;
1039
+ // 命令行没法直接给 JSON null,故约定空串与字面 null 都表示「回落远端家目录」
1040
+ const wd = value === '' || value === 'null' ? null : value;
1041
+ return { name: m[1], body: { workdir: wd } };
1042
+ }
1043
+
1044
+ export function buildDefaultsPatchFor(key, value) {
1045
+ if (key === 'manager.port') return { manager: { port: value } };
1046
+ if (key === 'defaults.remoteWebPort') return { remoteWebPort: value };
1047
+ if (key === 'defaults.localPortRange') return { localPortRange: value };
1048
+ return null;
1049
+ }
1050
+
1051
+ // ── dshc init(ENG-18) ─────────────────────────────────────────────────
1052
+
1053
+ /**
1054
+ * 给候选清单稳定地补一台本机。强制重配时复用已有 local;名字冲突才追加 `-local[-N]`。
1055
+ * @param {Array<string|{name:string,local?:boolean}>} candidates
1056
+ * @param {string} hostname
1057
+ * @param {object|null} current
1058
+ */
1059
+ export function withLocalCandidate(candidates, hostname, current = null) {
1060
+ const normalized = normalizeHostCandidates(candidates);
1061
+ const localName = canonicalSetupLocalName(hostname, {
1062
+ hosts: current?.hosts,
1063
+ sshNames: normalized.filter((candidate) => !candidate.local).map((candidate) => candidate.name),
1064
+ });
1065
+
1066
+ return [
1067
+ ...normalized.filter((candidate) => !candidate.local && candidate.name !== localName),
1068
+ { name: localName, local: true },
1069
+ ];
1070
+ }
1071
+
1072
+ async function cmdInit({ flags }) {
1073
+ const verdict = classifyConfigFile();
1074
+ if (verdict.kind === 'unreadable') return reportBadConfig(verdict);
1075
+ if (verdict.kind === 'damaged' && !flags.force) {
1076
+ errOut(`${resolvePaths().config} 已损坏(${verdict.reason})。`);
1077
+ errOut('要丢掉它重走向导请加 --force(会先备份成 config.json.bad-<时间戳>)。');
1078
+ return EXIT.usage;
1079
+ }
1080
+
1081
+ const existing = verdict.kind === 'ok' ? verdict.config : null;
1082
+ if (existing?.setupCompleted === true && !flags.force) {
1083
+ errOut('配置已初始化。要重走向导请加 --force(会预填现有值)。');
1084
+ return EXIT.usage;
1085
+ }
1086
+ if (!process.stdin.isTTY) {
1087
+ errOut('dshc init 需要交互终端。非交互场景请用页面向导或直接写 config.json。');
1088
+ return EXIT.usage;
1089
+ }
1090
+ if (verdict.kind === 'damaged') {
1091
+ const saved = backupDamagedConfig();
1092
+ if (saved) out(`原配置已损坏,先备份到 ${saved}`);
1093
+ }
1094
+
1095
+ const readline = await import('node:readline/promises');
1096
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1097
+ try {
1098
+ const { loadHosts } = await import('./ssh-config.js');
1099
+ const { probeOnce } = await import('./prober.js');
1100
+ const preferredLocalName = os.hostname();
1101
+ const sshNames = loadHosts().map((host) => host.name);
1102
+ const candidates = withLocalCandidate(
1103
+ sshNames,
1104
+ preferredLocalName,
1105
+ existing,
1106
+ );
1107
+
1108
+ const result = await runSetupWizard({
1109
+ ask: (prompt) => rl.question(prompt),
1110
+ current: existing?.setupCompleted ? existing : newFactoryConfig(),
1111
+ sshHosts: candidates,
1112
+ probeHost: (name, candidate) => probeOnce(name, { local: candidate.local, timeoutMs: 20_000 }),
1113
+ });
1114
+ if (!result) {
1115
+ out('已取消,未写入任何内容。');
1116
+ return EXIT.ok;
1117
+ }
1118
+ return persistSetup(result.config, flags, {
1119
+ current: existing,
1120
+ sshNames,
1121
+ preferredLocalName,
1122
+ });
1123
+ } finally {
1124
+ rl.close();
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * 四步向导本体(ENG-18)。把 IO 全收进 `ask`/`print` 两个注入点,
1130
+ * 才能让单测脚本化地走完整条路径——终端交互否则只能靠人肉走查。
1131
+ *
1132
+ * @param {{
1133
+ * ask: (prompt: string) => Promise<string>,
1134
+ * print?: (line?: string) => void,
1135
+ * current: object,
1136
+ * sshHosts?: Array<string|{name:string,local?:boolean}>,
1137
+ * probeHost?: ((name: string, candidate:{name:string,local:boolean}) => Promise<{phase:string}>) | null,
1138
+ * probeDeadlineMs?: number,
1139
+ * }} io
1140
+ * @returns {Promise<{config:object, answers:object, selection:object, probeResults:object}|null>}
1141
+ * null = 用户在确认步骤放弃
1142
+ */
1143
+ export async function runSetupWizard({
1144
+ ask, print = out, current, sshHosts = [], probeHost = null, probeDeadlineMs = 25_000,
1145
+ }) {
1146
+ const answers = defaultAnswers(current);
1147
+ const candidates = normalizeHostCandidates(sshHosts);
1148
+
1149
+ print('');
1150
+ print('DSH Center 初始化向导(回车即取方括号内的默认值)');
1151
+
1152
+ // 步骤 1–2:逐字段问答
1153
+ for (const step of SETUP_STEPS.filter((s) => s.fields)) {
1154
+ print('');
1155
+ print(`— ${step.title} —`);
1156
+ for (const f of step.fields) {
1157
+ // eslint-disable-next-line no-await-in-loop -- 交互问答天然串行
1158
+ await askField({ ask, print }, f, answers);
1159
+ }
1160
+ }
1161
+
1162
+ // 步骤 3:主机纳管与开启(探测并行,先回先显)
1163
+ const probeResults = {};
1164
+ print('');
1165
+ print('— 主机纳管与开启 —');
1166
+ if (candidates.length === 0) {
1167
+ print('~/.ssh/config 里没有可用主机,可稍后补充后重跑 dshc init --force。');
1168
+ } else if (probeHost) {
1169
+ print(`发现 ${candidates.length} 台候选主机,正在并行探测…`);
1170
+ const probing = Promise.all(candidates.map(async (candidate) => {
1171
+ const { name } = candidate;
1172
+ try {
1173
+ const r = await probeHost(name, candidate);
1174
+ probeResults[name] = r;
1175
+ print(` ${r.phase === 'ready' ? '✔' : '✘'} ${name}${candidate.local ? '(本机)' : ''}:${PHASE_LABEL[r.phase] ?? r.phase}`);
1176
+ } catch (err) {
1177
+ probeResults[name] = { phase: 'unreachable' };
1178
+ print(` ✘ ${name}${candidate.local ? '(本机)' : ''}:探测失败(${err.message})`);
1179
+ }
1180
+ }));
1181
+ // 探测慢的主机不该卡住向导:给它一个上限,超时的按未完成处理
1182
+ await raceWithDeadline(probing, probeDeadlineMs);
1183
+ }
1184
+
1185
+ const selection = await askSelection({ ask, print }, candidates, probeResults);
1186
+
1187
+ // 步骤 4:预览 + 确认
1188
+ // 本机身份不是普通的 disabled SSH 条目:用户明确“不纳管”就不提交 local:true,
1189
+ // 因而最终确认后也不会为了一个未选择的候选去提前创建本机身份。
1190
+ const selectedCandidates = candidates.filter(
1191
+ (candidate) => !candidate.local || selection[candidate.name]?.enabled !== false,
1192
+ );
1193
+ const config = buildConfigFromAnswers(answers, selectedCandidates, probeResults, FACTORY_DEFAULTS, { selection });
1194
+ print('');
1195
+ print('— 确认 —');
1196
+ print(previewJson(config));
1197
+ const pending = candidates.filter(({ name }) => !probeResults[name]).length;
1198
+ if (pending > 0) print(`仍有 ${pending} 台探测未完成:它们按当前纳管选择保存,自启一律关闭。`);
1199
+ const yes = await ask('写入配置?[Y/n] > ');
1200
+ if (/^n/i.test(yes.trim())) return null;
1201
+
1202
+ return { config, answers, selection, probeResults };
1203
+ }
1204
+
1205
+ async function askField({ ask, print }, field, answers) {
1206
+ const current = getByPath(answers, field.key);
1207
+ const shown = field.format ? field.format(current) : String(current);
1208
+ for (;;) {
1209
+ if (field.hint) print(` (${field.hint})`);
1210
+ // eslint-disable-next-line no-await-in-loop -- 校验失败要重问
1211
+ const raw = await ask(`${field.label} [${shown}] > `);
1212
+ if (raw.trim() === '') return current;
1213
+
1214
+ const parsed = field.parse(raw);
1215
+ if (!parsed.ok) {
1216
+ print(` ✘ ${parsed.error}`);
1217
+ continue;
1218
+ }
1219
+ const bad = field.validate(parsed.value);
1220
+ if (bad) {
1221
+ print(` ✘ ${bad}`);
1222
+ continue;
1223
+ }
1224
+ setByPath(answers, field.key, parsed.value);
1225
+ return parsed.value;
1226
+ }
1227
+ }
1228
+
1229
+ /** 默认全部纳管;开启链接默认勾选全部 ready(其余不允许勾)。 */
1230
+ async function askSelection({ ask, print }, candidates, probeResults) {
1231
+ const selection = {};
1232
+ for (const { name } of candidates) selection[name] = { enabled: true, autoStart: probeResults[name]?.phase === 'ready' };
1233
+ if (candidates.length === 0) return selection;
1234
+
1235
+ const listed = candidates.map((candidate, i) => `${i + 1}) ${candidate.name}${candidate.local ? '(本机)' : ''}`).join(' ');
1236
+ print(` ${listed}`);
1237
+ const skip = await ask('不纳管哪些?输入序号,逗号分隔(回车=全部纳管) > ');
1238
+ for (const token of skip.split(/[\s,]+/).filter(Boolean)) {
1239
+ const idx = Number(token) - 1;
1240
+ const name = candidates[idx]?.name;
1241
+ if (name) selection[name] = { enabled: false, autoStart: false };
1242
+ }
1243
+
1244
+ const readyHosts = candidates
1245
+ .map((candidate) => candidate.name)
1246
+ .filter((name) => selection[name].enabled && probeResults[name]?.phase === 'ready');
1247
+ if (readyHosts.length > 0) {
1248
+ print(` 可随 manager 自启(仅 ready):${readyHosts.map((n, i) => `${i + 1}) ${n}`).join(' ')}`);
1249
+ const off = await ask('不自启哪些?输入序号(回车=全部自启) > ');
1250
+ for (const token of off.split(/[\s,]+/).filter(Boolean)) {
1251
+ const name = readyHosts[Number(token) - 1];
1252
+ if (name) selection[name].autoStart = false;
1253
+ }
1254
+ }
1255
+ return selection;
1256
+ }
1257
+
1258
+ /**
1259
+ * CLI 侧与 server.assertSetupLocalIdentities 等价的可信来源判定。
1260
+ * 只认现有 local 或共享纯算法算出的 canonical local;SSH 名即使同名也优先拒绝。
1261
+ */
1262
+ export function assertCliSetupLocalIdentities(config, {
1263
+ current = null, preferredLocalName, sshNames = [],
1264
+ } = {}) {
1265
+ const currentHosts = current?.hosts ?? {};
1266
+ const ssh = new Set(sshNames);
1267
+ const canonicalLocal = canonicalSetupLocalName(preferredLocalName, {
1268
+ hosts: currentHosts,
1269
+ sshNames,
1270
+ });
1271
+ for (const [name, host] of Object.entries(config?.hosts ?? {})) {
1272
+ const existingLocal = currentHosts[name]?.local === true;
1273
+ const requestedLocal = host?.local === true;
1274
+ const existingRemote = (Object.hasOwn(currentHosts, name) && !existingLocal) || ssh.has(name);
1275
+ const trustedCandidate = name === canonicalLocal;
1276
+
1277
+ if (requestedLocal && (existingRemote || (!existingLocal && !trustedCandidate))) {
1278
+ const message = existingRemote
1279
+ ? `初始化配置不能把 SSH 主机 ${name} 改成本机`
1280
+ : `初始化配置不能把未经 CLI 认可的主机 ${name} 声明为本机`;
1281
+ throw new DshError('NOT_ALLOWED', message, { host: name });
1282
+ }
1283
+ if (!requestedLocal && existingLocal) {
1284
+ throw new DshError('NOT_ALLOWED', `初始化配置不能把本机主机 ${name} 改成 SSH 主机`, {
1285
+ host: name,
1286
+ });
1287
+ }
1288
+ }
1289
+ return config;
1290
+ }
1291
+
1292
+ /** server 在跑 → 单次 POST /api/setup;没在跑 → 本进程做等价身份校验后原子写盘。 */
1293
+ export async function persistSetup(config, flags = {}, {
1294
+ current = null,
1295
+ sshNames = [],
1296
+ preferredLocalName = os.hostname(),
1297
+ } = {}) {
1298
+ try {
1299
+ assertCliSetupLocalIdentities(config, { current, preferredLocalName, sshNames });
1300
+ } catch (err) {
1301
+ return reportApiError(err, flags);
1302
+ }
1303
+
1304
+ const check = await daemon.aliveCheck();
1305
+ if (check.alive) {
1306
+ try {
1307
+ const port = check.info.port;
1308
+ const res = await apiRequest(port, 'POST', '/api/setup', config);
1309
+ out('配置已提交给运行中的 manager。');
1310
+ if (res.json?.portChanged) out(`端口已改为 ${res.json.port},${res.json.restarting ? 'manager 正在自我重启' : '需要 dshc restart 生效'}。`);
1311
+ return EXIT.ok;
1312
+ } catch (err) {
1313
+ return reportApiError(err, flags);
1314
+ }
1315
+ }
1316
+
1317
+ const store = await import('./store.js');
1318
+ await store.init();
1319
+ try {
1320
+ store.assertSetupLocalIdentities(config, preferredLocalName, sshNames);
1321
+ store.saveConfigFromSetup(config);
1322
+ } catch (err) {
1323
+ errOut(`错误:${err.message}`);
1324
+ if (err.detail) errOut(err.detail);
1325
+ return EXIT.failed;
1326
+ }
1327
+ out(`已写入 ${resolvePaths().config}。执行 dshc up 启动 manager。`);
1328
+ return EXIT.ok;
1329
+ }
1330
+
1331
+ /** 到点就放行,但赢家出现后要清掉定时器——否则 CLI 白等到超时才退出。 */
1332
+ function raceWithDeadline(promise, ms) {
1333
+ let timer = null;
1334
+ const deadline = new Promise((resolve) => {
1335
+ timer = setTimeout(resolve, ms);
1336
+ });
1337
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
1338
+ }
1339
+
1340
+ // ── 命令表与分发 ─────────────────────────────────────────────────────────
1341
+
1342
+ export const COMMANDS = {
1343
+ init: { usage: 'dshc init [--force]', needsServer: false, run: cmdInit },
1344
+ up: { usage: 'dshc up [--port N] [--foreground]', needsServer: false, run: cmdUp },
1345
+ down: { usage: 'dshc down', needsServer: false, run: cmdDown },
1346
+ restart: { usage: 'dshc restart [<host>]', needsServer: false, run: cmdRestart },
1347
+ status: { usage: 'dshc status [--json]', needsServer: false, run: cmdStatus },
1348
+ logs: { usage: 'dshc logs [-f] [-n N]', needsServer: false, run: cmdLogs },
1349
+ service: { usage: 'dshc service install|uninstall|status', needsServer: false, run: cmdService },
1350
+ version: { usage: 'dshc version [--json]', needsServer: false, run: cmdVersion },
1351
+ update: { usage: 'dshc update [--pre] [--ref <分支|tag>] [--restart]', needsServer: false, run: cmdUpdate },
1352
+
1353
+ ls: { usage: 'dshc ls [--json]', needsServer: true, run: cmdLs },
1354
+ probe: { usage: 'dshc probe [<host>]', needsServer: true, run: cmdProbe },
1355
+ start: { usage: 'dshc start <host> [--no-wait]', needsServer: true, run: (p) => cmdHostAction('start', p) },
1356
+ stop: { usage: 'dshc stop <host> [--no-wait]', needsServer: true, run: (p) => cmdHostAction('stop', p) },
1357
+ reconnect: { usage: 'dshc reconnect <host> [--no-wait]', needsServer: true, run: (p) => cmdHostAction('reconnect', p) },
1358
+ log: { usage: 'dshc log <host> [-n N]', needsServer: true, run: cmdLog },
1359
+ // 先探活再开浏览器:manager 没起时打开一个必定打不开的页面,还报成功,
1360
+ // 只会让人去怀疑浏览器和端口(issue #23)。引导模式要放行,页面就是向导。
1361
+ open: { usage: 'dshc open [<host>]', needsServer: true, allowSetupMode: true, run: cmdOpen },
1362
+ config: { usage: 'dshc config get|set <key> [value]', needsServer: false, run: cmdConfig },
1363
+ };
1364
+
1365
+ export function usageText() {
1366
+ const lines = ['dshc —— DSH Center 本机入口', '', '生命周期:'];
1367
+ for (const key of ['init', 'up', 'down', 'restart', 'status', 'logs', 'service', 'version', 'update']) lines.push(` ${COMMANDS[key].usage}`);
1368
+ lines.push('', '主机操作:');
1369
+ for (const key of ['ls', 'probe', 'start', 'stop', 'reconnect', 'log', 'open', 'config']) lines.push(` ${COMMANDS[key].usage}`);
1370
+ lines.push('', '退出码:0 成功|1 操作失败|2 超时/通信失败|3 用法错误|130 等待被 Ctrl-C 打断(操作仍在继续)');
1371
+ return lines.join('\n');
1372
+ }
1373
+
1374
+ /**
1375
+ * @param {string[]} argv 不含 node 与脚本路径
1376
+ * @returns {Promise<number>} 退出码
1377
+ */
1378
+ export async function run(argv) {
1379
+ const [rawName, ...rest] = argv;
1380
+ if (!rawName || rawName === '-h' || rawName === '--help' || rawName === 'help') {
1381
+ out(usageText());
1382
+ return rawName ? EXIT.ok : EXIT.usage;
1383
+ }
1384
+
1385
+ // `--help`/`-h` 收而 `--version` 不收,说不过去:排查现场问「你装的哪版」,
1386
+ // 第一反应就是敲 `--version`(issue #98)。`-v` 留着不占——`--verbose` 迟早要个短名。
1387
+ const name = rawName === '--version' || rawName === '-V' ? 'version' : rawName;
1388
+
1389
+ const cmd = COMMANDS[name];
1390
+ if (!cmd) {
1391
+ errOut(`未知命令:${name}`);
1392
+ errOut(usageText());
1393
+ return EXIT.usage;
1394
+ }
1395
+
1396
+ let parsed;
1397
+ try {
1398
+ parsed = parseArgv(rest);
1399
+ } catch (err) {
1400
+ errOut(`用法错误:${err.message}`);
1401
+ errOut(cmd.usage);
1402
+ return EXIT.usage;
1403
+ }
1404
+
1405
+ // 需要 manager 的命令先探活:不做隐式自动拉起(02 §10)
1406
+ if (cmd.needsServer) {
1407
+ const port = managerPort(parsed.flags);
1408
+ const info = await daemon.fetchInfo(port);
1409
+ if (!info) {
1410
+ errOut(managerDownMessage(port));
1411
+ return EXIT.comm;
1412
+ }
1413
+ // open 是引导模式下唯一还该放行的命令——页面就是向导本身,拦住它等于把人锁在门外
1414
+ if (info.setupCompleted === false && !cmd.allowSetupMode) {
1415
+ errOut('manager 处于首启引导模式(配置未完成)。先执行 dshc init 或打开管理台完成配置。');
1416
+ return EXIT.failed;
1417
+ }
1418
+ }
1419
+
1420
+ try {
1421
+ return await cmd.run(parsed);
1422
+ } catch (err) {
1423
+ if (err instanceof UsageError) {
1424
+ errOut(`用法错误:${err.message}`);
1425
+ errOut(cmd.usage);
1426
+ return EXIT.usage;
1427
+ }
1428
+ return reportApiError(err, parsed.flags);
1429
+ }
1430
+ }
1431
+
1432
+ // 必须比真实路径:装到 PATH 用的是软链(npm link 同理),argv[1] 是链接名而
1433
+ // import.meta.url 已经是解引用后的真身,只比字面量会判成「被 import」而什么都不做
1434
+ const invokedDirectly = isMainEntry(import.meta.url);
1435
+
1436
+ if (invokedDirectly) {
1437
+ run(process.argv.slice(2))
1438
+ .then((code) => {
1439
+ process.exitCode = code;
1440
+ })
1441
+ .catch((err) => {
1442
+ errOut(`内部错误:${err.stack ?? err.message}`);
1443
+ process.exitCode = EXIT.failed;
1444
+ });
1445
+ }