feique 1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +220 -0
  3. package/README.md +265 -0
  4. package/dist/backend/claude.d.ts +36 -0
  5. package/dist/backend/claude.js +358 -0
  6. package/dist/backend/claude.js.map +1 -0
  7. package/dist/backend/codex.d.ts +31 -0
  8. package/dist/backend/codex.js +100 -0
  9. package/dist/backend/codex.js.map +1 -0
  10. package/dist/backend/factory.d.ts +9 -0
  11. package/dist/backend/factory.js +56 -0
  12. package/dist/backend/factory.js.map +1 -0
  13. package/dist/backend/types.d.ts +54 -0
  14. package/dist/backend/types.js +2 -0
  15. package/dist/backend/types.js.map +1 -0
  16. package/dist/bridge/commands.d.ts +135 -0
  17. package/dist/bridge/commands.js +860 -0
  18. package/dist/bridge/commands.js.map +1 -0
  19. package/dist/bridge/service.d.ts +160 -0
  20. package/dist/bridge/service.js +3785 -0
  21. package/dist/bridge/service.js.map +1 -0
  22. package/dist/bridge/task-queue.d.ts +14 -0
  23. package/dist/bridge/task-queue.js +81 -0
  24. package/dist/bridge/task-queue.js.map +1 -0
  25. package/dist/bridge/types.d.ts +39 -0
  26. package/dist/bridge/types.js +2 -0
  27. package/dist/bridge/types.js.map +1 -0
  28. package/dist/cli.d.ts +2 -0
  29. package/dist/cli.js +1199 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/codex/capabilities.d.ts +20 -0
  32. package/dist/codex/capabilities.js +41 -0
  33. package/dist/codex/capabilities.js.map +1 -0
  34. package/dist/codex/runner.d.ts +47 -0
  35. package/dist/codex/runner.js +294 -0
  36. package/dist/codex/runner.js.map +1 -0
  37. package/dist/codex/session-index.d.ts +22 -0
  38. package/dist/codex/session-index.js +205 -0
  39. package/dist/codex/session-index.js.map +1 -0
  40. package/dist/collaboration/awareness.d.ts +36 -0
  41. package/dist/collaboration/awareness.js +107 -0
  42. package/dist/collaboration/awareness.js.map +1 -0
  43. package/dist/collaboration/digest.d.ts +65 -0
  44. package/dist/collaboration/digest.js +178 -0
  45. package/dist/collaboration/digest.js.map +1 -0
  46. package/dist/collaboration/handoff.d.ts +66 -0
  47. package/dist/collaboration/handoff.js +94 -0
  48. package/dist/collaboration/handoff.js.map +1 -0
  49. package/dist/collaboration/insights.d.ts +24 -0
  50. package/dist/collaboration/insights.js +243 -0
  51. package/dist/collaboration/insights.js.map +1 -0
  52. package/dist/collaboration/knowledge.d.ts +26 -0
  53. package/dist/collaboration/knowledge.js +105 -0
  54. package/dist/collaboration/knowledge.js.map +1 -0
  55. package/dist/collaboration/timeline.d.ts +31 -0
  56. package/dist/collaboration/timeline.js +150 -0
  57. package/dist/collaboration/timeline.js.map +1 -0
  58. package/dist/collaboration/trust.d.ts +49 -0
  59. package/dist/collaboration/trust.js +176 -0
  60. package/dist/collaboration/trust.js.map +1 -0
  61. package/dist/config/codex-skill.d.ts +7 -0
  62. package/dist/config/codex-skill.js +44 -0
  63. package/dist/config/codex-skill.js.map +1 -0
  64. package/dist/config/doctor.d.ts +12 -0
  65. package/dist/config/doctor.js +314 -0
  66. package/dist/config/doctor.js.map +1 -0
  67. package/dist/config/init.d.ts +3 -0
  68. package/dist/config/init.js +123 -0
  69. package/dist/config/init.js.map +1 -0
  70. package/dist/config/load.d.ts +33 -0
  71. package/dist/config/load.js +252 -0
  72. package/dist/config/load.js.map +1 -0
  73. package/dist/config/mutate.d.ts +21 -0
  74. package/dist/config/mutate.js +86 -0
  75. package/dist/config/mutate.js.map +1 -0
  76. package/dist/config/paths.d.ts +3 -0
  77. package/dist/config/paths.js +33 -0
  78. package/dist/config/paths.js.map +1 -0
  79. package/dist/config/schema.d.ts +308 -0
  80. package/dist/config/schema.js +250 -0
  81. package/dist/config/schema.js.map +1 -0
  82. package/dist/control-plane/project-session.d.ts +67 -0
  83. package/dist/control-plane/project-session.js +234 -0
  84. package/dist/control-plane/project-session.js.map +1 -0
  85. package/dist/feishu/base.d.ts +19 -0
  86. package/dist/feishu/base.js +93 -0
  87. package/dist/feishu/base.js.map +1 -0
  88. package/dist/feishu/cards.d.ts +22 -0
  89. package/dist/feishu/cards.js +144 -0
  90. package/dist/feishu/cards.js.map +1 -0
  91. package/dist/feishu/client.d.ts +61 -0
  92. package/dist/feishu/client.js +315 -0
  93. package/dist/feishu/client.js.map +1 -0
  94. package/dist/feishu/diagnostics.d.ts +42 -0
  95. package/dist/feishu/diagnostics.js +194 -0
  96. package/dist/feishu/diagnostics.js.map +1 -0
  97. package/dist/feishu/doc.d.ts +13 -0
  98. package/dist/feishu/doc.js +59 -0
  99. package/dist/feishu/doc.js.map +1 -0
  100. package/dist/feishu/extractors.d.ts +7 -0
  101. package/dist/feishu/extractors.js +215 -0
  102. package/dist/feishu/extractors.js.map +1 -0
  103. package/dist/feishu/long-connection.d.ts +12 -0
  104. package/dist/feishu/long-connection.js +41 -0
  105. package/dist/feishu/long-connection.js.map +1 -0
  106. package/dist/feishu/message-resource.d.ts +14 -0
  107. package/dist/feishu/message-resource.js +309 -0
  108. package/dist/feishu/message-resource.js.map +1 -0
  109. package/dist/feishu/replay.d.ts +37 -0
  110. package/dist/feishu/replay.js +114 -0
  111. package/dist/feishu/replay.js.map +1 -0
  112. package/dist/feishu/task.d.ts +18 -0
  113. package/dist/feishu/task.js +86 -0
  114. package/dist/feishu/task.js.map +1 -0
  115. package/dist/feishu/text.d.ts +23 -0
  116. package/dist/feishu/text.js +155 -0
  117. package/dist/feishu/text.js.map +1 -0
  118. package/dist/feishu/webhook.d.ts +23 -0
  119. package/dist/feishu/webhook.js +130 -0
  120. package/dist/feishu/webhook.js.map +1 -0
  121. package/dist/feishu/wiki.d.ts +52 -0
  122. package/dist/feishu/wiki.js +300 -0
  123. package/dist/feishu/wiki.js.map +1 -0
  124. package/dist/index.d.ts +9 -0
  125. package/dist/index.js +9 -0
  126. package/dist/index.js.map +1 -0
  127. package/dist/knowledge/search.d.ts +11 -0
  128. package/dist/knowledge/search.js +83 -0
  129. package/dist/knowledge/search.js.map +1 -0
  130. package/dist/logging.d.ts +3 -0
  131. package/dist/logging.js +40 -0
  132. package/dist/logging.js.map +1 -0
  133. package/dist/mcp/server.d.ts +34 -0
  134. package/dist/mcp/server.js +1196 -0
  135. package/dist/mcp/server.js.map +1 -0
  136. package/dist/memory/embedding-factory.d.ts +6 -0
  137. package/dist/memory/embedding-factory.js +20 -0
  138. package/dist/memory/embedding-factory.js.map +1 -0
  139. package/dist/memory/embeddings.d.ts +40 -0
  140. package/dist/memory/embeddings.js +150 -0
  141. package/dist/memory/embeddings.js.map +1 -0
  142. package/dist/memory/ollama-embeddings.d.ts +63 -0
  143. package/dist/memory/ollama-embeddings.js +215 -0
  144. package/dist/memory/ollama-embeddings.js.map +1 -0
  145. package/dist/memory/retrieve.d.ts +17 -0
  146. package/dist/memory/retrieve.js +29 -0
  147. package/dist/memory/retrieve.js.map +1 -0
  148. package/dist/memory/summarize.d.ts +13 -0
  149. package/dist/memory/summarize.js +58 -0
  150. package/dist/memory/summarize.js.map +1 -0
  151. package/dist/observability/cost.d.ts +12 -0
  152. package/dist/observability/cost.js +22 -0
  153. package/dist/observability/cost.js.map +1 -0
  154. package/dist/observability/dashboard-html.d.ts +5 -0
  155. package/dist/observability/dashboard-html.js +304 -0
  156. package/dist/observability/dashboard-html.js.map +1 -0
  157. package/dist/observability/metrics.d.ts +36 -0
  158. package/dist/observability/metrics.js +230 -0
  159. package/dist/observability/metrics.js.map +1 -0
  160. package/dist/observability/readiness.d.ts +31 -0
  161. package/dist/observability/readiness.js +57 -0
  162. package/dist/observability/readiness.js.map +1 -0
  163. package/dist/observability/server.d.ts +84 -0
  164. package/dist/observability/server.js +181 -0
  165. package/dist/observability/server.js.map +1 -0
  166. package/dist/projects/paths.d.ts +9 -0
  167. package/dist/projects/paths.js +30 -0
  168. package/dist/projects/paths.js.map +1 -0
  169. package/dist/runtime/instance-lock.d.ts +12 -0
  170. package/dist/runtime/instance-lock.js +99 -0
  171. package/dist/runtime/instance-lock.js.map +1 -0
  172. package/dist/runtime/process.d.ts +2 -0
  173. package/dist/runtime/process.js +43 -0
  174. package/dist/runtime/process.js.map +1 -0
  175. package/dist/runtime/shutdown.d.ts +11 -0
  176. package/dist/runtime/shutdown.js +38 -0
  177. package/dist/runtime/shutdown.js.map +1 -0
  178. package/dist/security/access.d.ts +13 -0
  179. package/dist/security/access.js +160 -0
  180. package/dist/security/access.js.map +1 -0
  181. package/dist/service/install.d.ts +19 -0
  182. package/dist/service/install.js +35 -0
  183. package/dist/service/install.js.map +1 -0
  184. package/dist/service/templates.d.ts +22 -0
  185. package/dist/service/templates.js +118 -0
  186. package/dist/service/templates.js.map +1 -0
  187. package/dist/state/audit-log.d.ts +33 -0
  188. package/dist/state/audit-log.js +116 -0
  189. package/dist/state/audit-log.js.map +1 -0
  190. package/dist/state/config-history-store.d.ts +27 -0
  191. package/dist/state/config-history-store.js +65 -0
  192. package/dist/state/config-history-store.js.map +1 -0
  193. package/dist/state/handoff-store.d.ts +20 -0
  194. package/dist/state/handoff-store.js +97 -0
  195. package/dist/state/handoff-store.js.map +1 -0
  196. package/dist/state/idempotency-store.d.ts +19 -0
  197. package/dist/state/idempotency-store.js +84 -0
  198. package/dist/state/idempotency-store.js.map +1 -0
  199. package/dist/state/memory-store.d.ts +137 -0
  200. package/dist/state/memory-store.js +713 -0
  201. package/dist/state/memory-store.js.map +1 -0
  202. package/dist/state/pending-command-store.d.ts +30 -0
  203. package/dist/state/pending-command-store.js +108 -0
  204. package/dist/state/pending-command-store.js.map +1 -0
  205. package/dist/state/run-state-store.d.ts +58 -0
  206. package/dist/state/run-state-store.js +269 -0
  207. package/dist/state/run-state-store.js.map +1 -0
  208. package/dist/state/session-store.d.ts +56 -0
  209. package/dist/state/session-store.js +275 -0
  210. package/dist/state/session-store.js.map +1 -0
  211. package/dist/state/trust-store.d.ts +15 -0
  212. package/dist/state/trust-store.js +53 -0
  213. package/dist/state/trust-store.js.map +1 -0
  214. package/dist/utils/fs.d.ts +4 -0
  215. package/dist/utils/fs.js +26 -0
  216. package/dist/utils/fs.js.map +1 -0
  217. package/dist/utils/json.d.ts +1 -0
  218. package/dist/utils/json.js +9 -0
  219. package/dist/utils/json.js.map +1 -0
  220. package/dist/utils/path.d.ts +3 -0
  221. package/dist/utils/path.js +22 -0
  222. package/dist/utils/path.js.map +1 -0
  223. package/dist/utils/serial-executor.d.ts +5 -0
  224. package/dist/utils/serial-executor.js +12 -0
  225. package/dist/utils/serial-executor.js.map +1 -0
  226. package/package.json +71 -0
  227. package/skills/feique-session/SKILL.md +27 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1199 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import { closeSync, openSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { Command } from 'commander';
7
+ import packageJson from '../package.json' with { type: 'json' };
8
+ import { createLogger } from './logging.js';
9
+ import { buildInitialConfig, getInitTargetPath } from './config/init.js';
10
+ import { getGlobalConfigPath } from './config/paths.js';
11
+ import { fileExists, writeUtf8Atomic } from './utils/fs.js';
12
+ import { findNearestProjectConfig, loadBridgeConfig, loadRuntimeConfig } from './config/load.js';
13
+ import { SessionStore } from './state/session-store.js';
14
+ import { AuditLog } from './state/audit-log.js';
15
+ import { RunStateStore } from './state/run-state-store.js';
16
+ import { TrustStore } from './state/trust-store.js';
17
+ import { HandoffStore } from './state/handoff-store.js';
18
+ import { FeishuClient } from './feishu/client.js';
19
+ import { FeiqueService } from './bridge/service.js';
20
+ import { startLongConnectionBridge } from './feishu/long-connection.js';
21
+ import { startWebhookBridge } from './feishu/webhook.js';
22
+ import { bindProjectAlias, createProjectAlias } from './config/mutate.js';
23
+ import { findMissingEnvRefs, formatDoctorFinding, hasDoctorErrors, runDoctor, runRemoteDoctor } from './config/doctor.js';
24
+ import { installBundledCodexSkill } from './config/codex-skill.js';
25
+ import { buildServiceDescriptor } from './service/templates.js';
26
+ import { installServiceFile, resolveDefaultLogDirectory, uninstallServiceFile } from './service/install.js';
27
+ import { acquireInstanceLock } from './runtime/instance-lock.js';
28
+ import { formatFeishuInspect, inspectFeishuEnvironment } from './feishu/diagnostics.js';
29
+ import { MetricsRegistry } from './observability/metrics.js';
30
+ import { startMetricsServer } from './observability/server.js';
31
+ import { ServiceReadinessProbe } from './observability/readiness.js';
32
+ import { buildReplayCardAction, buildReplayMessageEvent, postWebhookPayload, requestWebhookEndpoint } from './feishu/replay.js';
33
+ import { isProcessAlive, terminateProcess } from './runtime/process.js';
34
+ import { startMcpServer } from './mcp/server.js';
35
+ import { getProjectArchiveDir, getProjectAuditDir } from './projects/paths.js';
36
+ import { expandHomePath } from './utils/path.js';
37
+ import { MemoryStore } from './state/memory-store.js';
38
+ import { createEmbeddingProvider } from './memory/embedding-factory.js';
39
+ const logger = createLogger();
40
+ const program = new Command();
41
+ program
42
+ .name('feique')
43
+ .description('飞鹊 (Feique) — Team AI Collaboration Hub for Feishu.')
44
+ .version(packageJson.version);
45
+ program
46
+ .command('init')
47
+ .description('Create a global or project-scoped config file')
48
+ .option('--mode <mode>', 'global or project', 'global')
49
+ .option('--force', 'overwrite existing config', false)
50
+ .action(async (options) => {
51
+ const targetPath = getInitTargetPath(options.mode, process.cwd());
52
+ if (!options.force && (await fileExists(targetPath))) {
53
+ throw new Error(`Config already exists: ${targetPath}`);
54
+ }
55
+ const content = buildInitialConfig(options.mode, process.cwd());
56
+ await writeUtf8Atomic(targetPath, content);
57
+ console.log(`Wrote ${targetPath}`);
58
+ });
59
+ const serveCommand = program
60
+ .command('serve [operation]')
61
+ .description('Start the 飞鹊 (Feique) service')
62
+ .option('--config <path>', 'config path override')
63
+ .option('--detach', 'run the bridge in the background and return immediately', false)
64
+ .option('--skip-doctor', 'skip startup doctor preflight', false)
65
+ .option('--json', 'print runtime management commands as JSON', false)
66
+ .option('--lines <number>', 'number of lines for `serve logs`')
67
+ .option('--follow', 'follow appended log output for `serve logs`', false)
68
+ .option('--rotate', 'rotate managed logs before printing `serve logs`', false)
69
+ .option('--force', 'use SIGKILL if SIGTERM does not stop the process in time', false)
70
+ .option('--wait-ms <number>', 'grace period for `serve stop`', '5000')
71
+ .option('--all', 'show all runs for `serve ps`', false)
72
+ .action(async (operation, options) => {
73
+ if (operation && operation !== 'start') {
74
+ if (operation === 'status') {
75
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
76
+ const runtimeStatus = await inspectRuntimeStatus(config);
77
+ if (options.json) {
78
+ console.log(JSON.stringify(runtimeStatus, null, 2));
79
+ return;
80
+ }
81
+ console.log(`service: ${config.service.name}`);
82
+ console.log(`running: ${runtimeStatus.running}`);
83
+ console.log(`pid: ${runtimeStatus.pid ?? '-'}`);
84
+ console.log(`pid_file: ${runtimeStatus.pidPath}`);
85
+ console.log(`log_file: ${runtimeStatus.logPath}`);
86
+ console.log(`active_runs: ${runtimeStatus.activeRuns}`);
87
+ return;
88
+ }
89
+ if (operation === 'stop') {
90
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
91
+ const runtimeStatus = await inspectRuntimeStatus(config);
92
+ if (!runtimeStatus.pid || !runtimeStatus.running) {
93
+ console.log('Bridge is not running.');
94
+ return;
95
+ }
96
+ const stopped = await stopRuntimeProcess(runtimeStatus.pid, Number(options.waitMs), options.force);
97
+ if (!stopped) {
98
+ throw new Error(`Failed to stop bridge pid ${runtimeStatus.pid}`);
99
+ }
100
+ await fs.rm(runtimeStatus.pidPath, { force: true });
101
+ console.log(`Stopped bridge pid ${runtimeStatus.pid}`);
102
+ return;
103
+ }
104
+ if (operation === 'restart') {
105
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
106
+ const runtimeStatus = await inspectRuntimeStatus(config);
107
+ if (runtimeStatus.pid && runtimeStatus.running) {
108
+ const stopped = await stopRuntimeProcess(runtimeStatus.pid, Number(options.waitMs), options.force);
109
+ if (!stopped) {
110
+ throw new Error(`Failed to stop bridge pid ${runtimeStatus.pid}`);
111
+ }
112
+ await fs.rm(runtimeStatus.pidPath, { force: true });
113
+ }
114
+ const detached = await detachServeProcess({
115
+ config,
116
+ configPath: options.config,
117
+ cwd: process.cwd(),
118
+ });
119
+ console.log(`Restarted bridge: pid=${detached.pid}`);
120
+ console.log(`Log file: ${detached.logPath}`);
121
+ console.log(`PID file: ${detached.pidPath}`);
122
+ return;
123
+ }
124
+ if (operation === 'logs') {
125
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
126
+ const runtimePaths = getRuntimePaths(config);
127
+ const lines = Number(options.lines ?? config.service.log_tail_lines);
128
+ if (options.rotate) {
129
+ const rotated = await rotateManagedLogs(config, { force: true });
130
+ process.stdout.write(rotated.length > 0 ? `Rotated logs:\n${rotated.map((file) => `- ${file}`).join('\n')}\n` : 'No logs rotated.\n');
131
+ }
132
+ if (options.follow) {
133
+ await followFile(runtimePaths.logPath, lines);
134
+ return;
135
+ }
136
+ const content = await tailFile(runtimePaths.logPath, lines);
137
+ process.stdout.write(content || 'No runtime log file found.\n');
138
+ return;
139
+ }
140
+ if (operation === 'ps') {
141
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
142
+ const runStateStore = new RunStateStore(config.storage.dir);
143
+ const runs = options.all ? await runStateStore.listRuns() : await runStateStore.listActiveRuns();
144
+ console.log(JSON.stringify(runs, null, 2));
145
+ return;
146
+ }
147
+ throw new Error(`Unknown serve operation: ${operation}`);
148
+ }
149
+ const { config, sources } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
150
+ logger.info({ sources, transport: config.feishu.transport }, 'Loaded bridge config');
151
+ const readiness = new ServiceReadinessProbe(config.service.name);
152
+ readiness.markStarting(config.feishu.transport, { transport: config.feishu.transport });
153
+ let findings = [];
154
+ if (!options.skipDoctor) {
155
+ findings = await runDoctor(config);
156
+ readiness.recordDoctorFindings(findings);
157
+ for (const finding of findings) {
158
+ const level = finding.level === 'error' ? 'error' : finding.level;
159
+ logger[level]({ finding: finding.message }, 'Startup preflight');
160
+ }
161
+ if (hasDoctorErrors(findings)) {
162
+ readiness.markDegraded('Doctor failed with blocking errors.');
163
+ throw new Error('Doctor failed with blocking errors. Run `feique doctor` to inspect the config.');
164
+ }
165
+ }
166
+ if (options.detach) {
167
+ const detached = await detachServeProcess({
168
+ config,
169
+ configPath: options.config,
170
+ cwd: process.cwd(),
171
+ });
172
+ console.log(`Detached bridge started: pid=${detached.pid}`);
173
+ console.log(`Log file: ${detached.logPath}`);
174
+ console.log(`PID file: ${detached.pidPath}`);
175
+ return;
176
+ }
177
+ const sessionStore = new SessionStore(config.storage.dir);
178
+ const auditLog = new AuditLog(config.storage.dir);
179
+ const metrics = new MetricsRegistry();
180
+ const embeddingProvider = createEmbeddingProvider(config);
181
+ if (config.embedding.provider === 'ollama' && config.embedding.ollama_model === 'auto') {
182
+ logger.info({ provider: 'ollama', model: 'auto' }, 'Embedding provider initialized (auto-detection will happen on first use)');
183
+ }
184
+ else if (config.embedding.provider === 'local') {
185
+ logger.info({ provider: 'local' }, 'Local embedding provider active');
186
+ }
187
+ else {
188
+ logger.info({ provider: config.embedding.provider, model: config.embedding.ollama_model }, 'Embedding provider initialized');
189
+ }
190
+ const instanceLock = await acquireInstanceLock({
191
+ storageDir: config.storage.dir,
192
+ serviceName: config.service.name,
193
+ transport: config.feishu.transport,
194
+ });
195
+ const feishuClient = new FeishuClient(config.feishu, logger, metrics);
196
+ const mutableConfigPath = options.config ? path.resolve(options.config) : sources[0];
197
+ const memoryStore = new MemoryStore(config.storage.dir, embeddingProvider);
198
+ const service = new FeiqueService(config, feishuClient, sessionStore, auditLog, logger, metrics, undefined, undefined, memoryStore, undefined, {
199
+ configPath: mutableConfigPath,
200
+ restart: async () => {
201
+ const detached = await detachServeProcess({
202
+ config,
203
+ ...(mutableConfigPath ? { configPath: mutableConfigPath } : {}),
204
+ cwd: process.cwd(),
205
+ });
206
+ logger.warn({ newPid: detached.pid, configPath: mutableConfigPath }, 'Restarted bridge from admin command');
207
+ setTimeout(() => {
208
+ process.kill(process.pid, 'SIGTERM');
209
+ }, 200);
210
+ },
211
+ });
212
+ const recoveredRuns = await service.recoverRuntimeState();
213
+ await service.runMaintenanceCycle();
214
+ service.startMaintenanceLoop();
215
+ const dashboardRunStateStore = new RunStateStore(config.storage.dir);
216
+ const dashboardTrustStore = new TrustStore(config.storage.dir);
217
+ const dashboardHandoffStore = new HandoffStore(config.storage.dir);
218
+ const metricsServer = config.service.metrics_port !== undefined
219
+ ? await startMetricsServer({
220
+ host: config.service.metrics_host,
221
+ port: config.service.metrics_port,
222
+ serviceName: config.service.name,
223
+ logger,
224
+ metrics,
225
+ readiness,
226
+ runStateStore: dashboardRunStateStore,
227
+ trustStore: dashboardTrustStore,
228
+ handoffStore: dashboardHandoffStore,
229
+ auditLog,
230
+ })
231
+ : undefined;
232
+ const runtimePaths = getRuntimePaths(config);
233
+ try {
234
+ await fs.mkdir(config.storage.dir, { recursive: true });
235
+ await fs.writeFile(runtimePaths.pidPath, `${process.pid}\n`, 'utf8');
236
+ await auditLog.append({ type: 'service.start', transport: config.feishu.transport, sources, lock_path: instanceLock.lockPath });
237
+ for (const recovered of recoveredRuns) {
238
+ logger.warn({ runId: recovered.run_id, status: recovered.status, pid: recovered.pid }, 'Recovered run state on startup');
239
+ }
240
+ let stopSignal;
241
+ try {
242
+ if (config.feishu.transport === 'long-connection') {
243
+ stopSignal = await startLongConnectionBridge({ config, service, feishuClient, logger, readiness });
244
+ return;
245
+ }
246
+ stopSignal = await startWebhookBridge({ config, service, logger, readiness });
247
+ }
248
+ catch (error) {
249
+ readiness.markDegraded(error instanceof Error ? error.message : String(error), { transport: config.feishu.transport });
250
+ throw error;
251
+ }
252
+ finally {
253
+ await auditLog.append({ type: 'service.stop', transport: config.feishu.transport, signal: stopSignal ?? 'unknown' });
254
+ }
255
+ }
256
+ finally {
257
+ readiness.markStopped({ transport: config.feishu.transport });
258
+ service.stopMaintenanceLoop();
259
+ if (metricsServer) {
260
+ await metricsServer.close();
261
+ }
262
+ await instanceLock.release();
263
+ await fs.rm(runtimePaths.pidPath, { force: true });
264
+ }
265
+ });
266
+ program
267
+ .command('start')
268
+ .description('Start the bridge in the background')
269
+ .option('--config <path>', 'config path override')
270
+ .action(async (options) => {
271
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
272
+ const runtimeStatus = await inspectRuntimeStatus(config);
273
+ if (runtimeStatus.pid && runtimeStatus.running) {
274
+ console.log(`Bridge is already running: pid=${runtimeStatus.pid}`);
275
+ return;
276
+ }
277
+ const detached = await detachServeProcess({
278
+ config,
279
+ configPath: options.config,
280
+ cwd: process.cwd(),
281
+ });
282
+ console.log(`Started bridge: pid=${detached.pid}`);
283
+ console.log(`Log file: ${detached.logPath}`);
284
+ console.log(`PID file: ${detached.pidPath}`);
285
+ });
286
+ program
287
+ .command('stop')
288
+ .description('Stop the bridge')
289
+ .option('--config <path>', 'config path override')
290
+ .option('--force', 'use SIGKILL if SIGTERM does not stop the process in time', false)
291
+ .option('--wait-ms <number>', 'grace period before forcing stop', '5000')
292
+ .action(async (options) => {
293
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
294
+ const runtimeStatus = await inspectRuntimeStatus(config);
295
+ if (!runtimeStatus.pid || !runtimeStatus.running) {
296
+ console.log('Bridge is not running.');
297
+ return;
298
+ }
299
+ const stopped = await stopRuntimeProcess(runtimeStatus.pid, Number(options.waitMs), options.force);
300
+ if (!stopped) {
301
+ throw new Error(`Failed to stop bridge pid ${runtimeStatus.pid}`);
302
+ }
303
+ await fs.rm(runtimeStatus.pidPath, { force: true });
304
+ console.log(`Stopped bridge pid ${runtimeStatus.pid}`);
305
+ });
306
+ program
307
+ .command('restart')
308
+ .description('Restart the bridge in the background')
309
+ .option('--config <path>', 'config path override')
310
+ .option('--force', 'use SIGKILL if SIGTERM does not stop the process in time', false)
311
+ .option('--wait-ms <number>', 'grace period before forcing stop', '5000')
312
+ .action(async (options) => {
313
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
314
+ const runtimeStatus = await inspectRuntimeStatus(config);
315
+ if (runtimeStatus.pid && runtimeStatus.running) {
316
+ const stopped = await stopRuntimeProcess(runtimeStatus.pid, Number(options.waitMs), options.force);
317
+ if (!stopped) {
318
+ throw new Error(`Failed to stop bridge pid ${runtimeStatus.pid}`);
319
+ }
320
+ await fs.rm(runtimeStatus.pidPath, { force: true });
321
+ }
322
+ const detached = await detachServeProcess({
323
+ config,
324
+ configPath: options.config,
325
+ cwd: process.cwd(),
326
+ });
327
+ console.log(`Restarted bridge: pid=${detached.pid}`);
328
+ console.log(`Log file: ${detached.logPath}`);
329
+ console.log(`PID file: ${detached.pidPath}`);
330
+ });
331
+ program
332
+ .command('status')
333
+ .description('Print the current bridge runtime status')
334
+ .option('--config <path>', 'config path override')
335
+ .option('--json', 'print status as JSON', false)
336
+ .action(async (options) => {
337
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
338
+ const runtimeStatus = await inspectRuntimeStatus(config);
339
+ if (options.json) {
340
+ console.log(JSON.stringify(runtimeStatus, null, 2));
341
+ return;
342
+ }
343
+ console.log(`service: ${config.service.name}`);
344
+ console.log(`running: ${runtimeStatus.running}`);
345
+ console.log(`pid: ${runtimeStatus.pid ?? '-'}`);
346
+ console.log(`pid_file: ${runtimeStatus.pidPath}`);
347
+ console.log(`log_file: ${runtimeStatus.logPath}`);
348
+ console.log(`active_runs: ${runtimeStatus.activeRuns}`);
349
+ });
350
+ program
351
+ .command('logs')
352
+ .description('Tail bridge logs')
353
+ .option('--config <path>', 'config path override')
354
+ .option('--lines <number>', 'number of lines to print')
355
+ .option('--rotate', 'rotate managed logs before printing', false)
356
+ .option('--follow', 'follow appended log output', false)
357
+ .action(async (options) => {
358
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
359
+ const runtimePaths = getRuntimePaths(config);
360
+ const lines = Number(options.lines ?? config.service.log_tail_lines);
361
+ if (options.rotate) {
362
+ const rotated = await rotateManagedLogs(config, { force: true });
363
+ process.stdout.write(rotated.length > 0 ? `Rotated logs:\n${rotated.map((file) => `- ${file}`).join('\n')}\n` : 'No logs rotated.\n');
364
+ }
365
+ if (options.follow) {
366
+ await followFile(runtimePaths.logPath, lines);
367
+ return;
368
+ }
369
+ const content = await tailFile(runtimePaths.logPath, lines);
370
+ process.stdout.write(content || 'No runtime log file found.\n');
371
+ });
372
+ program
373
+ .command('ps')
374
+ .description('Print current run states')
375
+ .option('--config <path>', 'config path override')
376
+ .option('--all', 'show all runs instead of only active runs', false)
377
+ .action(async (options) => {
378
+ const { config } = await loadRuntimeConfig({ cwd: process.cwd(), configPath: options.config });
379
+ const runStateStore = new RunStateStore(config.storage.dir);
380
+ const runs = options.all ? await runStateStore.listRuns() : await runStateStore.listActiveRuns();
381
+ console.log(JSON.stringify(runs, null, 2));
382
+ });
383
+ program
384
+ .command('doctor')
385
+ .description('Validate runtime prerequisites and config quality')
386
+ .option('--config <path>', 'config path override')
387
+ .option('--remote', 'run remote Feishu availability checks', false)
388
+ .option('--fix', 'apply safe local fixes such as creating storage directories and rotating oversized logs', false)
389
+ .option('--json', 'print findings as JSON', false)
390
+ .action(async (options) => {
391
+ try {
392
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
393
+ if (options.fix) {
394
+ const fixes = await applySafeDoctorFixes(config);
395
+ if (fixes.length > 0 && !options.json) {
396
+ for (const fix of fixes) {
397
+ console.log(`[fix] ${fix}`);
398
+ }
399
+ }
400
+ }
401
+ const findings = await runDoctor(config);
402
+ if (options.remote) {
403
+ findings.push(...(await runRemoteDoctor(config)));
404
+ }
405
+ printDoctorFindings(findings, options.json);
406
+ if (hasDoctorErrors(findings)) {
407
+ process.exitCode = 1;
408
+ }
409
+ return;
410
+ }
411
+ catch (error) {
412
+ const configPaths = await collectDoctorConfigPaths(process.cwd(), options.config);
413
+ const findings = await findMissingEnvRefs(configPaths);
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ findings.push({ level: 'error', message });
416
+ printDoctorFindings(findings, options.json);
417
+ process.exitCode = 1;
418
+ }
419
+ });
420
+ program
421
+ .command('bind <alias> <root>')
422
+ .description('Add or update a project alias in the config')
423
+ .option('--config <path>', 'config path override')
424
+ .option('--profile <profile>', 'Codex profile for this project')
425
+ .option('--sandbox <sandbox>', 'Sandbox override for this project')
426
+ .action(async (alias, root, options) => {
427
+ const projectConfigPath = options.config ? null : await findNearestProjectConfig(process.cwd());
428
+ const configPath = options.config
429
+ ? path.resolve(options.config)
430
+ : projectConfigPath ?? getGlobalConfigPath();
431
+ if (!(await fileExists(configPath))) {
432
+ throw new Error(`Config file not found: ${configPath}`);
433
+ }
434
+ await bindProjectAlias({
435
+ configPath,
436
+ alias,
437
+ root,
438
+ profile: options.profile,
439
+ sandbox: options.sandbox,
440
+ });
441
+ console.log(`Bound ${alias} -> ${path.resolve(expandHomePath(root))} in ${configPath}`);
442
+ });
443
+ program
444
+ .command('create-project <alias> <root>')
445
+ .description('Create a project directory and bind it as a new project alias')
446
+ .option('--config <path>', 'config path override')
447
+ .option('--profile <profile>', 'Codex profile for this project')
448
+ .option('--sandbox <sandbox>', 'Sandbox override for this project')
449
+ .action(async (alias, root, options) => {
450
+ const projectConfigPath = options.config ? null : await findNearestProjectConfig(process.cwd());
451
+ const configPath = options.config
452
+ ? path.resolve(options.config)
453
+ : projectConfigPath ?? getGlobalConfigPath();
454
+ if (!(await fileExists(configPath))) {
455
+ throw new Error(`Config file not found: ${configPath}`);
456
+ }
457
+ const created = await createProjectAlias({
458
+ configPath,
459
+ alias,
460
+ root,
461
+ profile: options.profile,
462
+ sandbox: options.sandbox,
463
+ });
464
+ console.log(`Created ${alias} -> ${created.root} in ${configPath}`);
465
+ });
466
+ program
467
+ .command('upgrade')
468
+ .description('Check or install the latest npm release of feique')
469
+ .option('--check', 'only print the latest available version', false)
470
+ .option('--yes', 'install the latest release immediately', false)
471
+ .action(async (options) => {
472
+ const latest = await fetchLatestPublishedVersion();
473
+ console.log(`current: ${packageJson.version}`);
474
+ console.log(`latest: ${latest}`);
475
+ if (options.check || !options.yes) {
476
+ if (!options.check) {
477
+ console.log('Re-run with `feique upgrade --yes` to install the latest npm release globally.');
478
+ }
479
+ return;
480
+ }
481
+ await installLatestPublishedVersion();
482
+ console.log(`Upgraded feique to ${latest}`);
483
+ });
484
+ program
485
+ .command('mcp')
486
+ .description('Run an MCP server for external tools such as OpenClaw')
487
+ .option('--config <path>', 'config path override')
488
+ .option('--transport <transport>', 'stdio or http')
489
+ .option('--host <host>', 'HTTP bind host')
490
+ .option('--port <number>', 'HTTP bind port')
491
+ .option('--path <path>', 'HTTP JSON-RPC path')
492
+ .option('--sse-path <path>', 'HTTP SSE path')
493
+ .option('--message-path <path>', 'HTTP SSE message POST path')
494
+ .option('--auth-token <token>', 'HTTP Bearer token')
495
+ .option('--auth-token-id <id>', 'logical token id used with --auth-token')
496
+ .action(async (options) => {
497
+ await startMcpServer({
498
+ cwd: process.cwd(),
499
+ configPath: options.config,
500
+ transport: options.transport,
501
+ host: options.host,
502
+ port: options.port ? Number(options.port) : undefined,
503
+ path: options.path,
504
+ ssePath: options.ssePath,
505
+ messagePath: options.messagePath,
506
+ authToken: options.authToken,
507
+ authTokenId: options.authTokenId,
508
+ });
509
+ });
510
+ const sessionsCommand = program.command('sessions').description('Inspect persisted session state');
511
+ sessionsCommand
512
+ .command('list')
513
+ .option('--config <path>', 'config path override')
514
+ .action(async (options) => {
515
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
516
+ const sessionStore = new SessionStore(config.storage.dir);
517
+ const conversations = await sessionStore.listConversations();
518
+ if (conversations.length === 0) {
519
+ console.log('No sessions found.');
520
+ return;
521
+ }
522
+ for (const [conversationKey, conversation] of conversations) {
523
+ console.log(`${conversationKey}`);
524
+ console.log(` selected_project: ${conversation.selected_project_alias ?? '-'}`);
525
+ for (const [projectAlias, session] of Object.entries(conversation.projects)) {
526
+ console.log(` - ${projectAlias}: ${session.thread_id ?? 'no-thread'} (${session.updated_at})`);
527
+ }
528
+ }
529
+ });
530
+ sessionsCommand
531
+ .command('clear <conversationKey>')
532
+ .option('--config <path>', 'config path override')
533
+ .action(async (conversationKey, options) => {
534
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
535
+ const sessionStore = new SessionStore(config.storage.dir);
536
+ await sessionStore.clearConversation(conversationKey);
537
+ console.log(`Cleared ${conversationKey}`);
538
+ });
539
+ const codexCommand = program.command('codex').description('Codex-side helpers');
540
+ codexCommand
541
+ .command('install-skill')
542
+ .description('Install the bundled Codex skill into ~/.codex/skills and enable it in ~/.codex/config.toml')
543
+ .option('--name <name>', 'target skill name', 'feique-session')
544
+ .action(async (options) => {
545
+ const skillSourceDir = path.resolve(process.cwd(), 'skills', 'feique-session');
546
+ if (!(await fileExists(skillSourceDir))) {
547
+ throw new Error(`Bundled skill not found: ${skillSourceDir}`);
548
+ }
549
+ const result = await installBundledCodexSkill({ skillSourceDir, skillName: options.name });
550
+ console.log(`Installed skill to ${result.skillPath}`);
551
+ console.log(`Updated Codex config ${result.configPath}`);
552
+ });
553
+ const feishuCommand = program.command('feishu').description('Feishu-side diagnostics and manual checks');
554
+ feishuCommand
555
+ .command('inspect')
556
+ .description('Inspect app/bot/IM availability using the configured Feishu credentials')
557
+ .option('--config <path>', 'config path override')
558
+ .option('--json', 'print raw JSON result', false)
559
+ .action(async (options) => {
560
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
561
+ const result = await inspectFeishuEnvironment(config.feishu);
562
+ if (options.json) {
563
+ console.log(JSON.stringify(result, null, 2));
564
+ if (!result.token.ok || !result.app.ok || !result.bot.ok || !result.chats_probe.ok) {
565
+ process.exitCode = 1;
566
+ }
567
+ return;
568
+ }
569
+ console.log(formatFeishuInspect(result));
570
+ if (!result.token.ok || !result.app.ok || !result.bot.ok || !result.chats_probe.ok) {
571
+ process.exitCode = 1;
572
+ }
573
+ });
574
+ feishuCommand
575
+ .command('send-test')
576
+ .description('Send a real Feishu text message to a specific receive_id')
577
+ .option('--config <path>', 'config path override')
578
+ .requiredOption('--receive-id-type <type>', 'chat_id | open_id | user_id | union_id | email')
579
+ .requiredOption('--receive-id <id>', 'target receive_id')
580
+ .option('--text <text>', 'message text', 'feique send-test')
581
+ .action(async (options) => {
582
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
583
+ const client = new FeishuClient(config.feishu, logger);
584
+ const response = await client.sendTextToReceiveId(options.receiveIdType, options.receiveId, options.text);
585
+ console.log(JSON.stringify(response, null, 2));
586
+ });
587
+ const webhookCommand = program.command('webhook').description('Webhook replay helpers for local and staging E2E');
588
+ webhookCommand
589
+ .command('replay-message')
590
+ .description('Replay a receive-message event into a local webhook endpoint')
591
+ .requiredOption('--url <url>', 'target event webhook URL')
592
+ .requiredOption('--chat-id <id>', 'chat_id')
593
+ .requiredOption('--actor-id <id>', 'sender open_id')
594
+ .option('--text <text>', 'message text', 'hello from feique replay')
595
+ .option('--chat-type <type>', 'p2p or group', 'p2p')
596
+ .option('--tenant-key <key>', 'tenant key', 'tenant-local')
597
+ .action(async (options) => {
598
+ const payload = buildReplayMessageEvent({
599
+ chatId: options.chatId,
600
+ actorId: options.actorId,
601
+ text: options.text,
602
+ chatType: options.chatType,
603
+ tenantKey: options.tenantKey,
604
+ });
605
+ const response = await postWebhookPayload({ url: options.url, payload });
606
+ console.log(JSON.stringify(response, null, 2));
607
+ });
608
+ webhookCommand
609
+ .command('replay-card')
610
+ .description('Replay an interactive card callback into a local webhook endpoint')
611
+ .requiredOption('--url <url>', 'target card callback URL')
612
+ .requiredOption('--chat-id <id>', 'chat_id')
613
+ .requiredOption('--actor-id <id>', 'operator open_id')
614
+ .requiredOption('--open-message-id <id>', 'open_message_id')
615
+ .option('--action <action>', 'status | rerun | new', 'status')
616
+ .option('--project-alias <alias>', 'project alias')
617
+ .option('--conversation-key <key>', 'conversation key')
618
+ .option('--tenant-key <key>', 'tenant key', 'tenant-local')
619
+ .action(async (options) => {
620
+ const payload = buildReplayCardAction({
621
+ chatId: options.chatId,
622
+ actorId: options.actorId,
623
+ openMessageId: options.openMessageId,
624
+ action: options.action,
625
+ tenantKey: options.tenantKey,
626
+ projectAlias: options.projectAlias,
627
+ conversationKey: options.conversationKey,
628
+ });
629
+ const response = await postWebhookPayload({ url: options.url, payload });
630
+ console.log(JSON.stringify(response, null, 2));
631
+ });
632
+ webhookCommand
633
+ .command('smoke')
634
+ .description('Run a smoke test against healthz, event, and optional card callback')
635
+ .requiredOption('--base-url <url>', 'base URL, for example http://127.0.0.1:3333')
636
+ .option('--event-path <path>', 'event webhook path', '/webhook/event')
637
+ .option('--card-path <path>', 'card callback path', '/webhook/card')
638
+ .option('--chat-id <id>', 'chat_id', 'oc_smoke')
639
+ .option('--actor-id <id>', 'sender/operator open_id', 'ou_smoke')
640
+ .option('--tenant-key <key>', 'tenant key', 'tenant-local')
641
+ .option('--project-alias <alias>', 'project alias for the card probe', 'default')
642
+ .option('--message-text <text>', 'message text used during smoke', '/help')
643
+ .option('--skip-card', 'skip the card callback probe', false)
644
+ .option('--timeout-ms <number>', 'request timeout in milliseconds', '5000')
645
+ .action(async (options) => {
646
+ const timeoutMs = Number(options.timeoutMs);
647
+ const baseUrl = ensureTrailingSlash(options.baseUrl);
648
+ const healthUrl = new URL('healthz', baseUrl).toString();
649
+ const eventUrl = new URL(trimLeadingSlash(options.eventPath), baseUrl).toString();
650
+ const cardUrl = new URL(trimLeadingSlash(options.cardPath), baseUrl).toString();
651
+ const conversationKey = `${options.tenantKey}/${options.chatId}/${options.actorId}`;
652
+ const health = await requestWebhookEndpoint({ url: healthUrl, method: 'GET', timeoutMs });
653
+ const message = await postWebhookPayload({
654
+ url: eventUrl,
655
+ timeoutMs,
656
+ payload: buildReplayMessageEvent({
657
+ chatId: options.chatId,
658
+ actorId: options.actorId,
659
+ chatType: 'p2p',
660
+ text: options.messageText,
661
+ tenantKey: options.tenantKey,
662
+ }),
663
+ });
664
+ const card = options.skipCard
665
+ ? undefined
666
+ : await postWebhookPayload({
667
+ url: cardUrl,
668
+ timeoutMs,
669
+ payload: buildReplayCardAction({
670
+ chatId: options.chatId,
671
+ actorId: options.actorId,
672
+ openMessageId: 'om_smoke',
673
+ action: 'status',
674
+ tenantKey: options.tenantKey,
675
+ projectAlias: options.projectAlias,
676
+ conversationKey,
677
+ }),
678
+ });
679
+ const summary = {
680
+ ok: health.statusCode === 200 && message.statusCode === 200 && (options.skipCard || card?.statusCode === 200),
681
+ health,
682
+ message,
683
+ ...(card ? { card } : {}),
684
+ };
685
+ console.log(JSON.stringify(summary, null, 2));
686
+ if (!summary.ok) {
687
+ process.exitCode = 1;
688
+ }
689
+ });
690
+ const auditCommand = program.command('audit').description('Inspect structured audit events');
691
+ auditCommand
692
+ .command('tail')
693
+ .description('Print the latest audit events as JSON')
694
+ .option('--config <path>', 'config path override')
695
+ .option('--limit <number>', 'number of events', '20')
696
+ .option('--admin', 'tail admin audit log instead of the main audit log', false)
697
+ .option('--project <alias>', 'tail one project audit log')
698
+ .action(async (options) => {
699
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
700
+ const auditLog = new AuditLog(resolveAuditLogDir(config, options.project), resolveAuditLogFileName(options));
701
+ const events = await auditLog.tail(Number(options.limit));
702
+ console.log(JSON.stringify(events, null, 2));
703
+ });
704
+ auditCommand
705
+ .command('cleanup')
706
+ .description('Archive and prune audit logs using configured or explicit retention settings')
707
+ .option('--config <path>', 'config path override')
708
+ .option('--retention-days <number>', 'drop events older than this many days')
709
+ .option('--archive-after-days <number>', 'archive events older than this many days before retention applies')
710
+ .option('--admin', 'only clean the admin audit log', false)
711
+ .option('--project <alias>', 'only clean one project audit log')
712
+ .action(async (options) => {
713
+ const { config } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
714
+ const targets = listAuditCleanupTargets(config, options.project, options.admin);
715
+ const retentionDays = Number(options.retentionDays ?? config.service.audit_retention_days);
716
+ const archiveAfterDays = Number(options.archiveAfterDays ?? config.service.audit_archive_after_days);
717
+ const results = await Promise.all(targets.map((target) => new AuditLog(target.stateDir, target.fileName).cleanup({
718
+ retentionDays,
719
+ archiveAfterDays,
720
+ archiveDir: target.archiveDir,
721
+ })));
722
+ console.log(JSON.stringify(results, null, 2));
723
+ });
724
+ const serviceCommand = program.command('service').description('Install or inspect an OS user service definition');
725
+ serviceCommand
726
+ .command('print')
727
+ .description('Print the launchd/systemd service definition and helper commands')
728
+ .option('--config <path>', 'config path override')
729
+ .option('--service-name <name>', 'service name', 'feique')
730
+ .option('--working-dir <dir>', 'working directory', process.cwd())
731
+ .option('--log-dir <dir>', 'log directory')
732
+ .option('--platform <platform>', 'darwin or linux')
733
+ .action(async (options) => {
734
+ const descriptor = buildServiceDescriptor({
735
+ serviceName: options.serviceName,
736
+ cliScriptPath: path.resolve(process.argv[1] ?? 'dist/cli.js'),
737
+ nodeBinaryPath: process.execPath,
738
+ workingDirectory: path.resolve(options.workingDir),
739
+ ...(options.config ? { configPath: path.resolve(options.config) } : {}),
740
+ logDirectory: path.resolve(options.logDir ?? resolveDefaultLogDirectory()),
741
+ ...(options.platform ? { platform: options.platform } : {}),
742
+ });
743
+ console.log(`# Target path
744
+ ${descriptor.targetPath}
745
+ `);
746
+ console.log(`# Install
747
+ ${descriptor.installHint}
748
+ `);
749
+ console.log(`# Start
750
+ ${descriptor.startHint}
751
+ `);
752
+ console.log(`# Stop
753
+ ${descriptor.stopHint}
754
+ `);
755
+ console.log(`# Status
756
+ ${descriptor.statusHint}
757
+ `);
758
+ console.log(`# Uninstall
759
+ ${descriptor.uninstallHint}
760
+ `);
761
+ console.log(descriptor.content);
762
+ });
763
+ serviceCommand
764
+ .command('install')
765
+ .description('Write a launchd/systemd user service file for the bridge')
766
+ .option('--config <path>', 'config path override')
767
+ .option('--service-name <name>', 'service name', 'feique')
768
+ .option('--working-dir <dir>', 'working directory', process.cwd())
769
+ .option('--log-dir <dir>', 'log directory')
770
+ .option('--platform <platform>', 'darwin or linux')
771
+ .action(async (options) => {
772
+ const descriptor = await installServiceFile({
773
+ serviceName: options.serviceName,
774
+ cliScriptPath: path.resolve(process.argv[1] ?? 'dist/cli.js'),
775
+ nodeBinaryPath: process.execPath,
776
+ workingDirectory: path.resolve(options.workingDir),
777
+ ...(options.config ? { configPath: path.resolve(options.config) } : {}),
778
+ ...(options.logDir ? { logDirectory: path.resolve(options.logDir) } : {}),
779
+ ...(options.platform ? { platform: options.platform } : {}),
780
+ });
781
+ console.log(`Wrote ${descriptor.targetPath}`);
782
+ console.log(`Install: ${descriptor.installHint}`);
783
+ console.log(`Start: ${descriptor.startHint}`);
784
+ console.log(`Stop: ${descriptor.stopHint}`);
785
+ console.log(`Status: ${descriptor.statusHint}`);
786
+ });
787
+ serviceCommand
788
+ .command('uninstall')
789
+ .description('Remove the generated launchd/systemd user service file')
790
+ .option('--service-name <name>', 'service name', 'feique')
791
+ .option('--platform <platform>', 'darwin or linux')
792
+ .action(async (options) => {
793
+ const result = await uninstallServiceFile({
794
+ serviceName: options.serviceName,
795
+ ...(options.platform ? { platform: options.platform } : {}),
796
+ });
797
+ console.log(result.removed ? `Removed ${result.targetPath}` : `No service file at ${result.targetPath}`);
798
+ });
799
+ program
800
+ .command('print-config')
801
+ .description('Print the merged effective config as JSON')
802
+ .option('--config <path>', 'config path override')
803
+ .action(async (options) => {
804
+ const { config, sources } = await loadBridgeConfig({ cwd: process.cwd(), configPath: options.config });
805
+ const printable = structuredClone(config);
806
+ if (typeof printable.feishu === 'object' && printable.feishu) {
807
+ printable.feishu.app_secret = '<redacted>';
808
+ if (printable.feishu.encrypt_key) {
809
+ printable.feishu.encrypt_key = '<redacted>';
810
+ }
811
+ if (printable.feishu.verification_token) {
812
+ printable.feishu.verification_token = '<redacted>';
813
+ }
814
+ }
815
+ if (typeof printable.mcp === 'object' && printable.mcp) {
816
+ if (printable.mcp.auth_token) {
817
+ printable.mcp.auth_token = '<redacted>';
818
+ }
819
+ if (Array.isArray(printable.mcp.auth_tokens)) {
820
+ printable.mcp.auth_tokens = printable.mcp.auth_tokens.map((token) => ({
821
+ ...token,
822
+ token: '<redacted>',
823
+ }));
824
+ }
825
+ }
826
+ console.log(JSON.stringify({ sources, config: printable }, null, 2));
827
+ });
828
+ async function collectDoctorConfigPaths(cwd, explicitConfigPath) {
829
+ const paths = new Set();
830
+ if (explicitConfigPath) {
831
+ paths.add(path.resolve(explicitConfigPath));
832
+ }
833
+ else {
834
+ paths.add(getGlobalConfigPath());
835
+ const projectPath = await findNearestProjectConfig(cwd);
836
+ if (projectPath) {
837
+ paths.add(projectPath);
838
+ }
839
+ }
840
+ return Array.from(paths);
841
+ }
842
+ function printDoctorFindings(findings, asJson) {
843
+ if (asJson) {
844
+ console.log(JSON.stringify(findings, null, 2));
845
+ return;
846
+ }
847
+ for (const finding of findings) {
848
+ console.log(formatDoctorFinding(finding));
849
+ }
850
+ }
851
+ function ensureTrailingSlash(input) {
852
+ return input.endsWith('/') ? input : `${input}/`;
853
+ }
854
+ function trimLeadingSlash(input) {
855
+ return input.startsWith('/') ? input.slice(1) : input;
856
+ }
857
+ function getRuntimePaths(config) {
858
+ return {
859
+ pidPath: path.join(config.storage.dir, `${config.service.name}.pid`),
860
+ logPath: path.join(config.storage.dir, `${config.service.name}.log`),
861
+ };
862
+ }
863
+ function getManagedLogPaths(config) {
864
+ const runtimePaths = getRuntimePaths(config);
865
+ return [
866
+ runtimePaths.logPath,
867
+ path.join(config.storage.dir, 'audit.jsonl'),
868
+ path.join(config.storage.dir, 'admin-audit.jsonl'),
869
+ ];
870
+ }
871
+ function resolveAuditLogDir(config, projectAlias) {
872
+ if (!projectAlias) {
873
+ return config.storage.dir;
874
+ }
875
+ const project = config.projects[projectAlias];
876
+ if (!project) {
877
+ throw new Error(`Unknown project alias: ${projectAlias}`);
878
+ }
879
+ return getProjectAuditDir(config.storage.dir, projectAlias, project);
880
+ }
881
+ function resolveAuditLogFileName(options) {
882
+ if (options.project) {
883
+ return 'project-audit.jsonl';
884
+ }
885
+ return options.admin ? 'admin-audit.jsonl' : 'audit.jsonl';
886
+ }
887
+ function listAuditCleanupTargets(config, projectAlias, adminOnly = false) {
888
+ if (projectAlias) {
889
+ const project = config.projects[projectAlias];
890
+ if (!project) {
891
+ throw new Error(`Unknown project alias: ${projectAlias}`);
892
+ }
893
+ return [
894
+ {
895
+ stateDir: getProjectAuditDir(config.storage.dir, projectAlias, project),
896
+ fileName: 'project-audit.jsonl',
897
+ archiveDir: getProjectArchiveDir(config.storage.dir, projectAlias),
898
+ },
899
+ ];
900
+ }
901
+ const targets = [
902
+ {
903
+ stateDir: config.storage.dir,
904
+ fileName: adminOnly ? 'admin-audit.jsonl' : 'audit.jsonl',
905
+ archiveDir: path.join(config.storage.dir, 'archive'),
906
+ },
907
+ ];
908
+ if (!adminOnly) {
909
+ targets.push({
910
+ stateDir: config.storage.dir,
911
+ fileName: 'admin-audit.jsonl',
912
+ archiveDir: path.join(config.storage.dir, 'archive'),
913
+ });
914
+ for (const [alias, project] of Object.entries(config.projects)) {
915
+ targets.push({
916
+ stateDir: getProjectAuditDir(config.storage.dir, alias, project),
917
+ fileName: 'project-audit.jsonl',
918
+ archiveDir: getProjectArchiveDir(config.storage.dir, alias),
919
+ });
920
+ }
921
+ }
922
+ return targets;
923
+ }
924
+ async function inspectRuntimeStatus(config) {
925
+ const runtimePaths = getRuntimePaths(config);
926
+ const pid = await readPid(runtimePaths.pidPath);
927
+ const runStateStore = new RunStateStore(config.storage.dir);
928
+ const activeRuns = (await runStateStore.listActiveRuns()).length;
929
+ return {
930
+ running: pid !== null && isProcessAlive(pid),
931
+ ...(pid !== null ? { pid } : {}),
932
+ pidPath: runtimePaths.pidPath,
933
+ logPath: runtimePaths.logPath,
934
+ activeRuns,
935
+ };
936
+ }
937
+ async function readPid(filePath) {
938
+ try {
939
+ const raw = (await fs.readFile(filePath, 'utf8')).trim();
940
+ const pid = Number(raw);
941
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
942
+ }
943
+ catch {
944
+ return null;
945
+ }
946
+ }
947
+ async function stopRuntimeProcess(pid, waitMs, force) {
948
+ if (!terminateProcess(pid, 'SIGTERM')) {
949
+ return !isProcessAlive(pid);
950
+ }
951
+ const deadline = Date.now() + waitMs;
952
+ while (Date.now() < deadline) {
953
+ if (!isProcessAlive(pid)) {
954
+ return true;
955
+ }
956
+ await sleep(100);
957
+ }
958
+ if (!force) {
959
+ return !isProcessAlive(pid);
960
+ }
961
+ terminateProcess(pid, 'SIGKILL');
962
+ const forceDeadline = Date.now() + 2000;
963
+ while (Date.now() < forceDeadline) {
964
+ if (!isProcessAlive(pid)) {
965
+ return true;
966
+ }
967
+ await sleep(100);
968
+ }
969
+ return !isProcessAlive(pid);
970
+ }
971
+ async function tailFile(filePath, lines) {
972
+ try {
973
+ const content = await fs.readFile(filePath, 'utf8');
974
+ const sliced = content.split(/\r?\n/).filter(Boolean).slice(-lines).join('\n');
975
+ return sliced ? `${sliced}\n` : '';
976
+ }
977
+ catch {
978
+ return '';
979
+ }
980
+ }
981
+ async function rotateManagedLogs(config, options = {}) {
982
+ await fs.mkdir(config.storage.dir, { recursive: true });
983
+ const rotated = [];
984
+ for (const filePath of getManagedLogPaths(config)) {
985
+ const rotatedFile = await rotateFileIfNeeded(filePath, config.service.log_rotate_max_bytes, config.service.log_rotate_keep_files, options.force === true);
986
+ if (rotatedFile) {
987
+ rotated.push(rotatedFile);
988
+ }
989
+ }
990
+ return rotated;
991
+ }
992
+ async function rotateFileIfNeeded(filePath, maxBytes, keepFiles, force) {
993
+ const size = await getFileSize(filePath);
994
+ if (size === 0 || (!force && size < maxBytes)) {
995
+ return null;
996
+ }
997
+ for (let index = keepFiles; index >= 1; index -= 1) {
998
+ const current = `${filePath}.${index}`;
999
+ const next = `${filePath}.${index + 1}`;
1000
+ if (index === keepFiles && (await fileExists(current))) {
1001
+ await fs.rm(current, { force: true });
1002
+ continue;
1003
+ }
1004
+ if (await fileExists(current)) {
1005
+ await fs.rename(current, next);
1006
+ }
1007
+ }
1008
+ await fs.rename(filePath, `${filePath}.1`);
1009
+ return `${filePath}.1`;
1010
+ }
1011
+ async function followFile(filePath, lines) {
1012
+ const initial = await tailFile(filePath, lines);
1013
+ if (initial) {
1014
+ process.stdout.write(initial);
1015
+ }
1016
+ else {
1017
+ process.stdout.write('Waiting for runtime log output...\n');
1018
+ }
1019
+ let offset = await getFileSize(filePath);
1020
+ await new Promise((resolve, reject) => {
1021
+ let closed = false;
1022
+ let polling = false;
1023
+ const stop = () => {
1024
+ if (closed) {
1025
+ return;
1026
+ }
1027
+ closed = true;
1028
+ clearInterval(timer);
1029
+ process.off('SIGINT', stop);
1030
+ process.off('SIGTERM', stop);
1031
+ resolve();
1032
+ };
1033
+ const timer = setInterval(() => {
1034
+ if (polling || closed) {
1035
+ return;
1036
+ }
1037
+ polling = true;
1038
+ void readAppendedContent()
1039
+ .then(() => {
1040
+ polling = false;
1041
+ })
1042
+ .catch((error) => {
1043
+ clearInterval(timer);
1044
+ process.off('SIGINT', stop);
1045
+ process.off('SIGTERM', stop);
1046
+ reject(error);
1047
+ });
1048
+ }, 500);
1049
+ process.on('SIGINT', stop);
1050
+ process.on('SIGTERM', stop);
1051
+ });
1052
+ async function readAppendedContent() {
1053
+ const size = await getFileSize(filePath);
1054
+ if (size < offset) {
1055
+ offset = 0;
1056
+ }
1057
+ if (size === offset) {
1058
+ return;
1059
+ }
1060
+ try {
1061
+ const handle = await fs.open(filePath, 'r');
1062
+ try {
1063
+ const length = size - offset;
1064
+ const buffer = Buffer.alloc(length);
1065
+ await handle.read(buffer, 0, length, offset);
1066
+ offset = size;
1067
+ process.stdout.write(buffer.toString('utf8'));
1068
+ }
1069
+ finally {
1070
+ await handle.close();
1071
+ }
1072
+ }
1073
+ catch (error) {
1074
+ if (error.code === 'ENOENT') {
1075
+ return;
1076
+ }
1077
+ throw error;
1078
+ }
1079
+ }
1080
+ }
1081
+ async function getFileSize(filePath) {
1082
+ try {
1083
+ const stat = await fs.stat(filePath);
1084
+ return stat.size;
1085
+ }
1086
+ catch (error) {
1087
+ if (error.code === 'ENOENT') {
1088
+ return 0;
1089
+ }
1090
+ throw error;
1091
+ }
1092
+ }
1093
+ async function sleep(ms) {
1094
+ await new Promise((resolve) => setTimeout(resolve, ms));
1095
+ }
1096
+ async function detachServeProcess(input) {
1097
+ await fs.mkdir(input.config.storage.dir, { recursive: true });
1098
+ await rotateManagedLogs(input.config);
1099
+ const logPath = path.join(input.config.storage.dir, `${input.config.service.name}.log`);
1100
+ const pidPath = path.join(input.config.storage.dir, `${input.config.service.name}.pid`);
1101
+ const stdoutFd = openSync(logPath, 'a');
1102
+ const stderrFd = openSync(logPath, 'a');
1103
+ try {
1104
+ const cliEntry = process.argv[1];
1105
+ if (!cliEntry) {
1106
+ throw new Error('Unable to determine CLI entry path for detached serve.');
1107
+ }
1108
+ const args = [...process.execArgv, cliEntry, 'serve', '--skip-doctor'];
1109
+ if (input.configPath) {
1110
+ args.push('--config', path.resolve(input.configPath));
1111
+ }
1112
+ const child = spawn(process.execPath, args, {
1113
+ cwd: input.cwd,
1114
+ detached: true,
1115
+ stdio: ['ignore', stdoutFd, stderrFd],
1116
+ env: {
1117
+ ...process.env,
1118
+ },
1119
+ });
1120
+ child.unref();
1121
+ await fs.writeFile(pidPath, `${child.pid}\n`, 'utf8');
1122
+ return {
1123
+ pid: child.pid ?? 0,
1124
+ logPath,
1125
+ pidPath,
1126
+ };
1127
+ }
1128
+ finally {
1129
+ closeSync(stdoutFd);
1130
+ closeSync(stderrFd);
1131
+ }
1132
+ }
1133
+ async function applySafeDoctorFixes(config) {
1134
+ const fixes = [];
1135
+ await fs.mkdir(config.storage.dir, { recursive: true });
1136
+ fixes.push(`ensured storage dir: ${config.storage.dir}`);
1137
+ const runtimeStatus = await inspectRuntimeStatus(config);
1138
+ if (!runtimeStatus.running && (await fileExists(runtimeStatus.pidPath))) {
1139
+ await fs.rm(runtimeStatus.pidPath, { force: true });
1140
+ fixes.push(`removed stale pid file: ${runtimeStatus.pidPath}`);
1141
+ }
1142
+ const rotated = await rotateManagedLogs(config);
1143
+ fixes.push(...rotated.map((file) => `rotated log: ${file}`));
1144
+ const cleanedAudits = await Promise.all(listAuditCleanupTargets(config).map((target) => new AuditLog(target.stateDir, target.fileName).cleanup({
1145
+ retentionDays: config.service.audit_retention_days,
1146
+ archiveAfterDays: config.service.audit_archive_after_days,
1147
+ archiveDir: target.archiveDir,
1148
+ })));
1149
+ for (const result of cleanedAudits) {
1150
+ if (result.archived > 0 || result.removed > 0) {
1151
+ fixes.push(`cleaned audit: ${result.filePath} (archived=${result.archived}, removed=${result.removed})`);
1152
+ }
1153
+ }
1154
+ return fixes;
1155
+ }
1156
+ async function fetchLatestPublishedVersion() {
1157
+ const stdout = await runChildForStdout('npm', ['view', 'feique', 'version']);
1158
+ return stdout.trim();
1159
+ }
1160
+ async function installLatestPublishedVersion() {
1161
+ await runChildForStdout('npm', ['install', '-g', 'feique@latest']);
1162
+ }
1163
+ async function runChildForStdout(command, args) {
1164
+ return new Promise((resolve, reject) => {
1165
+ const child = spawn(command, args, {
1166
+ env: { ...process.env },
1167
+ stdio: ['ignore', 'pipe', 'pipe'],
1168
+ });
1169
+ let stdout = '';
1170
+ let stderr = '';
1171
+ child.stdout.on('data', (chunk) => {
1172
+ stdout += String(chunk);
1173
+ });
1174
+ child.stderr.on('data', (chunk) => {
1175
+ stderr += String(chunk);
1176
+ });
1177
+ child.once('error', reject);
1178
+ child.once('close', (code) => {
1179
+ if (code === 0) {
1180
+ resolve(stdout);
1181
+ return;
1182
+ }
1183
+ reject(new Error(stderr.trim() || `${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`));
1184
+ });
1185
+ });
1186
+ }
1187
+ program.parseAsync(process.argv).catch(async (error) => {
1188
+ const message = error instanceof Error ? error.message : String(error);
1189
+ logger.error({ err: error }, 'Command failed');
1190
+ process.stderr.write(`${message}\n`);
1191
+ try {
1192
+ await fs.rm(path.join(process.cwd(), '.tmp'), { recursive: true, force: true });
1193
+ }
1194
+ catch {
1195
+ // ignore
1196
+ }
1197
+ process.exitCode = 1;
1198
+ });
1199
+ //# sourceMappingURL=cli.js.map