@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.
- package/bin/mebular.mjs +1133 -0
- package/package.json +30 -4
- package/scripts/publish-manifest.mjs +44 -0
- package/src/admin.mjs +1320 -0
- package/src/apply-supervisor.mjs +48 -0
- package/src/build-sha.mjs +36 -0
- package/src/client.mjs +49 -0
- package/src/clients.mjs +1242 -0
- package/src/cloud-switch.mjs +258 -0
- package/src/config-apply.mjs +251 -0
- package/src/config-schema.mjs +690 -0
- package/src/config.mjs +265 -0
- package/src/fleet-events.mjs +207 -0
- package/src/home.mjs +16 -0
- package/src/jointoken.mjs +31 -0
- package/src/lan-host.mjs +36 -0
- package/src/profiles.mjs +681 -0
- package/src/provision.mjs +673 -0
- package/src/qr.mjs +4 -0
- package/src/runtime-file.mjs +91 -0
- package/src/serve.mjs +3478 -0
- package/src/server.mjs +416 -0
- package/src/service-control.mjs +107 -0
- package/src/service-facade.mjs +43 -0
- package/src/task-tools.mjs +45 -0
- package/src/tool-envelope.mjs +50 -0
- package/src/tools.mjs +397 -0
- package/README.md +0 -3
package/src/config.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// MCP 侧配置加载(G6.2)
|
|
2
|
+
//
|
|
3
|
+
// 优先级:CLI > env > .mebular/config.json > 默认。
|
|
4
|
+
// 密钥不落 config:主密钥来自 env / config.encryption.keyFile / <home>/user-master-key.json,
|
|
5
|
+
// 都没有则生成并持久化到 <home>/user-master-key.json(0600)。
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { Mebular, IdentityManager, FileEndpointStore, EndpointBook } from '@mebular/core';
|
|
11
|
+
import { homeDir } from './home.mjs';
|
|
12
|
+
|
|
13
|
+
/** 统一家目录(唯一真源,F-INST-1):见 `./home.mjs`。 */
|
|
14
|
+
export { homeDir };
|
|
15
|
+
|
|
16
|
+
export function configPath(home) {
|
|
17
|
+
return process.env.MEBULAR_CONFIG ?? join(home, 'config.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** C2:候选地址簿文件(0600;机制在 core,路径由 app 决定)。 */
|
|
21
|
+
export function endpointsPath(home) {
|
|
22
|
+
return process.env.MEBULAR_ENDPOINTS_FILE ?? join(home, 'net', 'peers.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** C2:配置里的配对 hints(deviceId/peerId → multiaddr 列表),形状容错。 */
|
|
26
|
+
export function networkHints(config) {
|
|
27
|
+
const raw = config?.network?.endpoints;
|
|
28
|
+
const out = {};
|
|
29
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
30
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
31
|
+
if (typeof key !== 'string' || !Array.isArray(value)) continue;
|
|
32
|
+
const addrs = value.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
33
|
+
if (addrs.length > 0) out[key] = addrs;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* C2:从地址簿保留键读取 relay seeds(配对令牌随附;daemon 启动时并入 relayServers)。
|
|
40
|
+
* 读失败一律返回 [](地址簿问题不得阻断启动)。
|
|
41
|
+
*/
|
|
42
|
+
export async function relaySeedsFromBook(home) {
|
|
43
|
+
try {
|
|
44
|
+
const book = new EndpointBook({ store: new FileEndpointStore(endpointsPath(home)) });
|
|
45
|
+
await book.load();
|
|
46
|
+
return book.relaySeeds();
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** C2:relay seeds(客户端可用于中转的 relay multiaddr;并入 libp2p.relayServers)。 */
|
|
53
|
+
export function relaySeeds(config) {
|
|
54
|
+
const raw = config?.network?.relaySeeds;
|
|
55
|
+
if (!Array.isArray(raw)) return [];
|
|
56
|
+
return raw.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadConfigFile(home) {
|
|
60
|
+
const path = configPath(home);
|
|
61
|
+
if (!existsSync(path)) return {};
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`配置文件损坏:${path}(${error.message})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function truthy(value, fallback) {
|
|
70
|
+
if (value === undefined) return fallback;
|
|
71
|
+
return value === true || value === 'true' || value === '1';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readKeyRecord(source) {
|
|
75
|
+
const data = existsSync(source)
|
|
76
|
+
? JSON.parse(await readFile(source, 'utf-8'))
|
|
77
|
+
: JSON.parse(source);
|
|
78
|
+
return {
|
|
79
|
+
userMasterKey: new Uint8Array(Buffer.from(data.publicKey, 'base64')),
|
|
80
|
+
userMasterPrivateKey: await IdentityManager.importPrivateKey(data.privateKeyPkcs8),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 解析用户主密钥;按 env > config.keyFile > <home>/user-master-key.json > 生成并持久化 */
|
|
85
|
+
export async function resolveMasterKeys(home, config) {
|
|
86
|
+
const envFile = process.env.MEBULAR_USER_MASTER_KEY_FILE;
|
|
87
|
+
const envInline = process.env.MEBULAR_USER_MASTER_KEY;
|
|
88
|
+
if (envFile) return readKeyRecord(envFile);
|
|
89
|
+
if (envInline) return readKeyRecord(envInline);
|
|
90
|
+
|
|
91
|
+
const configuredFile = config.encryption?.keyFile;
|
|
92
|
+
if (configuredFile) return readKeyRecord(configuredFile);
|
|
93
|
+
|
|
94
|
+
const defaultFile = join(home, 'user-master-key.json');
|
|
95
|
+
if (existsSync(defaultFile)) return readKeyRecord(defaultFile);
|
|
96
|
+
|
|
97
|
+
const master = await new IdentityManager().generateUserMasterKey();
|
|
98
|
+
const record = {
|
|
99
|
+
publicKey: Buffer.from(master.publicKey).toString('base64'),
|
|
100
|
+
privateKeyPkcs8: await IdentityManager.exportPrivateKey(master.privateKey),
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
await mkdir(dirname(defaultFile), { recursive: true });
|
|
104
|
+
await writeFile(defaultFile, JSON.stringify(record, null, 2), 'utf-8');
|
|
105
|
+
await chmod(defaultFile, 0o600);
|
|
106
|
+
return { userMasterKey: master.publicKey, userMasterPrivateKey: master.privateKey };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 身份模式(W2):`root`(持有用户主密钥私钥)或 `delegated`(仅委派证书链 + 设备钥,**无主私钥**)。
|
|
111
|
+
* 判定:显式 `identity.mode` 优先;否则存在 `<storagePath>.identity.json` 且无本地主私钥 → delegated。
|
|
112
|
+
*/
|
|
113
|
+
export function identityMode(home, config, storagePath) {
|
|
114
|
+
const explicit = config.identity?.mode;
|
|
115
|
+
if (explicit === 'root' || explicit === 'delegated') return explicit;
|
|
116
|
+
const identityFile = `${storagePath}.identity.json`;
|
|
117
|
+
const masterFile = config.encryption?.keyFile ?? join(home, 'user-master-key.json');
|
|
118
|
+
if (existsSync(identityFile)) {
|
|
119
|
+
try {
|
|
120
|
+
const rec = JSON.parse(readFileSync(masterFile, 'utf-8'));
|
|
121
|
+
if (typeof rec.privateKeyPkcs8 !== 'string') return 'delegated';
|
|
122
|
+
} catch {
|
|
123
|
+
return 'delegated';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return 'root';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 读取**用户主公钥**(委派模式只需公钥;`config.encryption.userMasterPublicKeyFile` 或 <home>/user-master-key.json)。 */
|
|
130
|
+
export async function resolveMasterPublicKey(home, config) {
|
|
131
|
+
const file = config.encryption?.userMasterPublicKeyFile ?? join(home, 'user-master-key.json');
|
|
132
|
+
if (!existsSync(file)) {
|
|
133
|
+
throw new Error(`委派身份模式需要用户主公钥:${file}(配置 encryption.userMasterPublicKeyFile 或放入该文件)`);
|
|
134
|
+
}
|
|
135
|
+
const data = JSON.parse(await readFile(file, 'utf-8'));
|
|
136
|
+
if (typeof data.publicKey !== 'string') throw new Error(`主公钥文件形状非法:${file}`);
|
|
137
|
+
return new Uint8Array(Buffer.from(data.publicKey, 'base64'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 依配置构建并初始化 Mebular 门面 */
|
|
141
|
+
export async function createMebular() {
|
|
142
|
+
const home = homeDir();
|
|
143
|
+
const config = await loadConfigFile(home);
|
|
144
|
+
const storageAdapter = config.storageAdapter ?? 'json';
|
|
145
|
+
const storagePath = process.env.MEBULAR_STORAGE_PATH
|
|
146
|
+
?? config.storagePath
|
|
147
|
+
?? join(home, storageAdapter === 'sqlite' ? 'store.sqlite' : 'store.jsonl');
|
|
148
|
+
const deviceId = process.env.MEBULAR_DEVICE_ID ?? config.deviceId ?? `device-${process.env.HOSTNAME ?? 'local'}`;
|
|
149
|
+
const deviceName = process.env.MEBULAR_DEVICE_NAME ?? config.deviceName;
|
|
150
|
+
const mode = identityMode(home, config, storagePath);
|
|
151
|
+
// 生效值快照(console 设置卡应展示这些,而不是静态 config,否则 CLI/env 覆盖后卡片会失真)
|
|
152
|
+
const effective = {
|
|
153
|
+
deviceName: deviceName ?? null,
|
|
154
|
+
identityMode: mode,
|
|
155
|
+
storageAdapter,
|
|
156
|
+
encryptionLevel: config.encryption?.level ?? 'none',
|
|
157
|
+
networkEnabled: truthy(process.env.MEBULAR_NETWORK_ENABLED, config.network?.enabled ?? false),
|
|
158
|
+
autoSync: config.sync?.autoSync ?? true,
|
|
159
|
+
// C2:候选地址簿(自动选路)与端点簿文件
|
|
160
|
+
autoConnect: config.network?.autoConnect !== false,
|
|
161
|
+
endpointsPath: endpointsPath(home),
|
|
162
|
+
endpointsCount: Object.keys(networkHints(config)).length,
|
|
163
|
+
relaySeeds: relaySeeds(config),
|
|
164
|
+
// C3:LAN 自动发现
|
|
165
|
+
lanEnabled: config.network?.lan?.enabled !== false,
|
|
166
|
+
lanAutoDial: config.network?.lan?.autoDial !== false,
|
|
167
|
+
lanDefaultFactory: config.network?.lan?.defaultFactory !== false,
|
|
168
|
+
pushOnWrite: truthy(process.env.MEBULAR_PUSH_ON_WRITE, config.sync?.pushOnWrite ?? true),
|
|
169
|
+
pushOnWriteThrottleMs: config.sync?.pushOnWriteThrottleMs ?? null,
|
|
170
|
+
peerWhitelist: Array.isArray(config.sync?.peerWhitelist) ? [...config.sync.peerWhitelist] : [],
|
|
171
|
+
semanticEnabled: truthy(process.env.MEBULAR_SEMANTIC_ENABLED, config.semantic?.enabled ?? false),
|
|
172
|
+
semanticMinScore: config.semantic?.minScore ?? 0.2,
|
|
173
|
+
};
|
|
174
|
+
const masterKeys =
|
|
175
|
+
mode === 'delegated'
|
|
176
|
+
? { userMasterKey: await resolveMasterPublicKey(home, config) }
|
|
177
|
+
: await resolveMasterKeys(home, config);
|
|
178
|
+
|
|
179
|
+
// C2:地址簿里的 seeds(配对时随令牌写入)+ 配置 seeds → 客户端 relay 列表
|
|
180
|
+
const bookSeeds = effective.networkEnabled ? await relaySeedsFromBook(home) : [];
|
|
181
|
+
const app = new Mebular({
|
|
182
|
+
storagePath,
|
|
183
|
+
deviceId,
|
|
184
|
+
...(deviceName ? { deviceName } : {}),
|
|
185
|
+
...(config.storageAdapter ? { storageAdapter: config.storageAdapter } : {}),
|
|
186
|
+
encryption: {
|
|
187
|
+
level: effective.encryptionLevel,
|
|
188
|
+
userMasterKey: masterKeys.userMasterKey,
|
|
189
|
+
...(masterKeys.userMasterPrivateKey !== undefined ? { userMasterPrivateKey: masterKeys.userMasterPrivateKey } : {}),
|
|
190
|
+
...(process.env[config.encryption?.passphraseEnv ?? 'MEBULAR_PASSPHRASE']
|
|
191
|
+
? { passphrase: process.env[config.encryption?.passphraseEnv ?? 'MEBULAR_PASSPHRASE'] }
|
|
192
|
+
: {}),
|
|
193
|
+
},
|
|
194
|
+
network: {
|
|
195
|
+
enabled: effective.networkEnabled,
|
|
196
|
+
// C2:无显式地址时走候选地址簿(默认开);hints/端点簿由 app 注入,core 不读文件
|
|
197
|
+
autoConnect: effective.autoConnect,
|
|
198
|
+
// 常驻入口:默认真 mDNS(策略在 app;库形态默认关闭)
|
|
199
|
+
lan: {
|
|
200
|
+
enabled: effective.lanEnabled,
|
|
201
|
+
autoDial: effective.lanAutoDial,
|
|
202
|
+
defaultFactory: effective.lanEnabled && effective.lanDefaultFactory,
|
|
203
|
+
},
|
|
204
|
+
endpoints: networkHints(config),
|
|
205
|
+
endpointStore: new FileEndpointStore(effective.endpointsPath),
|
|
206
|
+
// C4:NAT 穿透(AutoNAT/DCUtR)直通;未配置 = auto(依赖在场即启用)
|
|
207
|
+
...(config.network?.nat ? { nat: config.network.nat } : {}),
|
|
208
|
+
...(config.network?.libp2p || bookSeeds.length > 0
|
|
209
|
+
? {
|
|
210
|
+
libp2p: {
|
|
211
|
+
...(config.network.libp2p.listen ? { listen: config.network.libp2p.listen } : {}),
|
|
212
|
+
...(() => {
|
|
213
|
+
const seeds = [...new Set([...(config.network.libp2p.relayServers ?? []), ...effective.relaySeeds, ...bookSeeds])];
|
|
214
|
+
return seeds.length > 0 ? { relayServers: seeds } : {};
|
|
215
|
+
})(),
|
|
216
|
+
...(config.network.libp2p.relayPolicy ? { relayPolicy: config.network.libp2p.relayPolicy } : {}),
|
|
217
|
+
...(config.network.libp2p.relayServer !== undefined
|
|
218
|
+
? { relayServer: config.network.libp2p.relayServer }
|
|
219
|
+
: {}),
|
|
220
|
+
...(config.network.libp2p.relayUnlimited !== undefined
|
|
221
|
+
? { relayUnlimited: config.network.libp2p.relayUnlimited }
|
|
222
|
+
: {}),
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
226
|
+
},
|
|
227
|
+
sync: {
|
|
228
|
+
autoSync: config.sync?.autoSync ?? true,
|
|
229
|
+
// 常驻入口(MCP serve)默认实时:写入即推(含反向 nudge)+ 周期 anti-entropy。
|
|
230
|
+
// 库形态默认关闭(见 README「同步触发时机」);可显式置 false 关闭。
|
|
231
|
+
pushOnWrite: effective.pushOnWrite,
|
|
232
|
+
...(effective.pushOnWriteThrottleMs !== null ? { pushOnWriteThrottleMs: effective.pushOnWriteThrottleMs } : {}),
|
|
233
|
+
antiEntropy: config.sync?.antiEntropy ?? { enabled: true, intervalMs: 600000, jitterRatio: 0.2 },
|
|
234
|
+
// P3 · 收敛健康度:常驻入口默认开启(纯观察层;形状照 antiEntropy 先例)。
|
|
235
|
+
// 会话尾状态指纹(C3 A 类可选字段)随之启用;kill-switch:config.json 置
|
|
236
|
+
// sync.convergence = { enabled:false } 即回默认静默观察。
|
|
237
|
+
convergence: config.sync?.convergence ?? { enabled: true },
|
|
238
|
+
...(config.sync?.snapshotThreshold !== undefined
|
|
239
|
+
? { snapshotThreshold: config.sync.snapshotThreshold }
|
|
240
|
+
: {}),
|
|
241
|
+
...(Array.isArray(config.sync?.namespaces) ? { namespaces: config.sync.namespaces } : {}),
|
|
242
|
+
...(Array.isArray(config.sync?.peerWhitelist) ? { peerWhitelist: config.sync.peerWhitelist } : {}),
|
|
243
|
+
},
|
|
244
|
+
semantic: {
|
|
245
|
+
enabled: effective.semanticEnabled,
|
|
246
|
+
...(config.semantic?.model ? { model: config.semantic.model } : {}),
|
|
247
|
+
...(config.semantic?.cacheDir ? { cacheDir: config.semantic.cacheDir } : {}),
|
|
248
|
+
...(config.semantic?.minScore !== undefined ? { minScore: config.semantic.minScore } : {}),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
await app.initialize();
|
|
252
|
+
// W2:按 config.network.peers 主动拨号(daemon 形态的点对点建立;只有一方需地址)
|
|
253
|
+
for (const peer of config.network?.peers ?? []) {
|
|
254
|
+
if (typeof peer?.addr !== 'string' || peer.addr.length === 0) continue;
|
|
255
|
+
const id = /\/p2p\/([^/]+)/.exec(peer.addr)?.[1] ?? peer.device;
|
|
256
|
+
try {
|
|
257
|
+
await app.node?.connectToPeer({ id, multihash: new Uint8Array(), pubKey: new Uint8Array() }, peer.addr);
|
|
258
|
+
} catch {
|
|
259
|
+
// 由后续 anti-entropy / doctor 暴露
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { app, home, config, storagePath, deviceId, identityMode: mode, effective };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { homedir } from 'node:os';
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Fleet 事件流:把**真实任务事件**映射为 `/admin/events` 的 `fleet` 广播载荷。
|
|
2
|
+
//
|
|
3
|
+
// 任务事件以图示记忆节点(`type='task_event'`)承载;事件日志里对应
|
|
4
|
+
// `node_created` 事件(`data.node` 即任务节点)。本模块只做只读映射与接线:
|
|
5
|
+
// - 本地写入:`eventLog.on('event-appended')`(仅本地 append 触发)
|
|
6
|
+
// - 远端应用:`sync.on('events-applied')`(按 eventIds 回查 eventLog)
|
|
7
|
+
// 不写存储、不改任务/同步协议。无效事件一律返回 null / 静默跳过,不抛异常。
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** 任务事件节点的类型(`MebularTaskEventStore` 默认值)。 */
|
|
13
|
+
export const TASK_EVENT_NODE_TYPE = 'task_event';
|
|
14
|
+
|
|
15
|
+
/** 任务域默认 namespace(`fleet.config.json` 缺省 / 缺文件)。 */
|
|
16
|
+
export const TASK_NAMESPACE_DEFAULT = 'tasks';
|
|
17
|
+
|
|
18
|
+
/** 当前可广播的 TaskEvent 类型 → `fleet` 载荷的 `eventType`(先只保证 created)。 */
|
|
19
|
+
const FLEET_EVENT_TYPES = Object.freeze({
|
|
20
|
+
created: 'created',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
|
|
25
|
+
const nonEmptyString = (value) => typeof value === 'string' && value.length > 0;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从事件日志事件中取任务节点。兼容三种形状:
|
|
29
|
+
* 1) 日志事件:`{ type:'node_created', data:{ node:{ type:'task_event', ... } } }`
|
|
30
|
+
* 2) 带 node 字段:`{ node:{ type:'task_event', ... } }`
|
|
31
|
+
* 3) 裸任务节点:`{ type:'task_event', content:{...}, namespace }`
|
|
32
|
+
*/
|
|
33
|
+
function taskNodeOf(event) {
|
|
34
|
+
if (!isRecord(event)) return null;
|
|
35
|
+
const data = isRecord(event.data) ? event.data : null;
|
|
36
|
+
const candidates = [
|
|
37
|
+
data?.node,
|
|
38
|
+
event.node,
|
|
39
|
+
data && data.type === TASK_EVENT_NODE_TYPE ? data : null,
|
|
40
|
+
event.type === TASK_EVENT_NODE_TYPE ? event : null,
|
|
41
|
+
];
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (isRecord(candidate) && candidate.type === TASK_EVENT_NODE_TYPE) return candidate;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 事件的 namespace:节点优先;日志事件回退自身;缺失按 core 语义视为 `default`。 */
|
|
49
|
+
function namespaceOf(event, node) {
|
|
50
|
+
if (nonEmptyString(node?.namespace)) return node.namespace;
|
|
51
|
+
if (nonEmptyString(event?.namespace)) return event.namespace;
|
|
52
|
+
if (nonEmptyString(event?.data?.namespace)) return event.data.namespace;
|
|
53
|
+
return 'default';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 纯映射:事件 → `fleet` SSE 载荷(无效 / 不属于任务域 / 非 created / 缺字段 → null)。
|
|
58
|
+
*
|
|
59
|
+
* 载荷契约(前端按此实现,勿改字段名):
|
|
60
|
+
* `{ at, fromDeviceId, toDeviceId, taskId, eventType:'created', namespace }`
|
|
61
|
+
* `at` 为广播时刻(与本文件其它 SSE 事件一致)。
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} event 事件日志事件(或任务节点)
|
|
64
|
+
* @param {{ namespace?: string, now?: (() => number) | number }} [options]
|
|
65
|
+
*/
|
|
66
|
+
export function fleetEventFromEvent(event, options = {}) {
|
|
67
|
+
try {
|
|
68
|
+
const namespace = nonEmptyString(options.namespace) ? options.namespace : TASK_NAMESPACE_DEFAULT;
|
|
69
|
+
const node = taskNodeOf(event);
|
|
70
|
+
if (!node) return null;
|
|
71
|
+
if (namespaceOf(event, node) !== namespace) return null;
|
|
72
|
+
|
|
73
|
+
const content = isRecord(node.content)
|
|
74
|
+
? node.content
|
|
75
|
+
: (isRecord(event?.content) ? event.content : null);
|
|
76
|
+
if (!content) return null;
|
|
77
|
+
if (typeof content.type !== 'string') return null;
|
|
78
|
+
|
|
79
|
+
const eventType = FLEET_EVENT_TYPES[content.type];
|
|
80
|
+
if (!eventType) return null;
|
|
81
|
+
|
|
82
|
+
const taskId = nonEmptyString(content.taskId) ? content.taskId : null;
|
|
83
|
+
const fromDeviceId = nonEmptyString(content.actor?.device) ? content.actor.device : null;
|
|
84
|
+
const toDeviceId = nonEmptyString(content.to?.device) ? content.to.device : null;
|
|
85
|
+
if (!taskId || !fromDeviceId || !toDeviceId) return null;
|
|
86
|
+
|
|
87
|
+
const now = typeof options.now === 'function' ? options.now() : options.now;
|
|
88
|
+
const at = Number.isFinite(now) ? Number(now) : Date.now();
|
|
89
|
+
|
|
90
|
+
return { at, fromDeviceId, toDeviceId, taskId, eventType, namespace };
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 读取本机任务域:优先 `fleet.config.json` 的 `namespace`;缺文件 / 解析失败时回退
|
|
98
|
+
* 守护运行时的 namespace(`config.sync.namespaces[0]`,默认 `tasks`)。
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveFleetNamespace({ home, fallback = TASK_NAMESPACE_DEFAULT } = {}) {
|
|
101
|
+
const fallbackNamespace = nonEmptyString(fallback) ? fallback : TASK_NAMESPACE_DEFAULT;
|
|
102
|
+
if (!nonEmptyString(home)) return fallbackNamespace;
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(await readFile(join(home, 'fleet.config.json'), 'utf-8'));
|
|
105
|
+
return nonEmptyString(raw?.namespace) ? raw.namespace : fallbackNamespace;
|
|
106
|
+
} catch {
|
|
107
|
+
return fallbackNamespace;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 接线:把事件源接到 `send('fleet', payload)`,返回清理函数(移除监听、停止后续投递)。
|
|
113
|
+
*
|
|
114
|
+
* - 同一事件只广播一次:以日志事件 id + `taskId#eventType` 双键去重;
|
|
115
|
+
* 本地 append 与远端应用的同一条事件共享日志 id,因此不会重复。
|
|
116
|
+
* - 远端 `events-applied` 只拿到 eventIds,用 `eventLog.getEvent(id)` 回查完整事件。
|
|
117
|
+
* - 去重集合有上界(默认 4096),按插入顺序淘汰最旧键,避免长连接无界增长。
|
|
118
|
+
*/
|
|
119
|
+
export function attachFleetEvents({
|
|
120
|
+
eventLog,
|
|
121
|
+
sync = null,
|
|
122
|
+
namespace = TASK_NAMESPACE_DEFAULT,
|
|
123
|
+
send,
|
|
124
|
+
now = Date.now,
|
|
125
|
+
dedupeLimit = 4096,
|
|
126
|
+
} = {}) {
|
|
127
|
+
if (!eventLog || typeof eventLog.on !== 'function' || typeof send !== 'function') {
|
|
128
|
+
return () => undefined;
|
|
129
|
+
}
|
|
130
|
+
const targetNamespace = nonEmptyString(namespace) ? namespace : TASK_NAMESPACE_DEFAULT;
|
|
131
|
+
const limit = Number.isFinite(dedupeLimit) && dedupeLimit > 0 ? Math.floor(dedupeLimit) : 4096;
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
let closed = false;
|
|
134
|
+
|
|
135
|
+
const claim = (keys) => {
|
|
136
|
+
const present = keys.filter(nonEmptyString);
|
|
137
|
+
if (present.some((key) => seen.has(key))) return false;
|
|
138
|
+
for (const key of present) {
|
|
139
|
+
seen.add(key);
|
|
140
|
+
if (seen.size > limit) {
|
|
141
|
+
const oldest = seen.values().next().value;
|
|
142
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const emit = (event) => {
|
|
149
|
+
if (closed) return false;
|
|
150
|
+
const payload = fleetEventFromEvent(event, { namespace: targetNamespace, now });
|
|
151
|
+
if (!payload) return false;
|
|
152
|
+
const keys = [];
|
|
153
|
+
if (nonEmptyString(event?.id)) keys.push(`event:${event.id}`);
|
|
154
|
+
keys.push(`task:${payload.taskId}#${payload.eventType}`);
|
|
155
|
+
if (!claim(keys)) return false;
|
|
156
|
+
try {
|
|
157
|
+
send('fleet', payload);
|
|
158
|
+
} catch {
|
|
159
|
+
// 连接已断开:不抛出,继续等清理
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const onAppended = (event) => {
|
|
165
|
+
emit(event);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const onEventsApplied = (info) => {
|
|
169
|
+
if (closed || typeof eventLog.getEvent !== 'function') return;
|
|
170
|
+
const ids = Array.isArray(info?.eventIds) ? info.eventIds : [];
|
|
171
|
+
if (ids.length === 0) return;
|
|
172
|
+
void (async () => {
|
|
173
|
+
for (const id of ids) {
|
|
174
|
+
if (closed) return;
|
|
175
|
+
let event = null;
|
|
176
|
+
try {
|
|
177
|
+
event = await eventLog.getEvent(id);
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (event) emit(event);
|
|
182
|
+
}
|
|
183
|
+
})().catch(() => undefined);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
eventLog.on('event-appended', onAppended);
|
|
187
|
+
const removeApplied = () => {
|
|
188
|
+
if (sync && typeof sync.off === 'function') sync.off('events-applied', onEventsApplied);
|
|
189
|
+
};
|
|
190
|
+
if (sync && typeof sync.on === 'function') sync.on('events-applied', onEventsApplied);
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
if (closed) return;
|
|
194
|
+
closed = true;
|
|
195
|
+
try {
|
|
196
|
+
if (typeof eventLog.off === 'function') eventLog.off('event-appended', onAppended);
|
|
197
|
+
else if (typeof eventLog.removeListener === 'function') eventLog.removeListener('event-appended', onAppended);
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
removeApplied();
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/home.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 统一家目录(唯一真源,F-INST-1)
|
|
2
|
+
//
|
|
3
|
+
// `MEBULAR_HOME` 覆盖,缺省 `~/.mebular`。CLI(bin/mebular.mjs)与配置解析
|
|
4
|
+
// (src/config.mjs)**必须共用本模块**,避免「bin 用 <cwd>/.mebular、config 用
|
|
5
|
+
// ~/.mebular」的分裂(会导致 store lock 与 service.heartbeat 各写一边)。
|
|
6
|
+
//
|
|
7
|
+
// 本模块刻意不引入任何重依赖(不 import @mebular/core),以便 bin 在任意
|
|
8
|
+
// 子命令(含 --version)下都能轻量加载。
|
|
9
|
+
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** 统一家目录:`MEBULAR_HOME` 覆盖,缺省 `~/.mebular`。 */
|
|
14
|
+
export function homeDir() {
|
|
15
|
+
return process.env.MEBULAR_HOME ?? join(homedir(), '.mebular');
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// W2/F-UNI:守护侧加入令牌与 join 服务的**薄 re-export**。
|
|
2
|
+
//
|
|
3
|
+
// 唯一实现在 `@mebular/fleet`(`packages/fleet/src/jointoken.ts`):令牌原语(canonical/encode/decode/verify/
|
|
4
|
+
// build)· nonce 状态 · inviter join 服务(`startJoinService`)· joiner 流程(`joinWithToken`)。
|
|
5
|
+
// 本文件**不定义任何业务逻辑**,只做名字映射,避免历史上「两份实现」再次漂移。
|
|
6
|
+
//
|
|
7
|
+
// 防漂移断言见 `npm run check:surface-parity`:全仓 `encodeJoinToken|verifyJoinToken` 的
|
|
8
|
+
// 定义数必须为 1(多一处即红)。
|
|
9
|
+
//
|
|
10
|
+
// 令牌签发前查 `app.getBlockedDevices()`(被移出设备拒签),见 `serve.mjs` 的
|
|
11
|
+
// `/admin/api/invite`;兑换侧不耦合,保持令牌与设备移出分离。
|
|
12
|
+
export {
|
|
13
|
+
canonicalJoinTokenData,
|
|
14
|
+
decodeJoinToken,
|
|
15
|
+
encodeJoinToken,
|
|
16
|
+
buildJoinToken,
|
|
17
|
+
describeJoinToken,
|
|
18
|
+
verifyJoinToken,
|
|
19
|
+
collectReachableEndpoints,
|
|
20
|
+
collectRelaySeeds,
|
|
21
|
+
isLoopbackAddress,
|
|
22
|
+
chainFingerprint,
|
|
23
|
+
joinNonceStatePath,
|
|
24
|
+
readJoinNonceState,
|
|
25
|
+
writeJoinNonceState,
|
|
26
|
+
activateJoinInvite,
|
|
27
|
+
requestJoin,
|
|
28
|
+
joinWithToken,
|
|
29
|
+
persistInviterHints,
|
|
30
|
+
startJoinService as createJoinServer,
|
|
31
|
+
} from '../../fleet/dist/index.js';
|
package/src/lan-host.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// F-C6:加入令牌里的 endpoint 必须是**新设备可直达**的地址(跨机时回环地址必然失败)。
|
|
2
|
+
// bind 为通配(0.0.0.0 / :: / 空)时选一个可对外通告的 LAN IPv4(无则回环,并由上层给出 warning)。
|
|
3
|
+
import { networkInterfaces } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const WILDCARD_BINDS = new Set(['0.0.0.0', '::', '[::]', '*', '']);
|
|
6
|
+
|
|
7
|
+
/** 选一个可对外通告的 LAN IPv4(无则 127.0.0.1)。 */
|
|
8
|
+
export function pickLanHost() {
|
|
9
|
+
const ifaces = networkInterfaces();
|
|
10
|
+
for (const list of Object.values(ifaces)) {
|
|
11
|
+
for (const info of list ?? []) {
|
|
12
|
+
if (info.family === 'IPv4' && !info.internal) return info.address;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return '127.0.0.1';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** bind 地址 → 对外通告地址(通配时取 LAN IPv4)。 */
|
|
19
|
+
export function advertiseHost(bind) {
|
|
20
|
+
const value = typeof bind === 'string' ? bind : '';
|
|
21
|
+
return WILDCARD_BINDS.has(value) ? pickLanHost() : value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 是否回环/本机地址(用于提示「新设备不可达」)。 */
|
|
25
|
+
export function isLoopbackHost(host) {
|
|
26
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '::ffff:127.0.0.1';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 解析 endpoint 的 hostname;非法 URL 返回 null。 */
|
|
30
|
+
export function endpointHostname(endpoint) {
|
|
31
|
+
try {
|
|
32
|
+
return new URL(endpoint).hostname;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|