@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 批量配置同步的唯一规则源。
3
+ *
4
+ * 只复制「下一次拉起」使用的 profile;身份、纳管、自启、本机映射和运行态一律不碰。
5
+ */
6
+
7
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+
10
+ import { DshError } from './lib/errors.js';
11
+
12
+ export const SYNC_PROFILE_FIELDS = Object.freeze([
13
+ 'remoteWebPort',
14
+ 'workdir',
15
+ 'inject.env',
16
+ 'inject.extraArgs',
17
+ 'inject.patches',
18
+ ]);
19
+
20
+ const PREVIEW_TOKEN_VERSION = 'v1';
21
+ const PREVIEW_TOKEN_KEY = randomBytes(32);
22
+
23
+ function cloneInject(value) {
24
+ return {
25
+ env: { ...(value?.env ?? {}) },
26
+ extraArgs: [...(value?.extraArgs ?? [])],
27
+ patches: [...(value?.patches ?? [])],
28
+ };
29
+ }
30
+
31
+ export function syncProfileOf(hostConfig) {
32
+ return {
33
+ remoteWebPort: hostConfig?.remoteWebPort ?? null,
34
+ workdir: hostConfig?.workdir ?? null,
35
+ inject: cloneInject(hostConfig?.inject),
36
+ };
37
+ }
38
+
39
+ function changedFields(source, target) {
40
+ const changed = [];
41
+ if (!isDeepStrictEqual(source.remoteWebPort, target.remoteWebPort)) changed.push('remoteWebPort');
42
+ if (!isDeepStrictEqual(source.workdir, target.workdir)) changed.push('workdir');
43
+ if (!isDeepStrictEqual(source.inject.env, target.inject.env)) changed.push('inject.env');
44
+ if (!isDeepStrictEqual(source.inject.extraArgs, target.inject.extraArgs)) changed.push('inject.extraArgs');
45
+ if (!isDeepStrictEqual(source.inject.patches, target.inject.patches)) changed.push('inject.patches');
46
+ return changed;
47
+ }
48
+
49
+ function requireHost(config, name, role) {
50
+ const hosts = config?.hosts;
51
+ if (hosts === null || typeof hosts !== 'object' || !Object.hasOwn(hosts, name)) {
52
+ throw new DshError('NOT_FOUND', `${role} ${name} 不存在`, { host: name });
53
+ }
54
+ const host = hosts[name];
55
+ if (!host) throw new DshError('NOT_FOUND', `${role} ${name} 不存在`, { host: name });
56
+ return host;
57
+ }
58
+
59
+ function canonicalize(value) {
60
+ if (Array.isArray(value)) return value.map((entry) => canonicalize(entry));
61
+ if (value === null || typeof value !== 'object') return value;
62
+ return Object.fromEntries(
63
+ Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
64
+ );
65
+ }
66
+
67
+ function previewTokenForPlan(config, plan) {
68
+ const payload = {
69
+ source: {
70
+ name: plan.source,
71
+ profile: syncProfileOf(requireHost(config, plan.source, '源主机')),
72
+ },
73
+ targets: plan.targets
74
+ .map(({ name }) => ({
75
+ name,
76
+ profile: syncProfileOf(requireHost(config, name, '目标主机')),
77
+ }))
78
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
79
+ };
80
+ const digest = createHmac('sha256', PREVIEW_TOKEN_KEY)
81
+ .update('dsh-center/config-sync-preview/v1\0')
82
+ .update(JSON.stringify(canonicalize(payload)))
83
+ .digest('base64url');
84
+ return `${PREVIEW_TOKEN_VERSION}.${digest}`;
85
+ }
86
+
87
+ function tokensEqual(actual, expected) {
88
+ if (typeof actual !== 'string') return false;
89
+ const actualBytes = Buffer.from(actual, 'utf8');
90
+ const expectedBytes = Buffer.from(expected, 'utf8');
91
+ return actualBytes.length === expectedBytes.length
92
+ && timingSafeEqual(actualBytes, expectedBytes);
93
+ }
94
+
95
+ export function planConfigSync(config, { source, targets } = {}) {
96
+ if (typeof source !== 'string' || source === '') {
97
+ throw new DshError('VALIDATION', '请选择源主机');
98
+ }
99
+ if (!Array.isArray(targets) || targets.length === 0) {
100
+ throw new DshError('VALIDATION', '至少选择一台目标主机');
101
+ }
102
+ if (new Set(targets).size !== targets.length) {
103
+ throw new DshError('VALIDATION', '目标主机不能重复');
104
+ }
105
+ if (targets.includes(source)) {
106
+ throw new DshError('VALIDATION', '源主机不能同时作为目标主机');
107
+ }
108
+
109
+ const profile = syncProfileOf(requireHost(config, source, '源主机'));
110
+ const targetPlans = targets.map((name) => {
111
+ const fields = changedFields(profile, syncProfileOf(requireHost(config, name, '目标主机')));
112
+ return { name, changed: fields.length > 0, changedFields: fields };
113
+ });
114
+
115
+ return {
116
+ source,
117
+ profile,
118
+ targets: targetPlans,
119
+ };
120
+ }
121
+
122
+ export function createConfigSyncPreview(config, request) {
123
+ const plan = planConfigSync(config, request);
124
+ return {
125
+ plan,
126
+ previewToken: previewTokenForPlan(config, plan),
127
+ };
128
+ }
129
+
130
+ export function requireConfigSyncPreview(config, request, previewToken) {
131
+ const preview = createConfigSyncPreview(config, request);
132
+ if (!tokensEqual(previewToken, preview.previewToken)) {
133
+ throw new DshError(
134
+ 'CONFIG_STALE',
135
+ '配置同步预览已过期或无效,请重新预览后再应用',
136
+ { detail: '源主机或任一目标主机的同步 profile 可能已变化。' },
137
+ );
138
+ }
139
+ return preview.plan;
140
+ }
141
+
142
+ export function applyConfigSync(draft, plan) {
143
+ requireHost(draft, plan?.source, '源主机');
144
+ const targets = (plan?.targets ?? []).map((targetPlan) => ({
145
+ config: requireHost(draft, targetPlan.name, '目标主机'),
146
+ plan: targetPlan,
147
+ }));
148
+ const changed = [];
149
+ for (const { config: target, plan: targetPlan } of targets) {
150
+ if (!targetPlan.changed) continue;
151
+ target.remoteWebPort = plan.profile.remoteWebPort;
152
+ target.workdir = plan.profile.workdir;
153
+ target.inject = cloneInject(plan.profile.inject);
154
+ changed.push(targetPlan.name);
155
+ }
156
+ return changed;
157
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * manager 自身的生命周期设施(11 §6.4):detach 拉起、pidfile、launchd 服务化。
3
+ *
4
+ * 只依赖 defaults(叶子),不 import server/store——「谁拉起谁」这件事必须能在
5
+ * server 尚未存在的情况下完成(dshc up 的第一拍)。
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import http from 'node:http';
10
+ import path from 'node:path';
11
+ import { spawn } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { LAUNCHD_LABEL, resolvePaths } from './defaults.js';
15
+ import { DshError } from './lib/errors.js';
16
+ import { monotonicMs } from './lib/clock.js';
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ export const SERVER_ENTRY = path.join(HERE, 'server.js');
20
+ export const CLI_ENTRY = path.join(HERE, 'cli.js');
21
+
22
+ /** @typedef {{pid:number, port:number, mode:'foreground'|'background'|'launchd', startedAt:string}} PidInfo */
23
+
24
+ function paths() {
25
+ return resolvePaths();
26
+ }
27
+
28
+ /** DSHC_MODE 由拉起方注入(plist / launchDetached);缺省即前台。 */
29
+ export function detectMode(env = process.env) {
30
+ const m = env.DSHC_MODE;
31
+ return m === 'launchd' || m === 'background' ? m : 'foreground';
32
+ }
33
+
34
+ // ── pidfile ──────────────────────────────────────────────────────────────
35
+
36
+ /** @returns {PidInfo|null} */
37
+ export function readPidfile() {
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(paths().pidfile, 'utf8'));
40
+ if (!Number.isInteger(raw?.pid)) return null;
41
+ return raw;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function writePidfile(info) {
48
+ const p = paths();
49
+ try {
50
+ fs.mkdirSync(p.dir, { recursive: true });
51
+ const tmp = `${p.pidfile}.tmp.${process.pid}`;
52
+ fs.writeFileSync(tmp, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
53
+ fs.renameSync(tmp, p.pidfile);
54
+ } catch (err) {
55
+ // 没有 pidfile 就没人能找到这个 manager(stop/restart/CLI 全靠它),故必须硬失败;
56
+ // 但要给人话,别把 Node 的栈直接摔在用户脸上(issue #87)
57
+ throw new DshError('PIDFILE_WRITE_FAILED', 'manager 的运行记录写不进去,没法启动', {
58
+ detail: `文件:${p.pidfile}\n${err.code ?? ''} ${err.message}\n`.trim()
59
+ + '\n常见原因:磁盘满、所在卷变成只读、目录属主不是当前用户(比如被 sudo 跑过一次)。',
60
+ cause: err,
61
+ });
62
+ }
63
+ return info;
64
+ }
65
+
66
+ /** 仅 pid===process.pid 才删(§3.4 竞态防护:别把继任者的 pidfile 删了)。 */
67
+ export function removePidfileIfOwn() {
68
+ const info = readPidfile();
69
+ if (info?.pid !== process.pid) return false;
70
+ try {
71
+ fs.rmSync(paths().pidfile, { force: true });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function processAlive(pid) {
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ } catch (err) {
83
+ return err.code === 'EPERM'; // 存在但非本用户
84
+ }
85
+ }
86
+
87
+ /** GET /api/manager/info(不经 store,daemon 要能在 server 之外独立判活)。 */
88
+ export function fetchInfo(port, { timeoutMs = 1_500 } = {}) {
89
+ return new Promise((resolve) => {
90
+ const req = http.request(
91
+ { host: '127.0.0.1', port, path: '/api/manager/info', method: 'GET', timeout: timeoutMs },
92
+ (res) => {
93
+ let text = '';
94
+ res.setEncoding('utf8');
95
+ res.on('data', (c) => { text += c; });
96
+ res.on('end', () => {
97
+ if (res.statusCode !== 200) return resolve(null);
98
+ try {
99
+ resolve(JSON.parse(text));
100
+ } catch {
101
+ resolve(null);
102
+ }
103
+ });
104
+ },
105
+ );
106
+ req.on('timeout', () => { req.destroy(); resolve(null); });
107
+ req.on('error', () => resolve(null));
108
+ req.end();
109
+ });
110
+ }
111
+
112
+ /**
113
+ * kill(pid,0) + info 双验证(防 PID 复用,02 §9.5)。
114
+ * @returns {Promise<{alive:boolean, stale:boolean, info:PidInfo|null, remote:any|null}>}
115
+ */
116
+ export async function aliveCheck() {
117
+ const info = readPidfile();
118
+ if (!info) return { alive: false, stale: false, info: null, remote: null };
119
+ if (!processAlive(info.pid)) return { alive: false, stale: true, info, remote: null };
120
+
121
+ const remote = await fetchInfo(info.port);
122
+ if (!remote) return { alive: false, stale: true, info, remote: null };
123
+ if (remote.pid !== info.pid) return { alive: false, stale: true, info, remote };
124
+ return { alive: true, stale: false, info, remote };
125
+ }
126
+
127
+ // ── 后台拉起 ─────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * detach 拉起 server(§3.4 第 7 步同款)。stdout/stderr 以 O_APPEND 重定向到 manager.log,
131
+ * 两进程短暂共写安全。
132
+ *
133
+ * 预算内没确认健康就把它收回来(issue #77):留着不管的话,命令报了失败,那个进程
134
+ * 还在后台待着——等占着端口的人一走它自己就把端口接过去,用户手上于是有一个
135
+ * 「启动失败过」的 manager 在跑。
136
+ *
137
+ * `entry` / `DSHC_SERVER_ENTRY` 是测试缝(同 `DSHC_SSH_BIN` 的用法):换一个永远不落
138
+ * pidfile 的假 manager,才能验「没确认健康」这条路。
139
+ * @returns {Promise<{pid:number, port:number|null, confirmed:boolean, reaped:boolean}>}
140
+ */
141
+ export async function launchDetached({
142
+ port = null,
143
+ waitMs = Number(process.env.DSHC_UP_WAIT_MS ?? '') || 10_000,
144
+ env = {},
145
+ entry = process.env.DSHC_SERVER_ENTRY || SERVER_ENTRY,
146
+ } = {}) {
147
+ const p = paths();
148
+ let fd;
149
+ try {
150
+ fs.mkdirSync(p.dir, { recursive: true });
151
+ // 后台 manager 的 stdout/stderr 就指这个 fd;开不出来它就是个没有现场的黑箱,不如不起
152
+ fd = fs.openSync(p.log, 'a');
153
+ } catch (err) {
154
+ throw new DshError('LOGFILE_OPEN_FAILED', 'manager 的日志文件打不开,没法启动', {
155
+ detail: `文件:${p.log}\n${err.code ?? ''} ${err.message}`.trim()
156
+ + '\n常见原因:磁盘满、所在卷变成只读、目录属主不是当前用户(比如被 sudo 跑过一次)。',
157
+ cause: err,
158
+ });
159
+ }
160
+
161
+ const args = [entry];
162
+ if (port !== null) args.push('--port', String(port));
163
+
164
+ const child = spawn(process.execPath, args, {
165
+ detached: true,
166
+ stdio: ['ignore', fd, fd],
167
+ env: { ...process.env, ...env, DSHC_MODE: 'background' },
168
+ });
169
+ child.unref();
170
+ fs.closeSync(fd);
171
+
172
+ const deadline = monotonicMs() + waitMs;
173
+ let confirmed = false;
174
+ let seenPort = port;
175
+ while (monotonicMs() < deadline) {
176
+ // eslint-disable-next-line no-await-in-loop -- 就绪轮询
177
+ await sleep(200);
178
+ const info = readPidfile();
179
+ if (!info) continue;
180
+ // eslint-disable-next-line no-await-in-loop -- 同上
181
+ const remote = await fetchInfo(info.port);
182
+ if (remote?.pid === info.pid) {
183
+ confirmed = true;
184
+ seenPort = info.port;
185
+ break;
186
+ }
187
+ }
188
+ if (confirmed) return { pid: child.pid, port: seenPort, confirmed, reaped: false };
189
+ return { pid: child.pid, port: seenPort, confirmed: false, reaped: await reap(child.pid) };
190
+ }
191
+
192
+ /**
193
+ * 收走一个没确认健康的拉起:TERM → 1s → KILL。
194
+ * @returns {Promise<boolean>} 真的动过手才算 true(它自己已经退了就不算)
195
+ */
196
+ async function reap(pid, { graceMs = 1_000 } = {}) {
197
+ if (!processAlive(pid)) return false;
198
+ try {
199
+ process.kill(pid, 'SIGTERM');
200
+ } catch {
201
+ return false; // 竞态:刚好退了
202
+ }
203
+ const deadline = monotonicMs() + graceMs;
204
+ while (monotonicMs() < deadline && processAlive(pid)) {
205
+ // eslint-disable-next-line no-await-in-loop -- 等它落幕
206
+ await sleep(50);
207
+ }
208
+ if (processAlive(pid)) {
209
+ try {
210
+ process.kill(pid, 'SIGKILL');
211
+ } catch {
212
+ // 已退出
213
+ }
214
+ }
215
+ return true;
216
+ }
217
+
218
+ /**
219
+ * 不能 unref:CLI 是短命进程,等待期间事件循环若无句柄会直接退出,
220
+ * launchDetached / stopDaemon 的轮询就永远等不到结果(表现为命令静默返回)。
221
+ */
222
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
223
+
224
+ /**
225
+ * dshc down:launchd 实例走 bootout;裸后台走 TERM → 3s → KILL。
226
+ * @returns {Promise<{stopped:boolean, mode:string|null, forced:boolean}>}
227
+ */
228
+ export async function stopDaemon({ graceMs = 3_000 } = {}) {
229
+ const check = await aliveCheck();
230
+ if (!check.info) return { stopped: false, mode: null, forced: false };
231
+
232
+ if (check.info.mode === 'launchd') {
233
+ await serviceUninstall({ keepPlist: true });
234
+ return { stopped: true, mode: 'launchd', forced: false };
235
+ }
236
+
237
+ const { pid } = check.info;
238
+ if (!processAlive(pid)) {
239
+ removeForeignPidfile(pid);
240
+ return { stopped: false, mode: check.info.mode, forced: false };
241
+ }
242
+
243
+ try {
244
+ process.kill(pid, 'SIGTERM');
245
+ } catch {
246
+ // 竞态:刚好退出
247
+ }
248
+
249
+ const deadline = monotonicMs() + graceMs;
250
+ while (monotonicMs() < deadline && processAlive(pid)) {
251
+ // eslint-disable-next-line no-await-in-loop -- 等待退出
252
+ await sleep(100);
253
+ }
254
+ let forced = false;
255
+ if (processAlive(pid)) {
256
+ forced = true;
257
+ try {
258
+ process.kill(pid, 'SIGKILL');
259
+ } catch {
260
+ // 已退出
261
+ }
262
+ await sleep(200);
263
+ }
264
+ removeForeignPidfile(pid);
265
+ return { stopped: true, mode: check.info.mode, forced };
266
+ }
267
+
268
+ /** 目标进程已死时清掉它留下的 pidfile(removePidfileIfOwn 只管自己那份)。 */
269
+ function removeForeignPidfile(pid) {
270
+ const info = readPidfile();
271
+ if (info?.pid === pid) {
272
+ try {
273
+ fs.rmSync(paths().pidfile, { force: true });
274
+ } catch {
275
+ // 忽略
276
+ }
277
+ }
278
+ }
279
+
280
+ // ── launchd(ENG-15) ────────────────────────────────────────────────────
281
+
282
+ export function buildPlist({
283
+ logPath = paths().log, execPath = process.execPath, cliEntry = CLI_ENTRY, home = process.env.DSHC_HOME ?? null,
284
+ } = {}) {
285
+ // 装服务时若当前用的是自定义 DSHC_HOME,必须一起写进 plist:
286
+ // 否则 launchd 起来的实例会去读默认 ~/.dsh_center,等于悄悄换了一份配置
287
+ const envEntries = [['DSHC_MODE', 'launchd']];
288
+ if (home) envEntries.push(['DSHC_HOME', home]);
289
+ const envXml = envEntries.map(([k, v]) => `<key>${k}</key><string>${v}</string>`).join('');
290
+
291
+ return `<?xml version="1.0" encoding="UTF-8"?>
292
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
293
+ <plist version="1.0"><dict>
294
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
295
+ <key>ProgramArguments</key><array>
296
+ <string>${execPath}</string><string>${cliEntry}</string>
297
+ <string>up</string><string>--foreground</string>
298
+ </array>
299
+ <key>RunAtLoad</key><true/>
300
+ <key>KeepAlive</key><true/>
301
+ <key>ThrottleInterval</key><integer>10</integer>
302
+ <key>StandardOutPath</key><string>${logPath}</string>
303
+ <key>StandardErrorPath</key><string>${logPath}</string>
304
+ <key>EnvironmentVariables</key><dict>${envXml}</dict>
305
+ </dict></plist>
306
+ `;
307
+ }
308
+
309
+ function launchctl(args) {
310
+ return new Promise((resolve) => {
311
+ const child = spawn('launchctl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
312
+ let stdout = '';
313
+ let stderr = '';
314
+ child.stdout.setEncoding('utf8');
315
+ child.stderr.setEncoding('utf8');
316
+ child.stdout.on('data', (c) => { stdout += c; });
317
+ child.stderr.on('data', (c) => { stderr += c; });
318
+ child.on('error', (err) => resolve({ code: null, stdout, stderr: String(err.message) }));
319
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
320
+ });
321
+ }
322
+
323
+ const domain = () => `gui/${process.getuid?.() ?? 501}`;
324
+ const serviceTarget = () => `${domain()}/${LAUNCHD_LABEL}`;
325
+
326
+ /** install:先接管裸后台实例(02 §9.3 无缝接管)→ 写 plist → bootstrap。 */
327
+ export async function serviceInstall() {
328
+ const p = paths();
329
+ const check = await aliveCheck();
330
+ if (check.alive && check.info?.mode !== 'launchd') await stopDaemon();
331
+
332
+ fs.mkdirSync(path.dirname(p.plist), { recursive: true });
333
+ fs.writeFileSync(p.plist, buildPlist());
334
+
335
+ await launchctl(['bootout', serviceTarget()]); // 幂等:已加载则先卸
336
+ const res = await launchctl(['bootstrap', domain(), p.plist]);
337
+ return { ok: res.code === 0, plist: p.plist, stderr: res.stderr.trim() || null };
338
+ }
339
+
340
+ export async function serviceUninstall({ keepPlist = false } = {}) {
341
+ const p = paths();
342
+ const res = await launchctl(['bootout', serviceTarget()]);
343
+ if (!keepPlist) {
344
+ try {
345
+ fs.rmSync(p.plist, { force: true });
346
+ } catch {
347
+ // 忽略
348
+ }
349
+ }
350
+ return { ok: res.code === 0 || /not find|no such/i.test(res.stderr), stderr: res.stderr.trim() || null };
351
+ }
352
+
353
+ /** launchctl print 解析 state/pid。 */
354
+ export async function serviceStatus() {
355
+ const p = paths();
356
+ const installed = fs.existsSync(p.plist);
357
+ const res = await launchctl(['print', serviceTarget()]);
358
+ if (res.code !== 0) return { installed, loaded: false, state: null, pid: null };
359
+ const state = /^\s*state\s*=\s*(\S+)/m.exec(res.stdout)?.[1] ?? null;
360
+ const pid = Number(/^\s*pid\s*=\s*(\d+)/m.exec(res.stdout)?.[1] ?? NaN);
361
+ return { installed, loaded: true, state, pid: Number.isInteger(pid) ? pid : null };
362
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 出厂默认表 —— 代码内唯一允许硬编码运行参数的位置(02 §3.0)。
3
+ * 运行期一切参数只认 ~/.dsh_center/config.json;本表仅用于首启预填与 setup 模式兜底。
4
+ */
5
+
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ export const CONFIG_VERSION = 1;
10
+
11
+ export const FACTORY_DEFAULTS = Object.freeze({
12
+ manager: Object.freeze({ port: 7788 }),
13
+ defaults: Object.freeze({
14
+ remoteWebPort: 8899,
15
+ localPortRange: Object.freeze([17701, 17799]),
16
+ }),
17
+ hostDefaults: Object.freeze({
18
+ local: false,
19
+ enabled: true,
20
+ autoStart: false,
21
+ localPort: null,
22
+ remoteWebPort: null,
23
+ // null = 不注入 cd,远端 dsh 以 sshd 给的初始目录($HOME)启动
24
+ workdir: null,
25
+ inject: Object.freeze({
26
+ env: Object.freeze({}),
27
+ extraArgs: Object.freeze([]),
28
+ patches: Object.freeze([]),
29
+ }),
30
+ }),
31
+ });
32
+
33
+ /** 深拷贝一份可写的主机默认配置(FACTORY_DEFAULTS 全冻结,不能直接塞进草稿)。 */
34
+ export function newHostConfig() {
35
+ return {
36
+ local: FACTORY_DEFAULTS.hostDefaults.local,
37
+ enabled: FACTORY_DEFAULTS.hostDefaults.enabled,
38
+ autoStart: FACTORY_DEFAULTS.hostDefaults.autoStart,
39
+ localPort: FACTORY_DEFAULTS.hostDefaults.localPort,
40
+ remoteWebPort: FACTORY_DEFAULTS.hostDefaults.remoteWebPort,
41
+ workdir: FACTORY_DEFAULTS.hostDefaults.workdir,
42
+ inject: { env: {}, extraArgs: [], patches: [] },
43
+ };
44
+ }
45
+
46
+ /** 出厂 config 骨架(setup 未完成状态)。 */
47
+ export function newFactoryConfig() {
48
+ return {
49
+ configVersion: CONFIG_VERSION,
50
+ setupCompleted: false,
51
+ manager: { port: FACTORY_DEFAULTS.manager.port },
52
+ defaults: {
53
+ remoteWebPort: FACTORY_DEFAULTS.defaults.remoteWebPort,
54
+ localPortRange: [...FACTORY_DEFAULTS.defaults.localPortRange],
55
+ },
56
+ hosts: {},
57
+ };
58
+ }
59
+
60
+ /**
61
+ * 全部持久化路径。DSHC_HOME 环境变量可整体重定向(集成测试隔离用,见 14 §2)。
62
+ */
63
+ export function resolvePaths(env = process.env, homedir = os.homedir()) {
64
+ const dir = env.DSHC_HOME ? path.resolve(env.DSHC_HOME) : path.join(homedir, '.dsh_center');
65
+ return Object.freeze({
66
+ dir,
67
+ config: path.join(dir, 'config.json'),
68
+ state: path.join(dir, 'state.json'),
69
+ pidfile: path.join(dir, 'manager.pid'),
70
+ log: path.join(dir, 'manager.log'),
71
+ plist: path.join(homedir, 'Library', 'LaunchAgents', 'com.dsh-center.manager.plist'),
72
+ });
73
+ }
74
+
75
+ export const PATHS = resolvePaths();
76
+
77
+ export const LAUNCHD_LABEL = 'com.dsh-center.manager';
78
+
79
+ /** 远端落地目录(03 §1),相对远端 $HOME。 */
80
+ export const REMOTE_DIR = '.dsh_center_remote';
81
+
82
+ /**
83
+ * 「对每台各来一次 ssh」这类扇出的同时在飞上限(issue #85)。
84
+ *
85
+ * 取 6 是照着 sshd 的出厂 `MaxStartups 10:30:100` 来的:未完成认证的连接过 10 条就开始
86
+ * 被随机丢,而多台远端共用一台跳板机时这个额度是合起来算的。留 4 条余量给用户自己的
87
+ * ssh 会话与隧道重连。这不是用户可调项,故只在出厂表里,不进 config schema。
88
+ */
89
+ export const SSH_FANOUT_LIMIT = 6;