@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/bin/mebular.mjs
ADDED
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mebular CLI(@mebular/mcp)
|
|
3
|
+
//
|
|
4
|
+
// G6.2:mcp(stdio);G6.3:serve(Streamable HTTP + 鉴权 + 单实例)、token。
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { homeDir } from '../src/home.mjs';
|
|
12
|
+
import { serviceBuildSha } from '../src/build-sha.mjs';
|
|
13
|
+
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
const command = argv[0];
|
|
16
|
+
|
|
17
|
+
function parseFlags(list) {
|
|
18
|
+
const flags = {};
|
|
19
|
+
for (let i = 0; i < list.length; i++) {
|
|
20
|
+
const token = list[i];
|
|
21
|
+
if (token.startsWith('--')) {
|
|
22
|
+
const key = token.slice(2);
|
|
23
|
+
const next = list[i + 1];
|
|
24
|
+
if (next === undefined || next.startsWith('--')) flags[key] = true;
|
|
25
|
+
else {
|
|
26
|
+
flags[key] = next;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return flags;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// F-INST-1:home 默认与 config.mjs 共用同一真源(~/.mebular,MEBULAR_HOME 覆盖)。
|
|
35
|
+
// 历史分裂(bin 用 <cwd>/.mebular)会导致 store lock 与 service.heartbeat 各写一边。
|
|
36
|
+
function tokensPath(flagValue) {
|
|
37
|
+
return flagValue ?? process.env.MEBULAR_TOKENS_FILE ?? join(homeDir(), 'auth', 'tokens.json');
|
|
38
|
+
}
|
|
39
|
+
function clientsPath(flagValue) {
|
|
40
|
+
return flagValue ?? process.env.MEBULAR_OAUTH_CLIENTS_FILE ?? join(homeDir(), 'auth', 'clients.json');
|
|
41
|
+
}
|
|
42
|
+
function consentPath(flagValue) {
|
|
43
|
+
return flagValue ?? process.env.MEBULAR_OAUTH_CONSENT_FILE ?? join(homeDir(), 'auth', 'consent.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseScopes(value, fallback = 'memory.read') {
|
|
47
|
+
return String(value ?? fallback).split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonFile(file, shape) {
|
|
51
|
+
if (!existsSync(file)) return shape;
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(await readFile(file, 'utf-8'));
|
|
54
|
+
return data;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`✗ JSON 文件损坏:${file}(${error.message})`);
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runTokenClient(sub, flags) {
|
|
62
|
+
const file = clientsPath(typeof flags['clients-file'] === 'string' ? flags['clients-file'] : undefined);
|
|
63
|
+
const data = await readJsonFile(file, { clients: [] });
|
|
64
|
+
if (!Array.isArray(data.clients)) data.clients = [];
|
|
65
|
+
|
|
66
|
+
if (sub === 'add') {
|
|
67
|
+
const redirect = flags.redirect;
|
|
68
|
+
if (typeof redirect !== 'string' || redirect.length === 0) {
|
|
69
|
+
console.error('用法:mebular token client add --redirect <uri> [--scope memory.read,memory.write]');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const record = { clientId: randomUUID(), redirectUris: [redirect], allowedScopes: parseScopes(flags.scope), createdAt: new Date().toISOString() };
|
|
73
|
+
data.clients.push(record);
|
|
74
|
+
await mkdir(dirname(file), { recursive: true });
|
|
75
|
+
await writeFile(file, JSON.stringify(data, null, 2), 'utf-8');
|
|
76
|
+
await chmod(file, 0o600);
|
|
77
|
+
console.log(JSON.stringify(record, null, 2));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (sub === 'list') {
|
|
81
|
+
console.log(JSON.stringify(data.clients, null, 2));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (sub === 'remove') {
|
|
85
|
+
const before = data.clients.length;
|
|
86
|
+
data.clients = data.clients.filter((c) => c.clientId !== flags.id);
|
|
87
|
+
if (data.clients.length === before) {
|
|
88
|
+
console.error(`✗ 未找到 client:${flags.id}`);
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
await writeFile(file, JSON.stringify(data, null, 2), 'utf-8');
|
|
92
|
+
console.log(`已移除 client ${flags.id}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.error('用法:mebular token client <add|list|remove> [--redirect uri] [--scope a,b] [--id x]');
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runTokenConsent(flags) {
|
|
100
|
+
const ttl = flags.ttl !== undefined ? Number(flags.ttl) : 300;
|
|
101
|
+
const scopes = parseScopes(flags.scope);
|
|
102
|
+
const file = consentPath(typeof flags['consent-file'] === 'string' ? flags['consent-file'] : undefined);
|
|
103
|
+
const data = await readJsonFile(file, { codes: [] });
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const codes = (Array.isArray(data.codes) ? data.codes : []).filter((c) => c.exp > now && !c.usedAt);
|
|
106
|
+
const code = `meb_consent_${randomUUID().replace(/-/g, '')}`;
|
|
107
|
+
codes.push({ code, scopes, createdAt: new Date(now).toISOString(), exp: now + ttl * 1000 });
|
|
108
|
+
await mkdir(dirname(file), { recursive: true });
|
|
109
|
+
await writeFile(file, JSON.stringify({ codes }, null, 2), 'utf-8');
|
|
110
|
+
await chmod(file, 0o600);
|
|
111
|
+
console.log(JSON.stringify({ code, scopes, expiresAt: new Date(now + ttl * 1000).toISOString(), consentFile: file }, null, 2));
|
|
112
|
+
console.error('(将同意码提交到 /authorize 表示同意;一次性、短时有效)');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runToken(action, flags, subArg) {
|
|
116
|
+
if (action === 'consent') {
|
|
117
|
+
await runTokenConsent(flags);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (action === 'client') {
|
|
121
|
+
await runTokenClient(subArg, flags);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const path = tokensPath(typeof flags['tokens-file'] === 'string' ? flags['tokens-file'] : undefined);
|
|
125
|
+
let data = { tokens: [] };
|
|
126
|
+
if (existsSync(path)) {
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(await readFile(path, 'utf-8'));
|
|
129
|
+
if (!Array.isArray(data.tokens)) data = { tokens: [] };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`✗ tokens 文件损坏:${path}(${error.message})`);
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const { createHash } = await import('node:crypto');
|
|
136
|
+
if (action === 'grant') {
|
|
137
|
+
const scope = String(flags.scope ?? 'memory.read').split(',').map((s) => s.trim()).filter(Boolean);
|
|
138
|
+
const token = `meb_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`;
|
|
139
|
+
const id = randomUUID();
|
|
140
|
+
data.tokens.push({ id, sha256: createHash('sha256').update(token).digest('hex'), scope, label: flags.label ?? undefined, createdAt: new Date().toISOString(), revoked: false });
|
|
141
|
+
await mkdir(dirname(path), { recursive: true });
|
|
142
|
+
await writeFile(path, JSON.stringify(data, null, 2), 'utf-8');
|
|
143
|
+
await chmod(path, 0o600);
|
|
144
|
+
console.log(JSON.stringify({ id, token, scope, tokensFile: path }, null, 2));
|
|
145
|
+
console.log('(token 仅显示一次,请立即保存)');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (action === 'list') {
|
|
149
|
+
console.log(JSON.stringify(data.tokens.map(({ id, scope, label, createdAt, revoked }) => ({ id, scope, label, createdAt, revoked })), null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (action === 'revoke') {
|
|
153
|
+
const id = flags.id;
|
|
154
|
+
const record = data.tokens.find((t) => t.id === id);
|
|
155
|
+
if (!record) {
|
|
156
|
+
console.error(`✗ 未找到 token:${id}`);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
record.revoked = true;
|
|
160
|
+
await writeFile(path, JSON.stringify(data, null, 2), 'utf-8');
|
|
161
|
+
console.log(`已吊销 token ${id}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
console.error('用法:mebular token <grant|list|revoke> [--scope a,b] [--id x] [--tokens-file path]');
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runKeygen(flags) {
|
|
169
|
+
const { IdentityManager } = await import('@mebular/core');
|
|
170
|
+
const out = typeof flags.out === 'string' ? flags.out : join(homeDir(), 'user-master-key.json');
|
|
171
|
+
const master = await new IdentityManager().generateUserMasterKey();
|
|
172
|
+
const record = {
|
|
173
|
+
publicKey: Buffer.from(master.publicKey).toString('base64'),
|
|
174
|
+
privateKeyPkcs8: await IdentityManager.exportPrivateKey(master.privateKey),
|
|
175
|
+
createdAt: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
await mkdir(dirname(out), { recursive: true });
|
|
178
|
+
await writeFile(out, JSON.stringify(record, null, 2), 'utf-8');
|
|
179
|
+
await chmod(out, 0o600);
|
|
180
|
+
console.log(JSON.stringify({ keyFile: out, publicKey: record.publicKey }, null, 2));
|
|
181
|
+
console.error('(主私钥是信任根,请妥善保管;权限 0600)');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runInit(flags) {
|
|
185
|
+
const home = homeDir();
|
|
186
|
+
const configFile = join(home, 'config.json');
|
|
187
|
+
const keyFile = join(home, 'user-master-key.json');
|
|
188
|
+
await mkdir(home, { recursive: true });
|
|
189
|
+
|
|
190
|
+
if (!existsSync(keyFile)) await runKeygen({ out: keyFile });
|
|
191
|
+
|
|
192
|
+
if (existsSync(configFile)) {
|
|
193
|
+
console.log(`配置已存在,未覆盖:${configFile}`);
|
|
194
|
+
} else {
|
|
195
|
+
const config = {
|
|
196
|
+
storagePath: typeof flags['storage'] === 'string' ? flags['storage'] : join(home, 'store.jsonl'),
|
|
197
|
+
storageAdapter: 'json',
|
|
198
|
+
deviceId: typeof flags['device-id'] === 'string' ? flags['device-id'] : `device-${process.env.HOSTNAME ?? 'local'}`,
|
|
199
|
+
encryption: { level: 'none', keyFile },
|
|
200
|
+
network: { enabled: false, libp2p: { listen: [], relayServers: [], relayUnlimited: false } },
|
|
201
|
+
// 常驻入口默认实时(写入即推 + 周期 anti-entropy);可显式关闭
|
|
202
|
+
sync: { autoSync: true, pushOnWrite: true, antiEntropy: { enabled: true } },
|
|
203
|
+
semantic: { enabled: false, minScore: 0.2 },
|
|
204
|
+
mcp: { http: { host: '127.0.0.1', port: 7331, auth: 'none', tls: false, tokensFile: join(home, 'auth', 'tokens.json') } },
|
|
205
|
+
};
|
|
206
|
+
await writeFile(configFile, JSON.stringify(config, null, 2), 'utf-8');
|
|
207
|
+
console.log(`已写入配置:${configFile}`);
|
|
208
|
+
}
|
|
209
|
+
console.log(
|
|
210
|
+
[
|
|
211
|
+
'',
|
|
212
|
+
'下一步:',
|
|
213
|
+
' node packages/mcp/bin/mebular.mjs mcp # stdio 接入(各 MCP client)',
|
|
214
|
+
' node packages/mcp/bin/mebular.mjs serve # Streamable HTTP',
|
|
215
|
+
' node packages/mcp/bin/mebular.mjs status # 查看状态',
|
|
216
|
+
' node packages/skill/scripts/install.mjs # 安装行为层 Skill',
|
|
217
|
+
].join('\n'),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* F-FIX-B:`print-config` **委托注册表**(单一真源)——条目形态、键位、HTTP/stdio 选择都在
|
|
223
|
+
* `src/clients.mjs`(`mebularEntry`/`snippetFor`)。有 `--url` 即产出指向守护 HTTP `/mcp` 的形态;
|
|
224
|
+
* 无 `--url` 回落 stdio(并提示守护运行时应改指向 HTTP)。
|
|
225
|
+
*/
|
|
226
|
+
async function runPrintConfig(flags) {
|
|
227
|
+
const client = typeof flags.client === 'string' ? flags.client : 'generic';
|
|
228
|
+
const url = typeof flags.url === 'string' ? flags.url : null;
|
|
229
|
+
const { snippetFor, descriptorFor } = await import('../src/clients.mjs');
|
|
230
|
+
const ids = { opencode: 'opencode', claude: 'claude-code', cursor: 'cursor', dsh: 'dsh', generic: 'generic' };
|
|
231
|
+
const id = ids[client];
|
|
232
|
+
if (!id) {
|
|
233
|
+
console.error(`未知 client:${client}(opencode/claude/cursor/dsh/generic)`);
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
const assistant = descriptorFor(id);
|
|
237
|
+
const options = {
|
|
238
|
+
...(url ? { url } : {}),
|
|
239
|
+
...(assistant?.landing?.httpShape ? { httpShape: assistant.landing.httpShape } : {}),
|
|
240
|
+
};
|
|
241
|
+
console.log(snippetFor(assistant.format, options));
|
|
242
|
+
if (!url) {
|
|
243
|
+
console.error('提示:守护运行中 stdio `mebular mcp` 会被拒(MCP_STORAGE_LOCKED);请用 --url http://<host>:<port>/mcp');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runConsole(flags) {
|
|
248
|
+
const { homeDir: home, loadConfigFile } = await import('../src/config.mjs');
|
|
249
|
+
const dir = home();
|
|
250
|
+
const config = await loadConfigFile(dir).catch(() => ({}));
|
|
251
|
+
const httpCfg = config.mcp?.http ?? {};
|
|
252
|
+
const host = typeof flags.host === 'string' ? flags.host : httpCfg.host ?? '127.0.0.1';
|
|
253
|
+
const port = flags.port !== undefined ? Number(flags.port) : httpCfg.port ?? 7331;
|
|
254
|
+
// F-C1:scheme 以**生效真值**为准——与 server.mjs 同一定义:证书齐备即实际启用 TLS;
|
|
255
|
+
// tls=true 是「必须启用」开关,缺证书时 serve 启动即失败(此处同步明确警告)。
|
|
256
|
+
const consoleKey = flags['tls-key'] ?? httpCfg.tlsKey;
|
|
257
|
+
const consoleCert = flags['tls-cert'] ?? httpCfg.tlsCert;
|
|
258
|
+
const tlsReady = Boolean(consoleKey && consoleCert);
|
|
259
|
+
if (httpCfg.tls === true && !tlsReady) {
|
|
260
|
+
console.error('⚠ mcp.http.tls=true 但缺少 tlsKey/tlsCert:serve 将启动失败(MCP_INSECURE_CONFIG);请补证书或关闭 tls');
|
|
261
|
+
}
|
|
262
|
+
const scheme = tlsReady ? 'https' : 'http';
|
|
263
|
+
const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
264
|
+
const consoleUrl = `${scheme}://${target}:${port}/console`;
|
|
265
|
+
const probe = `${scheme}://${target}:${port}/healthz`;
|
|
266
|
+
try {
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
269
|
+
const res = await fetch(probe, { signal: controller.signal });
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
if (res.ok) {
|
|
272
|
+
console.log(`Mebular 控制台:${consoleUrl}`);
|
|
273
|
+
if (httpCfg.auth && httpCfg.auth !== 'none') {
|
|
274
|
+
console.error(`(serve 鉴权为 ${httpCfg.auth}:页面写操作需要 memory.admin scope)`);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.error(`✗ serve 已响应但 /healthz 非 200:${res.status}`);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
const reason = error?.name === 'AbortError' ? '探测超时' : error?.message ?? error;
|
|
281
|
+
console.error(`✗ 未检测到运行中的 serve(${probe}):${reason}`);
|
|
282
|
+
}
|
|
283
|
+
console.error('');
|
|
284
|
+
console.error('请先启动 serve,再打开控制台:');
|
|
285
|
+
console.error(` node packages/mcp/bin/mebular.mjs serve --host ${host} --port ${port}`);
|
|
286
|
+
console.error(` 控制台 URL:${consoleUrl}`);
|
|
287
|
+
process.exit(2);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** F-CLI-2:从**运行中的守护**读本机网络真状态(只读 admin API;不初始化自己的 P2P)。 */
|
|
291
|
+
async function readDaemonNetView(home, config) {
|
|
292
|
+
const httpCfg = config?.mcp?.http ?? {};
|
|
293
|
+
const host = httpCfg.host ?? '127.0.0.1';
|
|
294
|
+
const port = httpCfg.port ?? 7331;
|
|
295
|
+
const scheme = (httpCfg.tls === true || (httpCfg.tlsKey && httpCfg.tlsCert)) ? 'https' : 'http';
|
|
296
|
+
const token = process.env.MEBULAR_TOKEN;
|
|
297
|
+
try {
|
|
298
|
+
const res = await fetch(`${scheme}://${host}:${port}/admin/api/net`, {
|
|
299
|
+
headers: { accept: 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
300
|
+
signal: AbortSignal.timeout(2500),
|
|
301
|
+
});
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
return { error: `守护只读端点不可用(HTTP ${res.status}${res.status === 401 ? ':需 token(设置 MEBULAR_TOKEN)' : ''})` };
|
|
304
|
+
}
|
|
305
|
+
return { view: await res.json() };
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { error: `无法连接守护(${error?.message ?? error})` };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* P3:从**运行中的守护**读收敛健康度(只读 admin API /admin/api/devices 的
|
|
313
|
+
* convergence 字段;未启用/守护不可达 = error/空)。不初始化自己的网络。
|
|
314
|
+
*/
|
|
315
|
+
async function readDaemonConvergence(config) {
|
|
316
|
+
const httpCfg = config?.mcp?.http ?? {};
|
|
317
|
+
const host = httpCfg.host ?? '127.0.0.1';
|
|
318
|
+
const port = httpCfg.port ?? 7331;
|
|
319
|
+
const scheme = (httpCfg.tls === true || (httpCfg.tlsKey && httpCfg.tlsCert)) ? 'https' : 'http';
|
|
320
|
+
const token = process.env.MEBULAR_TOKEN;
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`${scheme}://${host}:${port}/admin/api/devices`, {
|
|
323
|
+
headers: { accept: 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
324
|
+
signal: AbortSignal.timeout(2500),
|
|
325
|
+
});
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
return { error: `守护只读端点不可用(HTTP ${res.status}${res.status === 401 ? ':需 token(设置 MEBULAR_TOKEN)' : ''})` };
|
|
328
|
+
}
|
|
329
|
+
const devices = await res.json();
|
|
330
|
+
const peers = (Array.isArray(devices) ? devices : [])
|
|
331
|
+
.filter((d) => d && d.convergence)
|
|
332
|
+
.map((d) => ({ peerDeviceId: d.deviceId, ...d.convergence }));
|
|
333
|
+
return { peers };
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return { error: `无法连接守护(${error?.message ?? error})` };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** C2:`doctor --net` —— 读地址簿/路径状态 + 轻量可达性探针,输出「下一步建议」。 */
|
|
340
|
+
async function runNetDoctor() {
|
|
341
|
+
const { createMebular, loadConfigFile, endpointsPath, networkHints, relaySeeds } = await import('../src/config.mjs');
|
|
342
|
+
const net = await import('node:net');
|
|
343
|
+
const home = homeDir();
|
|
344
|
+
const config = await loadConfigFile(home).catch(() => ({}));
|
|
345
|
+
|
|
346
|
+
const probe = (host, port, timeoutMs = 1500) => new Promise((resolve) => {
|
|
347
|
+
const socket = net.connect({ host, port });
|
|
348
|
+
const done = (ok, error) => { socket.destroy(); resolve({ ok, error: error ?? null }); };
|
|
349
|
+
socket.setTimeout(timeoutMs);
|
|
350
|
+
socket.once('connect', () => done(true));
|
|
351
|
+
socket.once('timeout', () => done(false, 'timeout'));
|
|
352
|
+
socket.once('error', (error) => done(false, error.code ?? error.message));
|
|
353
|
+
});
|
|
354
|
+
const parseTcp = (multiaddr) => {
|
|
355
|
+
const m = /\/(ip4|ip6|dns4|dns6|dns)\/([^/]+)\/tcp\/(\d+)/.exec(String(multiaddr));
|
|
356
|
+
return m ? { host: m[2], port: Number(m[3]) } : null;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const peersFile = endpointsPath(home);
|
|
360
|
+
const hints = networkHints(config);
|
|
361
|
+
const seeds = relaySeeds(config);
|
|
362
|
+
|
|
363
|
+
// 地址簿(离线读;不存在 = 合法)
|
|
364
|
+
let book = null;
|
|
365
|
+
let bookError = null;
|
|
366
|
+
try {
|
|
367
|
+
const { FileEndpointStore, EndpointBook } = await import('@mebular/core');
|
|
368
|
+
const store = new FileEndpointStore(peersFile);
|
|
369
|
+
book = new EndpointBook({ store });
|
|
370
|
+
await book.load();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
bookError = error.message;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// F-CLI-2:守护感知 —— 守护在运行时不初始化自己的 P2P(否则抢监听端口 → EADDRINUSE 污染全部段落)
|
|
376
|
+
const lockPath = join(home, 'lock');
|
|
377
|
+
let storeLock = null;
|
|
378
|
+
if (existsSync(lockPath)) {
|
|
379
|
+
try {
|
|
380
|
+
storeLock = JSON.parse(await readFile(lockPath, 'utf-8'));
|
|
381
|
+
} catch {
|
|
382
|
+
storeLock = { corrupt: true };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const { pidAlive } = await import('../src/serve.mjs');
|
|
386
|
+
const daemonLive = Boolean(storeLock?.pid) && pidAlive(storeLock.pid);
|
|
387
|
+
let daemon = null;
|
|
388
|
+
|
|
389
|
+
let runtime = { enabled: false, listen: [], paths: [], nodeError: null };
|
|
390
|
+
let lan = null;
|
|
391
|
+
let netStatus = null;
|
|
392
|
+
let natStatus = null;
|
|
393
|
+
let relay = null;
|
|
394
|
+
|
|
395
|
+
if (daemonLive) {
|
|
396
|
+
const { view, error } = await readDaemonNetView(home, config);
|
|
397
|
+
if (view) {
|
|
398
|
+
runtime = { enabled: Boolean(view.enabled), listen: view.listen ?? [], paths: view.paths ?? [], nodeError: null };
|
|
399
|
+
lan = view.lan ?? null;
|
|
400
|
+
netStatus = view.net ?? null;
|
|
401
|
+
natStatus = view.nat ?? null;
|
|
402
|
+
relay = view.relay ?? null;
|
|
403
|
+
daemon = { reused: true, pid: storeLock.pid, source: 'admin-api' };
|
|
404
|
+
} else {
|
|
405
|
+
// 只读端点不可用(如需 token):退回 config 判据,并明确标注不是本进程实测
|
|
406
|
+
runtime = { enabled: config.network?.enabled === true, listen: [], paths: [], nodeError: error };
|
|
407
|
+
daemon = { reused: true, pid: storeLock.pid, source: 'config-only', readError: error };
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
// 本地诊断(无守护):进程内状态(一次装配,多处取用;失败不阻断诊断)
|
|
411
|
+
try {
|
|
412
|
+
const { app } = await createMebular();
|
|
413
|
+
try {
|
|
414
|
+
const node = app.node;
|
|
415
|
+
runtime.enabled = Boolean(node?.isRunning());
|
|
416
|
+
runtime.listen = node?.getLocalMultiaddrs?.() ?? [];
|
|
417
|
+
const bookRef = app.endpointBook;
|
|
418
|
+
runtime.paths = bookRef ? bookRef.keys().map((key) => ({ key, path: bookRef.getPath(key) })) : [];
|
|
419
|
+
lan = node?.getLanStatus?.() ?? null;
|
|
420
|
+
netStatus = app.getNetEndpointsStatus?.() ?? null;
|
|
421
|
+
natStatus = app.getNatStatus?.() ?? null;
|
|
422
|
+
relay = node?.getRelayStatus?.() ?? null;
|
|
423
|
+
} finally {
|
|
424
|
+
await app.shutdown().catch(() => undefined);
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
runtime.nodeError = error.message;
|
|
428
|
+
lan = { error: error.message };
|
|
429
|
+
netStatus = { error: error.message };
|
|
430
|
+
natStatus = { error: error.message };
|
|
431
|
+
relay = { error: error.message };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// relay seeds 探针(TCP 可达性)
|
|
436
|
+
const seedProbes = [];
|
|
437
|
+
for (const seed of seeds) {
|
|
438
|
+
const target = parseTcp(seed);
|
|
439
|
+
if (!target) { seedProbes.push({ seed, reachable: null, error: '非 TCP multiaddr(跳过)' }); continue; }
|
|
440
|
+
const result = await probe(target.host, target.port);
|
|
441
|
+
seedProbes.push({ seed, reachable: result.ok, error: result.error });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const next = [];
|
|
445
|
+
// F-CLI-2:守护在运行时 runtime 取自守护实测;仅**无守护**时才用「未开启 P2P」建议(避免误导)
|
|
446
|
+
if (!runtime.enabled && !daemon) next.push('network.enabled=false:先开启 P2P(可达设备会自动成为桥;无需手工起 relay)');
|
|
447
|
+
if (daemon?.readError) next.push(`守护在运行但只读端点不可用(${daemon.readError}):可设置 MEBULAR_TOKEN 后重试,或直接看 \`mebular status\``);
|
|
448
|
+
if (runtime.listen.length === 0 && runtime.enabled) next.push('本机暂无监听地址:检查 network.libp2p.listen / 防火墙 / relay 预约');
|
|
449
|
+
const totalHints = Object.values(hints).reduce((sum, list) => sum + list.length, 0);
|
|
450
|
+
if (totalHints === 0) next.push('无配对 hints:用 `fleet invite`/控制台邀请,让新设备拿到可达地址(token.endpoints)');
|
|
451
|
+
const failedSeeds = seedProbes.filter((p) => p.reachable === false);
|
|
452
|
+
if (failedSeeds.length > 0) next.push(`relay seed 不可达(${failedSeeds.map((p) => p.seed).join(', ')}):确认 relay 进程在跑、端口放行`);
|
|
453
|
+
if (natStatus && natStatus.loadError) next.push(`NAT 打洞不可用(${natStatus.loadError}):安装可选依赖 @libp2p/autonat @libp2p/dcutr 可启用(缺包不影响其他连接方式)`);
|
|
454
|
+
if (natStatus && natStatus.dcutrEnabled && (natStatus.directUpgrades ?? 0) === 0 && (natStatus.relayConnections ?? 0) > 0) next.push('有 relay 连接但尚未观察到直连升级:对端/本机可能都是对称 NAT(打洞失败属预期,保留 relay)');
|
|
455
|
+
if (netStatus && !netStatus.enabled) next.push('地址自动广播未开启(opt-in):把 `__net__` 加入 sync.namespaces 或配置 network.broadcast:{mode:"full"} 即可让可达设备互相广播地址');
|
|
456
|
+
if (netStatus && netStatus.enabled && netStatus.published === 0) next.push('已开启地址广播但尚未发布:启动/可达性变化后会自动发布(首次可能因地址未变而跳过)');
|
|
457
|
+
if (netStatus && Object.values(netStatus.ignored ?? {}).reduce((a, b) => a + b, 0) > 0) next.push(`有被忽略的广播记录(${Object.entries(netStatus.ignored).filter(([, v]) => v > 0).map(([k, v]) => `${k}:${v}`).join(' ')}):过期/吊销/伪装 subject 属预期忽略`);
|
|
458
|
+
if (relay && relay.mode === 'auto' && !relay.serving) next.push(`本机不当桥(${relay.reason}):如需当桥,确保有公网监听地址或已被外部直连(network.relayService=on 可强制)`);
|
|
459
|
+
if (relay && relay.serving && relay.allowedClients === 0) next.push('本机在当桥但白名单为空:先配对(地址簿 paired)后才会放行中转预约');
|
|
460
|
+
if (lan && lan.enabled && !lan.running) next.push(`LAN 发现未运行${lan.lastError ? `(${lan.lastError})` : ''}:检查 bonjour 依赖或 network.lan.enabled`);
|
|
461
|
+
if (lan && lan.ignoredUnknown > 0) next.push(`发现了 ${lan.ignoredUnknown} 个未配对设备(已忽略、未拨号):如确需连通,先用邀请令牌配对(写入地址簿)或加入 sync.peerWhitelist`);
|
|
462
|
+
if (lan && lan.lanCandidates === 0 && lan.running) next.push('LAN 发现已启用但暂无 LAN 候选:确认同网段、mDNS 未被防火墙拦截(跨网段请用 relay seeds)');
|
|
463
|
+
const disconnected = runtime.paths.filter((p) => !p.path);
|
|
464
|
+
if (disconnected.length > 0) next.push(`有候选但未连上的对端 ${disconnected.length} 个:看 lastError,必要时把 relay 加入 network.relaySeeds`);
|
|
465
|
+
|
|
466
|
+
console.log(JSON.stringify({
|
|
467
|
+
ok: true,
|
|
468
|
+
kind: 'net-doctor',
|
|
469
|
+
home,
|
|
470
|
+
peersFile,
|
|
471
|
+
book: book
|
|
472
|
+
? {
|
|
473
|
+
loaded: true,
|
|
474
|
+
peers: book.keys().map((key) => ({ key, candidates: book.list(key).map((c) => ({ address: c.address, kind: c.kind, source: c.source, lastError: c.lastError ?? null })) })),
|
|
475
|
+
}
|
|
476
|
+
: { loaded: false, error: bookError },
|
|
477
|
+
configHints: hints,
|
|
478
|
+
daemon,
|
|
479
|
+
lan,
|
|
480
|
+
net: netStatus,
|
|
481
|
+
nat: natStatus,
|
|
482
|
+
relay,
|
|
483
|
+
relaySeeds: seedProbes,
|
|
484
|
+
runtime,
|
|
485
|
+
next,
|
|
486
|
+
}, null, 2));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function runStatus() {
|
|
490
|
+
const { loadConfigFile, homeDir: resolveHome, identityMode: resolveIdentityMode } = await import('../src/config.mjs');
|
|
491
|
+
const home = resolveHome();
|
|
492
|
+
const configFile = await loadConfigFile(home).catch(() => ({}));
|
|
493
|
+
const storagePath = process.env.MEBULAR_STORAGE_PATH
|
|
494
|
+
?? configFile.storagePath
|
|
495
|
+
?? join(home, (configFile.storageAdapter ?? 'json') === 'sqlite' ? 'store.sqlite' : 'store.jsonl');
|
|
496
|
+
// store 锁持有者(单写者证据)
|
|
497
|
+
let storeLock = null;
|
|
498
|
+
const lockPath = join(home, 'lock');
|
|
499
|
+
if (existsSync(lockPath)) {
|
|
500
|
+
try {
|
|
501
|
+
storeLock = JSON.parse(await readFile(lockPath, 'utf-8'));
|
|
502
|
+
} catch {
|
|
503
|
+
storeLock = { path: lockPath, corrupt: true };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// F-NET-1(C):守护在跑时**不初始化网络**(否则抢监听端口 → 裸 EADDRINUSE)。
|
|
507
|
+
// status 走只读最小路径:返回 storeLock 等可观测项,SETUP §0.1 的判据(storeLock 非 null)成立。
|
|
508
|
+
if (storeLock?.pid) {
|
|
509
|
+
const { pidAlive } = await import('../src/serve.mjs');
|
|
510
|
+
if (pidAlive(storeLock.pid)) {
|
|
511
|
+
// P3:收敛健康度读运行中守护(只读 admin API;未启用/不可达 = null/error)
|
|
512
|
+
const convergence = await readDaemonConvergence(configFile);
|
|
513
|
+
console.log(JSON.stringify({
|
|
514
|
+
ok: true,
|
|
515
|
+
running: true,
|
|
516
|
+
reused: true,
|
|
517
|
+
deviceId: process.env.MEBULAR_DEVICE_ID ?? configFile.deviceId ?? `device-${process.env.HOSTNAME ?? 'local'}`,
|
|
518
|
+
peerId: null,
|
|
519
|
+
nodeCount: null,
|
|
520
|
+
stateHash: null,
|
|
521
|
+
home,
|
|
522
|
+
storagePath,
|
|
523
|
+
identityMode: resolveIdentityMode(home, configFile, storagePath),
|
|
524
|
+
network: { enabled: configFile.network?.enabled ?? false, listen: configFile.network?.libp2p?.listen ?? [], reused: true },
|
|
525
|
+
storeLock,
|
|
526
|
+
namespaces: [],
|
|
527
|
+
lan: null,
|
|
528
|
+
joinService: configFile.joinService ?? null,
|
|
529
|
+
convergence,
|
|
530
|
+
note: `检测到守护进程(pid ${storeLock.pid})正在运行:status 走只读最小路径(未初始化网络、未抢端口)。完整状态请走守护 API。`,
|
|
531
|
+
}, null, 2));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const { createMebular } = await import('../src/config.mjs');
|
|
536
|
+
const { MemoryService } = await import('@mebular/core');
|
|
537
|
+
const { app, storagePath: appStoragePath, deviceId, identityMode, config } = await createMebular();
|
|
538
|
+
try {
|
|
539
|
+
const status = await new MemoryService(app).status();
|
|
540
|
+
const nodes = await app.graph.listNodes({});
|
|
541
|
+
const namespaces = [...new Set(nodes.map((n) => n.namespace ?? 'default'))].sort();
|
|
542
|
+
console.log(JSON.stringify({
|
|
543
|
+
...status,
|
|
544
|
+
home,
|
|
545
|
+
storagePath: appStoragePath,
|
|
546
|
+
deviceId,
|
|
547
|
+
identityMode,
|
|
548
|
+
network: { enabled: config.network?.enabled ?? false, listen: config.network?.libp2p?.listen ?? [] },
|
|
549
|
+
storeLock,
|
|
550
|
+
namespaces,
|
|
551
|
+
joinService: config.joinService ?? null,
|
|
552
|
+
// P3:收敛健康度(本进程刚初始化;对端会话发生前通常为空汇总——持续观察请走守护)
|
|
553
|
+
convergence: (() => {
|
|
554
|
+
try {
|
|
555
|
+
const conv = app.getConvergenceStatus?.();
|
|
556
|
+
return conv ? { at: conv.at, summary: conv.summary, peers: conv.peers } : null;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
})(),
|
|
561
|
+
}, null, 2));
|
|
562
|
+
} finally {
|
|
563
|
+
await app.shutdown().catch(() => undefined);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
/** 由 flags 组装工具入参(`--input` JSON 优先,其余 flag 直填并做整数还原)。 */
|
|
569
|
+
function buildToolInput(flags) {
|
|
570
|
+
if (typeof flags.input === 'string') {
|
|
571
|
+
const parsed = JSON.parse(flags.input);
|
|
572
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('--input 必须是 JSON 对象');
|
|
573
|
+
return parsed;
|
|
574
|
+
}
|
|
575
|
+
const reserved = new Set(['home', 'token', 'config', 'storage', 'device-id', 'device-name', 'input']);
|
|
576
|
+
const out = {};
|
|
577
|
+
for (const [k, v] of Object.entries(flags)) {
|
|
578
|
+
if (reserved.has(k)) continue;
|
|
579
|
+
out[k] = typeof v === 'string' && /^-?[0-9]+$/.test(v) ? Number(v) : v;
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* W3 表面一致性:`mebular memory_*` 与 MCP **同工具名、同 handler**。
|
|
586
|
+
* **单写者**:守护持锁时作为本机 MCP 客户端走 `/mcp`+bearer;未持锁(dev)允许直连。
|
|
587
|
+
*/
|
|
588
|
+
async function runMemoryTool(name, flags) {
|
|
589
|
+
const { toolByName } = await import('../src/tools.mjs');
|
|
590
|
+
const spec = toolByName(name);
|
|
591
|
+
if (!spec) {
|
|
592
|
+
console.error(`未知命令:${name}`);
|
|
593
|
+
process.exit(2);
|
|
594
|
+
}
|
|
595
|
+
const args = buildToolInput(flags);
|
|
596
|
+
const home = homeDir();
|
|
597
|
+
const lockPath = join(home, 'lock');
|
|
598
|
+
let result;
|
|
599
|
+
if (existsSync(lockPath)) {
|
|
600
|
+
const { loadConfigFile } = await import('../src/config.mjs');
|
|
601
|
+
const { callToolViaHttp } = await import('../src/client.mjs');
|
|
602
|
+
const fileConfig = await loadConfigFile(home).catch(() => ({}));
|
|
603
|
+
const httpConf = fileConfig.mcp?.http ?? {};
|
|
604
|
+
const host = httpConf.host ?? '127.0.0.1';
|
|
605
|
+
const port = httpConf.port ?? 7331;
|
|
606
|
+
const token = typeof flags.token === 'string' ? flags.token : process.env.MEBULAR_TOKEN;
|
|
607
|
+
if ((httpConf.auth ?? 'none') !== 'none' && !token) {
|
|
608
|
+
console.error('守护持锁且启用鉴权:请提供 --token 或 MEBULAR_TOKEN(`mebular token grant`)');
|
|
609
|
+
process.exit(2);
|
|
610
|
+
}
|
|
611
|
+
const mcpResult = await callToolViaHttp({ endpoint: `http://${host}:${port}`, token, name, args });
|
|
612
|
+
if (mcpResult?.isError) {
|
|
613
|
+
console.error(JSON.stringify(mcpResult.structuredContent ?? mcpResult));
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
result = mcpResult?.structuredContent;
|
|
617
|
+
} else {
|
|
618
|
+
const { createMebular } = await import('../src/config.mjs');
|
|
619
|
+
const { MemoryService } = await import('@mebular/core');
|
|
620
|
+
const { app } = await createMebular();
|
|
621
|
+
try {
|
|
622
|
+
const specResult = await spec.handler(new MemoryService(app), args);
|
|
623
|
+
if (specResult.isError) {
|
|
624
|
+
console.error(specResult.content[0]?.text ?? '工具失败');
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
result = specResult.structuredContent;
|
|
628
|
+
} finally {
|
|
629
|
+
await app.shutdown().catch(() => undefined);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
console.log(JSON.stringify(result, null, 2));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------- 忆云(profile)CLI:list / use / rename ----------
|
|
636
|
+
|
|
637
|
+
async function readHomeConfig(home) {
|
|
638
|
+
try {
|
|
639
|
+
return JSON.parse(await readFile(join(home, 'config.json'), 'utf-8'));
|
|
640
|
+
} catch {
|
|
641
|
+
return {};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cloudHttpTarget(config) {
|
|
646
|
+
const http = config?.mcp?.http ?? {};
|
|
647
|
+
const rawHost = typeof http.host === 'string' && http.host.length > 0 ? http.host : '127.0.0.1';
|
|
648
|
+
const host = rawHost === '0.0.0.0' || rawHost === '::' ? '127.0.0.1' : rawHost;
|
|
649
|
+
const port = Number.isInteger(http.port) && http.port > 0 ? http.port : 7331;
|
|
650
|
+
const tls = http.tls === true || Boolean(http.tlsKey && http.tlsCert);
|
|
651
|
+
return { host, port, scheme: tls ? 'https' : 'http' };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function probeCloudHealth(home) {
|
|
655
|
+
const config = await readHomeConfig(home);
|
|
656
|
+
const target = cloudHttpTarget(config);
|
|
657
|
+
const url = `${target.scheme}://${target.host}:${target.port}/healthz`;
|
|
658
|
+
try {
|
|
659
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
|
660
|
+
if (!res.ok) return { ok: false, status: res.status, url };
|
|
661
|
+
return { ok: true, body: await res.json(), url };
|
|
662
|
+
} catch (error) {
|
|
663
|
+
return { ok: false, url, error: error?.message ?? String(error) };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function consoleUrlForHome(home) {
|
|
668
|
+
const target = cloudHttpTarget(await readHomeConfig(home));
|
|
669
|
+
return `${target.scheme}://${target.host}:${target.port}/console`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function readHomeLock(home) {
|
|
673
|
+
try {
|
|
674
|
+
return JSON.parse(await readFile(join(home, 'lock'), 'utf-8'));
|
|
675
|
+
} catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
681
|
+
|
|
682
|
+
async function waitForCloudStopped(home, { pid = null, timeoutMs = 10_000 } = {}) {
|
|
683
|
+
const deadline = Date.now() + timeoutMs;
|
|
684
|
+
while (Date.now() < deadline) {
|
|
685
|
+
const health = await probeCloudHealth(home);
|
|
686
|
+
const lock = await readHomeLock(home);
|
|
687
|
+
const lockAlive = (pid !== null && pidAliveSafe(pid))
|
|
688
|
+
|| (Boolean(lock?.pid) && pidAliveSafe(lock.pid));
|
|
689
|
+
if (!health.ok && !lockAlive) return true;
|
|
690
|
+
await sleep(200);
|
|
691
|
+
}
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function waitForCloudHealthy(home, { expectedInstance, cloudId = null, timeoutMs = 20_000 } = {}) {
|
|
696
|
+
const deadline = Date.now() + timeoutMs;
|
|
697
|
+
let last = null;
|
|
698
|
+
while (Date.now() < deadline) {
|
|
699
|
+
const health = await probeCloudHealth(home);
|
|
700
|
+
last = health;
|
|
701
|
+
if (health.ok) {
|
|
702
|
+
const instance = health.body?.instance;
|
|
703
|
+
if (typeof expectedInstance === 'string') {
|
|
704
|
+
if (typeof instance !== 'string') {
|
|
705
|
+
return { ok: false, reason: 'instance_missing', url: health.url };
|
|
706
|
+
}
|
|
707
|
+
if (instance !== expectedInstance) {
|
|
708
|
+
return { ok: false, reason: 'instance_mismatch', instance, url: health.url };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (cloudId !== null && typeof health.body?.cloudId === 'string' && health.body.cloudId !== cloudId) {
|
|
712
|
+
return { ok: false, reason: 'cloud_mismatch', cloudId: health.body.cloudId, url: health.url };
|
|
713
|
+
}
|
|
714
|
+
return { ok: true, body: health.body, url: health.url };
|
|
715
|
+
}
|
|
716
|
+
await sleep(250);
|
|
717
|
+
}
|
|
718
|
+
return { ok: false, reason: 'timeout', url: last?.url ?? null, error: last?.error ?? null };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function pidAliveSafe(pid) {
|
|
722
|
+
try {
|
|
723
|
+
process.kill(pid, 0);
|
|
724
|
+
return true;
|
|
725
|
+
} catch {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function looksLikeMebularServeAsync(pid) {
|
|
731
|
+
if (process.platform === 'win32') return true;
|
|
732
|
+
try {
|
|
733
|
+
const { execFileSync } = await import('node:child_process');
|
|
734
|
+
const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }).trim();
|
|
735
|
+
return command.length === 0 || /mebular|serve/i.test(command);
|
|
736
|
+
} catch {
|
|
737
|
+
return true; // ps 不可用/进程刚退出:由 --force 的显式语义兜底
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function runCloudList() {
|
|
742
|
+
const { displayPath, readRegistry, resolveCloudRoot } = await import('../src/profiles.mjs');
|
|
743
|
+
const cloud = resolveCloudRoot({ home: homeDir(), env: process.env });
|
|
744
|
+
if (!cloud.available) {
|
|
745
|
+
console.error(`✗ 忆云档案功能不可用(${cloud.reason}):当前为自定义 home / 受限 env`);
|
|
746
|
+
process.exit(2);
|
|
747
|
+
}
|
|
748
|
+
const state = await readRegistry(cloud.root).catch((error) => {
|
|
749
|
+
console.error(`✗ 忆云注册表不可读(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
750
|
+
process.exit(2);
|
|
751
|
+
});
|
|
752
|
+
const current = state.registry.current;
|
|
753
|
+
console.log(JSON.stringify({
|
|
754
|
+
ok: true,
|
|
755
|
+
root: displayPath(cloud.root),
|
|
756
|
+
current,
|
|
757
|
+
profiles: state.registry.profiles.map((profile) => ({
|
|
758
|
+
id: profile.id,
|
|
759
|
+
name: profile.name,
|
|
760
|
+
home: profile.home,
|
|
761
|
+
kind: profile.kind,
|
|
762
|
+
createdAt: profile.createdAt ?? null,
|
|
763
|
+
lastUsedAt: profile.lastUsedAt ?? null,
|
|
764
|
+
masterFingerprint: profile.masterFingerprint ?? null,
|
|
765
|
+
deviceId: profile.deviceId ?? null,
|
|
766
|
+
current: profile.id === current,
|
|
767
|
+
})),
|
|
768
|
+
...(state.warning ? { warning: state.warning } : {}),
|
|
769
|
+
}, null, 2));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function runCloudRename(id, name) {
|
|
773
|
+
if (typeof id !== 'string' || id.length === 0 || typeof name !== 'string' || name.length === 0) {
|
|
774
|
+
console.error('用法:mebular cloud rename <id> <name>');
|
|
775
|
+
process.exit(2);
|
|
776
|
+
}
|
|
777
|
+
const { renameProfile, resolveCloudRoot } = await import('../src/profiles.mjs');
|
|
778
|
+
const cloud = resolveCloudRoot({ home: homeDir(), env: process.env });
|
|
779
|
+
if (!cloud.available) {
|
|
780
|
+
console.error(`✗ 忆云档案功能不可用(${cloud.reason})`);
|
|
781
|
+
process.exit(2);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const result = await renameProfile(cloud.root, id, name);
|
|
785
|
+
const profile = result.value;
|
|
786
|
+
console.log(JSON.stringify({
|
|
787
|
+
ok: true,
|
|
788
|
+
profile: { ...profile, current: profile.id === result.registry.current },
|
|
789
|
+
}, null, 2));
|
|
790
|
+
} catch (error) {
|
|
791
|
+
console.error(`✗ 改名失败(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
792
|
+
process.exit(2);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function runCloudUse(id, flags) {
|
|
797
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
798
|
+
console.error('用法:mebular cloud use <id> [--force]');
|
|
799
|
+
process.exit(2);
|
|
800
|
+
}
|
|
801
|
+
const {
|
|
802
|
+
homeKey,
|
|
803
|
+
profileHomeForEntry,
|
|
804
|
+
readRegistry,
|
|
805
|
+
resolveCloudRoot,
|
|
806
|
+
} = await import('../src/profiles.mjs');
|
|
807
|
+
const {
|
|
808
|
+
installProfileService,
|
|
809
|
+
profileServiceDescriptor,
|
|
810
|
+
profileServiceStatus,
|
|
811
|
+
switchProfile,
|
|
812
|
+
uninstallProfileService,
|
|
813
|
+
} = await import('../src/cloud-switch.mjs');
|
|
814
|
+
|
|
815
|
+
const cloud = resolveCloudRoot({ home: homeDir(), env: process.env });
|
|
816
|
+
if (!cloud.available) {
|
|
817
|
+
console.error(`✗ 忆云档案功能不可用(${cloud.reason}):当前为自定义 home / 受限 env`);
|
|
818
|
+
process.exit(2);
|
|
819
|
+
}
|
|
820
|
+
const state = await readRegistry(cloud.root).catch((error) => {
|
|
821
|
+
console.error(`✗ 忆云注册表不可读(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
822
|
+
process.exit(2);
|
|
823
|
+
});
|
|
824
|
+
const target = state.registry.profiles.find((profile) => profile.id === id);
|
|
825
|
+
if (!target) {
|
|
826
|
+
console.error(`✗ 未找到忆云:${id}`);
|
|
827
|
+
process.exit(2);
|
|
828
|
+
}
|
|
829
|
+
if (state.registry.current === id) {
|
|
830
|
+
console.log(JSON.stringify({
|
|
831
|
+
ok: true,
|
|
832
|
+
changed: false,
|
|
833
|
+
current: id,
|
|
834
|
+
profile: target,
|
|
835
|
+
consoleUrl: await consoleUrlForHome(profileHomeForEntry(cloud.root, target)),
|
|
836
|
+
}, null, 2));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const previous = state.registry.profiles.find((profile) => profile.id === state.registry.current) ?? null;
|
|
841
|
+
const previousHome = previous ? profileHomeForEntry(cloud.root, previous) : null;
|
|
842
|
+
const targetHome = profileHomeForEntry(cloud.root, target);
|
|
843
|
+
if (!existsSync(targetHome)) {
|
|
844
|
+
console.error(`✗ 忆云「${target.name}」的目录不存在(PROFILE_HOME_MISSING):${targetHome}`);
|
|
845
|
+
process.exit(2);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const cliBin = fileURLToPath(new URL('./mebular.mjs', import.meta.url));
|
|
849
|
+
const serviceHome = (await import('node:os')).homedir();
|
|
850
|
+
const descriptorFor = (home) => profileServiceDescriptor({ root: cloud.root, home, cliBin });
|
|
851
|
+
|
|
852
|
+
const oldHealth = previousHome ? await probeCloudHealth(previousHome) : { ok: false };
|
|
853
|
+
if (oldHealth.ok && typeof oldHealth.body?.instance === 'string'
|
|
854
|
+
&& previousHome && oldHealth.body.instance !== homeKey(previousHome)) {
|
|
855
|
+
console.error('✗ 旧忆云端口上运行的是另一张忆云(CLOUD_INSTANCE_MISMATCH:/healthz instance 不匹配):请先停止它');
|
|
856
|
+
process.exit(2);
|
|
857
|
+
}
|
|
858
|
+
const oldLock = previousHome ? await readHomeLock(previousHome) : null;
|
|
859
|
+
const oldPid = Number.isInteger(oldLock?.pid) && pidAliveSafe(oldLock.pid) ? oldLock.pid : null;
|
|
860
|
+
const oldWasLive = Boolean(oldHealth.ok || oldPid);
|
|
861
|
+
const force = flags.force === true;
|
|
862
|
+
|
|
863
|
+
let statusNew = { registered: false, autostart: null };
|
|
864
|
+
let statusLegacy = { registered: false, autostart: null };
|
|
865
|
+
let statusTarget = { registered: false };
|
|
866
|
+
try {
|
|
867
|
+
if (previousHome) {
|
|
868
|
+
statusNew = await profileServiceStatus({ descriptor: descriptorFor(previousHome), serviceHome });
|
|
869
|
+
statusLegacy = await profileServiceStatus({ descriptor: descriptorFor(previousHome), serviceHome: previousHome });
|
|
870
|
+
}
|
|
871
|
+
// 目标档案可能残留旧服务单元(上次失败切换):起新前一并卸掉,避免与 child 抢端口/单活。
|
|
872
|
+
statusTarget = await profileServiceStatus({ descriptor: descriptorFor(targetHome), serviceHome });
|
|
873
|
+
} catch {
|
|
874
|
+
// 服务包不可用:按未托管处理
|
|
875
|
+
}
|
|
876
|
+
const serviceManaged = Boolean(statusNew.registered || statusLegacy.registered);
|
|
877
|
+
if (!serviceManaged && oldWasLive && !force) {
|
|
878
|
+
console.error('✗ 当前守护由外部托管(CLOUD_EXTERNALLY_MANAGED),CLI 不自动停止它。');
|
|
879
|
+
console.error(` 先停止它,或显式:mebular cloud use ${id} --force(会杀 <oldHome>/lock 的 PID)`);
|
|
880
|
+
process.exit(2);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const stop = async () => {
|
|
884
|
+
if (serviceManaged) {
|
|
885
|
+
if (statusLegacy.registered) {
|
|
886
|
+
await uninstallProfileService({ descriptor: descriptorFor(previousHome), serviceHome: previousHome });
|
|
887
|
+
}
|
|
888
|
+
if (statusNew.registered) {
|
|
889
|
+
await uninstallProfileService({ descriptor: descriptorFor(previousHome), serviceHome });
|
|
890
|
+
}
|
|
891
|
+
} else if (oldWasLive) {
|
|
892
|
+
if (oldPid === null) {
|
|
893
|
+
throw new Error('旧忆云健康响应存在但找不到 lock PID:拒绝盲目停止');
|
|
894
|
+
}
|
|
895
|
+
if (!(await looksLikeMebularServeAsync(oldPid))) {
|
|
896
|
+
throw new Error(`lock PID ${oldPid} 不是 mebular serve:拒绝 --force 误杀`);
|
|
897
|
+
}
|
|
898
|
+
process.kill(oldPid, 'SIGTERM');
|
|
899
|
+
}
|
|
900
|
+
if (statusTarget.registered) {
|
|
901
|
+
await uninstallProfileService({ descriptor: descriptorFor(targetHome), serviceHome }).catch(() => undefined);
|
|
902
|
+
}
|
|
903
|
+
if (await waitForCloudStopped(previousHome ?? cloud.root, { pid: oldPid, timeoutMs: 10_000 })) return;
|
|
904
|
+
if (!serviceManaged && force && oldPid !== null && pidAliveSafe(oldPid)) {
|
|
905
|
+
process.kill(oldPid, 'SIGKILL');
|
|
906
|
+
if (await waitForCloudStopped(previousHome ?? cloud.root, { pid: oldPid, timeoutMs: 5_000 })) return;
|
|
907
|
+
}
|
|
908
|
+
throw new Error('旧忆云未能停止(端口/锁仍被占用):请检查诊断');
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const start = async ({ to, toHome }) => {
|
|
912
|
+
// 回滚到一个切换前就未运行的旧忆云:恢复「未运行」状态,不额外拉起。
|
|
913
|
+
if (to?.id === previous?.id && !oldWasLive) return;
|
|
914
|
+
if (serviceManaged) {
|
|
915
|
+
await installProfileService({
|
|
916
|
+
descriptor: descriptorFor(toHome),
|
|
917
|
+
serviceHome,
|
|
918
|
+
autostart: statusNew.autostart ?? statusLegacy.autostart ?? true,
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const { spawn } = await import('node:child_process');
|
|
923
|
+
const child = spawn(process.execPath, [cliBin, 'serve'], {
|
|
924
|
+
detached: true,
|
|
925
|
+
stdio: 'ignore',
|
|
926
|
+
env: { ...process.env, MEBULAR_HOME: toHome, MEBULAR_CLOUD_ROOT: cloud.root },
|
|
927
|
+
});
|
|
928
|
+
child.unref();
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const verify = async ({ to, home, rollback }) => {
|
|
932
|
+
if (to?.id === previous?.id && !oldWasLive) return true;
|
|
933
|
+
const result = await waitForCloudHealthy(home, {
|
|
934
|
+
expectedInstance: homeKey(home),
|
|
935
|
+
cloudId: to?.id ?? null,
|
|
936
|
+
timeoutMs: 20_000,
|
|
937
|
+
});
|
|
938
|
+
if (result.ok) return true;
|
|
939
|
+
if (result.reason === 'instance_mismatch') {
|
|
940
|
+
throw new Error(`端口上运行的是另一张忆云(instance=${result.instance}),不是「${to?.name ?? '目标'}」`);
|
|
941
|
+
}
|
|
942
|
+
if (result.reason === 'instance_missing') {
|
|
943
|
+
throw new Error('目标端口上的守护没有 /healthz instance 身份(旧版本?):无法确认是目标忆云');
|
|
944
|
+
}
|
|
945
|
+
if (result.reason === 'cloud_mismatch') {
|
|
946
|
+
throw new Error(`/healthz cloudId=${result.cloudId} 与目标「${to?.id}」不一致`);
|
|
947
|
+
}
|
|
948
|
+
void rollback;
|
|
949
|
+
return false;
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// 起新失败/健康校验失败:停掉可能已半启动的新忆云,避免回滚时双活。
|
|
953
|
+
const abortTarget = async ({ toHome }) => {
|
|
954
|
+
if (serviceManaged) {
|
|
955
|
+
try {
|
|
956
|
+
await uninstallProfileService({ descriptor: descriptorFor(toHome), serviceHome });
|
|
957
|
+
} catch {
|
|
958
|
+
// 未安装/已退出:继续按 PID 兜底
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const lock = await readHomeLock(toHome);
|
|
962
|
+
const pid = Number.isInteger(lock?.pid) && pidAliveSafe(lock.pid) ? lock.pid : null;
|
|
963
|
+
if (pid === null || !(await looksLikeMebularServeAsync(pid))) return;
|
|
964
|
+
try { process.kill(pid, 'SIGTERM'); } catch { return; }
|
|
965
|
+
if (await waitForCloudStopped(toHome, { pid, timeoutMs: 5_000 })) return;
|
|
966
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
967
|
+
await waitForCloudStopped(toHome, { pid, timeoutMs: 3_000 });
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const result = await switchProfile({ root: cloud.root, id, stop, start, verify, abort: abortTarget });
|
|
972
|
+
console.log(JSON.stringify({
|
|
973
|
+
ok: true,
|
|
974
|
+
changed: result.changed,
|
|
975
|
+
current: result.current,
|
|
976
|
+
previous: result.previous,
|
|
977
|
+
profile: result.profile,
|
|
978
|
+
consoleUrl: await consoleUrlForHome(result.home),
|
|
979
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
980
|
+
}, null, 2));
|
|
981
|
+
console.log(`控制台:${await consoleUrlForHome(result.home)}`);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
console.error(`✗ 切换失败(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
984
|
+
if (error?.rollback && error.rollback.ok === false) {
|
|
985
|
+
console.error(` 回滚也失败:${error.rollback.error}`);
|
|
986
|
+
}
|
|
987
|
+
process.exit(2);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function runCloud(action, args, flags) {
|
|
992
|
+
if (action === 'list') return runCloudList();
|
|
993
|
+
if (action === 'use') return runCloudUse(args[0], flags);
|
|
994
|
+
if (action === 'rename') return runCloudRename(args[0], args[1]);
|
|
995
|
+
console.error('用法:mebular cloud <list|use|rename> [id] [name] [--force]');
|
|
996
|
+
process.exit(2);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function main() {
|
|
1000
|
+
const flags = parseFlags(argv.slice(1));
|
|
1001
|
+
switch (command) {
|
|
1002
|
+
case 'mcp': {
|
|
1003
|
+
const { startStdioServer } = await import('../src/server.mjs');
|
|
1004
|
+
try {
|
|
1005
|
+
await startStdioServer();
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
console.error(`✗ mcp 启动失败(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
1008
|
+
process.exit(2);
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
case 'serve': {
|
|
1013
|
+
const { startServeServer } = await import('../src/server.mjs');
|
|
1014
|
+
const { loadConfigFile } = await import('../src/config.mjs');
|
|
1015
|
+
const home = homeDir();
|
|
1016
|
+
const fileConfig = await loadConfigFile(home).catch(() => ({}));
|
|
1017
|
+
const httpConf = fileConfig.mcp?.http ?? {};
|
|
1018
|
+
// D4:常驻进程写 service.heartbeat(role=mebular-serve),供 `service status`/doctor 判定。
|
|
1019
|
+
try {
|
|
1020
|
+
const { startHeartbeat, resolveBuildSha } = await import('@mebular/service');
|
|
1021
|
+
startHeartbeat(homeDir(), { role: 'mebular-serve', sha: resolveBuildSha() });
|
|
1022
|
+
} catch {
|
|
1023
|
+
// service 包缺失不应阻止 serve。
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
const result = await startServeServer({
|
|
1027
|
+
host: typeof flags.host === 'string' ? flags.host : httpConf.host,
|
|
1028
|
+
port: flags.port !== undefined ? Number(flags.port) : httpConf.port,
|
|
1029
|
+
auth: typeof flags.auth === 'string' ? flags.auth : httpConf.auth,
|
|
1030
|
+
tlsKey: typeof flags['tls-key'] === 'string' ? flags['tls-key'] : undefined,
|
|
1031
|
+
tlsCert: typeof flags['tls-cert'] === 'string' ? flags['tls-cert'] : undefined,
|
|
1032
|
+
tokensFile: typeof flags['tokens-file'] === 'string' ? flags['tokens-file'] : httpConf.tokensFile,
|
|
1033
|
+
});
|
|
1034
|
+
if (result.provision) {
|
|
1035
|
+
console.log(`PROVISION_READY ${JSON.stringify({ host: result.host, port: result.port, home: homeDir() })}`);
|
|
1036
|
+
console.log('引导态:家目录为空。请在浏览器打开控制台完成「新建忆云 / 加入已有忆云」,之后各设备自行订阅记忆域。');
|
|
1037
|
+
} else {
|
|
1038
|
+
console.log(`SERVE_READY ${JSON.stringify({ host: result.host, port: result.port, auth: result.auth, issuer: result.issuer })}`);
|
|
1039
|
+
}
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
console.error(`✗ serve 启动失败(${error?.code ?? 'ERROR'}):${error?.message ?? error}`);
|
|
1042
|
+
process.exit(2);
|
|
1043
|
+
}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
case 'service': {
|
|
1047
|
+
const { runServiceCli } = await import('@mebular/service');
|
|
1048
|
+
const home = homeDir();
|
|
1049
|
+
const bin = process.argv[1];
|
|
1050
|
+
const descriptor = {
|
|
1051
|
+
kind: 'mebular-serve',
|
|
1052
|
+
args: [bin, 'serve'],
|
|
1053
|
+
heartbeatDir: home,
|
|
1054
|
+
workingDir: home,
|
|
1055
|
+
env: { MEBULAR_HOME: home },
|
|
1056
|
+
};
|
|
1057
|
+
process.exit(runServiceCli({ descriptors: [descriptor], argv: argv.slice(1), sha: serviceBuildSha() }));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
case 'console': {
|
|
1061
|
+
await runConsole(flags);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
case 'cloud': {
|
|
1065
|
+
await runCloud(argv[1], argv.slice(2), flags);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
case 'token': {
|
|
1069
|
+
await runToken(argv[1], flags, argv[2]);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
case '--help':
|
|
1073
|
+
case '-h':
|
|
1074
|
+
case undefined:
|
|
1075
|
+
console.log(
|
|
1076
|
+
[
|
|
1077
|
+
'用法:mebular <command>',
|
|
1078
|
+
'',
|
|
1079
|
+
'命令:',
|
|
1080
|
+
' mcp 启动 stdio MCP server',
|
|
1081
|
+
' serve [--host --port --auth --tls-key --tls-cert --tokens-file] Streamable HTTP server',
|
|
1082
|
+
' service install|uninstall|restart|status|logs [--no-autostart --label L] 常驻服务管理(mebular-serve)',
|
|
1083
|
+
' console [--host --port] 打印控制台 URL(需 serve 正在运行)',
|
|
1084
|
+
' cloud list|use|rename [id] [name] [--force] 忆云管理(use 停旧/起新/失败回滚)',
|
|
1085
|
+
' token grant|list|revoke [--scope a,b] [--id x] [--tokens-file p] bearer 令牌管理',
|
|
1086
|
+
' token client add|list|remove [--redirect uri] [--scope a,b] [--id x] OAuth 客户端预注册',
|
|
1087
|
+
' token consent [--scope a,b] [--ttl sec] 生成一次性本地同意码(/authorize 用)',
|
|
1088
|
+
' init / keygen / print-config / status|doctor 初始化、状态与自检(身份模式/网络/锁/域/join)',
|
|
1089
|
+
' doctor --net 网络排障:地址簿/路径状态 + relay 可达性 + 下一步建议',
|
|
1090
|
+
' tools 打印 MCP 工具 ↔ CLI 对照表',
|
|
1091
|
+
' memory_write|memory_write_batch|memory_query|memory_search|memory_profile|memory_skills|',
|
|
1092
|
+
' memory_history|memory_graph|memory_import|memory_status|memory_sync 与 MCP 同名同 handler',
|
|
1093
|
+
].join('\n'),
|
|
1094
|
+
);
|
|
1095
|
+
process.exit(0);
|
|
1096
|
+
return;
|
|
1097
|
+
case 'init':
|
|
1098
|
+
await runInit(flags);
|
|
1099
|
+
return;
|
|
1100
|
+
case 'keygen':
|
|
1101
|
+
await runKeygen(flags);
|
|
1102
|
+
return;
|
|
1103
|
+
case 'print-config':
|
|
1104
|
+
await runPrintConfig(flags);
|
|
1105
|
+
return;
|
|
1106
|
+
case 'tools': {
|
|
1107
|
+
const { ALL_TOOL_SPECS } = await import('../src/tools.mjs');
|
|
1108
|
+
console.log(JSON.stringify({ ok: true, tools: ALL_TOOL_SPECS.map((t) => ({ tool: t.name, cli: t.name })) }, null, 2));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
case 'status':
|
|
1112
|
+
await runStatus();
|
|
1113
|
+
return;
|
|
1114
|
+
case 'doctor':
|
|
1115
|
+
if (flags.net === true || flags.net === 'true') {
|
|
1116
|
+
await runNetDoctor();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
await runStatus();
|
|
1120
|
+
return;
|
|
1121
|
+
default: {
|
|
1122
|
+
const { toolByName } = await import('../src/tools.mjs');
|
|
1123
|
+
if (typeof command === 'string' && toolByName(command)) {
|
|
1124
|
+
await runMemoryTool(command, flags);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
console.error(`未知命令:${command ?? '(空)'}`);
|
|
1128
|
+
process.exit(2);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
await main();
|