@mebular/mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,673 @@
1
+ // GUI 引导态(provision mode):真正的空家目录 → 不自举 root,先让人类在控制台选「建新 / 加入」。
2
+ //
3
+ // 判定极窄(不得影响现有流程):同时满足
4
+ // ① 无 config.json ② 无 <home>/user-master-key.json ③ 无 <storagePath>.identity.json
5
+ // 才进入引导态;显式 `MEBULAR_PROVISION=0`(脚本/测试自举 root)或已设主密钥 env → 视为非引导态。
6
+ //
7
+ // 加入侧**复用 @mebular/fleet 的 join 实现**(decodeJoinToken/requestJoin/joinWithToken/persistInviterHints),
8
+ // 不复制第二套令牌验签/证书兑换逻辑;本模块只负责「mebular 守护 home」的落盘形态与端口预检。
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
12
+ import { createServer } from 'node:net';
13
+ import { hostname } from 'node:os';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { configPath, resolveMasterKeys } from './config.mjs';
16
+ import {
17
+ DEFAULT_PROFILE_NAME,
18
+ masterFingerprintForHome,
19
+ normalizeProfileName,
20
+ readRegistry,
21
+ resolveCloudRoot,
22
+ writeRegistry,
23
+ } from './profiles.mjs';
24
+
25
+ /** 引导态判定(见文件头注释)。 */
26
+ export function isProvisionHome(home, { storagePath, env = process.env } = {}) {
27
+ if (env.MEBULAR_PROVISION === '0') return false;
28
+ if (env.MEBULAR_USER_MASTER_KEY || env.MEBULAR_USER_MASTER_KEY_FILE) return false;
29
+ if (existsSync(configPath(home))) return false;
30
+ if (existsSync(join(home, 'user-master-key.json'))) return false;
31
+ const storage = storagePath ?? env.MEBULAR_STORAGE_PATH ?? join(home, 'store.jsonl');
32
+ if (existsSync(`${storage}.identity.json`)) return false;
33
+ return true;
34
+ }
35
+
36
+ const sanitize = (value) => String(value ?? '').trim().replace(/[^0-9A-Za-z._-]+/g, '-').replace(/^-+|-+$/g, '');
37
+ const defaultDeviceName = () => hostname() || 'mebular-device';
38
+
39
+ /**
40
+ * 设备展示名:与忆云名同一口径(1–24 Unicode,拒绝 `/` `\` `..`)。
41
+ * 仅在显式提供名字时校验;空值仍回退系统主机名(首启无输入)。
42
+ */
43
+ export function normalizeDeviceName(raw) {
44
+ if (raw === undefined || raw === null) return defaultDeviceName();
45
+ if (typeof raw !== 'string') {
46
+ const error = new Error('设备名必须是字符串');
47
+ error.code = 'INVALID_NAME';
48
+ throw error;
49
+ }
50
+ const name = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim();
51
+ if (name.length === 0) return defaultDeviceName();
52
+ if (name.includes('/') || name.includes('\\')) {
53
+ const error = new Error('设备名不能包含 / 或 \\');
54
+ error.code = 'INVALID_NAME';
55
+ throw error;
56
+ }
57
+ if (name.includes('..')) {
58
+ const error = new Error('设备名不能包含 ..');
59
+ error.code = 'INVALID_NAME';
60
+ throw error;
61
+ }
62
+ if ([...name].length > 24) {
63
+ const error = new Error('设备名最多 24 个字符');
64
+ error.code = 'INVALID_NAME';
65
+ throw error;
66
+ }
67
+ return name;
68
+ }
69
+
70
+ /**
71
+ * P1:新建首个忆云时落盘 profile registry(current/default + 忆云名)。
72
+ * 仅当 home 就是忆云 root 时写入;自定义 home / 危险 env 下跳过并返回原因(不阻塞初始化)。
73
+ */
74
+ async function writeInitialProfile({ home, name, kind, deviceId, masterFingerprint }) {
75
+ const cloud = resolveCloudRoot({ home, env: process.env });
76
+ if (!cloud.available) return { written: false, reason: cloud.reason ?? 'unavailable' };
77
+ if (resolve(cloud.root) !== resolve(home)) return { written: false, reason: 'home_not_root' };
78
+ const state = await readRegistry(cloud.root);
79
+ const registry = {
80
+ ...state.registry,
81
+ profiles: state.registry.profiles.map((entry) => ({ ...entry })),
82
+ };
83
+ let entry = registry.profiles.find((profile) => profile.id === 'default');
84
+ const now = new Date().toISOString();
85
+ if (!entry) {
86
+ entry = {
87
+ id: 'default',
88
+ name: name ?? DEFAULT_PROFILE_NAME,
89
+ home: '.',
90
+ kind,
91
+ createdAt: now,
92
+ lastUsedAt: now,
93
+ masterFingerprint: masterFingerprint ?? null,
94
+ deviceId: deviceId ?? null,
95
+ };
96
+ registry.profiles.push(entry);
97
+ } else {
98
+ entry.name = name ?? entry.name ?? DEFAULT_PROFILE_NAME;
99
+ entry.kind = kind;
100
+ entry.deviceId = deviceId ?? entry.deviceId ?? null;
101
+ entry.masterFingerprint = masterFingerprint ?? entry.masterFingerprint ?? null;
102
+ entry.lastUsedAt = now;
103
+ }
104
+ registry.current = 'default';
105
+ await writeRegistry(cloud.root, registry);
106
+ return { written: true, id: entry.id, name: entry.name };
107
+ }
108
+
109
+ const PROVISION_STATE_PATHS = [
110
+ 'user-master-key.json',
111
+ 'store.jsonl',
112
+ 'store.jsonl.identity.json',
113
+ 'master-key.json',
114
+ 'fleet.config.json',
115
+ join('net', 'peers.json'),
116
+ 'provision.json',
117
+ 'profiles.json',
118
+ 'profiles.json.bak',
119
+ 'join.error.json',
120
+ ];
121
+
122
+ /**
123
+ * P1:create/join 的落盘事务快照。热初始化失败时只删除**本次新建**的文件,
124
+ * 不触碰调用前已存在的任何内容(home 锁始终由当前进程持有)。
125
+ */
126
+ export async function snapshotProvisionState(home) {
127
+ const paths = new Set(PROVISION_STATE_PATHS.map((rel) => join(home, rel)));
128
+ paths.add(configPath(home));
129
+ const cloud = resolveCloudRoot({ home, env: process.env });
130
+ if (cloud.available) {
131
+ paths.add(join(cloud.root, 'profiles.json'));
132
+ paths.add(join(cloud.root, 'profiles.json.bak'));
133
+ }
134
+ const entries = new Map();
135
+ for (const path of paths) entries.set(path, existsSync(path));
136
+ return {
137
+ entries,
138
+ async rollback() {
139
+ for (const [path, existed] of entries) {
140
+ if (!existed) await rm(path, { recursive: true, force: true }).catch(() => undefined);
141
+ }
142
+ },
143
+ };
144
+ }
145
+
146
+ // F-ONB-1:设备名 → deviceId 归一化(create / join / status 唯一入口)
147
+ // 规则:先 sanitize(合法字符集 + 去首尾 '-'),再去掉**所有重复的 `device-` 前缀**(大小写不敏感,
148
+ // 含连续重复),最后拼**一次**前缀。空名 / 只剩 `device` → `local`(保持 `device-local` 现状)。
149
+ // 例:TestB→device-TestB · device-TestB→device-TestB · device-device-TestB→device-TestB · ''→device-local
150
+ const DEVICE_PREFIX_RE = /^device-+/i;
151
+ /** 去 `device-` 前缀后的「名字本体」;空或只剩 `device` → `local`。 */
152
+ export function deviceNameCore(raw) {
153
+ let core = sanitize(raw);
154
+ while (DEVICE_PREFIX_RE.test(core)) core = core.replace(DEVICE_PREFIX_RE, '');
155
+ if (core.length === 0 || /^device$/i.test(core)) core = 'local';
156
+ return core;
157
+ }
158
+
159
+ /** 设备名 → deviceId(先 sanitize,再去重复 `device-` 前缀,再拼一次前缀)。 */
160
+ export const deriveDeviceId = (name) => `device-${deviceNameCore(name)}`;
161
+
162
+ /**
163
+ * 显式 `deviceId` 参数:以 `device-` 开头时按同一规则归一化(去重复前缀),否则原样保留(自定义 ID)。
164
+ * 返回 null 表示未提供。
165
+ */
166
+ export function normalizeExplicitDeviceId(value) {
167
+ const raw = String(value ?? '').trim();
168
+ if (raw.length === 0) return null;
169
+ return /^device-+/i.test(raw) ? deriveDeviceId(raw) : (sanitize(raw) || null);
170
+ }
171
+
172
+ export function provisionStatus({ home, mcpPort, phase = 'UNINITIALIZED', provisioned = false } = {}) {
173
+ const name = defaultDeviceName();
174
+ const base = {
175
+ ok: true,
176
+ provision: true,
177
+ phase,
178
+ hostname: name,
179
+ defaultDeviceName: name,
180
+ defaultDeviceId: deriveDeviceId(name),
181
+ home,
182
+ configPath: configPath(home),
183
+ mcpPort,
184
+ // 兼容旧控制台字段:P1 起初始化不再需要重启。
185
+ restartRequired: false,
186
+ restarting: false,
187
+ };
188
+ if (phase === 'INITIALIZING') {
189
+ return {
190
+ ...base,
191
+ provisioned: Boolean(provisioned),
192
+ note: '正在当前进程内完成初始化(写入配置并启动应用);完成后本页自动进入正常态,无需重启。',
193
+ };
194
+ }
195
+ if (provisioned) {
196
+ return {
197
+ ...base,
198
+ provisioned: true,
199
+ note: '检测到已初始化的配置;当前进程尚未加载应用,请重新启动守护后进入正常态。',
200
+ };
201
+ }
202
+ return {
203
+ ...base,
204
+ provisioned: false,
205
+ note: '家目录为空:请选择「新建忆云」或「加入已有忆云」;完成后当前进程会直接进入正常态(无需重启),之后由各设备自行订阅记忆域。',
206
+ };
207
+ }
208
+
209
+ /** 端口预检:优先用期望端口,被占则取系统分配(返回实际可用端口)。 */
210
+ export async function findFreePort(preferred, host = '0.0.0.0') {
211
+ const tryListen = (port) => new Promise((resolve) => {
212
+ const server = createServer();
213
+ server.once('error', () => resolve(null));
214
+ server.listen(port, host, () => {
215
+ const actual = server.address()?.port ?? port;
216
+ server.close(() => resolve(actual));
217
+ });
218
+ });
219
+ const chosen = await tryListen(preferred);
220
+ if (chosen !== null) return { port: chosen, preferredAvailable: true };
221
+ const fallback = await tryListen(0);
222
+ if (fallback === null) throw new Error(`无法在 ${host} 上分配端口(preferred=${preferred})`);
223
+ return { port: fallback, preferredAvailable: false };
224
+ }
225
+
226
+ async function writeConfigFile(home, config) {
227
+ await mkdir(dirname(configPath(home)), { recursive: true });
228
+ const path = configPath(home);
229
+ await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
230
+ try { await chmod(path, 0o600); } catch { /* Windows ACL 兜底 */ }
231
+ return path;
232
+ }
233
+
234
+ /**
235
+ * 写引导态 config.json(create/restore 共用)。调用方必须已确保 `<home>/user-master-key.json` 存在
236
+ * (create 由 resolveMasterKeys 生成;restore 由备份解密落盘)。
237
+ */
238
+ async function writeInitialProvisionConfig({ home, deviceName, deviceId, mcpPort, joinPort, listenPort, action = 'create', notes = [] }) {
239
+ const name = String(deviceName ?? '').trim() || defaultDeviceName();
240
+ const id = normalizeExplicitDeviceId(deviceId) ?? deriveDeviceId(name);
241
+ // 端口预检(避免重启即失败):mcp 端口 = 当前引导服务端口(我们自己持有,无需探测)
242
+ const joinChoice = await findFreePort(Number(joinPort) > 0 ? Number(joinPort) : 4002);
243
+ const listenChoice = await findFreePort(Number(listenPort) > 0 ? Number(listenPort) : 4001);
244
+
245
+ const config = {
246
+ deviceId: id,
247
+ deviceName: name,
248
+ encryption: { level: 'none' },
249
+ network: {
250
+ enabled: true,
251
+ autoConnect: true,
252
+ libp2p: { listen: [`/ip4/0.0.0.0/tcp/${listenChoice.port}`] },
253
+ },
254
+ sync: {
255
+ autoSync: true,
256
+ pushOnWrite: true,
257
+ namespaces: ['tasks'],
258
+ antiEntropy: { enabled: true, intervalMs: 600000, jitterRatio: 0.2 },
259
+ },
260
+ joinService: { enabled: true, bind: '0.0.0.0', port: joinChoice.port },
261
+ mcp: { http: { host: '127.0.0.1', port: mcpPort, auth: 'none', tls: false } },
262
+ };
263
+ const path = await writeConfigFile(home, config);
264
+ return {
265
+ ok: true,
266
+ action,
267
+ deviceId: id,
268
+ deviceName: name,
269
+ configPath: path,
270
+ ports: { mcp: mcpPort, join: joinChoice.port, listen: listenChoice.port },
271
+ notes: [
272
+ ...notes,
273
+ joinChoice.preferredAvailable ? `join 端口 ${joinChoice.port}` : `join 端口 4002 被占 → 已改用 ${joinChoice.port}`,
274
+ listenChoice.preferredAvailable ? `监听 /ip4/0.0.0.0/tcp/${listenChoice.port}` : `监听端口 4001 被占 → 已改用 ${listenChoice.port}`,
275
+ ],
276
+ };
277
+ }
278
+
279
+ /**
280
+ * 建新:生成 root 主密钥(复用 resolveMasterKeys:env > keyFile > <home>/user-master-key.json > 生成并持久化)
281
+ * + 写 config.json(joinService 开、mcp 回环、network 默认)+ 写首个 profile registry,返回落盘结果与端口。
282
+ * P1:失败即回滚本次新建的主密钥 / config / registry,不留下「config 已写但 app 没起来」的半状态。
283
+ */
284
+ export async function provisionCreate({ home, cloudName, deviceName, deviceId, mcpPort, joinPort, listenPort }) {
285
+ if (!isProvisionHome(home)) {
286
+ const error = new Error('该家目录已完成初始化(已存在 config.json / 主密钥 / 身份文件)');
287
+ error.code = 'ALREADY_PROVISIONED';
288
+ throw error;
289
+ }
290
+ const name = normalizeDeviceName(deviceName);
291
+ const profileName = cloudName === undefined ? null : normalizeProfileName(cloudName);
292
+ const keyPath = join(home, 'user-master-key.json');
293
+ const cfgPath = configPath(home);
294
+ const keyExisted = existsSync(keyPath);
295
+ const configExisted = existsSync(cfgPath);
296
+ const cloud = resolveCloudRoot({ home, env: process.env });
297
+ const registryPath = cloud.available ? join(cloud.root, 'profiles.json') : null;
298
+ const registryBackupPath = cloud.available ? join(cloud.root, 'profiles.json.bak') : null;
299
+ const registryExisted = registryPath !== null && existsSync(registryPath);
300
+ const registryBackupExisted = registryBackupPath !== null && existsSync(registryBackupPath);
301
+ try {
302
+ const master = await resolveMasterKeys(home, {});
303
+ if (master.userMasterPrivateKey === undefined) {
304
+ const error = new Error('建新需要 root 主密钥(生成失败:未获得私钥)');
305
+ error.code = 'MASTER_KEY_FAILED';
306
+ throw error;
307
+ }
308
+ const result = await writeInitialProvisionConfig({
309
+ home,
310
+ deviceName: name,
311
+ deviceId,
312
+ mcpPort,
313
+ joinPort,
314
+ listenPort,
315
+ action: 'create',
316
+ notes: ['已生成 root 主密钥(<home>/user-master-key.json,0600)'],
317
+ });
318
+ const profile = await writeInitialProfile({
319
+ home,
320
+ name: profileName,
321
+ kind: 'root',
322
+ deviceId: result.deviceId,
323
+ masterFingerprint: masterFingerprintForHome(home, {}),
324
+ }).catch((error) => {
325
+ const wrapped = new Error(`写入忆云档案失败:${error?.message ?? error}`);
326
+ wrapped.code = error?.code ?? 'PROFILE_WRITE_FAILED';
327
+ throw wrapped;
328
+ });
329
+ return {
330
+ ...result,
331
+ cloudName: profile.written ? profile.name : DEFAULT_PROFILE_NAME,
332
+ profile: profile.written ? { id: profile.id, name: profile.name } : null,
333
+ notes: [
334
+ ...result.notes,
335
+ profile.written
336
+ ? `已写入忆云档案(current=default,名字=「${profile.name}」)`
337
+ : `未写入忆云档案(${profile.reason ?? '当前 home 不是忆云 root'}):控制台沿用默认名字`,
338
+ ],
339
+ };
340
+ } catch (error) {
341
+ if (!keyExisted) await rm(keyPath, { force: true }).catch(() => undefined);
342
+ if (!configExisted) await rm(cfgPath, { force: true }).catch(() => undefined);
343
+ if (registryPath !== null && !registryExisted) await rm(registryPath, { force: true }).catch(() => undefined);
344
+ if (registryBackupPath !== null && !registryBackupExisted) await rm(registryBackupPath, { force: true }).catch(() => undefined);
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * 从备份恢复(§4.3):解密主密钥 → 写 `<home>/user-master-key.json`(0600)→ 写 config.json。
351
+ * 失败不残留(备份模块先解密/验公钥再写盘;本函数失败时清理本次新建的主密钥)。
352
+ */
353
+ export async function provisionRestore({ home, file, code, cloudName, deviceName, deviceId, mcpPort, joinPort, listenPort }) {
354
+ if (!isProvisionHome(home)) {
355
+ const error = new Error('该家目录已完成初始化(已存在 config.json / 主密钥 / 身份文件)');
356
+ error.code = 'ALREADY_PROVISIONED';
357
+ throw error;
358
+ }
359
+ const name = normalizeDeviceName(deviceName);
360
+ const profileName = cloudName === undefined ? null : normalizeProfileName(cloudName);
361
+ if (typeof file !== 'string' || file.trim().length === 0) {
362
+ const error = new Error('需要备份文件路径');
363
+ error.code = 'BACKUP_FILE_REQUIRED';
364
+ throw error;
365
+ }
366
+ if (typeof code !== 'string' || code.trim().length === 0) {
367
+ const error = new Error('需要备份码');
368
+ error.code = 'BACKUP_CODE_REQUIRED';
369
+ throw error;
370
+ }
371
+ const fleet = await import('../../fleet/dist/index.js');
372
+ const keyPath = join(home, 'user-master-key.json');
373
+ let restored;
374
+ try {
375
+ restored = await fleet.restoreMasterKeyBackup({
376
+ file: await readFile(file, 'utf-8'),
377
+ code: code.trim(),
378
+ dir: home,
379
+ keyPath,
380
+ });
381
+ } catch (error) {
382
+ await rm(keyPath, { force: true }).catch(() => undefined);
383
+ const wrapped = new Error(`恢复失败:${error?.message ?? error}`);
384
+ wrapped.code = 'RESTORE_FAILED';
385
+ throw wrapped;
386
+ }
387
+ const transaction = await snapshotProvisionState(home);
388
+ try {
389
+ const result = await writeInitialProvisionConfig({
390
+ home,
391
+ deviceName: name,
392
+ deviceId,
393
+ mcpPort,
394
+ joinPort,
395
+ listenPort,
396
+ action: 'restore',
397
+ notes: [
398
+ `已从备份恢复主密钥(${restored.fingerprint})`,
399
+ '请核对指纹与既有设备「关于本机」一致',
400
+ ],
401
+ });
402
+ const profile = await writeInitialProfile({
403
+ home,
404
+ name: profileName,
405
+ kind: 'root',
406
+ deviceId: result.deviceId,
407
+ masterFingerprint: restored.fingerprint ?? masterFingerprintForHome(home, {}),
408
+ });
409
+ return {
410
+ ...result,
411
+ cloudName: profile.written ? profile.name : DEFAULT_PROFILE_NAME,
412
+ profile: profile.written ? { id: profile.id, name: profile.name } : null,
413
+ fingerprint: restored.fingerprint,
414
+ masterPublicKeyHex: restored.masterPublicKeyHex,
415
+ notes: [
416
+ ...result.notes,
417
+ ...(profile.written ? [`已写入忆云档案(current=default,名字=「${profile.name}」)`] : []),
418
+ '既有设备无需重新入网(主密钥未变);与恢复机建立连接即可继续同步',
419
+ '恢复机成为新 root:建议重新导出备份并轮换恢复码',
420
+ ],
421
+ };
422
+ } catch (error) {
423
+ await transaction.rollback();
424
+ throw error;
425
+ }
426
+ }
427
+
428
+ /** 令牌可读化校验(形状/TTL)+ 提前失败,避免把不可用的令牌发到 inviter。 */
429
+ export function inspectJoinToken(decodeJoinToken, token, now = Date.now()) {
430
+ let parsed;
431
+ try {
432
+ parsed = decodeJoinToken(token);
433
+ } catch (error) {
434
+ const wrapped = new Error(`令牌无法解析:${error?.message ?? error}`);
435
+ wrapped.code = 'TOKEN_INVALID';
436
+ throw wrapped;
437
+ }
438
+ if (typeof parsed.expiresAt === 'number' && parsed.expiresAt <= now) {
439
+ const error = new Error('令牌已过期:请让邀请方重新生成(邀请面板 → 一次性令牌)');
440
+ error.code = 'TOKEN_EXPIRED';
441
+ throw error;
442
+ }
443
+ return parsed;
444
+ }
445
+
446
+ async function resolveJoinPorts({ ports = {}, mcpPort, joinPort, listenPort }) {
447
+ const positive = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
448
+ const mcp = positive(ports.mcp) ? Number(ports.mcp) : (positive(mcpPort) ? Number(mcpPort) : 0);
449
+ // 档案加入必须复用当前忆云的端口(旧守护还占着,不能探测);只有首启/显式 0 才回退 findFreePort。
450
+ const join = positive(ports.join)
451
+ ? Number(ports.join)
452
+ : (positive(joinPort) ? Number(joinPort) : (await findFreePort(4002)).port);
453
+ const listen = positive(ports.listen)
454
+ ? Number(ports.listen)
455
+ : (positive(listenPort) ? Number(listenPort) : (await findFreePort(4001)).port);
456
+ return { mcp, join, listen };
457
+ }
458
+
459
+ function delegatedConfig({ home, id, name, namespace, ports, peers = [] }) {
460
+ return {
461
+ storagePath: join(home, 'store.jsonl'),
462
+ deviceId: id,
463
+ deviceName: name,
464
+ identity: { mode: 'delegated' },
465
+ encryption: { level: 'none', userMasterPublicKeyFile: join(home, 'master-key.json') },
466
+ network: {
467
+ enabled: true,
468
+ autoConnect: true,
469
+ libp2p: { listen: [`/ip4/0.0.0.0/tcp/${ports.listen}`] },
470
+ ...(peers.length > 0 ? { peers } : {}),
471
+ },
472
+ sync: {
473
+ autoSync: true,
474
+ pushOnWrite: true,
475
+ namespaces: [namespace],
476
+ antiEntropy: { enabled: true, intervalMs: 600000, jitterRatio: 0.2 },
477
+ },
478
+ joinService: { enabled: true, bind: '0.0.0.0', port: ports.join },
479
+ mcp: { http: { host: '127.0.0.1', port: ports.mcp, auth: 'none', tls: false } },
480
+ };
481
+ }
482
+
483
+ /**
484
+ * 加入一个**已存在的空 home**(档案加入复用):
485
+ * ① 先解码/校验令牌(形状/TTL)② 预写 delegated config(避免「令牌已兑换、本机写失败」窗口)
486
+ * ③ 复用 fleet joinWithToken(身份/证书/hints)④ 补写 peers。
487
+ *
488
+ * `ports` 由调用方显式给出时**原样使用**(档案切换复用当前忆云端口,不探测、不抢端口);
489
+ * 未给出时按首启习惯 findFreePort。`rollbackDirectory` 只允许调用方对「本次独占创建、尚未注册」的
490
+ * 目录使用:失败时整目录回滚;默认仅清理本次新建的文件(保留首启 provisionJoin 的旧语义)。
491
+ */
492
+ export async function provisionJoinInto({
493
+ home,
494
+ token,
495
+ cloudName,
496
+ deviceName,
497
+ deviceId,
498
+ ports,
499
+ mcpPort,
500
+ joinPort,
501
+ listenPort,
502
+ timeoutMs,
503
+ rollbackDirectory = false,
504
+ registerCurrentProfile = false,
505
+ }) {
506
+ if (!isProvisionHome(home)) {
507
+ const error = new Error('该家目录已完成初始化(已存在 config.json / 主密钥 / 身份文件)');
508
+ error.code = 'ALREADY_PROVISIONED';
509
+ throw error;
510
+ }
511
+ if (typeof token !== 'string' || token.trim().length === 0) {
512
+ const error = new Error('需要令牌文本(邀请面板生成的 base64 令牌)');
513
+ error.code = 'TOKEN_REQUIRED';
514
+ throw error;
515
+ }
516
+ const fleet = await import('../../fleet/dist/index.js');
517
+ const parsed = inspectJoinToken(fleet.decodeJoinToken, token.trim());
518
+
519
+ const name = normalizeDeviceName(deviceName);
520
+ const profileName = cloudName === undefined ? null : normalizeProfileName(cloudName);
521
+ const id = normalizeExplicitDeviceId(deviceId) ?? deriveDeviceId(name);
522
+ const namespace = parsed.namespace;
523
+ const resolvedPorts = await resolveJoinPorts({ ports, mcpPort, joinPort, listenPort });
524
+
525
+ // 复用 fleet join(不复制第二套实现);agents 用 fleet CLI 同款占位(fleet 要求非空)
526
+ const artifacts = ['store.jsonl', 'store.jsonl.identity.json', 'master-key.json', 'fleet.config.json', join('net', 'peers.json')];
527
+ const preexisting = new Set(artifacts.filter((rel) => existsSync(join(home, rel))));
528
+ const configFile = configPath(home);
529
+ const configPreexisting = existsSync(configFile);
530
+ const rollbackArtifacts = async () => {
531
+ if (rollbackDirectory) {
532
+ await rm(home, { recursive: true, force: true }).catch(() => undefined);
533
+ return;
534
+ }
535
+ // 失败不得留下半成品(否则引导态判定失效、无法重试):仅清理本次新建的产物
536
+ for (const rel of artifacts) {
537
+ if (!preexisting.has(rel)) await rm(join(home, rel), { force: true }).catch(() => undefined);
538
+ }
539
+ if (!configPreexisting) await rm(configFile, { force: true }).catch(() => undefined);
540
+ };
541
+
542
+ // 预写 config:兑换前完成本机落盘(端口复用当前忆云;准备阶段不 bind)。
543
+ await writeConfigFile(home, delegatedConfig({ home, id, name, namespace, ports: resolvedPorts }));
544
+
545
+ let joined;
546
+ try {
547
+ joined = await fleet.joinWithToken({
548
+ dir: home,
549
+ token: token.trim(),
550
+ device: id,
551
+ namespace,
552
+ listen: `/ip4/0.0.0.0/tcp/${resolvedPorts.listen}`,
553
+ agents: [{ name: 'echo', kind: 'echo' }],
554
+ ...(Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
555
+ });
556
+ } catch (error) {
557
+ await rollbackArtifacts();
558
+ const raw = String(error?.message ?? error);
559
+ const code = error?.code ?? (error?.cause?.code ?? '');
560
+ if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(code) || /超时|timeout/i.test(raw)) {
561
+ const wrapped = new Error(`邀请方端点不可达(${parsed.endpoint}):请确认对方 serve 在运行且该地址可达`);
562
+ wrapped.code = 'ENDPOINT_UNREACHABLE';
563
+ throw wrapped;
564
+ }
565
+ if (/令牌不可用|used|expired|signature|签名/i.test(raw)) {
566
+ const wrapped = new Error(`令牌不可用:${raw}`);
567
+ wrapped.code = 'TOKEN_REJECTED';
568
+ throw wrapped;
569
+ }
570
+ throw error;
571
+ }
572
+
573
+ // fleet 落盘:<home>/store.jsonl.identity.json(委派身份,无主私钥)+ <home>/master-key.json(仅公钥)
574
+ // + <home>/fleet.config.json(登记 inviter 地址)+ <home>/net/peers.json(hints)
575
+ const fleetCfg = await fleet.loadFleetConfig(fleet.fleetConfigPath(home)).catch(() => null);
576
+ const peers = Array.isArray(fleetCfg?.peers)
577
+ ? fleetCfg.peers.filter((p) => p && typeof p.device === 'string').map((p) => (p.addr ? { device: p.device, addr: p.addr } : { device: p.device }))
578
+ : [];
579
+ const masterKeyPublic = join(home, 'master-key.json');
580
+ const fingerprint = typeof joined.fingerprint === 'string' ? joined.fingerprint : null;
581
+ try {
582
+ const path = await writeConfigFile(home, delegatedConfig({ home, id, name, namespace, ports: resolvedPorts, peers }));
583
+ const profile = registerCurrentProfile
584
+ ? await writeInitialProfile({
585
+ home,
586
+ name: profileName,
587
+ kind: 'delegated',
588
+ deviceId: id,
589
+ masterFingerprint: fingerprint,
590
+ })
591
+ : { written: false, reason: 'not_current_profile' };
592
+ return {
593
+ ok: true,
594
+ action: 'join',
595
+ deviceId: id,
596
+ deviceName: name,
597
+ inviterDeviceId: parsed.inviterDeviceId,
598
+ namespace,
599
+ hinted: joined.hinted,
600
+ peers,
601
+ configPath: path,
602
+ masterKeyPublicFile: masterKeyPublic,
603
+ fingerprint,
604
+ cloudName: profile.written ? profile.name : DEFAULT_PROFILE_NAME,
605
+ profile: profile.written ? { id: profile.id, name: profile.name } : null,
606
+ ports: { mcp: resolvedPorts.mcp, join: resolvedPorts.join, listen: resolvedPorts.listen },
607
+ notes: [
608
+ '已取得委派证书链(无主私钥):<home>/store.jsonl.identity.json',
609
+ `用户主公钥(仅公钥):${masterKeyPublic}`,
610
+ `默认订阅:${namespace}(加入后由本机自行声明;不复制任何权限记录)`,
611
+ ...(profile.written
612
+ ? [`已写入忆云档案(current=default,名字=「${profile.name}」;令牌不含忆云名时沿用默认)`]
613
+ : []),
614
+ ...(joined.hinted > 0 ? [`inviter 地址 hints 已写入 <home>/net/peers.json(${joined.hinted} 条)`] : []),
615
+ ],
616
+ };
617
+ } catch (error) {
618
+ await rollbackArtifacts();
619
+ throw error;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * 首启加入:保留「只认空 home」的 gate;内部委托 provisionJoinInto。
625
+ * 失败只清理本次新建产物(home 本身由引导流程保留,便于重试)。
626
+ */
627
+ export async function provisionJoin({
628
+ home,
629
+ token,
630
+ cloudName,
631
+ deviceName,
632
+ mcpPort,
633
+ joinPort,
634
+ listenPort,
635
+ timeoutMs,
636
+ }) {
637
+ if (!isProvisionHome(home)) {
638
+ const error = new Error('该家目录已完成初始化(已存在 config.json / 主密钥 / 身份文件)');
639
+ error.code = 'ALREADY_PROVISIONED';
640
+ throw error;
641
+ }
642
+ if (typeof token !== 'string' || token.trim().length === 0) {
643
+ const error = new Error('需要令牌文本(邀请面板生成的 base64 令牌)');
644
+ error.code = 'TOKEN_REQUIRED';
645
+ throw error;
646
+ }
647
+ return provisionJoinInto({
648
+ home,
649
+ token,
650
+ cloudName,
651
+ deviceName,
652
+ mcpPort,
653
+ joinPort,
654
+ listenPort,
655
+ timeoutMs,
656
+ registerCurrentProfile: true,
657
+ });
658
+ }
659
+
660
+ /** 读回已落盘的 provider 结果(供控制台「已加入」展示当前路径/订阅分区)。 */
661
+ export async function readProvisionRecord(home) {
662
+ try {
663
+ const record = JSON.parse(await readFile(join(home, 'provision.json'), 'utf-8'));
664
+ return record && typeof record === 'object' ? record : null;
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+
670
+ /** 记录最近一次引导(create/join)结果,供重启后控制台展示。 */
671
+ export async function writeProvisionRecord(home, record) {
672
+ await writeFile(join(home, 'provision.json'), JSON.stringify(record, null, 2), { mode: 0o600 }).catch(() => undefined);
673
+ }
package/src/qr.mjs ADDED
@@ -0,0 +1,4 @@
1
+ // C7 · 配对二维码渲染(与 @mebular/fleet 同实现;mcp 已是 fleet 的下游,不重复编码器逻辑)
2
+ //
3
+ // QR 内容 = 内联令牌文本本身(不引自定义 scheme);编码失败 → null + 告警(只给文本,不报错)。
4
+ export { renderTerminalQr, renderSvgQr } from '../../fleet/dist/qr.js';