@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.
- package/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.json / state.json 读写(11 §4)。
|
|
3
|
+
*
|
|
4
|
+
* 三条不变式:
|
|
5
|
+
* 1. 写入一律原子(tmp + fsync + rename)。
|
|
6
|
+
* 2. 事件发射点收敛在 setPhase / mutateHostState / updateConfig 三处——
|
|
7
|
+
* 「凡是持久化了的变化必有事件,凡有事件必已持久化(或已进 debounce 队列)」。
|
|
8
|
+
* 3. phase 迁移只经 setPhase,由 machine 守卫终审。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { CONFIG_VERSION, PATHS, newFactoryConfig, newHostConfig, resolvePaths } from './defaults.js';
|
|
15
|
+
import { DshError } from './lib/errors.js';
|
|
16
|
+
import { assertTransition } from './lib/machine.js';
|
|
17
|
+
import { emitConfigChanged, emitHostChanged, logEvent } from './lib/bus.js';
|
|
18
|
+
import { configSchema, hostStateSchema, validate } from './lib/validate.js';
|
|
19
|
+
import { assertSafeHost } from './lib/shq.js';
|
|
20
|
+
|
|
21
|
+
const STATE_DEBOUNCE_MS = 100;
|
|
22
|
+
|
|
23
|
+
/** @type {ReturnType<typeof resolvePaths>} */
|
|
24
|
+
let paths = PATHS;
|
|
25
|
+
|
|
26
|
+
/** @type {any} */
|
|
27
|
+
let config = null;
|
|
28
|
+
/** @type {{hosts: Record<string, any>}} */
|
|
29
|
+
let state = { hosts: {} };
|
|
30
|
+
/** ssh config 解析结果(内存,不持久化)。 */
|
|
31
|
+
let sshInfoByName = new Map();
|
|
32
|
+
/** config 有而 ssh config 无的主机(内存标记,不持久化,不删配置)。 */
|
|
33
|
+
let orphaned = new Set();
|
|
34
|
+
/** setup 向导内置的本机候选(只驻内存,绝不写入 config)。 */
|
|
35
|
+
let setupLocalCandidate = null;
|
|
36
|
+
/** 由 server.js 注入,避免 store → tunnel 依赖(防环规则 3)。 */
|
|
37
|
+
let tunnelStatusProvider = () => null;
|
|
38
|
+
|
|
39
|
+
let revision = 0;
|
|
40
|
+
let stateTimer = null;
|
|
41
|
+
let stateDirty = false;
|
|
42
|
+
|
|
43
|
+
// ── 原子写与 debounce(§4.1、§4.2) ─────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function atomicWrite(file, data) {
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
const tmp = `${file}.tmp.${process.pid}`; // 同目录保证 rename 同文件系统
|
|
48
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
49
|
+
try {
|
|
50
|
+
fs.writeSync(fd, data);
|
|
51
|
+
fs.fsyncSync(fd); // 显式 fsync,防 rename 后掉电空文件
|
|
52
|
+
} finally {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
fs.renameSync(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanupTmpLeftovers() {
|
|
59
|
+
try {
|
|
60
|
+
for (const name of fs.readdirSync(paths.dir)) {
|
|
61
|
+
if (/\.tmp\.\d+$/.test(name)) fs.rmSync(path.join(paths.dir, name), { force: true });
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// 目录不存在即无残留
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serializeState() {
|
|
69
|
+
return `${JSON.stringify(state, null, 2)}\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* state 落盘写不进时的记账(issue #87)。
|
|
74
|
+
*
|
|
75
|
+
* 这条路不许抛:debounce 的落盘在定时器回调里,抛出去就是未捕获异常,manager 当场死、
|
|
76
|
+
* 所有隧道陪葬,launchd 还会把它拉起来接着死。写不进的正确姿态是继续用内存里的状态跑,
|
|
77
|
+
* 下次状态一变再试——代价只是「manager 重启会回到上次成功落盘的那份」。
|
|
78
|
+
* 同一个毛病只报一次:写不进往往每拍都写不进,逐次报会把日志刷没。
|
|
79
|
+
* @type {string|null} 上次失败的 code,null = 上次是成功的
|
|
80
|
+
*/
|
|
81
|
+
let stateSaveFailure = null;
|
|
82
|
+
|
|
83
|
+
/** @returns {boolean} 写进去了没有 */
|
|
84
|
+
function trySaveState() {
|
|
85
|
+
try {
|
|
86
|
+
atomicWrite(paths.state, serializeState());
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const code = err.code ?? 'UNKNOWN';
|
|
89
|
+
if (stateSaveFailure !== code) {
|
|
90
|
+
stateSaveFailure = code;
|
|
91
|
+
logEvent(null, 'warn', '运行状态写不进磁盘,manager 照常运行,但重启后会回到上次写成功的那份',
|
|
92
|
+
`文件:${paths.state}\n${code} ${err.message}\n`
|
|
93
|
+
+ '常见原因:磁盘满、所在卷变成只读、目录属主不是当前用户(比如被 sudo 跑过一次)。');
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (stateSaveFailure !== null) {
|
|
98
|
+
stateSaveFailure = null;
|
|
99
|
+
logEvent(null, 'info', '运行状态又能写进磁盘了,已把最新的一份落下去');
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function scheduleStateSave() {
|
|
105
|
+
stateDirty = true;
|
|
106
|
+
stateTimer ??= setTimeout(() => {
|
|
107
|
+
stateTimer = null;
|
|
108
|
+
if (!stateDirty) return;
|
|
109
|
+
stateDirty = false;
|
|
110
|
+
// 没写成就把脏标记还回去:下次状态一变会再排一次,恢复可写时自己就补上了
|
|
111
|
+
if (!trySaveState()) stateDirty = true;
|
|
112
|
+
}, STATE_DEBOUNCE_MS);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 退出路径:取消 debounce,同步原子写。同样不许抛——抛了后面的隧道回收就跳过去了。 */
|
|
116
|
+
export function flushStateSync() {
|
|
117
|
+
if (stateTimer) {
|
|
118
|
+
clearTimeout(stateTimer);
|
|
119
|
+
stateTimer = null;
|
|
120
|
+
}
|
|
121
|
+
if (!stateDirty) return;
|
|
122
|
+
stateDirty = false;
|
|
123
|
+
if (!trySaveState()) stateDirty = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 上一次由我们读入或写出的 config 文本。落盘前拿它跟磁盘上那份比:不一致就是有人
|
|
128
|
+
* 在 manager 跑着的时候手改了文件,这时整份落盘会把他的编辑无声抹掉(issue #65)。
|
|
129
|
+
* @type {string|null} null = 还没读到过(首次落盘,无从比对)
|
|
130
|
+
*/
|
|
131
|
+
let configOnDiskText = null;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 磁盘上那份还是我们上次见到的样子吗。
|
|
135
|
+
* @returns {boolean} true = 没被外部动过(或本来就没有这个文件)
|
|
136
|
+
*/
|
|
137
|
+
function diskMatchesLastSeen() {
|
|
138
|
+
if (configOnDiskText === null) return true;
|
|
139
|
+
let current;
|
|
140
|
+
try {
|
|
141
|
+
current = fs.readFileSync(paths.config, 'utf8');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// 文件被删了:让写去重建,不当成外部改动拦下来
|
|
144
|
+
if (err.code === 'ENOENT') return true;
|
|
145
|
+
throw new DshError('CONFIG_WRITE_FAILED', '配置没能写入磁盘,本次修改已放弃', {
|
|
146
|
+
detail: `${err.code ?? ''} ${err.message}`.trim(),
|
|
147
|
+
cause: err,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return current === configOnDiskText;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 把一份配置落盘。失败一律翻译成 DshError——fs 的原始错误(`EACCES: permission denied,
|
|
155
|
+
* open '/Users/.../config.json.tmp.123'`)当 message 端给用户既看不懂,又把内部路径抖出去。
|
|
156
|
+
*/
|
|
157
|
+
function writeConfig(next) {
|
|
158
|
+
if (!diskMatchesLastSeen()) {
|
|
159
|
+
throw new DshError('CONFIG_STALE', '配置文件被外部改过,这次没写——免得拿旧值盖掉你的改动', {
|
|
160
|
+
detail: `文件:${paths.config}\n`
|
|
161
|
+
+ '要让 manager 用上磁盘里的版本:dshc restart(会瞬断隧道,页签会自愈重连)。',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
165
|
+
try {
|
|
166
|
+
atomicWrite(paths.config, text);
|
|
167
|
+
configOnDiskText = text;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw new DshError('CONFIG_WRITE_FAILED', '配置没能写入磁盘,本次修改已放弃', {
|
|
170
|
+
detail: `${err.code ?? ''} ${err.message}`.trim(),
|
|
171
|
+
cause: err,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeConfigNow() {
|
|
177
|
+
writeConfig(config);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── configVersion 迁移器(§4.4) ─────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** 追加式迁移,禁止改语义。 */
|
|
183
|
+
const MIGRATIONS = [
|
|
184
|
+
// { from: 1, to: 2, up(cfg) { cfg.newField ??= …; } },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
function migrateConfig(raw) {
|
|
188
|
+
const cfg = raw;
|
|
189
|
+
let v = Number.isInteger(cfg.configVersion) ? cfg.configVersion : 1;
|
|
190
|
+
if (v > CONFIG_VERSION) {
|
|
191
|
+
throw new DshError(
|
|
192
|
+
'VALIDATION',
|
|
193
|
+
`config.json 版本 ${v} 高于本程序支持的 ${CONFIG_VERSION},拒绝启动(请升级 dshc)`,
|
|
194
|
+
{ detail: '旧代码写入新配置会造成字段丢失,故硬失败而非降级兜底。' },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
let migrated = false;
|
|
198
|
+
while (v < CONFIG_VERSION) {
|
|
199
|
+
const step = MIGRATIONS.find((m) => m.from === v);
|
|
200
|
+
if (!step) break;
|
|
201
|
+
step.up(cfg);
|
|
202
|
+
v = step.to;
|
|
203
|
+
migrated = true;
|
|
204
|
+
}
|
|
205
|
+
if (cfg.configVersion !== CONFIG_VERSION) {
|
|
206
|
+
cfg.configVersion = CONFIG_VERSION;
|
|
207
|
+
migrated = true;
|
|
208
|
+
}
|
|
209
|
+
// 补默认字段(低版本或手改缺字段)
|
|
210
|
+
const factory = newFactoryConfig();
|
|
211
|
+
cfg.setupCompleted ??= false;
|
|
212
|
+
cfg.manager ??= factory.manager;
|
|
213
|
+
cfg.manager.port ??= factory.manager.port;
|
|
214
|
+
cfg.defaults ??= factory.defaults;
|
|
215
|
+
cfg.defaults.remoteWebPort ??= factory.defaults.remoteWebPort;
|
|
216
|
+
cfg.defaults.localPortRange ??= factory.defaults.localPortRange;
|
|
217
|
+
cfg.hosts ??= {};
|
|
218
|
+
for (const [name, host] of Object.entries(cfg.hosts)) {
|
|
219
|
+
cfg.hosts[name] = { ...newHostConfig(), ...host, inject: { ...newHostConfig().inject, ...(host.inject ?? {}) } };
|
|
220
|
+
}
|
|
221
|
+
return { config: cfg, migrated };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 加载(§4.5) ────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
function loadConfigFile() {
|
|
227
|
+
let text;
|
|
228
|
+
try {
|
|
229
|
+
text = fs.readFileSync(paths.config, 'utf8');
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err.code === 'ENOENT') {
|
|
232
|
+
configOnDiskText = null;
|
|
233
|
+
return { config: newFactoryConfig(), fresh: true };
|
|
234
|
+
}
|
|
235
|
+
throw new DshError('INTERNAL', `无法读取 ${paths.config}:${err.message}`, { cause: err });
|
|
236
|
+
}
|
|
237
|
+
configOnDiskText = text;
|
|
238
|
+
|
|
239
|
+
let raw;
|
|
240
|
+
try {
|
|
241
|
+
raw = JSON.parse(text);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// config 是用户契约,静默兜底会掩盖手改失误 → 拒绝启动
|
|
244
|
+
throw new DshError('VALIDATION', `config.json 不是合法 JSON:${err.message}`, {
|
|
245
|
+
detail: `文件:${paths.config}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
249
|
+
throw new DshError('VALIDATION', 'config.json 顶层必须是对象', { detail: `文件:${paths.config}` });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { config: migrated, migrated: didMigrate } = migrateConfig(raw);
|
|
253
|
+
const { ok, errors } = validate(configSchema, migrated);
|
|
254
|
+
if (!ok) {
|
|
255
|
+
throw new DshError('VALIDATION', 'config.json 校验失败,拒绝启动', { detail: errors.join('\n') });
|
|
256
|
+
}
|
|
257
|
+
return { config: migrated, fresh: false, migrated: didMigrate };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function loadStateFile() {
|
|
261
|
+
let text;
|
|
262
|
+
try {
|
|
263
|
+
text = fs.readFileSync(paths.state, 'utf8');
|
|
264
|
+
} catch {
|
|
265
|
+
return { hosts: {} };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let raw;
|
|
269
|
+
try {
|
|
270
|
+
raw = JSON.parse(text);
|
|
271
|
+
} catch {
|
|
272
|
+
const backup = `${paths.state}.corrupt.${Date.now()}`; // 墙钟:备份文件名要给人看
|
|
273
|
+
try {
|
|
274
|
+
fs.renameSync(paths.state, backup);
|
|
275
|
+
} catch {
|
|
276
|
+
// 留证失败不阻塞启动
|
|
277
|
+
}
|
|
278
|
+
logEvent(null, 'warn', `state.json 解析失败,已留证为 ${path.basename(backup)},以空状态启动`);
|
|
279
|
+
return { hosts: {} };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const hosts = {};
|
|
283
|
+
for (const [name, entry] of Object.entries(raw?.hosts ?? {})) {
|
|
284
|
+
if (validate(hostStateSchema, entry).ok) {
|
|
285
|
+
hosts[name] = entry;
|
|
286
|
+
} else {
|
|
287
|
+
logEvent(name, 'warn', 'state.json 中该主机条目非法,已丢弃');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { hosts };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 加载 + 迁移 + 校验 config;宽容加载 state。 */
|
|
294
|
+
export async function init({ pathsOverride } = {}) {
|
|
295
|
+
paths = pathsOverride ?? resolvePaths();
|
|
296
|
+
cleanupTmpLeftovers();
|
|
297
|
+
setupLocalCandidate = null;
|
|
298
|
+
|
|
299
|
+
const loaded = loadConfigFile();
|
|
300
|
+
config = loaded.config;
|
|
301
|
+
if (loaded.migrated && !loaded.fresh) {
|
|
302
|
+
writeConfigNow();
|
|
303
|
+
logEvent(null, 'info', `config.json 已迁移到 configVersion=${CONFIG_VERSION}`);
|
|
304
|
+
}
|
|
305
|
+
state = loadStateFile();
|
|
306
|
+
revision = 0;
|
|
307
|
+
return { fresh: loaded.fresh };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 测试/重启用:丢弃内存态。 */
|
|
311
|
+
export function _reset() {
|
|
312
|
+
if (stateTimer) clearTimeout(stateTimer);
|
|
313
|
+
stateTimer = null;
|
|
314
|
+
stateDirty = false;
|
|
315
|
+
config = null;
|
|
316
|
+
configOnDiskText = null;
|
|
317
|
+
state = { hosts: {} };
|
|
318
|
+
sshInfoByName = new Map();
|
|
319
|
+
orphaned = new Set();
|
|
320
|
+
setupLocalCandidate = null;
|
|
321
|
+
tunnelStatusProvider = () => null;
|
|
322
|
+
revision = 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function getPaths() {
|
|
326
|
+
return paths;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── revision(13 §3.1) ─────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
export function currentRevision() {
|
|
332
|
+
return revision;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 每个对外帧取一个新 revision(api 的 sseHub 广播时调用一次,全客户端同值)。 */
|
|
336
|
+
export function bumpRevision() {
|
|
337
|
+
revision += 1;
|
|
338
|
+
return revision;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── config 读写 ─────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
export function isSetupCompleted() {
|
|
344
|
+
return config?.setupCompleted === true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 深冻结快照(防调用方误改内存态)。 */
|
|
348
|
+
export function getConfig() {
|
|
349
|
+
return deepFreeze(structuredClone(config));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function deepFreeze(o) {
|
|
353
|
+
if (o === null || typeof o !== 'object') return o;
|
|
354
|
+
for (const v of Object.values(o)) deepFreeze(v);
|
|
355
|
+
return Object.freeze(o);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function assertNoLocalSshConflict(candidate, sshHosts = sshInfoByName) {
|
|
359
|
+
const conflict = Object.entries(candidate?.hosts ?? {})
|
|
360
|
+
.find(([name, host]) => host?.local === true && sshHosts.has(name));
|
|
361
|
+
if (!conflict) return;
|
|
362
|
+
const [name] = conflict;
|
|
363
|
+
throw new DshError('LOCAL_NAME_CONFLICT', `本机名称 ${name} 与 SSH Host 重名`, { host: name });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isSafeHostName(name) {
|
|
367
|
+
try {
|
|
368
|
+
assertSafeHost(name);
|
|
369
|
+
return true;
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* setup 的可信本机名纯算法:已有 local 优先,否则 hostname safe fallback 后稳定避让
|
|
377
|
+
* config/SSH 名称。CLI 与 server 共用,避免两边各算各的发生漂移。
|
|
378
|
+
*/
|
|
379
|
+
export function canonicalSetupLocalName(preferredName, {
|
|
380
|
+
hosts = {},
|
|
381
|
+
sshNames = [],
|
|
382
|
+
} = {}) {
|
|
383
|
+
const existing = Object.entries(hosts).find(([, host]) => host?.local === true)?.[0];
|
|
384
|
+
if (existing) return existing;
|
|
385
|
+
|
|
386
|
+
const base = isSafeHostName(preferredName) ? preferredName : 'local-host';
|
|
387
|
+
const occupied = new Set([
|
|
388
|
+
...Object.keys(hosts),
|
|
389
|
+
...sshNames,
|
|
390
|
+
]);
|
|
391
|
+
if (!occupied.has(base)) return base;
|
|
392
|
+
if (!occupied.has(`${base}-local`)) return `${base}-local`;
|
|
393
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
394
|
+
const candidate = `${base}-local-${suffix}`;
|
|
395
|
+
if (!occupied.has(candidate)) return candidate;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* setup 请求里的 local 是执行身份,不信任客户端自行声明:
|
|
401
|
+
* 只认当前配置里已经持久化的本机身份,或服务端此刻算出的 canonical 候选名。
|
|
402
|
+
*/
|
|
403
|
+
export function assertSetupLocalIdentities(
|
|
404
|
+
incoming,
|
|
405
|
+
preferredName,
|
|
406
|
+
sshNames = sshInfoByName.keys(),
|
|
407
|
+
) {
|
|
408
|
+
const ssh = sshNames instanceof Set ? sshNames : new Set(sshNames);
|
|
409
|
+
const canonicalLocal = canonicalSetupLocalName(preferredName, {
|
|
410
|
+
hosts: config?.hosts,
|
|
411
|
+
sshNames: ssh,
|
|
412
|
+
});
|
|
413
|
+
for (const [name, host] of Object.entries(incoming?.hosts ?? {})) {
|
|
414
|
+
const existingLocal = config?.hosts?.[name]?.local === true;
|
|
415
|
+
const candidateLocal = canonicalLocal === name;
|
|
416
|
+
const requestedLocal = host?.local === true;
|
|
417
|
+
const existingRemote = (Object.hasOwn(config?.hosts ?? {}, name) && !existingLocal)
|
|
418
|
+
|| ssh.has(name);
|
|
419
|
+
|
|
420
|
+
if (requestedLocal && (existingRemote || (!existingLocal && !candidateLocal))) {
|
|
421
|
+
const message = existingRemote
|
|
422
|
+
? `初始化配置不能把 SSH 主机 ${name} 改成本机`
|
|
423
|
+
: `初始化配置不能把未经服务器认可的主机 ${name} 声明为本机`;
|
|
424
|
+
throw new DshError('NOT_ALLOWED', message, { host: name });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!requestedLocal && (existingLocal || candidateLocal)) {
|
|
428
|
+
throw new DshError('NOT_ALLOWED', `初始化配置不能把本机主机 ${name} 改成 SSH 主机`, {
|
|
429
|
+
host: name,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* config 写入唯一入口:mutator 改草稿 → 校验 → 原子写 → 按改动面 emit。
|
|
437
|
+
* @param {(draft:any)=>void} mutator
|
|
438
|
+
* @returns {{changed:string[]}}
|
|
439
|
+
*/
|
|
440
|
+
export function updateConfig(mutator) {
|
|
441
|
+
const before = structuredClone(config);
|
|
442
|
+
const draft = structuredClone(config);
|
|
443
|
+
mutator(draft);
|
|
444
|
+
|
|
445
|
+
const { ok, errors } = validate(configSchema, draft);
|
|
446
|
+
if (!ok) {
|
|
447
|
+
throw new DshError('VALIDATION', '配置修改后校验失败,已放弃本次写入', { detail: errors.join('\n') });
|
|
448
|
+
}
|
|
449
|
+
assertNoLocalSshConflict(draft);
|
|
450
|
+
|
|
451
|
+
// 先落盘、成了才换内存。反过来写的后果是:盘写失败(目录只读、磁盘满、卷被卸载)时
|
|
452
|
+
// 请求报 500、用户以为没生效,可跑着的 manager 已经在用新值,重启后又从盘上读回旧值
|
|
453
|
+
// 静默回退——同一时刻三种说法。
|
|
454
|
+
writeConfig(draft);
|
|
455
|
+
config = draft;
|
|
456
|
+
|
|
457
|
+
const changed = diffPaths(before, draft);
|
|
458
|
+
const touchedHosts = changedHostNames(before, draft);
|
|
459
|
+
const globalTouched = changed.some((p) => !p.startsWith('hosts.'));
|
|
460
|
+
for (const name of touchedHosts) emitHostChanged(name);
|
|
461
|
+
if (globalTouched) emitConfigChanged(changed);
|
|
462
|
+
return { changed };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** setup 提交专用:整份替换 + setupCompleted:true + 原子写。 */
|
|
466
|
+
export function saveConfigFromSetup(incoming) {
|
|
467
|
+
const draft = structuredClone(incoming);
|
|
468
|
+
draft.configVersion = CONFIG_VERSION;
|
|
469
|
+
draft.setupCompleted = true;
|
|
470
|
+
draft.hosts ??= {};
|
|
471
|
+
for (const [name, host] of Object.entries(draft.hosts)) {
|
|
472
|
+
const base = newHostConfig();
|
|
473
|
+
draft.hosts[name] = {
|
|
474
|
+
...base,
|
|
475
|
+
...host,
|
|
476
|
+
inject: { ...base.inject, ...(host.inject ?? {}) },
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const { ok, errors } = validate(configSchema, draft);
|
|
481
|
+
if (!ok) {
|
|
482
|
+
throw new DshError('VALIDATION', '初始化配置校验失败', { detail: errors.join('\n') });
|
|
483
|
+
}
|
|
484
|
+
assertNoLocalSshConflict(draft);
|
|
485
|
+
|
|
486
|
+
writeConfig(draft);
|
|
487
|
+
config = draft;
|
|
488
|
+
emitConfigChanged(['setup']);
|
|
489
|
+
for (const name of Object.keys(config.hosts)) emitHostChanged(name);
|
|
490
|
+
return getConfig();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** POST /api/reload:重读 → diff → emit。 */
|
|
494
|
+
export function reloadConfig() {
|
|
495
|
+
const before = structuredClone(config);
|
|
496
|
+
const beforeOnDiskText = configOnDiskText;
|
|
497
|
+
const loaded = loadConfigFile();
|
|
498
|
+
try {
|
|
499
|
+
assertNoLocalSshConflict(loaded.config);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
configOnDiskText = beforeOnDiskText;
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
config = loaded.config;
|
|
505
|
+
if (loaded.migrated) writeConfigNow();
|
|
506
|
+
|
|
507
|
+
const changed = diffPaths(before, config);
|
|
508
|
+
const touchedHosts = changedHostNames(before, config);
|
|
509
|
+
const globalTouched = changed.some((p) => !p.startsWith('hosts.'));
|
|
510
|
+
for (const name of touchedHosts) emitHostChanged(name);
|
|
511
|
+
if (globalTouched || changed.length > 0) emitConfigChanged(changed);
|
|
512
|
+
return { changed };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Host 名允许含点,不能从 `hosts.<name>.<field>` 字符串靠正则截第一段。 */
|
|
516
|
+
function changedHostNames(a, b) {
|
|
517
|
+
const names = new Set([...Object.keys(a?.hosts ?? {}), ...Object.keys(b?.hosts ?? {})]);
|
|
518
|
+
return new Set([...names].filter(
|
|
519
|
+
(name) => JSON.stringify(hostValue(a, name)) !== JSON.stringify(hostValue(b, name)),
|
|
520
|
+
));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function hostValue(candidate, name) {
|
|
524
|
+
return candidate?.hosts && Object.hasOwn(candidate.hosts, name) ? candidate.hosts[name] : undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** 逐叶节点比较,产出点路径清单(emit 决策与 /api/reload 响应共用)。 */
|
|
528
|
+
function diffPaths(a, b, prefix = '') {
|
|
529
|
+
const out = [];
|
|
530
|
+
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
|
|
531
|
+
for (const key of keys) {
|
|
532
|
+
const p = prefix ? `${prefix}.${key}` : key;
|
|
533
|
+
const va = a?.[key];
|
|
534
|
+
const vb = b?.[key];
|
|
535
|
+
const objA = va !== null && typeof va === 'object' && !Array.isArray(va);
|
|
536
|
+
const objB = vb !== null && typeof vb === 'object' && !Array.isArray(vb);
|
|
537
|
+
if (objA && objB) out.push(...diffPaths(va, vb, p));
|
|
538
|
+
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(p);
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── ssh config 合并(mergeSshHosts) ────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 启动/reload 时并入 ssh config 清单:新主机以 hostDefaults 写入 config;
|
|
547
|
+
* config 有而 ssh config 无 → 内存标记 orphaned(不持久化,不删配置)。
|
|
548
|
+
* @param {{name:string, hostName?:string, user?:string, port?:number}[]} sshHosts
|
|
549
|
+
*/
|
|
550
|
+
export function mergeSshHosts(sshHosts) {
|
|
551
|
+
const nextSshInfo = new Map(sshHosts.map((h) => [h.name, h]));
|
|
552
|
+
assertNoLocalSshConflict(config, nextSshInfo);
|
|
553
|
+
sshInfoByName = nextSshInfo;
|
|
554
|
+
|
|
555
|
+
const added = sshHosts.filter((h) => !Object.hasOwn(config.hosts, h.name)).map((h) => h.name);
|
|
556
|
+
if (added.length > 0) {
|
|
557
|
+
updateConfig((draft) => {
|
|
558
|
+
draft.hosts = {
|
|
559
|
+
...draft.hosts,
|
|
560
|
+
...Object.fromEntries(added.map((name) => [name, newHostConfig()])),
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
orphaned = new Set(Object.keys(config.hosts)
|
|
566
|
+
.filter((n) => config.hosts[n]?.local !== true && !sshInfoByName.has(n)));
|
|
567
|
+
for (const name of orphaned) emitHostChanged(name);
|
|
568
|
+
return { added, orphaned: [...orphaned] };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* setup 模式追加一个只驻内存的本机候选。若配置已有本机则直接复用;
|
|
573
|
+
* 默认名冲突时给出稳定且无冲突的 `-local[-N]` 建议名。
|
|
574
|
+
*/
|
|
575
|
+
export function ensureSetupLocalCandidate(preferredName) {
|
|
576
|
+
const existing = Object.entries(config?.hosts ?? {}).find(([, host]) => host?.local === true);
|
|
577
|
+
if (existing) {
|
|
578
|
+
setupLocalCandidate = null;
|
|
579
|
+
return existing[0];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const name = canonicalSetupLocalName(preferredName, {
|
|
583
|
+
hosts: config?.hosts,
|
|
584
|
+
sshNames: sshInfoByName.keys(),
|
|
585
|
+
});
|
|
586
|
+
setupLocalCandidate = {
|
|
587
|
+
name,
|
|
588
|
+
config: { ...newHostConfig(), local: true, localPort: null },
|
|
589
|
+
};
|
|
590
|
+
return name;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function clearSetupLocalCandidate() {
|
|
594
|
+
setupLocalCandidate = null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 已初始化用户添加本机:所有冲突检查都位于 updateConfig 的同一份草稿内,
|
|
599
|
+
* 只有原子写成功后 HostView 才可见。
|
|
600
|
+
*/
|
|
601
|
+
export function createLocalHost(name) {
|
|
602
|
+
assertSafeHost(name);
|
|
603
|
+
updateConfig((draft) => {
|
|
604
|
+
if (Object.values(draft.hosts).some((host) => host?.local === true)) {
|
|
605
|
+
throw new DshError('LOCAL_HOST_EXISTS', '已经存在本机主机,不能重复添加');
|
|
606
|
+
}
|
|
607
|
+
if (Object.hasOwn(draft.hosts, name) || sshInfoByName.has(name)) {
|
|
608
|
+
throw new DshError('LOCAL_NAME_CONFLICT', `本机名称 ${name} 已被现有主机或 SSH Host 使用`, {
|
|
609
|
+
host: name,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
draft.hosts = {
|
|
613
|
+
...draft.hosts,
|
|
614
|
+
[name]: { ...newHostConfig(), local: true, localPort: null },
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
return getHostView(name);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function setTunnelStatusProvider(fn) {
|
|
621
|
+
tunnelStatusProvider = typeof fn === 'function' ? fn : () => null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── state 读写 ──────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
function ensureHostState(name) {
|
|
627
|
+
state.hosts[name] ??= { phase: 'unknown', probe: null, web: null, tunnel: null, patchSync: { files: {} }, manualInstances: [] };
|
|
628
|
+
return state.hosts[name];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function getHostState(name) {
|
|
632
|
+
return state.hosts[name] ?? null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function getPhase(name) {
|
|
636
|
+
return state.hosts[name]?.phase ?? 'unknown';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** state 写入唯一入口(phase 除外)。 */
|
|
640
|
+
export function mutateHostState(name, mutator) {
|
|
641
|
+
const entry = ensureHostState(name);
|
|
642
|
+
mutator(entry);
|
|
643
|
+
scheduleStateSave();
|
|
644
|
+
emitHostChanged(name);
|
|
645
|
+
return entry;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* phase 迁移唯一入口:machine 守卫 → 写 phase → emitHostChanged。
|
|
650
|
+
* @throws {DshError} STATE_ILLEGAL_TRANSITION
|
|
651
|
+
*/
|
|
652
|
+
export function setPhase(name, next, cause = 'unknown') {
|
|
653
|
+
const entry = ensureHostState(name);
|
|
654
|
+
const from = entry.phase;
|
|
655
|
+
try {
|
|
656
|
+
assertTransition(from, next, cause);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
logEvent(name, 'error', `拒绝非法状态迁移 ${from} → ${next}(${cause})`, err.detail ?? null);
|
|
659
|
+
throw err;
|
|
660
|
+
}
|
|
661
|
+
if (from === next) {
|
|
662
|
+
// 自环只刷新数据,仍发事件(视图里的 probe/web 等可能已变)
|
|
663
|
+
emitHostChanged(name);
|
|
664
|
+
return from;
|
|
665
|
+
}
|
|
666
|
+
entry.phase = next;
|
|
667
|
+
scheduleStateSave();
|
|
668
|
+
emitHostChanged(name);
|
|
669
|
+
return next;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** 删除某主机的 state 条目(主机从 config 移除时)。 */
|
|
673
|
+
export function dropHostState(name) {
|
|
674
|
+
if (name in state.hosts) {
|
|
675
|
+
delete state.hosts[name];
|
|
676
|
+
scheduleStateSave();
|
|
677
|
+
emitHostChanged(name);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── HostView(13 §1.3) ─────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
export function effectiveRemotePort(name) {
|
|
684
|
+
const host = hostConfigFor(name);
|
|
685
|
+
return host?.remoteWebPort ?? config.defaults.remoteWebPort;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function hostConfigFor(name) {
|
|
689
|
+
if (config?.hosts && Object.hasOwn(config.hosts, name)) return config.hosts[name];
|
|
690
|
+
return setupLocalCandidate?.name === name ? setupLocalCandidate.config : null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** @returns {any|null} */
|
|
694
|
+
export function getHostView(name) {
|
|
695
|
+
const hostConfig = hostConfigFor(name);
|
|
696
|
+
if (!hostConfig) return null;
|
|
697
|
+
const st = state.hosts[name] ?? {};
|
|
698
|
+
const tunnelRuntime = tunnelStatusProvider(name);
|
|
699
|
+
|
|
700
|
+
const local = hostConfig.local === true;
|
|
701
|
+
const ssh = local ? null : (sshInfoByName.get(name) ?? null);
|
|
702
|
+
const localPort = tunnelRuntime?.localPort ?? st.tunnel?.localPort ?? hostConfig.localPort ?? null;
|
|
703
|
+
const phase = st.phase ?? 'unknown';
|
|
704
|
+
const tunnelUsable = (phase === 'running' || phase === 'degraded') && localPort !== null;
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
name,
|
|
708
|
+
local,
|
|
709
|
+
sshInfo: ssh
|
|
710
|
+
? { hostName: ssh.hostName ?? null, user: ssh.user ?? null, port: ssh.port ?? null }
|
|
711
|
+
: null,
|
|
712
|
+
orphaned: local ? false : orphaned.has(name),
|
|
713
|
+
config: {
|
|
714
|
+
local,
|
|
715
|
+
enabled: hostConfig.enabled,
|
|
716
|
+
autoStart: hostConfig.autoStart,
|
|
717
|
+
localPort: hostConfig.localPort,
|
|
718
|
+
remoteWebPort: hostConfig.remoteWebPort,
|
|
719
|
+
// 下次拉起生效值;本次实例的实际值在 web.workdir,两者不等即「重启后生效」
|
|
720
|
+
workdir: hostConfig.workdir ?? null,
|
|
721
|
+
inject: {
|
|
722
|
+
env: { ...hostConfig.inject.env },
|
|
723
|
+
extraArgs: [...hostConfig.inject.extraArgs],
|
|
724
|
+
patches: [...hostConfig.inject.patches],
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
phase,
|
|
728
|
+
effectiveRemotePort: effectiveRemotePort(name),
|
|
729
|
+
mappedUrl: tunnelUsable ? `http://127.0.0.1:${localPort}/` : null,
|
|
730
|
+
probe: st.probe ?? null,
|
|
731
|
+
// workdir/cwd 补 null:上一代 manager 写的 state 里没有这两个键(补丁 01 §5.2)
|
|
732
|
+
web: st.web ? { ...st.web, workdir: st.web.workdir ?? null, cwd: st.web.cwd ?? null } : null,
|
|
733
|
+
tunnel: tunnelRuntime
|
|
734
|
+
? {
|
|
735
|
+
localPort: tunnelRuntime.localPort ?? localPort,
|
|
736
|
+
connected: tunnelRuntime.connected === true,
|
|
737
|
+
reconnectAttempt: tunnelRuntime.reconnectAttempt ?? 0,
|
|
738
|
+
suspendedReason: tunnelRuntime.suspendedReason ?? null,
|
|
739
|
+
}
|
|
740
|
+
: (st.tunnel
|
|
741
|
+
? { localPort: st.tunnel.localPort ?? null, connected: false, reconnectAttempt: 0, suspendedReason: null }
|
|
742
|
+
: null),
|
|
743
|
+
patchSync: st.patchSync ?? { files: {} },
|
|
744
|
+
manualInstances: st.manualInstances ?? [],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** HostView[],按 name 升序(GET /api/hosts 与 snapshot 的数据源)。 */
|
|
749
|
+
export function listHostViews() {
|
|
750
|
+
return listHostNames()
|
|
751
|
+
.sort()
|
|
752
|
+
.map((n) => getHostView(n))
|
|
753
|
+
.filter(Boolean);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function listHostNames() {
|
|
757
|
+
const names = new Set(Object.keys(config?.hosts ?? {}));
|
|
758
|
+
if (setupLocalCandidate) names.add(setupLocalCandidate.name);
|
|
759
|
+
return [...names].sort();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function hostCounts() {
|
|
763
|
+
const counts = { total: 0, running: 0, degraded: 0, crashed: 0 };
|
|
764
|
+
for (const name of listHostNames()) {
|
|
765
|
+
counts.total += 1;
|
|
766
|
+
const p = getPhase(name);
|
|
767
|
+
if (p in counts) counts[p] += 1;
|
|
768
|
+
}
|
|
769
|
+
return counts;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export { STATE_DEBOUNCE_MS };
|