@mebular/fleet 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.
Files changed (178) hide show
  1. package/dist/agentdir.d.ts +43 -0
  2. package/dist/agentdir.d.ts.map +1 -0
  3. package/dist/agentdir.js +107 -0
  4. package/dist/agentdir.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +1135 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/collab/chatter.d.ts +54 -0
  10. package/dist/collab/chatter.d.ts.map +1 -0
  11. package/dist/collab/chatter.js +94 -0
  12. package/dist/collab/chatter.js.map +1 -0
  13. package/dist/collab/dag.d.ts +27 -0
  14. package/dist/collab/dag.d.ts.map +1 -0
  15. package/dist/collab/dag.js +107 -0
  16. package/dist/collab/dag.js.map +1 -0
  17. package/dist/collab/negotiation.d.ts +60 -0
  18. package/dist/collab/negotiation.d.ts.map +1 -0
  19. package/dist/collab/negotiation.js +90 -0
  20. package/dist/collab/negotiation.js.map +1 -0
  21. package/dist/collab/tree.d.ts +39 -0
  22. package/dist/collab/tree.d.ts.map +1 -0
  23. package/dist/collab/tree.js +177 -0
  24. package/dist/collab/tree.js.map +1 -0
  25. package/dist/config.d.ts +123 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +206 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +40 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/join.d.ts +50 -0
  34. package/dist/join.d.ts.map +1 -0
  35. package/dist/join.js +167 -0
  36. package/dist/join.js.map +1 -0
  37. package/dist/jointoken.d.ts +144 -0
  38. package/dist/jointoken.d.ts.map +1 -0
  39. package/dist/jointoken.js +472 -0
  40. package/dist/jointoken.js.map +1 -0
  41. package/dist/model.d.ts +64 -0
  42. package/dist/model.d.ts.map +1 -0
  43. package/dist/model.js +158 -0
  44. package/dist/model.js.map +1 -0
  45. package/dist/onboard.d.ts +90 -0
  46. package/dist/onboard.d.ts.map +1 -0
  47. package/dist/onboard.js +510 -0
  48. package/dist/onboard.js.map +1 -0
  49. package/dist/protocol/envelope.d.ts +94 -0
  50. package/dist/protocol/envelope.d.ts.map +1 -0
  51. package/dist/protocol/envelope.js +169 -0
  52. package/dist/protocol/envelope.js.map +1 -0
  53. package/dist/protocol/events.d.ts +51 -0
  54. package/dist/protocol/events.d.ts.map +1 -0
  55. package/dist/protocol/events.js +87 -0
  56. package/dist/protocol/events.js.map +1 -0
  57. package/dist/qr.d.ts +15 -0
  58. package/dist/qr.d.ts.map +1 -0
  59. package/dist/qr.js +52 -0
  60. package/dist/qr.js.map +1 -0
  61. package/dist/quickstart.d.ts +167 -0
  62. package/dist/quickstart.d.ts.map +1 -0
  63. package/dist/quickstart.js +482 -0
  64. package/dist/quickstart.js.map +1 -0
  65. package/dist/quota.d.ts +41 -0
  66. package/dist/quota.d.ts.map +1 -0
  67. package/dist/quota.js +84 -0
  68. package/dist/quota.js.map +1 -0
  69. package/dist/runtime/agent.d.ts +100 -0
  70. package/dist/runtime/agent.d.ts.map +1 -0
  71. package/dist/runtime/agent.js +260 -0
  72. package/dist/runtime/agent.js.map +1 -0
  73. package/dist/runtime/executor.d.ts +42 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +87 -0
  76. package/dist/runtime/executor.js.map +1 -0
  77. package/dist/runtime/fairness.d.ts +25 -0
  78. package/dist/runtime/fairness.d.ts.map +1 -0
  79. package/dist/runtime/fairness.js +79 -0
  80. package/dist/runtime/fairness.js.map +1 -0
  81. package/dist/runtime/node.d.ts +91 -0
  82. package/dist/runtime/node.d.ts.map +1 -0
  83. package/dist/runtime/node.js +203 -0
  84. package/dist/runtime/node.js.map +1 -0
  85. package/dist/runtime/openchamber-http.d.ts +27 -0
  86. package/dist/runtime/openchamber-http.d.ts.map +1 -0
  87. package/dist/runtime/openchamber-http.js +118 -0
  88. package/dist/runtime/openchamber-http.js.map +1 -0
  89. package/dist/runtime/openchamber.d.ts +38 -0
  90. package/dist/runtime/openchamber.d.ts.map +1 -0
  91. package/dist/runtime/openchamber.js +43 -0
  92. package/dist/runtime/openchamber.js.map +1 -0
  93. package/dist/runtime/planner.d.ts +23 -0
  94. package/dist/runtime/planner.d.ts.map +1 -0
  95. package/dist/runtime/planner.js +17 -0
  96. package/dist/runtime/planner.js.map +1 -0
  97. package/dist/runtime/worker.d.ts +96 -0
  98. package/dist/runtime/worker.d.ts.map +1 -0
  99. package/dist/runtime/worker.js +296 -0
  100. package/dist/runtime/worker.js.map +1 -0
  101. package/dist/security.d.ts +462 -0
  102. package/dist/security.d.ts.map +1 -0
  103. package/dist/security.js +1197 -0
  104. package/dist/security.js.map +1 -0
  105. package/dist/store/daemon-store.d.ts +33 -0
  106. package/dist/store/daemon-store.d.ts.map +1 -0
  107. package/dist/store/daemon-store.js +115 -0
  108. package/dist/store/daemon-store.js.map +1 -0
  109. package/dist/store/file-store.d.ts +22 -0
  110. package/dist/store/file-store.d.ts.map +1 -0
  111. package/dist/store/file-store.js +63 -0
  112. package/dist/store/file-store.js.map +1 -0
  113. package/dist/store/mebular-store.d.ts +22 -0
  114. package/dist/store/mebular-store.d.ts.map +1 -0
  115. package/dist/store/mebular-store.js +53 -0
  116. package/dist/store/mebular-store.js.map +1 -0
  117. package/dist/store/message-store.d.ts +26 -0
  118. package/dist/store/message-store.d.ts.map +1 -0
  119. package/dist/store/message-store.js +51 -0
  120. package/dist/store/message-store.js.map +1 -0
  121. package/dist/surface.d.ts +43 -0
  122. package/dist/surface.d.ts.map +1 -0
  123. package/dist/surface.js +515 -0
  124. package/dist/surface.js.map +1 -0
  125. package/dist/transport/null.d.ts +7 -0
  126. package/dist/transport/null.d.ts.map +1 -0
  127. package/dist/transport/null.js +16 -0
  128. package/dist/transport/null.js.map +1 -0
  129. package/dist/transport/spool.d.ts +12 -0
  130. package/dist/transport/spool.d.ts.map +1 -0
  131. package/dist/transport/spool.js +54 -0
  132. package/dist/transport/spool.js.map +1 -0
  133. package/dist/transport/types.d.ts +21 -0
  134. package/dist/transport/types.d.ts.map +1 -0
  135. package/dist/transport/types.js +6 -0
  136. package/dist/transport/types.js.map +1 -0
  137. package/dist/version.json +6 -0
  138. package/package.json +37 -4
  139. package/protocol/collab.example.json +23 -0
  140. package/protocol/daemon-home.example.json +66 -0
  141. package/protocol/envelope.example.json +12 -0
  142. package/protocol/state-machine.json +14 -0
  143. package/protocol/task-tree.example.json +72 -0
  144. package/src/agentdir.ts +138 -0
  145. package/src/cli.ts +1192 -0
  146. package/src/collab/chatter.ts +116 -0
  147. package/src/collab/dag.ts +124 -0
  148. package/src/collab/negotiation.ts +130 -0
  149. package/src/collab/tree.ts +184 -0
  150. package/src/config.ts +286 -0
  151. package/src/index.ts +39 -0
  152. package/src/join.ts +227 -0
  153. package/src/jointoken.ts +609 -0
  154. package/src/model.ts +203 -0
  155. package/src/onboard.ts +601 -0
  156. package/src/protocol/envelope.ts +228 -0
  157. package/src/protocol/events.ts +135 -0
  158. package/src/qr.ts +68 -0
  159. package/src/quickstart.ts +632 -0
  160. package/src/quota.ts +112 -0
  161. package/src/runtime/agent.ts +313 -0
  162. package/src/runtime/executor.ts +114 -0
  163. package/src/runtime/fairness.ts +90 -0
  164. package/src/runtime/node.ts +250 -0
  165. package/src/runtime/openchamber-http.ts +137 -0
  166. package/src/runtime/openchamber.ts +66 -0
  167. package/src/runtime/planner.ts +36 -0
  168. package/src/runtime/worker.ts +354 -0
  169. package/src/security.ts +1660 -0
  170. package/src/store/daemon-store.ts +144 -0
  171. package/src/store/file-store.ts +75 -0
  172. package/src/store/mebular-store.ts +66 -0
  173. package/src/store/message-store.ts +62 -0
  174. package/src/surface.ts +535 -0
  175. package/src/transport/null.ts +20 -0
  176. package/src/transport/spool.ts +62 -0
  177. package/src/transport/types.ts +27 -0
  178. package/README.md +0 -3
package/src/cli.ts ADDED
@@ -0,0 +1,1192 @@
1
+ #!/usr/bin/env node
2
+ // fleet CLI(M2):`fleet node`(任务板/发起端)、`fleet worker`(执行端)。
3
+ //
4
+ // 两个命令各自使用**独立 storage 路径 + 独立设备身份**,通过共享 spool 目录交换
5
+ // **显式状态事件**(本地最少形态;M3 换真实传输)。输出 JSON 事实供脚本断言。
6
+
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { Mebular } from '@mebular/core';
12
+ import {
13
+ installService,
14
+ runServiceCli,
15
+ resolveBuildSha,
16
+ startHeartbeat,
17
+ type ServiceDescriptor,
18
+ type ServicePlatform,
19
+ } from '../../service/dist/index.js';
20
+ import { echoResultFor, EchoExecutor, ExecutionLog } from './runtime/executor.js';
21
+ import { FleetNode } from './runtime/node.js';
22
+ import { FleetWorker } from './runtime/worker.js';
23
+ import { FileTaskEventStore } from './store/file-store.js';
24
+ import { MebularTaskEventStore } from './store/mebular-store.js';
25
+ import { DaemonTaskEventStore } from './store/daemon-store.js';
26
+ import type { TaskEventStore } from './store/file-store.js';
27
+ import { NullTransport } from './transport/null.js';
28
+ import { SpoolTransport } from './transport/spool.js';
29
+ import { LocalQuota } from './quota.js';
30
+ import { fleetConfigPath, loadFleetConfig, loadToolConfig, parseAgentSpecs, readMasterKeyFile, type FleetConfig } from './config.js';
31
+ import {
32
+ buildRegistry,
33
+ declareNamespace,
34
+ doctor,
35
+ mebularOptions,
36
+ namespaceSubscribers,
37
+ namespaceSubscriptions,
38
+ leaveNamespace,
39
+ offlineMebularOptions,
40
+ planHandoff,
41
+ rejoinNamespace,
42
+ onboardDevice,
43
+ restoreDevice,
44
+ subscribeNamespace,
45
+ type DoctorReport,
46
+ } from './onboard.js';
47
+ import {
48
+ defaultDeviceName,
49
+ defaultFleetDir,
50
+ joinFleet,
51
+ pickLanHost,
52
+ agentMcpConfig,
53
+ quickstart,
54
+ readJoinCode,
55
+ writeJoinCodeFile,
56
+ type ServiceInstaller,
57
+ } from './quickstart.js';
58
+ import { activateJoinInvite, buildJoinToken, startJoinService, type JoinService } from './jointoken.js';
59
+ import { renderTerminalQr, renderSvgQr } from './qr.js';
60
+ import { createRequire } from 'node:module';
61
+ import { joinWithToken } from './join.js';
62
+ import { toolByCli, toolCliTable } from './surface.js';
63
+ import {
64
+ collectRecoveryRegistry,
65
+ deriveRecoveryKey,
66
+ evaluateSecurityGate,
67
+ exportMasterKeyBackup,
68
+ formatRecoveryCode,
69
+ generateRecoveryCode,
70
+ generateTotpSecret,
71
+ importTotpSeed,
72
+ latestRecoveryRecord,
73
+ listPolicyEvents,
74
+ readSecurityState,
75
+ readTotpSeed,
76
+ renderTotpShare,
77
+ renderTotpShareSvg,
78
+ restoreMasterKeyBackup,
79
+ signBreakGlass,
80
+ verifyTotp,
81
+ writeBackupFile,
82
+ writeSecurityRecovery,
83
+ writeSecurityTotpAudit,
84
+ writeTotpSeed,
85
+ } from './security.js';
86
+
87
+ interface Args {
88
+ [key: string]: string | boolean | undefined;
89
+ }
90
+
91
+ function parseArgs(argv: string[]): { command: string | undefined; args: Args; positionals: string[] } {
92
+ const args: Args = {};
93
+ const positionals: string[] = [];
94
+ let command: string | undefined;
95
+ for (let i = 0; i < argv.length; i++) {
96
+ const token = argv[i]!;
97
+ if (token.startsWith('--')) {
98
+ const key = token.slice(2);
99
+ const next = argv[i + 1];
100
+ if (next === undefined || next.startsWith('--')) args[key] = true;
101
+ else {
102
+ args[key] = next;
103
+ i += 1;
104
+ }
105
+ } else if (command === undefined) {
106
+ command = token;
107
+ } else {
108
+ positionals.push(token);
109
+ }
110
+ }
111
+ return { command, args, positionals };
112
+ }
113
+
114
+ const DEFAULT_JOIN_PORT_FALLBACK = 4002;
115
+ const str = (v: string | boolean | undefined, fallback: string): string =>
116
+ typeof v === 'string' ? v : fallback;
117
+ const num = (v: string | boolean | undefined, fallback: number): number => {
118
+ if (typeof v !== 'string') return fallback;
119
+ const n = Number(v);
120
+ return Number.isFinite(n) ? n : fallback;
121
+ };
122
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
123
+
124
+ /** 默认设备目录:`--dir` → `FLEET_DIR` → `~/.fleet`。 */
125
+ function fleetDirFrom(args: Args): string {
126
+ return str(args.dir, defaultFleetDir());
127
+ }
128
+ /** 默认设备名:`--device` → `FLEET_DEVICE` → 主机名(清洗)。 */
129
+ function fleetDeviceFrom(args: Args): string {
130
+ return str(args.device, process.env.FLEET_DEVICE ?? defaultDeviceName());
131
+ }
132
+ /** 医生报告的**脱敏摘要**(只含状态与检查名,不含任何材料)。 */
133
+ function summarizeDoctor(report: DoctorReport): Record<string, unknown> {
134
+ return {
135
+ ok: report.ok,
136
+ skipped: report.skipped,
137
+ failed: report.checks.filter((c) => c.status === 'FAIL').map((c) => c.name),
138
+ warn: report.checks.filter((c) => c.status === 'WARN').map((c) => c.name),
139
+ };
140
+ }
141
+ /** 服务安装器:安装 `fleet-node`(监听方/任务板),失败不致命(doctor 会明列 SKIP/FAIL)。 */
142
+ function fleetNodeInstaller(): ServiceInstaller {
143
+ return async (dir: string) => {
144
+ try {
145
+ const node = fleetServiceDescriptors(dir).find((d) => d.kind === 'fleet-node');
146
+ if (!node) return { installed: false, note: '无 fleet-node 描述子' };
147
+ const result = installService(node, { sha: buildSha() });
148
+ return result.ok ? { installed: true } : { installed: false, note: 'install 返回非 ok' };
149
+ } catch (error) {
150
+ return { installed: false, note: (error as Error).message };
151
+ }
152
+ };
153
+ }
154
+
155
+ /** 构建 SHA(心跳/服务单元用):version.json → env → git → unknown。 */
156
+ function buildSha(): string {
157
+ try {
158
+ const v = JSON.parse(readFileSync(new URL('./version.json', import.meta.url), 'utf-8')) as { sha?: string };
159
+ if (v.sha && v.sha !== 'unknown') return v.sha;
160
+ } catch {
161
+ // fall through
162
+ }
163
+ return resolveBuildSha();
164
+ }
165
+
166
+ /** 常驻模式(`--run-forever`):注册终止信号,返回 `isStopping` 与注销函数。 */
167
+ function installShutdownHandlers(): { isStopping: () => boolean; dispose: () => void } {
168
+ let stopping = false;
169
+ const onSignal = (): void => {
170
+ stopping = true;
171
+ };
172
+ process.on('SIGINT', onSignal);
173
+ process.on('SIGTERM', onSignal);
174
+ process.on('SIGBREAK', onSignal);
175
+ return {
176
+ isStopping: () => stopping,
177
+ dispose: () => {
178
+ process.off('SIGINT', onSignal);
179
+ process.off('SIGTERM', onSignal);
180
+ process.off('SIGBREAK', onSignal);
181
+ },
182
+ };
183
+ }
184
+
185
+ /** 服务描述子(fleet-node / fleet-worker),供 `fleet service` 与单元生成复用。 */
186
+ function fleetServiceDescriptors(dir: string): ServiceDescriptor[] {
187
+ const cliPath = fileURLToPath(import.meta.url);
188
+ const common = { execPath: process.execPath, heartbeatDir: dir, workingDir: dir, env: { MEBULAR_FLEET_DIR: dir } };
189
+ return [
190
+ { kind: 'fleet-node', args: [cliPath, 'node', '--dir', dir, '--run-forever'], ...common },
191
+ { kind: 'fleet-worker', args: [cliPath, 'worker', '--dir', dir, '--run-forever'], ...common },
192
+ ];
193
+ }
194
+
195
+ function flagValue(argv: readonly string[], name: string): string | undefined {
196
+ const i = argv.indexOf(name);
197
+ return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') ? argv[i + 1] : undefined;
198
+ }
199
+
200
+ /** `fleet service …`:raw argv(保留 service 自身 flags,如 --no-autostart/--label/--extra)。 */
201
+ function runFleetService(argv: readonly string[]): number {
202
+ const dir = flagValue(argv, '--dir') ?? defaultFleetDir();
203
+ return runServiceCli({
204
+ descriptors: fleetServiceDescriptors(dir),
205
+ argv,
206
+ sha: buildSha(), // F-INSTALL-1:模块邻接 version.json/模块仓库 git,与 cwd 无关
207
+ ...(flagValue(argv, '--home') !== undefined ? { home: flagValue(argv, '--home')! } : {}),
208
+ ...(flagValue(argv, '--platform') !== undefined
209
+ ? { platform: flagValue(argv, '--platform')! as ServicePlatform }
210
+ : {}),
211
+ });
212
+ }
213
+
214
+ /** `fleet --version`:打印包版本与**构建时的 commit SHA**(`dist/version.json`;缺失→unknown)。 */
215
+ function versionString(): string {
216
+ try {
217
+ const raw = readFileSync(new URL('./version.json', import.meta.url), 'utf-8');
218
+ const v = JSON.parse(raw) as { version?: string; sha?: string };
219
+ return `${v.version ?? '0.0.0'} (${v.sha ?? 'unknown'})`;
220
+ } catch {
221
+ return '0.0.0 (unknown)';
222
+ }
223
+ }
224
+
225
+ async function runNode(args: Args): Promise<number> {
226
+ const device = str(args.device, 'device-A');
227
+ const spool = str(args.spool, './.fleet/spool');
228
+ const storage = str(args.storage, `./.fleet/${device}.jsonl`);
229
+ const n = num(args.submit, 0);
230
+ const targetDevice = str(args['target-device'], 'device-B');
231
+ const targetAgent = str(args['target-agent'], '*');
232
+ const quotaLimit = num(args['quota-limit'], 1_000_000);
233
+ const quotaMode = str(args['quota-mode'], 'queue') === 'reject' ? 'reject' : 'queue';
234
+ const expires = num(args.expires, 0);
235
+ const duplicateEvery = num(args['duplicate-every'], 1);
236
+ const timeoutMs = num(args['timeout-ms'], 60_000);
237
+
238
+ const store = await FileTaskEventStore.open(storage);
239
+ const transport = new SpoolTransport(spool);
240
+ const node = new FleetNode({
241
+ device,
242
+ store,
243
+ transport,
244
+ quota: new LocalQuota({ limitPerDevice: quotaLimit, onOverflow: quotaMode }),
245
+ });
246
+
247
+ const decisions = { accepted: 0, queued: 0, rejected: 0 };
248
+ const taskIds: string[] = [];
249
+ for (let i = 0; i < n; i++) {
250
+ const deliveries = duplicateEvery > 1 && i % duplicateEvery === 0 ? 2 : 1;
251
+ const request: Parameters<FleetNode['submit']>[0] = {
252
+ intent: `t-${i}`,
253
+ to: { device: targetDevice, agent: targetAgent },
254
+ deliveries,
255
+ };
256
+ if (i < expires) request.expiresAt = Date.now() - 1_000; // 已过期(本机展示用)
257
+ const { taskId, decision } = await node.submit(request);
258
+ decisions[decision] += 1;
259
+ if (taskId !== null) taskIds.push(taskId);
260
+ }
261
+
262
+ const allTerminal = await node.waitForTerminal(taskIds, { timeoutMs });
263
+ const states = (await node.states()).filter((s) => taskIds.includes(s.taskId));
264
+ const byId = new Map(states.map((s) => [s.taskId, s]));
265
+ const done = states.filter((s) => s.status === 'done');
266
+ const failed = states.filter((s) => s.status === 'failed');
267
+ const resultsMatch = taskIds.every((id) => {
268
+ const s = byId.get(id);
269
+ return s?.status === 'done' && s.resultRef === echoResultFor(s.intent);
270
+ });
271
+ const expiredSubmitted = states.filter((s) => s.expiresAt !== undefined).length;
272
+ const expiredCompleted = states.filter((s) => s.expiresAt !== undefined && s.status === 'done').length;
273
+
274
+ const facts = {
275
+ role: 'node',
276
+ device,
277
+ submitted: taskIds.length,
278
+ decisions,
279
+ done: done.length,
280
+ failed: failed.length,
281
+ resultsMatch,
282
+ expiredSubmitted,
283
+ expiredCompleted,
284
+ allTerminal,
285
+ taskIds,
286
+ };
287
+ const ok = allTerminal && done.length === n && failed.length === 0 && resultsMatch;
288
+ console.log(JSON.stringify(facts, null, 2));
289
+ await node.close();
290
+ return ok ? 0 : 1;
291
+ }
292
+
293
+ async function runWorker(args: Args): Promise<number> {
294
+ const device = str(args.device, 'device-B');
295
+ const agent = str(args.agent, 'echo');
296
+ const spool = str(args.spool, './.fleet/spool');
297
+ const storage = str(args.storage, `./.fleet/${device}.jsonl`);
298
+ const execLogPath = str(args['exec-log'], `${storage}.exec.jsonl`);
299
+ const timeoutMs = num(args['timeout-ms'], 60_000);
300
+ const exitAfter = num(args['exit-after'], 0);
301
+ const maxPerPoll = num(args['max-per-poll'], 0);
302
+ const intervalMs = num(args['interval-ms'], 5);
303
+
304
+ const store = await FileTaskEventStore.open(storage);
305
+ const transport = new SpoolTransport(spool);
306
+ const log = await ExecutionLog.open(execLogPath);
307
+ const worker = new FleetWorker({ device, agent, store, transport, executor: new EchoExecutor(), log });
308
+
309
+ await worker.reconcile();
310
+ const deadline = Date.now() + timeoutMs;
311
+ while (Date.now() < deadline) {
312
+ await worker.pollOnce(maxPerPoll > 0 ? maxPerPoll : Number.POSITIVE_INFINITY);
313
+ if (exitAfter > 0 && log.size() >= exitAfter) break;
314
+ await sleep(intervalMs);
315
+ }
316
+ const facts = { role: 'worker', device, agent, executed: log.size(), execLog: execLogPath, taskIds: log.all().map((e) => e.taskId) };
317
+ console.log(JSON.stringify(facts, null, 2));
318
+ await worker.close();
319
+ return 0;
320
+ }
321
+
322
+ function parseAgents(
323
+ value: string | boolean | undefined,
324
+ command: string | boolean | undefined,
325
+ baseArgs: string | boolean | undefined,
326
+ ): ReturnType<typeof parseAgentSpecs> | undefined {
327
+ if (typeof value !== 'string' || value.length === 0) return undefined;
328
+ const commandValue = typeof command === 'string' ? command : undefined;
329
+ const baseArgsValue =
330
+ typeof baseArgs === 'string' && baseArgs.length > 0
331
+ ? baseArgs.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
332
+ : undefined;
333
+ return parseAgentSpecs(value, {
334
+ ...(commandValue !== undefined ? { command: commandValue } : {}),
335
+ ...(baseArgsValue !== undefined ? { baseArgs: baseArgsValue } : {}),
336
+ });
337
+ }
338
+
339
+ async function runOnboard(args: Args): Promise<number> {
340
+ const dir = fleetDirFrom(args);
341
+ const result = await onboardDevice({
342
+ dir,
343
+ device: fleetDeviceFrom(args),
344
+ ...(typeof args['master-key'] === 'string' ? { masterKeyFile: args['master-key'] } : {}),
345
+ ...(typeof args['peer-device'] === 'string' ? { peerDevice: args['peer-device'] } : {}),
346
+ ...(typeof args['peer-addr'] === 'string' ? { peerAddr: args['peer-addr'] } : {}),
347
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
348
+ ...(typeof args.listen === 'string' ? { listen: args.listen } : {}),
349
+ ...(parseAgents(args.agent, args['agent-command'], args['agent-base-args']) !== undefined
350
+ ? { agents: parseAgents(args.agent, args['agent-command'], args['agent-base-args'])! }
351
+ : {}),
352
+ });
353
+ console.log(JSON.stringify({
354
+ ok: true,
355
+ role: 'onboard',
356
+ device: result.config.device,
357
+ dir: result.config.dir,
358
+ alreadyOnboarded: result.alreadyOnboarded,
359
+ masterKeyCreated: result.masterKeyCreated,
360
+ masterKeyFingerprint: result.masterKeyFingerprint,
361
+ next: [`fleet node --dir ${result.config.dir}`, `fleet worker --dir ${result.config.dir}`, `fleet doctor --dir ${result.config.dir}`],
362
+ }, null, 2));
363
+ return 0;
364
+ }
365
+
366
+ async function runSubscribe(args: Args): Promise<number> {
367
+ const dir = fleetDirFrom(args);
368
+ const result = await subscribeNamespace(dir, {
369
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
370
+ active: true,
371
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
372
+ });
373
+ console.log(JSON.stringify({ ok: true, role: 'subscribe', ...result }, null, 2));
374
+ return 0;
375
+ }
376
+
377
+ async function runUnsubscribe(args: Args): Promise<number> {
378
+ const dir = fleetDirFrom(args);
379
+ const result = await subscribeNamespace(dir, {
380
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
381
+ active: false,
382
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
383
+ });
384
+ console.log(JSON.stringify({ ok: true, role: 'unsubscribe', ...result }, null, 2));
385
+ return 0;
386
+ }
387
+
388
+ async function runSubscribers(args: Args): Promise<number> {
389
+ const dir = fleetDirFrom(args);
390
+ const namespace = typeof args.namespace === 'string' ? args.namespace : undefined;
391
+ const view = await namespaceSubscriptions(dir, namespace);
392
+ const subscribers = await namespaceSubscribers(dir, namespace);
393
+ console.log(
394
+ JSON.stringify(
395
+ { ok: true, role: 'subscribers', namespace: namespace ?? '(config default)', active: view.active, subscribers },
396
+ null,
397
+ 2,
398
+ ),
399
+ );
400
+ return 0;
401
+ }
402
+
403
+ async function runDeclareNamespace(args: Args): Promise<number> {
404
+ const dir = fleetDirFrom(args);
405
+ const result = await declareNamespace(dir, {
406
+ namespace: str(args.namespace, ''),
407
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
408
+ });
409
+ console.log(JSON.stringify({ ok: true, role: 'namespace', ...result }, null, 2));
410
+ return 0;
411
+ }
412
+
413
+ async function runRestore(args: Args): Promise<number> {
414
+ const dir = fleetDirFrom(args);
415
+ const result = await restoreDevice(dir, {
416
+ subject: str(args.to ?? args.device, ''),
417
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
418
+ ...(typeof args.nonce === 'string' ? { nonce: args.nonce } : {}),
419
+ ...(typeof args.cosign === 'string' ? { cosign: JSON.parse(args.cosign) } : {}),
420
+ ...(typeof args['break-glass'] === 'string' ? { breakGlass: JSON.parse(args['break-glass']) } : {}),
421
+ });
422
+ console.log(JSON.stringify({ ok: true, role: 'restore', ...result }, null, 2));
423
+ return 0;
424
+ }
425
+
426
+ async function runLeave(args: Args): Promise<number> {
427
+ const dir = fleetDirFrom(args);
428
+ const namespace = typeof args.namespace === 'string' ? args.namespace : undefined;
429
+ const successor = str(args.successor, '');
430
+ if (args['dry-run'] === true) {
431
+ const plan = await planHandoff(dir, { successor, ...(namespace !== undefined ? { namespace } : {}) });
432
+ console.log(JSON.stringify({ role: 'leave', dryRun: true, ...plan }, null, 2));
433
+ return plan.ok ? 0 : 1;
434
+ }
435
+ const result = await leaveNamespace(dir, {
436
+ successor,
437
+ ...(namespace !== undefined ? { namespace } : {}),
438
+ ...(args.force === true ? { force: true } : {}),
439
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
440
+ });
441
+ console.log(JSON.stringify({ role: 'leave', ...result }, null, 2));
442
+ return result.ok ? 0 : 1;
443
+ }
444
+
445
+ async function runRejoin(args: Args): Promise<number> {
446
+ const dir = fleetDirFrom(args);
447
+ const result = await rejoinNamespace(dir, typeof args.namespace === 'string' ? { namespace: args.namespace } : {});
448
+ console.log(JSON.stringify({ role: 'rejoin', ...result }, null, 2));
449
+ return result.ok ? 0 : 1;
450
+ }
451
+
452
+ async function runDoctor(args: Args): Promise<number> {
453
+ const report = await doctor(fleetDirFrom(args));
454
+ if (args.json === true) console.log(JSON.stringify(report, null, 2));
455
+ else {
456
+ for (const c of report.checks) {
457
+ const hint = (c.status === 'FAIL' || c.status === 'WARN') && c.hint ? ` → ${c.hint}` : '';
458
+ console.log(`${c.status} ${c.name} ${c.detail}${hint}`);
459
+ }
460
+ console.log(`summary: ok=${report.ok} skipped=[${report.skipped.join(', ')}]`);
461
+ }
462
+ return report.ok ? 0 : 1;
463
+ }
464
+
465
+
466
+ /** W2:存储模式(`--store` 覆盖 config.store;缺省 embedded 以保持既有验收)。 */
467
+ function storeMode(config: FleetConfig, args: Args): 'daemon' | 'embedded' {
468
+ const flag = str(args.store, '');
469
+ if (flag === 'daemon' || flag === 'embedded') return flag;
470
+ return config.store ?? 'embedded';
471
+ }
472
+
473
+ /**
474
+ * W2:打开任务存储。`daemon` 模式走守护 app 接口(fleet 客户端化:**不监听 libp2p、不托管 join**);
475
+ * `embedded` 模式保留本地 Mebular(**测试/CI**)。
476
+ */
477
+ async function openTaskStore(config: FleetConfig, args: Args): Promise<{ store: TaskEventStore; mebular: Mebular | null }> {
478
+ if (storeMode(config, args) === 'daemon') {
479
+ if (config.daemon?.endpoint === undefined) {
480
+ throw new Error('daemon 模式需要 config.daemon.endpoint(请用统一上车 quickstart/join 配置守护)');
481
+ }
482
+ return {
483
+ store: new DaemonTaskEventStore({
484
+ endpoint: config.daemon.endpoint,
485
+ namespace: config.namespace,
486
+ ...(config.daemon.token !== undefined ? { token: config.daemon.token } : {}),
487
+ ...(config.daemon.tokenFile !== undefined ? { tokenFile: config.daemon.tokenFile } : {}),
488
+ }),
489
+ mebular: null,
490
+ };
491
+ }
492
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
493
+ const mebular = new Mebular(mebularOptions(config, encryption) as never);
494
+ await mebular.initialize();
495
+ return { store: new MebularTaskEventStore(mebular), mebular };
496
+ }
497
+
498
+ /**
499
+ * `fleet node`(任务板/发起端)。
500
+ * `--run-forever`:常驻(服务模式),直到收到 SIGINT/SIGTERM;写 `service.heartbeat`(role=node)。
501
+ */
502
+ async function runFleetNode(args: Args): Promise<number> {
503
+ const dir = fleetDirFrom(args);
504
+ const runForever = args['run-forever'] === true;
505
+ const config = await loadFleetConfig(fleetConfigPath(dir));
506
+ if (storeMode(config, args) === 'embedded') {
507
+ console.error('[embedded] 本地 Mebular 模式(test/dev only;生产请用 --store daemon + mebular serve 守护)');
508
+ }
509
+ const { store, mebular } = await openTaskStore(config, args);
510
+ const stopHeartbeat = startHeartbeat(dir, { role: 'node', sha: buildSha() });
511
+ const shutdown = installShutdownHandlers();
512
+ if (mebular !== null) {
513
+ console.log(JSON.stringify({
514
+ role: 'node', event: 'listening', device: config.device, mode: 'embedded',
515
+ multiaddr: mebular.node!.getLocalMultiaddrs()[0] ?? null, peerId: mebular.node!.peerId.id,
516
+ }));
517
+ } else {
518
+ console.log(JSON.stringify({ role: 'node', event: 'listening', device: config.device, mode: 'daemon', daemon: config.daemon?.endpoint ?? null }));
519
+ }
520
+ // T2:join 服务(令牌 → 委派证书)。默认由 config.joinService 控制,可用 --join-serve 临时开启。
521
+ // W2:daemon 模式下 join 由**守护**托管(此处不启动)。
522
+ let joinService: JoinService | null = null;
523
+ if (mebular !== null && (config.joinService?.enabled === true || args['join-serve'] === true)) {
524
+ const port = num(args['join-port'], config.joinService?.port ?? 4002);
525
+ const bind = str(args['join-bind'], config.joinService?.bind ?? '0.0.0.0');
526
+ joinService = await startJoinService({
527
+ mebular, deviceId: config.device, storagePath: config.storagePath, bind, port,
528
+ log: (m) => console.error(m),
529
+ });
530
+ console.log(JSON.stringify({ role: 'node', event: 'join-service', endpoint: `http://${bind}:${joinService.port}`, port: joinService.port }));
531
+ }
532
+ const node = new FleetNode({ device: config.device, store, transport: new NullTransport(), quota: new LocalQuota({ limitPerDevice: config.quotaLimitPerDevice ?? 1_000_000 }) });
533
+ const submit = num(args.submit, 0);
534
+ let code = 0;
535
+ if (submit > 0) {
536
+ // 先等首轮同步(对端连入)再提交,避免在无连接时提交导致事件丢失触发。
537
+ const waitSyncMs = num(args['wait-sync-ms'], 0);
538
+ if (waitSyncMs > 0 && mebular !== null) {
539
+ await new Promise<void>((resolve) => {
540
+ let settled = false;
541
+ const timer = setTimeout(() => {
542
+ if (!settled) {
543
+ settled = true;
544
+ resolve();
545
+ }
546
+ }, waitSyncMs);
547
+ mebular!.sync.once('sync-completed', () => {
548
+ if (!settled) {
549
+ settled = true;
550
+ clearTimeout(timer);
551
+ resolve();
552
+ }
553
+ });
554
+ });
555
+ }
556
+ const target = config.peers[0];
557
+ const ids: string[] = [];
558
+ for (let i = 0; i < submit; i++) {
559
+ const { taskId } = await node.submit({ intent: `t-${i}`, to: { device: target?.device ?? 'device-B', agent: str(args['target-agent'], 'echo') } });
560
+ if (taskId !== null) ids.push(taskId);
561
+ }
562
+ const ok = await node.waitForTerminal(ids, { timeoutMs: num(args['timeout-ms'], 60_000) });
563
+ const states = (await node.states()).filter((s) => ids.includes(s.taskId));
564
+ const done = states.filter((s) => s.status === 'done');
565
+ const expectPrefix = str(args['expect-prefix'], '');
566
+ const resultsMatch = states.every((s) =>
567
+ s.status === 'done' && (expectPrefix.length > 0 ? (s.resultRef ?? '').startsWith(expectPrefix) : (s.resultRef ?? '').length > 0),
568
+ );
569
+ console.log(JSON.stringify({ role: 'node', submitted: ids.length, done: done.length, allTerminal: ok, resultsMatch }));
570
+ code = ok && done.length === ids.length && resultsMatch ? 0 : 1;
571
+ if (runForever) {
572
+ while (!shutdown.isStopping()) {
573
+ await sleep(200); // 常驻:保持在线供对端同步
574
+ }
575
+ } else {
576
+ const lingerMs = num(args['linger-ms'], 0);
577
+ if (lingerMs > 0) await sleep(lingerMs);
578
+ }
579
+ } else if (runForever) {
580
+ while (!shutdown.isStopping()) {
581
+ await sleep(200);
582
+ }
583
+ } else {
584
+ await sleep(num(args['timeout-ms'], 60_000));
585
+ }
586
+ if (joinService !== null) await joinService.close();
587
+ stopHeartbeat();
588
+ shutdown.dispose();
589
+ await mebular?.shutdown();
590
+ return code;
591
+ }
592
+
593
+ /**
594
+ * `fleet worker`(执行端)。
595
+ * `--run-forever`:常驻(服务模式);写 `service.heartbeat`(role=worker)。
596
+ */
597
+ async function runFleetWorker(args: Args): Promise<number> {
598
+ const dir = fleetDirFrom(args);
599
+ const runForever = args['run-forever'] === true;
600
+ const config = await loadFleetConfig(fleetConfigPath(dir));
601
+ if (storeMode(config, args) === 'embedded') {
602
+ console.error('[embedded] 本地 Mebular 模式(test/dev only;生产请用 --store daemon + mebular serve 守护)');
603
+ }
604
+ const { store, mebular } = await openTaskStore(config, args);
605
+ const stopHeartbeat = startHeartbeat(dir, { role: 'worker', sha: buildSha() });
606
+ if (args['print-listen'] === true && mebular !== null) {
607
+ console.log(JSON.stringify({ role: 'worker', event: 'listening', device: config.device, multiaddrs: mebular.node!.getLocalMultiaddrs() }));
608
+ }
609
+ const shutdown = installShutdownHandlers();
610
+ if (mebular !== null) {
611
+ for (const peer of config.peers) {
612
+ if (peer.addr === undefined) continue;
613
+ const id = /\/p2p\/([^/]+)/.exec(peer.addr)?.[1] ?? peer.device;
614
+ try {
615
+ await mebular.node!.connectToPeer({ id, multihash: new Uint8Array(), pubKey: new Uint8Array() }, peer.addr);
616
+ } catch {
617
+ // 连接失败由后续收敛/doctor 暴露
618
+ }
619
+ }
620
+ }
621
+ const log = await ExecutionLog.open(str(args['exec-log'], join(dir, 'exec.jsonl')));
622
+ const worker = new FleetWorker({ device: config.device, agent: str(args.agent, 'worker'), store, transport: new NullTransport(), registry: buildRegistry(config.agents), log });
623
+ await worker.reconcile();
624
+ const deadline = Date.now() + num(args['timeout-ms'], 60_000);
625
+ while (runForever ? !shutdown.isStopping() : Date.now() < deadline) {
626
+ await worker.pollOnce();
627
+ await sleep(num(args['interval-ms'], 10));
628
+ }
629
+ console.log(JSON.stringify({ role: 'worker', device: config.device, executed: log.size() }));
630
+ stopHeartbeat();
631
+ shutdown.dispose();
632
+ await mebular?.shutdown();
633
+ return 0;
634
+ }
635
+
636
+ /** W2 B2:守护服务安装器(`mebular-serve`,同一 home)。 */
637
+ function daemonInstaller(): ServiceInstaller {
638
+ return async (dir: string) => {
639
+ try {
640
+ const require = createRequire(import.meta.url);
641
+ const mcpPkg = require.resolve('@mebular/mcp/package.json');
642
+ const mcpBin = join(dirname(mcpPkg), 'bin', 'mebular.mjs');
643
+ const descriptor: ServiceDescriptor = {
644
+ kind: 'mebular-serve',
645
+ args: [mcpBin, 'serve'],
646
+ heartbeatDir: dir,
647
+ workingDir: dir,
648
+ env: { MEBULAR_HOME: dir },
649
+ };
650
+ const result = installService(descriptor, { sha: buildSha() });
651
+ return result.ok ? { installed: true } : { installed: false, note: 'install 返回非 ok' };
652
+ } catch (error) {
653
+ return { installed: false, note: (error as Error).message };
654
+ }
655
+ };
656
+ }
657
+
658
+ /** `fleet quickstart`:A 一条命令上车(onboard + 本机订阅 + 加入码 + 服务 + doctor)。 */
659
+ async function runQuickstart(args: Args): Promise<number> {
660
+ const dir = fleetDirFrom(args);
661
+ const noService = args['no-service'] === true;
662
+ const agents = parseAgents(args.agent, args['agent-command'], args['agent-base-args']);
663
+ const result = await quickstart({
664
+ dir,
665
+ device: fleetDeviceFrom(args),
666
+ buildSha: buildSha(),
667
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
668
+ ...(typeof args.listen === 'string' ? { listen: args.listen } : {}),
669
+ ...(typeof args['code-file'] === 'string' ? { codeFile: args['code-file'] } : {}),
670
+ ...(typeof args['join-port'] === 'string' ? { joinPort: num(args['join-port'], DEFAULT_JOIN_PORT_FALLBACK) } : {}),
671
+ ...(typeof args['join-host'] === 'string' ? { joinHost: args['join-host'] } : {}),
672
+ ...(agents !== undefined ? { agents } : {}),
673
+ ...(args.daemon === true ? { daemon: true } : {}),
674
+ ...(typeof args['daemon-port'] === 'string' ? { daemonPort: num(args['daemon-port'], 7331) } : {}),
675
+ ...(args.daemon === true && !noService ? { installDaemon: daemonInstaller() } : {}),
676
+ ...(noService ? {} : { installService: fleetNodeInstaller() }),
677
+ });
678
+ const report = await doctor(dir);
679
+ console.log(JSON.stringify({
680
+ ok: true,
681
+ role: 'quickstart',
682
+ device: result.device,
683
+ dir: result.dir,
684
+ namespace: result.namespace,
685
+ fingerprint: result.fingerprint,
686
+ multiaddrs: result.multiaddrs,
687
+ agents: result.agents,
688
+ agentSources: result.agentSources,
689
+ serviceInstalled: result.serviceInstalled,
690
+ ...(result.serviceNote !== undefined ? { serviceNote: result.serviceNote } : {}),
691
+ code: result.code,
692
+ ...(result.codeFile !== undefined ? { codeFile: result.codeFile } : {}),
693
+ inviteToken: result.inviteToken,
694
+ joinEndpoint: result.joinEndpoint,
695
+ joinPort: result.joinPort,
696
+ ...(result.daemon !== undefined ? { daemon: result.daemon } : {}),
697
+ agentMcp: agentMcpConfig(),
698
+ warnings: result.warnings,
699
+ doctor: summarizeDoctor(report),
700
+ next: [...result.joinNext, `fleet node --dir ${dir} --run-forever`],
701
+ }, null, 2));
702
+ return 0;
703
+ }
704
+
705
+ /** `fleet join`:B 一条命令上车(版本核对 + 导入信任材料/地址 + onboard + 声明成员 + 服务 + doctor)。 */
706
+ async function runJoin(args: Args): Promise<number> {
707
+ const dir = fleetDirFrom(args);
708
+ const noService = args['no-service'] === true;
709
+ const agents = parseAgents(args.agent, args['agent-command'], args['agent-base-args']);
710
+ // T2:令牌加入(推荐;主密钥不复制)
711
+ // C7:`--qr <字符串>` 与 `--token` **完全等价**(QR 内容就是内联令牌文本本身)
712
+ if (typeof args.qr === 'string') args.token = args.qr.trim();
713
+ if (typeof args.token === 'string' || typeof args['token-file'] === 'string') {
714
+ const tokenText = await readJoinCode({
715
+ ...(typeof args.token === 'string' ? { code: args.token } : {}),
716
+ ...(typeof args['token-file'] === 'string' ? { codeFile: args['token-file'] } : {}),
717
+ });
718
+ const result = await joinWithToken({
719
+ dir,
720
+ token: tokenText,
721
+ device: fleetDeviceFrom(args),
722
+ agents: agents ?? [{ name: 'echo', kind: 'echo' }],
723
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
724
+ ...(typeof args.listen === 'string' ? { listen: args.listen } : {}),
725
+ ...(args.daemon === true ? { daemon: true } : {}),
726
+ ...(typeof args['daemon-port'] === 'string' ? { daemonPort: num(args['daemon-port'], 7331) } : {}),
727
+ ...(typeof args['join-port'] === 'string' ? { joinPort: num(args['join-port'], 4002) } : {}),
728
+ ...(args.daemon === true && noService !== true ? { installDaemon: daemonInstaller() } : {}),
729
+ });
730
+ const report = await doctor(dir);
731
+ console.log(JSON.stringify({
732
+ ok: true,
733
+ role: 'join',
734
+ mode: 'token',
735
+ device: result.device,
736
+ dir: result.dir,
737
+ peer: result.peer,
738
+ namespace: result.namespace,
739
+ fingerprint: result.fingerprint,
740
+ alreadyJoined: result.alreadyJoined,
741
+ inviterDeviceId: result.inviterDeviceId,
742
+ ...(result.daemon !== undefined ? { daemon: result.daemon } : {}),
743
+ agentMcp: result.agentMcp,
744
+ doctor: summarizeDoctor(report),
745
+ next: result.next,
746
+ }, null, 2));
747
+ return 0;
748
+ }
749
+ const code = await readJoinCode({
750
+ ...(typeof args.code === 'string' ? { code: args.code } : {}),
751
+ ...(typeof args['code-file'] === 'string' ? { codeFile: args['code-file'] } : {}),
752
+ });
753
+ const result = await joinFleet({
754
+ dir,
755
+ code,
756
+ device: fleetDeviceFrom(args),
757
+ buildSha: buildSha(),
758
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
759
+ ...(typeof args.listen === 'string' ? { listen: args.listen } : {}),
760
+ ...(agents !== undefined ? { agents } : {}),
761
+ ...(noService ? {} : { installService: fleetNodeInstaller() }),
762
+ });
763
+ const report = await doctor(dir);
764
+ console.log(JSON.stringify({
765
+ ok: true,
766
+ role: 'join',
767
+ device: result.device,
768
+ dir: result.dir,
769
+ peer: result.peer,
770
+ namespace: result.namespace,
771
+ fingerprint: result.fingerprint,
772
+ alreadyJoined: result.alreadyJoined,
773
+ serviceInstalled: result.serviceInstalled,
774
+ ...(result.serviceNote !== undefined ? { serviceNote: result.serviceNote } : {}),
775
+ agents: result.agents,
776
+ agentSources: result.agentSources,
777
+ doctor: summarizeDoctor(report),
778
+ next: result.next,
779
+ }, null, 2));
780
+ return 0;
781
+ }
782
+
783
+ /** `fleet invite`:任一在册设备生成**加入令牌**(T2;主密钥不出本机,任意设备均可)。 */
784
+ async function runInvite(args: Args): Promise<number> {
785
+ const dir = fleetDirFrom(args);
786
+ const config = await loadFleetConfig(fleetConfigPath(dir));
787
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
788
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
789
+ await mebular.initialize();
790
+ try {
791
+ const gate = await evaluateSecurityGate(mebular as never, dir, {
792
+ action: 'invite',
793
+ ...(typeof args.code === 'string' ? { code: args.code } : {}),
794
+ ...(typeof args['approval-id'] === 'string' ? { approvalId: args['approval-id'] } : {}),
795
+ namespace: str(args.namespace, config.namespace),
796
+ });
797
+ if (!gate.ok) {
798
+ console.log(JSON.stringify({ role: 'invite', ...gate, ok: false }, null, 2));
799
+ return 1;
800
+ }
801
+ const port = num(args['join-port'], config.joinService?.port ?? 4002);
802
+ const endpoint = str(args.endpoint, `http://${pickLanHost()}:${port}`);
803
+ const ttlMs = typeof args.ttl === 'string' ? num(args.ttl, 900) * 1000 : 900_000;
804
+ const token = await buildJoinToken({
805
+ mebular,
806
+ deviceId: config.device,
807
+ namespace: str(args.namespace, config.namespace),
808
+ endpoint,
809
+ ttlMs,
810
+ });
811
+ // 单一有效邀请:与守护 /admin/api/invite 共用同一 nonce 状态(跨进程文件锁)。
812
+ await activateJoinInvite(config.storagePath, token.nonce);
813
+ const inline = Buffer.from(JSON.stringify(token), 'utf-8').toString('base64');
814
+ if (typeof args['token-file'] === 'string') await writeJoinCodeFile(args['token-file'], inline);
815
+
816
+ // C7:二维码 + 文本一起提供(二维码内容 = 内联令牌文本;QR 编码失败时只给文本,不报错)
817
+ const warnings: string[] = [];
818
+ const terminalQr = args['no-qr'] === true ? null : await renderTerminalQr(inline, { onWarn: (m) => warnings.push(m) });
819
+ const svgQr = args['no-qr'] === true ? null : await renderSvgQr(inline, { onWarn: (m) => warnings.push(m) });
820
+ if (terminalQr) console.log(terminalQr.value);
821
+
822
+ console.log(JSON.stringify({
823
+ ok: true,
824
+ role: 'invite',
825
+ inviter: config.device,
826
+ endpoint,
827
+ namespace: token.namespace,
828
+ nonce: token.nonce,
829
+ expiresAt: token.expiresAt,
830
+ token: inline,
831
+ qr: terminalQr ? { kind: terminalQr.kind, value: terminalQr.value } : null,
832
+ qrSvg: svgQr ? svgQr.value : null,
833
+ ...(typeof args['token-file'] === 'string' ? { tokenFile: args['token-file'] } : {}),
834
+ ...(warnings.length > 0 ? { warnings } : {}),
835
+ next: [
836
+ '把二维码或令牌安全传给新设备(QR 内容即令牌文本)',
837
+ '新设备运行:fleet join --qr \'<二维码内容>\' 或 fleet join --token <内联|文件>',
838
+ ],
839
+ }, null, 2));
840
+ } finally {
841
+ await mebular.shutdown();
842
+ }
843
+ return 0;
844
+ }
845
+
846
+ /** 工具面 CLI:`fleet <tool-cli> --input '<json>'`(与 MCP `tools/call` 同一 handler)。 */
847
+ async function runTaskTool(cliName: string, args: Args): Promise<number> {
848
+ const tool = toolByCli(cliName);
849
+ if (tool === undefined) throw new Error(`未知工具子命令:${cliName}`);
850
+ let input: Record<string, unknown> = {};
851
+ if (typeof args.input === 'string') {
852
+ const parsed: unknown = JSON.parse(args.input);
853
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('--input 必须是 JSON 对象');
854
+ input = parsed as Record<string, unknown>;
855
+ }
856
+ const reserved = new Set(['input', 'dir', 'namespace', 'agent', 'version']);
857
+ for (const [key, value] of Object.entries(args)) {
858
+ if (reserved.has(key) || value === undefined) continue;
859
+ if (key === 'budget' || key === 'to' || key === 'cursor') {
860
+ input[key] = typeof value === 'string' ? JSON.parse(value) : value;
861
+ } else if (typeof value === 'string' && /^-?[0-9]+$/.test(value)) {
862
+ input[key] = Number(value);
863
+ } else {
864
+ input[key] = value;
865
+ }
866
+ }
867
+ const ctx = {
868
+ dir: fleetDirFrom(args),
869
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
870
+ ...(typeof args.agent === 'string' ? { agent: args.agent } : {}),
871
+ };
872
+ const result = await tool.handler(input, ctx);
873
+ console.log(JSON.stringify(result, null, 2));
874
+ return 0;
875
+ }
876
+
877
+ /** `fleet tools`:打印工具 ↔ CLI 对照表(供 A2 核对)。 */
878
+ /** `fleet security …`:TOTP / 恢复码 / 主密钥备份(S2)。 */
879
+ async function runSecurity(args: Args, positionals: string[]): Promise<number> {
880
+ const sub = positionals[0];
881
+ const action = positionals[1];
882
+ const dir = fleetDirFrom(args);
883
+
884
+ const openApp = async (): Promise<{ config: FleetConfig; mebular: Mebular }> => {
885
+ const config = await loadToolConfig(dir);
886
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
887
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
888
+ await mebular.initialize();
889
+ return { config, mebular };
890
+ };
891
+
892
+ if (sub === 'totp' && action === 'show') {
893
+ const { config, mebular } = await openApp();
894
+ try {
895
+ let seed = await readTotpSeed(dir);
896
+ if (seed === null) {
897
+ if (args.generate !== true) {
898
+ throw new Error('尚无动态码种子:先 `fleet security totp show --generate`(root)或从另一台设备 `totp import`');
899
+ }
900
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
901
+ if (encryption.userMasterPrivateKey === undefined) {
902
+ throw new Error('仅 root(持有主密钥私钥的设备)可生成动态码种子');
903
+ }
904
+ seed = await writeTotpSeed(dir, generateTotpSecret());
905
+ } else {
906
+ const code = str(args.code, '');
907
+ if (code.length === 0) {
908
+ throw new Error('显示动态码种子前需要输入一次本机动态码:--code <6 位>');
909
+ }
910
+ const check = await verifyTotp(seed.secret, code, Date.now(), { dir });
911
+ if (!check.ok) {
912
+ throw new Error(check.reason === 'locked'
913
+ ? `动态码已锁定(剩余 ${Math.ceil((check.retryAfterMs ?? 0) / 1000)}s)`
914
+ : check.reason === 'replay'
915
+ ? '动态码已使用过(重放被拒)'
916
+ : '动态码无效:请检查设备时间');
917
+ }
918
+ }
919
+ const share = await renderTotpShare(seed.secret, config.device, { qr: args['no-qr'] !== true });
920
+ const svg = args.svg === true ? await renderTotpShareSvg(seed.secret, config.device) : null;
921
+ await writeSecurityTotpAudit(mebular as never, {
922
+ device: config.device,
923
+ fingerprint: seed.fingerprint,
924
+ issuedAt: Date.now(),
925
+ note: 'fleet security totp show',
926
+ });
927
+ console.log(JSON.stringify({
928
+ ok: true,
929
+ role: 'security-totp-show',
930
+ device: config.device,
931
+ fingerprint: seed.fingerprint,
932
+ secret: seed.secret,
933
+ groups: share.groups,
934
+ otpauth: share.otpauth,
935
+ qr: share.qrText ?? null,
936
+ ...(svg !== null ? { qrSvg: svg } : {}),
937
+ warning: '种子等于这一档的钥匙:只在两台你自己的设备之间当面出示;不要截图、不要发聊天工具。',
938
+ }, null, 2));
939
+ return 0;
940
+ } finally {
941
+ await mebular.shutdown();
942
+ }
943
+ }
944
+
945
+ if (sub === 'totp' && action === 'import') {
946
+ const input = str(args.secret, str(args.code, str(args.uri, '')));
947
+ if (input.length === 0) throw new Error('需要 --secret <base32|otpauth://…>(或 --uri)');
948
+ const { config, mebular } = await openApp();
949
+ try {
950
+ const record = await importTotpSeed(dir, input, {
951
+ ...(typeof args['expected-fingerprint'] === 'string' ? { expectedFingerprint: args['expected-fingerprint'] } : {}),
952
+ });
953
+ await writeSecurityTotpAudit(mebular as never, {
954
+ device: config.device,
955
+ fingerprint: record.fingerprint,
956
+ issuedAt: Date.now(),
957
+ ...(typeof args.epoch === 'string' ? { epoch: args.epoch } : {}),
958
+ note: 'fleet security totp import',
959
+ });
960
+ console.log(JSON.stringify({
961
+ ok: true,
962
+ role: 'security-totp-import',
963
+ device: config.device,
964
+ fingerprint: record.fingerprint,
965
+ createdAt: record.createdAt,
966
+ }, null, 2));
967
+ return 0;
968
+ } finally {
969
+ await mebular.shutdown();
970
+ }
971
+ }
972
+
973
+ if (sub === 'recovery' && action === 'generate') {
974
+ const { config, mebular } = await openApp();
975
+ try {
976
+ const events = await listPolicyEvents(mebular as never);
977
+ const existing = latestRecoveryRecord(events);
978
+ if (existing !== null) {
979
+ throw new Error(`已存在恢复码(keyId ${existing.keyId.slice(0, 8)}…):请使用 recovery rotate --old-code 轮换`);
980
+ }
981
+ const material = generateRecoveryCode();
982
+ const event = await writeSecurityRecovery(mebular as never, {
983
+ keyId: material.keyId,
984
+ publicKey: material.publicKeyHex,
985
+ createdAt: material.createdAt,
986
+ mode: 'set',
987
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
988
+ });
989
+ console.log(JSON.stringify({
990
+ ok: true,
991
+ role: 'security-recovery-generate',
992
+ device: config.device,
993
+ keyId: material.keyId,
994
+ code: material.code,
995
+ codeFormatted: formatRecoveryCode(material.code),
996
+ eventId: event.id,
997
+ shownOnce: true,
998
+ guidance: [
999
+ '恢复码只在屏幕上出现这一次;请抄写到离线介质(纸/金属/密码器)。',
1000
+ '与主密钥备份分开存放;不要截图、不要发聊天工具。',
1001
+ '任何看到它的人都能在 high 档替代会签——丢失只能轮换。',
1002
+ ],
1003
+ }, null, 2));
1004
+ return 0;
1005
+ } finally {
1006
+ await mebular.shutdown();
1007
+ }
1008
+ }
1009
+
1010
+ if (sub === 'recovery' && action === 'rotate') {
1011
+ const oldCode = str(args['old-code'], str(args.code, ''));
1012
+ if (oldCode.length === 0) throw new Error('需要 --old-code <当前恢复码>');
1013
+ const { config, mebular } = await openApp();
1014
+ try {
1015
+ const events = await listPolicyEvents(mebular as never);
1016
+ const registry = collectRecoveryRegistry(events);
1017
+ const state = await readSecurityState(mebular as never);
1018
+ const currentKeyId = state.recoveryKeyId ?? registry.currentKeyId ?? latestRecoveryRecord(events)?.keyId;
1019
+ if (currentKeyId === undefined) {
1020
+ throw new Error('尚未生成恢复码:先 `fleet security recovery generate`');
1021
+ }
1022
+ const oldDerived = deriveRecoveryKey(oldCode);
1023
+ if (oldDerived.keyId !== currentKeyId) {
1024
+ throw new Error(`恢复码不匹配当前 keyId(当前 ${currentKeyId.slice(0, 8)}…);请使用当前恢复码`);
1025
+ }
1026
+ const material = generateRecoveryCode();
1027
+ const signed = signBreakGlass({
1028
+ code: oldCode,
1029
+ op: 'rotate',
1030
+ initiator: config.device,
1031
+ oldKeyId: currentKeyId,
1032
+ newPublicKey: material.publicKeyHex,
1033
+ });
1034
+ const event = await writeSecurityRecovery(mebular as never, {
1035
+ keyId: material.keyId,
1036
+ publicKey: material.publicKeyHex,
1037
+ createdAt: material.createdAt,
1038
+ mode: 'rotate',
1039
+ nonce: signed.proof.nonce,
1040
+ breakGlass: signed.proof,
1041
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
1042
+ });
1043
+ console.log(JSON.stringify({
1044
+ ok: true,
1045
+ role: 'security-recovery-rotate',
1046
+ device: config.device,
1047
+ oldKeyId: currentKeyId,
1048
+ keyId: material.keyId,
1049
+ code: material.code,
1050
+ codeFormatted: formatRecoveryCode(material.code),
1051
+ eventId: event.id,
1052
+ shownOnce: true,
1053
+ guidance: ['旧恢复码立即失效(新签名不再被采纳);请把新码抄写到离线介质并销毁旧码。'],
1054
+ }, null, 2));
1055
+ return 0;
1056
+ } finally {
1057
+ await mebular.shutdown();
1058
+ }
1059
+ }
1060
+
1061
+ if (sub === 'backup' && action === 'export') {
1062
+ const out = str(args.out, str(args.file, ''));
1063
+ if (out.length === 0) throw new Error('需要 --out <备份文件路径>(或 --file)');
1064
+ const result = await exportMasterKeyBackup({
1065
+ dir,
1066
+ ...(typeof args.code === 'string' ? { code: args.code } : {}),
1067
+ ...(typeof args.note === 'string' ? { note: args.note } : {}),
1068
+ });
1069
+ await writeBackupFile(out, result.file);
1070
+ console.log(JSON.stringify({
1071
+ ok: true,
1072
+ role: 'security-backup-export',
1073
+ out,
1074
+ deviceId: result.deviceId,
1075
+ fingerprint: result.fingerprint,
1076
+ code: result.code,
1077
+ codeFormatted: formatRecoveryCode(result.code),
1078
+ codeGenerated: result.codeGenerated,
1079
+ createdAt: result.file.createdAt,
1080
+ warning: '备份码只在本次输出;请与备份文件分开保管(两处不同介质/地点)。恢复码不要数字化。',
1081
+ }, null, 2));
1082
+ return 0;
1083
+ }
1084
+
1085
+ if (sub === 'backup' && action === 'restore') {
1086
+ const file = str(args.file, '');
1087
+ const code = str(args.code, '');
1088
+ if (file.length === 0) throw new Error('需要 --file <备份文件路径>');
1089
+ if (code.length === 0) throw new Error('需要 --code <备份码>');
1090
+ let keyPath: string | undefined;
1091
+ if (existsSync(fleetConfigPath(dir))) {
1092
+ keyPath = (await loadFleetConfig(fleetConfigPath(dir))).masterKeyFile;
1093
+ } else if (existsSync(join(dir, 'config.json'))) {
1094
+ const daemon = JSON.parse(await readFile(join(dir, 'config.json'), 'utf-8')) as Record<string, any>;
1095
+ const encryption = (daemon.encryption ?? {}) as Record<string, unknown>;
1096
+ keyPath = typeof encryption.userMasterPublicKeyFile === 'string'
1097
+ ? encryption.userMasterPublicKeyFile
1098
+ : typeof encryption.keyFile === 'string'
1099
+ ? encryption.keyFile
1100
+ : join(dir, 'user-master-key.json');
1101
+ } else if (typeof args.device === 'string' && args.device.length > 0) {
1102
+ keyPath = join(dir, 'master-key.json');
1103
+ } else {
1104
+ keyPath = join(dir, 'user-master-key.json');
1105
+ }
1106
+ const raw = await readFile(file, 'utf-8');
1107
+ const result = await restoreMasterKeyBackup({
1108
+ file: raw,
1109
+ code,
1110
+ dir,
1111
+ keyPath,
1112
+ ...(typeof args.device === 'string' && args.device.length > 0 ? { device: args.device } : {}),
1113
+ ...(typeof args.namespace === 'string' ? { namespace: args.namespace } : {}),
1114
+ ...(typeof args.listen === 'string' ? { listen: args.listen } : {}),
1115
+ });
1116
+ console.log(JSON.stringify({
1117
+ ok: true,
1118
+ role: 'security-backup-restore',
1119
+ dir,
1120
+ keyPath: result.keyPath,
1121
+ ...(result.configPath !== undefined ? { configPath: result.configPath } : {}),
1122
+ deviceId: result.deviceId,
1123
+ fingerprint: result.fingerprint,
1124
+ masterPublicKeyHex: result.masterPublicKeyHex,
1125
+ onboarded: result.onboarded,
1126
+ next: result.onboarded
1127
+ ? ['核对主公钥指纹与既有设备「关于本机」一致', '重新导出备份并轮换恢复码(恢复机成为新 root)']
1128
+ : [`如需成为新 root:fleet onboard --dir ${dir} --device <deviceId> --master-key ${result.keyPath}`],
1129
+ }, null, 2));
1130
+ return 0;
1131
+ }
1132
+
1133
+ console.error('用法:fleet security totp show|import | recovery generate|rotate | backup export|restore …');
1134
+ return 2;
1135
+ }
1136
+
1137
+ function runTools(): number {
1138
+ console.log(JSON.stringify({ ok: true, tools: toolCliTable() }, null, 2));
1139
+ return 0;
1140
+ }
1141
+
1142
+ async function main(): Promise<void> {
1143
+ const raw = process.argv.slice(2);
1144
+ // `service` 有自身 flags(--no-autostart/--label/--extra),走 raw argv,不经通用解析。
1145
+ if (raw[0] === 'service') {
1146
+ process.exit(runFleetService(raw.slice(1)));
1147
+ }
1148
+ const { command, args, positionals } = parseArgs(raw);
1149
+ let code = 2;
1150
+ if (args.version === true) {
1151
+ console.log(`@mebular/fleet ${versionString()}`);
1152
+ process.exit(0);
1153
+ }
1154
+ try {
1155
+ if (command === 'spool') {
1156
+ const sub = positionals[0];
1157
+ if (sub === 'node') code = await runNode(args);
1158
+ else if (sub === 'worker') code = await runWorker(args);
1159
+ else {
1160
+ console.error('用法:fleet spool node|worker …(M2 单机双进程,spool 传输)');
1161
+ code = 2;
1162
+ }
1163
+ }
1164
+ else if (command === 'node') code = await runFleetNode(args);
1165
+ else if (command === 'worker') code = await runFleetWorker(args);
1166
+ else if (command === 'onboard') code = await runOnboard(args);
1167
+ else if (command === 'quickstart') code = await runQuickstart(args);
1168
+ else if (command === 'join') code = await runJoin(args);
1169
+ else if (command === 'invite') code = await runInvite(args);
1170
+ else if (command === 'security') code = await runSecurity(args, positionals);
1171
+ else if (command === 'tools') code = runTools();
1172
+ else if (command !== undefined && toolByCli(command) !== undefined) code = await runTaskTool(command, args);
1173
+ else if (command === 'doctor') code = await runDoctor(args);
1174
+ else if (command === 'subscribe') code = await runSubscribe(args);
1175
+ else if (command === 'unsubscribe') code = await runUnsubscribe(args);
1176
+ else if (command === 'subscribers') code = await runSubscribers(args);
1177
+ else if (command === 'namespace') code = await runDeclareNamespace(args);
1178
+ else if (command === 'restore') code = await runRestore(args);
1179
+ else if (command === 'leave') code = await runLeave(args);
1180
+ else if (command === 'rejoin') code = await runRejoin(args);
1181
+ else {
1182
+ console.error('用法:fleet quickstart|join|invite|tools|task_submit|task_submit_batch|task_cancel|task_retry|task_status|task_list|task_history|task_children|task_summarize|task_subscribe|task_negotiate|chatter_send|chatter_inbox|task_quota|task_targets|board_create|onboard|doctor|subscribe|unsubscribe|subscribers|namespace|restore|leave|rejoin|security|node|worker|service|spool … | fleet --version');
1183
+ code = 2;
1184
+ }
1185
+ } catch (error) {
1186
+ console.error(JSON.stringify({ ok: false, error: (error as Error).message }));
1187
+ process.exit(1);
1188
+ }
1189
+ process.exit(code);
1190
+ }
1191
+
1192
+ await main();