@lasting-beauty/chat-hub 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1794 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from 'node:child_process';
3
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import {
6
+ chmodSync,
7
+ closeSync,
8
+ existsSync,
9
+ mkdtempSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ renameSync,
15
+ rmSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { homedir, networkInterfaces, tmpdir } from 'node:os';
21
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
+ import { createServer, isIP } from 'node:net';
23
+ import {
24
+ alinuxCaddyRelease,
25
+ alinuxCaddySystemdUnit,
26
+ assertSupportedDeploymentHost,
27
+ createDeploymentPlan,
28
+ detectDeploymentHost,
29
+ deploymentPaths,
30
+ stageAdministratorReset,
31
+ stageDeployment,
32
+ verifiedPlatformCliVersionPatterns,
33
+ } from '../cli/deploy.mjs';
34
+ import {
35
+ createStoppedBackup,
36
+ pruneUpgradeBackups,
37
+ stageRestore,
38
+ validateBackup,
39
+ } from '../cli/data-lifecycle.mjs';
40
+ import { verifyExternalDeployment } from '../cli/external-verification.mjs';
41
+ import {
42
+ consumePurgeConfirmation,
43
+ createPurgeConfirmation,
44
+ stageUninstall,
45
+ validatePurgeConfirmation,
46
+ } from '../cli/server-maintenance.mjs';
47
+ import { fetchInternalGateway, verifyServerDeployment } from '../cli/server-verification.mjs';
48
+ import { migrateLegacyWindowsData } from '../cli/windows-data-migration.mjs';
49
+ import { PlatformCommandError, runPlatformCommand } from '../cli/platform.mjs';
50
+ import {
51
+ certbotIssueArguments,
52
+ certbotVersionSupported,
53
+ ipCertificateAutomationPaths,
54
+ stageIpCertificateAutomation,
55
+ stageIpCertificateChallenge,
56
+ } from '../cli/ip-certificate.mjs';
57
+
58
+ const packageRoot = realpathSync(resolve(import.meta.dirname, '..'));
59
+ const packageRequire = createRequire(import.meta.url);
60
+ const help = `Chat Hub 命令行
61
+
62
+ 用法:chat-hub <doctor|diagnose|platform|start|status|stop|restart|logs|deploy|verify-server|verify-external|admin-reset|backup|restore|upgrade|uninstall|purge> [选项]
63
+
64
+ doctor 只读检查运行环境与平台 CLI
65
+ diagnose 输出不含凭证和业务正文的本地诊断
66
+ platform login/status 以 chat-hub 服务用户完成平台授权或查看脱敏状态
67
+ start [--lan] 后台启动;默认仅本机回环访问
68
+ status 查看当前实例状态
69
+ stop 停止本命令管理的健康实例并保留数据
70
+ restart [--lan] 显式重启并应用新参数
71
+ logs 显示日志文件位置
72
+ deploy --domain <域名> 输出域名 HTTPS 计划;无域名正式入口使用 --ip <公网IPv4>
73
+ verify-server 只读验收已部署的 Linux 服务、权限、端口和公网入口
74
+ verify-external 从另一网络验收公网入口和内部端口公网阻断
75
+ admin-reset 服务器上重置管理员密码并立即撤销全部旧会话
76
+ backup 停止服务器服务后创建一致性本地备份
77
+ restore <备份目录名> 校验备份、可回滚恢复并重置管理员会话
78
+ upgrade [--to <版本>] 用当前安装包升级服务器,升级前备份并保留最近 3 份
79
+ uninstall 移除服务器服务和代理配置,保留数据、凭证与备份
80
+ purge 显示删除路径并生成 10 分钟二次确认令牌
81
+
82
+ 选项:
83
+ --data-dir <目录> 数据目录(必须位于安装包外)
84
+ --runtime-dir <目录> 运行状态目录(必须位于安装包外)
85
+ --lan 启用可信局域网 HTTP 与一次性配对
86
+ --lan-ip <IPv4> 多网卡时指定可信局域网地址
87
+ --api-port <端口> 内部应用端口,默认 4310
88
+ --http-port <端口> 本机/LAN 入口端口,默认 8080
89
+ --domain <域名> 公网部署域名
90
+ --ip <公网IPv4> 使用 Certbot 5.4+ shortlived 证书部署正式 IP HTTPS
91
+ --email <邮箱> IP HTTPS 证书到期联系邮箱(apply 时必填)
92
+ --http-ip <公网IPv4> 显式启用不加密的临时公网 HTTP 部署(高风险)
93
+ --new-app 飞书首次登录时明确创建新应用
94
+ --app-id <App ID> 飞书首次登录时绑定已有应用;Secret 从 stdin 读取
95
+ --origin <IP> 外部验收时指定 ECS 公网 IP(域名经过 CDN 时使用)
96
+ --apply 确认并应用受支持 Linux 的 systemd/Caddy 部署计划
97
+ --install-dependencies 在部署计划中包含受控系统依赖安装
98
+ --to <版本> 断言当前执行包的精确版本,用于受控服务器升级
99
+ --confirm <令牌> 执行 purge 首次调用生成的二次确认
100
+
101
+ 默认不自动安装依赖或更新。deploy --apply、upgrade、uninstall 和确认后的 purge 会修改受支持的 Linux 系统。`;
102
+
103
+ const { command, options, positionals } = parseArguments(process.argv.slice(2));
104
+
105
+ if (!command || command === 'help') {
106
+ console.log(help);
107
+ process.exit(0);
108
+ }
109
+
110
+ main().catch((error) => {
111
+ console.error(
112
+ JSON.stringify(
113
+ {
114
+ ok: false,
115
+ ...(typeof error?.code === 'string' ? { code: error.code } : {}),
116
+ error: error instanceof Error ? error.message : '命令执行失败',
117
+ },
118
+ null,
119
+ 2,
120
+ ),
121
+ );
122
+ process.exitCode = 1;
123
+ });
124
+
125
+ async function main() {
126
+ if (
127
+ ![
128
+ 'doctor',
129
+ 'diagnose',
130
+ 'platform',
131
+ 'start',
132
+ 'status',
133
+ 'stop',
134
+ 'restart',
135
+ 'logs',
136
+ 'deploy',
137
+ 'verify-server',
138
+ 'verify-external',
139
+ 'admin-reset',
140
+ 'backup',
141
+ 'restore',
142
+ 'upgrade',
143
+ 'uninstall',
144
+ 'purge',
145
+ ].includes(command)
146
+ ) {
147
+ throw new Error('未知命令,请运行 chat-hub --help');
148
+ }
149
+ assertCommandOptions();
150
+ assertSupportedNode();
151
+ if (command !== 'restore' && command !== 'platform' && positionals.length > 0)
152
+ throw new Error('当前命令不接受位置参数');
153
+ if (command === 'platform') return report(platformCommand());
154
+ if (command === 'deploy') return report(await deploy());
155
+ if (command === 'verify-server') {
156
+ const result = await verifyServer();
157
+ report({ ok: result.passed, ...result });
158
+ if (!result.passed) process.exitCode = 1;
159
+ return;
160
+ }
161
+ if (command === 'verify-external') {
162
+ const result = await verifyExternal();
163
+ report({ ok: result.passed, ...result });
164
+ if (!result.passed) process.exitCode = 1;
165
+ return;
166
+ }
167
+ if (command === 'admin-reset') return report(await resetAdministrator());
168
+ if (command === 'backup') return report(await backupServerData());
169
+ if (command === 'upgrade') return report(await upgradeServer());
170
+ if (command === 'uninstall') return report(await uninstallServer());
171
+ if (command === 'purge') return report(await purgeServer());
172
+ if (command === 'restore') {
173
+ if (positionals.length !== 1) throw new Error('restore 必须提供一个备份目录名');
174
+ return report(await restoreServerData(positionals[0]));
175
+ }
176
+ const runtimeDirectory = checkedExternalDirectory(
177
+ options['--runtime-dir'] ?? defaultRuntimeDirectory(),
178
+ );
179
+ const configPath = join(runtimeDirectory, 'config.json');
180
+ const statePath = join(runtimeDirectory, 'state.json');
181
+ const saved = readJson(configPath);
182
+
183
+ if (command === 'doctor') return report(runDoctor(runtimeDirectory));
184
+ if (command === 'diagnose') return report(runDiagnosis(runtimeDirectory));
185
+ if (command === 'logs') {
186
+ return report({
187
+ runtimeDirectory,
188
+ stdout: join(runtimeDirectory, 'chat-hub.log'),
189
+ bootstrapErrors: join(runtimeDirectory, 'bootstrap-error.log'),
190
+ });
191
+ }
192
+ if (command === 'status') return report(await currentStatus(saved, statePath));
193
+ if (command === 'stop') return report(await stop(saved, statePath));
194
+
195
+ if (command === 'restart' && saved) await stop(saved, statePath);
196
+ if (command === 'start' && saved) {
197
+ const status = await currentStatus(saved, statePath);
198
+ if (status.running && status.healthy) {
199
+ if (hasRuntimeChanges(saved)) {
200
+ throw new Error('运行参数有变化,请显式使用 chat-hub restart');
201
+ }
202
+ return report({
203
+ reused: true,
204
+ localUrl: `http://127.0.0.1:${saved.httpPort}`,
205
+ phoneUrl: phoneUrl(saved),
206
+ runtimeDirectory,
207
+ });
208
+ }
209
+ if (status.running) {
210
+ throw new Error('已有进程但健康检查失败;不会停止未知状态进程,请先检查日志');
211
+ }
212
+ }
213
+
214
+ assertPackagedRuntime();
215
+ const dataDirectory = checkedExternalDirectory(
216
+ options['--data-dir'] ?? saved?.dataDirectory ?? (await defaultDataDirectory()),
217
+ );
218
+ if (saved?.dataDirectory && saved.dataDirectory !== dataDirectory) {
219
+ throw new Error('已有实例的数据目录不能在启动时更换;请先执行受控迁移');
220
+ }
221
+ mkdirSecure(runtimeDirectory);
222
+ mkdirSecure(dataDirectory);
223
+ const apiPort = parsePort('--api-port', saved?.apiPort ?? 4310);
224
+ const httpPort = parsePort('--http-port', saved?.httpPort ?? 8080);
225
+ if (apiPort === httpPort) throw new Error('内部应用端口和入口端口必须不同');
226
+ await freePort(apiPort, '127.0.0.1');
227
+ await freePort(httpPort, '127.0.0.1');
228
+
229
+ const lan = selectLan(saved);
230
+ if (lan) await freePort(httpPort, lan.address);
231
+ const masterKeyFile =
232
+ process.platform === 'linux' ? ensureLocalLinuxMasterKey(runtimeDirectory) : undefined;
233
+ const larkCliPath = process.env.LARK_CLI_PATH ?? packagedPlatformCliEntry('lark-cli');
234
+ const dingtalkCliPath = process.env.DINGTALK_CLI_PATH ?? packagedPlatformCliEntry('dws');
235
+ const id = createHash('sha256').update(dataDirectory).digest('hex').slice(0, 16);
236
+ const config = {
237
+ id,
238
+ runId: randomUUID(),
239
+ packageRoot,
240
+ runtimeDirectory,
241
+ dataDirectory,
242
+ apiPort,
243
+ httpPort,
244
+ frontendPort: apiPort,
245
+ phoneProtocol: 'http',
246
+ lanIp: lan?.address,
247
+ netmask: lan?.netmask,
248
+ environment: {
249
+ CHAT_HUB_HOST: '127.0.0.1',
250
+ CHAT_HUB_PORT: String(apiPort),
251
+ CHAT_HUB_DATA_DIR: dataDirectory,
252
+ CHAT_HUB_STATIC_DIR: join(packageRoot, 'dist'),
253
+ ...(masterKeyFile ? { CHAT_HUB_MASTER_KEY_FILE: masterKeyFile } : {}),
254
+ ...(process.env.CHAT_HUB_ACCOUNT_KEY
255
+ ? { CHAT_HUB_ACCOUNT_KEY: process.env.CHAT_HUB_ACCOUNT_KEY }
256
+ : {}),
257
+ ...(larkCliPath ? { LARK_CLI_PATH: larkCliPath } : {}),
258
+ ...(dingtalkCliPath ? { DINGTALK_CLI_PATH: dingtalkCliPath } : {}),
259
+ },
260
+ };
261
+ writeJson(configPath, config);
262
+ const log = openSync(join(runtimeDirectory, 'chat-hub.log'), 'a', 0o600);
263
+ const errorLog = openSync(join(runtimeDirectory, 'bootstrap-error.log'), 'a', 0o600);
264
+ try {
265
+ const child = spawn(process.execPath, [join(packageRoot, 'cli', 'runtime.mjs'), configPath], {
266
+ cwd: runtimeDirectory,
267
+ detached: true,
268
+ windowsHide: true,
269
+ stdio: ['ignore', log, errorLog],
270
+ });
271
+ child.unref();
272
+ } finally {
273
+ closeSync(log);
274
+ closeSync(errorLog);
275
+ }
276
+ const deadline = Date.now() + 45_000;
277
+ while (Date.now() < deadline) {
278
+ if (await probe(config)) {
279
+ return report({
280
+ started: true,
281
+ localUrl: `http://127.0.0.1:${httpPort}`,
282
+ phoneUrl: phoneUrl(config),
283
+ pairingPage: lan ? `http://127.0.0.1:${httpPort}/__setup/phone` : undefined,
284
+ runtimeDirectory,
285
+ autoStartAtLogin: false,
286
+ });
287
+ }
288
+ const state = readJson(statePath);
289
+ if (state?.runId === config.runId && state.status === 'failed') break;
290
+ await delay(250);
291
+ }
292
+ throw new Error('启动未通过健康检查,请运行 chat-hub logs 查看原因');
293
+ }
294
+
295
+ function assertCommandOptions() {
296
+ const commonRuntime = new Set(['--runtime-dir']);
297
+ const allowedByCommand = {
298
+ doctor: commonRuntime,
299
+ diagnose: commonRuntime,
300
+ platform: new Set(['--new-app', '--app-id', '--device']),
301
+ logs: commonRuntime,
302
+ status: commonRuntime,
303
+ stop: commonRuntime,
304
+ start: new Set([
305
+ '--runtime-dir',
306
+ '--data-dir',
307
+ '--lan',
308
+ '--lan-ip',
309
+ '--api-port',
310
+ '--http-port',
311
+ ]),
312
+ restart: new Set([
313
+ '--runtime-dir',
314
+ '--data-dir',
315
+ '--lan',
316
+ '--lan-ip',
317
+ '--api-port',
318
+ '--http-port',
319
+ ]),
320
+ deploy: new Set([
321
+ '--domain',
322
+ '--ip',
323
+ '--email',
324
+ '--http-ip',
325
+ '--apply',
326
+ '--install-dependencies',
327
+ ]),
328
+ 'verify-server': new Set(),
329
+ 'verify-external': new Set(['--domain', '--ip', '--http-ip', '--origin']),
330
+ 'admin-reset': new Set(),
331
+ backup: new Set(),
332
+ restore: new Set(),
333
+ upgrade: new Set(['--to']),
334
+ uninstall: new Set(),
335
+ purge: new Set(['--confirm']),
336
+ };
337
+ const allowed = allowedByCommand[command];
338
+ if (!allowed) return;
339
+ const invalid = Object.keys(options).find((option) => !allowed.has(option));
340
+ if (invalid) throw new Error(`${command} 不支持选项 ${invalid}`);
341
+ }
342
+
343
+ function parseArguments(values) {
344
+ if (values.length === 0 || values.includes('--help') || values.includes('-h')) {
345
+ return { command: 'help', options: {}, positionals: [] };
346
+ }
347
+ const command = values.shift();
348
+ const options = {};
349
+ const positionals = [];
350
+ const supported = new Set([
351
+ '--data-dir',
352
+ '--runtime-dir',
353
+ '--lan',
354
+ '--lan-ip',
355
+ '--api-port',
356
+ '--http-port',
357
+ '--domain',
358
+ '--ip',
359
+ '--email',
360
+ '--http-ip',
361
+ '--new-app',
362
+ '--app-id',
363
+ '--device',
364
+ '--origin',
365
+ '--apply',
366
+ '--install-dependencies',
367
+ '--to',
368
+ '--confirm',
369
+ ]);
370
+ for (let index = 0; index < values.length; index++) {
371
+ const key = values[index];
372
+ if (!key.startsWith('--')) {
373
+ positionals.push(key);
374
+ continue;
375
+ }
376
+ if (!supported.has(key) || key in options) throw new Error('未知或重复的选项,请查看 --help');
377
+ if (
378
+ key === '--lan' ||
379
+ key === '--apply' ||
380
+ key === '--install-dependencies' ||
381
+ key === '--new-app' ||
382
+ key === '--device'
383
+ )
384
+ options[key] = true;
385
+ else {
386
+ const value = values[++index];
387
+ if (!value || value.startsWith('--')) throw new Error(`${key} 缺少值`);
388
+ options[key] = value;
389
+ }
390
+ }
391
+ return { command, options, positionals };
392
+ }
393
+
394
+ async function defaultDataDirectory() {
395
+ if (process.platform === 'darwin')
396
+ return join(homedir(), 'Library', 'Application Support', 'Chat Hub');
397
+ if (process.platform === 'win32') {
398
+ const current = join(
399
+ process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
400
+ 'Chat Hub',
401
+ );
402
+ const legacy = join(homedir(), 'Library', 'Application Support', 'Chat Hub');
403
+ if (!existsSync(legacy)) return current;
404
+ const { default: Database } = await import('better-sqlite3');
405
+ return migrateLegacyWindowsData({
406
+ legacyDirectory: legacy,
407
+ currentDirectory: current,
408
+ backupRoot: join(dirname(current), 'Chat Hub Migration Backups'),
409
+ verifyCopy(directory) {
410
+ const databasePath = join(directory, 'chat-hub.sqlite');
411
+ if (!existsSync(databasePath)) return;
412
+ const database = new Database(databasePath, { readonly: true, fileMustExist: true });
413
+ try {
414
+ const result = database.pragma('quick_check', { simple: true });
415
+ if (result !== 'ok') throw new Error(`SQLite 快速完整性检查失败:${String(result)}`);
416
+ } finally {
417
+ database.close();
418
+ }
419
+ },
420
+ }).currentDirectory;
421
+ }
422
+ return join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'chat-hub');
423
+ }
424
+
425
+ function defaultRuntimeDirectory() {
426
+ if (process.platform === 'win32') {
427
+ return join(
428
+ process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
429
+ 'Chat Hub',
430
+ 'runtime',
431
+ );
432
+ }
433
+ return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.chat-hub'), 'chat-hub');
434
+ }
435
+
436
+ function checkedExternalDirectory(value) {
437
+ const directory = resolve(value);
438
+ const pathFromPackage = relative(packageRoot, directory);
439
+ if (
440
+ pathFromPackage === '' ||
441
+ (pathFromPackage !== '..' &&
442
+ !pathFromPackage.startsWith(`..${sep}`) &&
443
+ !isAbsolute(pathFromPackage))
444
+ ) {
445
+ throw new Error('数据和运行目录必须位于 Chat Hub 安装包之外');
446
+ }
447
+ return directory;
448
+ }
449
+
450
+ function assertSupportedNode() {
451
+ const [major, minor, patch] = process.versions.node.split('.').map(Number);
452
+ const supported22 = major === 22 && (minor > 22 || (minor === 22 && patch >= 2));
453
+ const supported24 = major === 24 && minor >= 15;
454
+ if (!(supported22 || supported24)) {
455
+ throw new Error('首个 beta 需要 Node.js 22.22.2+ 的 22.x 或 24.15+ 的 24.x');
456
+ }
457
+ }
458
+
459
+ function assertPackagedRuntime() {
460
+ for (const path of [
461
+ join(packageRoot, 'dist', 'index.html'),
462
+ join(packageRoot, 'dist', 'server', 'index.js'),
463
+ join(packageRoot, 'cli', 'runtime.mjs'),
464
+ join(packageRoot, 'skills', 'chat-hub-setup', 'scripts', 'gateway.mjs'),
465
+ packagedPlatformCliEntry('lark-cli'),
466
+ packagedPlatformCliEntry('dws'),
467
+ ]) {
468
+ if (!path || !existsSync(path)) {
469
+ throw new Error(`安装包缺少生产文件:${path ? relative(packageRoot, path) : '平台 CLI'}`);
470
+ }
471
+ }
472
+ }
473
+
474
+ function runDoctor(runtimeDirectory) {
475
+ const versions = {};
476
+ for (const executable of ['npm', 'lark-cli', 'dws']) {
477
+ try {
478
+ const selected =
479
+ executable === 'npm'
480
+ ? process.env.npm_execpath
481
+ ? { executable: process.execPath, prefixArgs: [process.env.npm_execpath] }
482
+ : { executable: 'npm', prefixArgs: [] }
483
+ : executableInvocation(selectedPlatformCli(executable));
484
+ versions[executable] = execFileSync(
485
+ selected.executable,
486
+ [...selected.prefixArgs, '--version'],
487
+ {
488
+ encoding: 'utf8',
489
+ timeout: 10_000,
490
+ stdio: 'pipe',
491
+ windowsHide: true,
492
+ },
493
+ )
494
+ .trim()
495
+ .split(/\r?\n/)[0];
496
+ } catch {
497
+ versions[executable] = '不可执行';
498
+ }
499
+ }
500
+ return {
501
+ platform: process.platform,
502
+ architecture: process.arch,
503
+ serverDeploymentHost: detectDeploymentHost(),
504
+ node: process.version,
505
+ packageVersion: readCurrentPackageVersion(),
506
+ packageRoot,
507
+ packageReady: [
508
+ join(packageRoot, 'dist', 'index.html'),
509
+ join(packageRoot, 'dist', 'server', 'index.js'),
510
+ packagedPlatformCliEntry('lark-cli'),
511
+ packagedPlatformCliEntry('dws'),
512
+ ].every((path) => Boolean(path && existsSync(path))),
513
+ runtimeDirectory,
514
+ stableCommand: stableCommandStatus(),
515
+ lanAddresses: lanAddresses(),
516
+ versions,
517
+ };
518
+ }
519
+
520
+ function runDiagnosis(runtimeDirectory) {
521
+ const doctor = runDoctor(runtimeDirectory);
522
+ const manifest = existsSync(deploymentPaths.manifest) ? readDeploymentManifest() : undefined;
523
+ const runtimeConfig = readJson(join(runtimeDirectory, 'config.json'));
524
+ const runtimeState = readJson(join(runtimeDirectory, 'state.json'));
525
+ return {
526
+ generatedAt: new Date().toISOString(),
527
+ redacted: true,
528
+ includesBusinessContent: false,
529
+ includesCredentials: false,
530
+ environment: doctor,
531
+ localRuntime: {
532
+ configured: Boolean(runtimeConfig),
533
+ processRunning: Boolean(runtimeState?.pid && isProcessAlive(runtimeState.pid)),
534
+ lastCode: typeof runtimeState?.code === 'string' ? runtimeState.code : undefined,
535
+ },
536
+ server: manifest
537
+ ? {
538
+ installed: true,
539
+ publicScheme: manifest.publicScheme,
540
+ publicHost: manifest.publicHost,
541
+ chatHubService: systemServiceActive('chat-hub.service') ? 'active' : 'inactive',
542
+ caddyService: systemServiceActive('caddy.service') ? 'active' : 'inactive',
543
+ certificateRenewalTimer: systemServiceActive('chat-hub-cert-renew.timer')
544
+ ? 'active'
545
+ : manifest.certificateMode === 'ip-shortlived'
546
+ ? 'inactive'
547
+ : 'not_applicable',
548
+ }
549
+ : { installed: false },
550
+ };
551
+ }
552
+
553
+ function stableCommandStatus() {
554
+ const expected = realpathSync(join(packageRoot, 'bin', 'chat-hub.mjs'));
555
+ let detected;
556
+ try {
557
+ const output = execFileSync(
558
+ process.platform === 'win32' ? 'where.exe' : 'which',
559
+ ['chat-hub'],
560
+ {
561
+ encoding: 'utf8',
562
+ timeout: 10_000,
563
+ stdio: 'pipe',
564
+ },
565
+ );
566
+ detected = output.trim().split(/\r?\n/)[0];
567
+ } catch {
568
+ return {
569
+ ready: false,
570
+ code: 'chat_hub_not_on_path',
571
+ expected,
572
+ nextStep: '使用安装器建立 /usr/local/bin/chat-hub,或把 npm 全局 bin 加入 PATH',
573
+ };
574
+ }
575
+ return {
576
+ ready: true,
577
+ detected,
578
+ expected,
579
+ currentPackage:
580
+ resolve(detected) === expected || resolve(detected) === join(packageRoot, 'bin', 'chat-hub'),
581
+ };
582
+ }
583
+
584
+ function platformCommand() {
585
+ assertSupportedDeploymentHost({ commandName: 'platform' });
586
+ if (positionals.length < 1 || positionals.length > 2) {
587
+ throw new PlatformCommandError(
588
+ 'platform_usage_invalid',
589
+ '用法:chat-hub platform status [lark|dingtalk],或 chat-hub platform login <lark|dingtalk>',
590
+ );
591
+ }
592
+ const [action, requestedPlatform] = positionals;
593
+ if (!['status', 'login'].includes(action)) {
594
+ throw new PlatformCommandError('platform_action_invalid', 'platform 只支持 login 或 status');
595
+ }
596
+ const targets = requestedPlatform ? [requestedPlatform] : ['lark', 'dingtalk'];
597
+ if (targets.some((platform) => !['lark', 'dingtalk'].includes(platform))) {
598
+ throw new PlatformCommandError('platform_invalid', '平台必须是 lark 或 dingtalk');
599
+ }
600
+ if (action === 'login' && targets.length !== 1) {
601
+ throw new PlatformCommandError('platform_required', 'platform login 必须指定 lark 或 dingtalk');
602
+ }
603
+ if (Boolean(options['--new-app']) && Boolean(options['--app-id'])) {
604
+ throw new PlatformCommandError(
605
+ 'lark_initialization_choice_conflict',
606
+ '--new-app 与 --app-id 只能选择一个',
607
+ );
608
+ }
609
+ const resolveInvocation = (platform) => {
610
+ const executable = platform === 'lark' ? 'lark-cli' : 'dws';
611
+ const pattern = verifiedPlatformCliVersionPatterns[executable];
612
+ return executableInvocation(compatibleCli(executable, pattern));
613
+ };
614
+ const status = (platform) =>
615
+ runPlatformCommand({ platform, action: 'status', resolveInvocation });
616
+ if (action === 'status') {
617
+ return {
618
+ platforms: targets.map((platform) => {
619
+ try {
620
+ return status(platform);
621
+ } catch (error) {
622
+ return {
623
+ platform,
624
+ configured: false,
625
+ authenticated: false,
626
+ status: 'unavailable',
627
+ code: error instanceof PlatformCommandError ? error.code : 'platform_status_failed',
628
+ };
629
+ }
630
+ }),
631
+ };
632
+ }
633
+ const platform = targets[0];
634
+ let larkMode;
635
+ if (platform === 'lark') {
636
+ if (options['--new-app']) larkMode = 'new';
637
+ else if (options['--app-id']) larkMode = 'existing';
638
+ else {
639
+ try {
640
+ if (status('lark').configured) larkMode = 'configured';
641
+ } catch {
642
+ // platformCliSteps returns an actionable first-run choice error below.
643
+ }
644
+ }
645
+ }
646
+ runPlatformCommand({
647
+ platform,
648
+ action: 'login',
649
+ larkMode,
650
+ appId: options['--app-id'],
651
+ device: options['--device'] !== false,
652
+ resolveInvocation,
653
+ });
654
+ return { loginCompleted: true, ...status(platform) };
655
+ }
656
+
657
+ async function deploy() {
658
+ const endpoint = normalizedPublicEndpoint(options);
659
+ const domain = endpoint.publicHost;
660
+ const installDependencies = Boolean(options['--install-dependencies']);
661
+ const detectedHost = detectDeploymentHost();
662
+ const plan = createDeploymentPlan(
663
+ domain,
664
+ installDependencies,
665
+ process.platform,
666
+ process.arch,
667
+ detectedHost,
668
+ endpoint.publicScheme,
669
+ endpoint.certificateMode,
670
+ );
671
+ if (!options['--apply']) return plan;
672
+
673
+ const deploymentHost = assertSupportedDeploymentHost();
674
+ assertPackagedRuntime();
675
+ if (
676
+ resolve(packageRoot).startsWith('/root/') ||
677
+ realpathSync(process.execPath).startsWith('/root/')
678
+ ) {
679
+ throw new Error(
680
+ 'npm 包或 Node.js 安装在 /root 下,专用服务用户无法安全读取;请安装到 /usr/local',
681
+ );
682
+ }
683
+ if (installDependencies && !findExecutable('caddy')) {
684
+ await installOfficialCaddyPackage(deploymentHost);
685
+ }
686
+ for (const executable of [
687
+ 'systemctl',
688
+ 'systemd-analyze',
689
+ 'caddy',
690
+ 'useradd',
691
+ 'chown',
692
+ 'chmod',
693
+ 'install',
694
+ ]) {
695
+ if (!findExecutable(executable)) throw new Error(`部署依赖不可用:${executable}`);
696
+ }
697
+ const caddyPath = findExecutable('caddy');
698
+ if (!caddyPath) throw new Error('部署依赖不可用:caddy');
699
+ const installPath = findExecutable('install');
700
+ if (!installPath) throw new Error('部署依赖不可用:install');
701
+ const larkCliPath = compatibleCli('lark-cli', verifiedPlatformCliVersionPatterns['lark-cli']);
702
+ const dingtalkCliPath = compatibleCli('dws', verifiedPlatformCliVersionPatterns.dws);
703
+ ensureSystemUser();
704
+
705
+ let certificateChallenge;
706
+ let certbotPath;
707
+ if (endpoint.certificateMode === 'ip-shortlived') {
708
+ if (typeof options['--email'] !== 'string') {
709
+ throw new Error('deploy --ip --apply 必须提供 --email 作为证书到期联系人');
710
+ }
711
+ certbotPath = compatibleCertbot();
712
+ certificateChallenge = stageIpCertificateChallenge({ publicIp: endpoint.publicHost });
713
+ try {
714
+ runSystem('caddy', [
715
+ 'validate',
716
+ '--config',
717
+ deploymentPaths.caddyfile,
718
+ '--adapter',
719
+ 'caddyfile',
720
+ ]);
721
+ runSystem('systemctl', ['enable', 'caddy.service']);
722
+ if (systemServiceActive('caddy.service')) runSystem('systemctl', ['reload', 'caddy.service']);
723
+ else runSystem('systemctl', ['start', 'caddy.service']);
724
+ runSystem(
725
+ certbotPath,
726
+ certbotIssueArguments({
727
+ publicIp: endpoint.publicHost,
728
+ email: options['--email'],
729
+ }),
730
+ );
731
+ } catch (error) {
732
+ certificateChallenge.rollback();
733
+ runSystem('systemctl', ['reload', 'caddy.service'], true);
734
+ throw error;
735
+ }
736
+ }
737
+
738
+ const staged = stageDeployment({
739
+ publicHost: endpoint.publicHost,
740
+ publicScheme: endpoint.publicScheme,
741
+ certificateMode: endpoint.certificateMode,
742
+ packageRoot,
743
+ nodePath: realpathSync(process.execPath),
744
+ larkCliPath,
745
+ dingtalkCliPath,
746
+ });
747
+ const certificateAutomation = certbotPath
748
+ ? stageIpCertificateAutomation({
749
+ publicIp: endpoint.publicHost,
750
+ certbotPath,
751
+ caddyPath,
752
+ installPath,
753
+ nodePath: realpathSync(process.execPath),
754
+ })
755
+ : undefined;
756
+ try {
757
+ runSystem('chown', ['-R', 'chat-hub:chat-hub', deploymentPaths.data, deploymentPaths.backups]);
758
+ runSystem('chown', ['root:chat-hub', deploymentPaths.configuration]);
759
+ runSystem('chmod', ['0750', deploymentPaths.configuration]);
760
+ runSystem('chown', ['root:chat-hub', deploymentPaths.runtimeConfig]);
761
+ runSystem('chmod', ['0640', deploymentPaths.runtimeConfig]);
762
+ runSystem('systemd-analyze', ['verify', deploymentPaths.service]);
763
+ if (certificateAutomation) {
764
+ runSystem('systemd-analyze', ['verify', ipCertificateAutomationPaths.service]);
765
+ runSystem(ipCertificateAutomationPaths.deployHook, []);
766
+ }
767
+ runSystem('caddy', [
768
+ 'validate',
769
+ '--config',
770
+ deploymentPaths.caddyfile,
771
+ '--adapter',
772
+ 'caddyfile',
773
+ ]);
774
+ runSystem('systemctl', ['daemon-reload']);
775
+ runSystem('systemctl', ['enable', 'chat-hub.service']);
776
+ runSystem('systemctl', ['restart', 'chat-hub.service']);
777
+ await waitForPublicGateway(endpoint);
778
+ runSystem('systemctl', ['enable', 'caddy.service']);
779
+ if (systemServiceActive('caddy.service')) runSystem('systemctl', ['reload', 'caddy.service']);
780
+ else runSystem('systemctl', ['start', 'caddy.service']);
781
+ if (certbotPath) {
782
+ runSystem(certbotPath, [
783
+ 'renew',
784
+ '--cert-name',
785
+ endpoint.publicHost,
786
+ '--dry-run',
787
+ '--run-deploy-hooks',
788
+ ]);
789
+ runSystem(ipCertificateAutomationPaths.renewRunner, []);
790
+ runSystem('systemctl', ['enable', '--now', 'chat-hub-cert-renew.timer']);
791
+ }
792
+ } catch (error) {
793
+ certificateAutomation?.rollback();
794
+ staged.rollback();
795
+ certificateChallenge?.rollback();
796
+ try {
797
+ runSystem('systemctl', ['daemon-reload']);
798
+ runSystem('systemctl', ['restart', 'chat-hub.service'], true);
799
+ runSystem('systemctl', ['reload', 'caddy.service'], true);
800
+ } catch {
801
+ // The original error is more actionable; retained data and credentials are never removed.
802
+ }
803
+ throw error;
804
+ }
805
+
806
+ const sessionConfig = await fetchPublicGateway(endpoint, '/api/session/config');
807
+ const initialized = sessionConfig?.data?.initialized === true;
808
+ return {
809
+ ...plan,
810
+ mode: 'applied',
811
+ applied: true,
812
+ initializationUrl: initialized ? undefined : staged.initializationUrl,
813
+ initializationExpiresAt: initialized ? undefined : new Date(staged.expiresAt).toISOString(),
814
+ administratorInitialized: initialized,
815
+ service: 'active',
816
+ publicUrl: `${endpoint.publicScheme}://${endpoint.publicHost}`,
817
+ };
818
+ }
819
+
820
+ async function verifyServer() {
821
+ assertSupportedDeploymentHost({ commandName: 'verify-server' });
822
+ const manifest = readDeploymentManifest();
823
+ const larkCliPath = packagedPlatformCliEntry('lark-cli');
824
+ const dingtalkCliPath = packagedPlatformCliEntry('dws');
825
+ if (!larkCliPath || !dingtalkCliPath) {
826
+ throw new Error('安装包缺少固定版本平台 CLI,不能完成服务器验收');
827
+ }
828
+ return verifyServerDeployment({
829
+ domain: manifest.publicHost,
830
+ publicHost: manifest.publicHost,
831
+ publicScheme: manifest.publicScheme,
832
+ certificateMode: manifest.certificateMode,
833
+ larkCliPath,
834
+ dingtalkCliPath,
835
+ nodePath: process.execPath,
836
+ });
837
+ }
838
+
839
+ async function verifyExternal() {
840
+ const endpoint = normalizedPublicEndpoint(options);
841
+ return verifyExternalDeployment({
842
+ domain: endpoint.publicHost,
843
+ publicHost: endpoint.publicHost,
844
+ publicScheme: endpoint.publicScheme,
845
+ origin: options['--origin'],
846
+ });
847
+ }
848
+
849
+ async function resetAdministrator() {
850
+ assertSupportedDeploymentHost({ commandName: 'admin-reset' });
851
+ const manifest = readDeploymentManifest();
852
+ if (!systemServiceActive('chat-hub.service')) {
853
+ throw new Error('Chat Hub systemd 服务当前未运行;先修复服务再重置管理员');
854
+ }
855
+ const reset = stageAdministratorReset({
856
+ publicHost: manifest.publicHost,
857
+ publicScheme: manifest.publicScheme,
858
+ });
859
+ runSystem('systemctl', ['restart', 'chat-hub.service']);
860
+ await waitForPublicGateway(manifest);
861
+ const sessionConfig = await fetchPublicGateway(manifest, '/api/session/config');
862
+ if (sessionConfig?.data?.initialized !== false) {
863
+ throw new Error('管理员重置未进入初始化状态,请查看 Chat Hub 服务日志');
864
+ }
865
+ return {
866
+ command: 'admin-reset',
867
+ reset: true,
868
+ previousSessionsRevoked: true,
869
+ initializationUrl: reset.initializationUrl,
870
+ initializationExpiresAt: new Date(reset.expiresAt).toISOString(),
871
+ };
872
+ }
873
+
874
+ async function backupServerData() {
875
+ assertSupportedDeploymentHost({ commandName: 'backup' });
876
+ const manifest = readDeploymentManifest();
877
+ const wasActive = systemServiceActive('chat-hub.service');
878
+ if (wasActive) runSystem('systemctl', ['stop', 'chat-hub.service']);
879
+ try {
880
+ const backup = createStoppedBackup({ kind: 'manual' });
881
+ await validateSqliteBackup(backup.backupDirectory);
882
+ return {
883
+ command: 'backup',
884
+ created: true,
885
+ backupDirectory: backup.backupDirectory,
886
+ includesMasterKey: false,
887
+ serviceRestarted: wasActive,
888
+ };
889
+ } finally {
890
+ if (wasActive) {
891
+ runSystem('systemctl', ['start', 'chat-hub.service']);
892
+ await waitForPublicGateway(manifest);
893
+ }
894
+ }
895
+ }
896
+
897
+ async function restoreServerData(backup) {
898
+ assertSupportedDeploymentHost({ commandName: 'restore' });
899
+ const manifest = readDeploymentManifest();
900
+ const validated = validateBackup(backup);
901
+ await validateSqliteBackup(validated.backupDirectory);
902
+ const wasActive = systemServiceActive('chat-hub.service');
903
+ if (wasActive) runSystem('systemctl', ['stop', 'chat-hub.service']);
904
+ let safety;
905
+ let restored;
906
+ let previousBootstrap;
907
+ try {
908
+ safety = createStoppedBackup({ kind: 'restore-safety' });
909
+ previousBootstrap = existsSync(deploymentPaths.adminBootstrap)
910
+ ? readFileSync(deploymentPaths.adminBootstrap)
911
+ : undefined;
912
+ restored = stageRestore({ backup: validated.backupDirectory });
913
+ runSystem('chown', ['-R', 'chat-hub:chat-hub', deploymentPaths.data]);
914
+ const reset = stageAdministratorReset({
915
+ publicHost: manifest.publicHost,
916
+ publicScheme: manifest.publicScheme,
917
+ });
918
+ runSystem('systemctl', ['start', 'chat-hub.service']);
919
+ await waitForPublicGateway(manifest);
920
+ const sessionConfig = await fetchPublicGateway(manifest, '/api/session/config');
921
+ if (sessionConfig?.data?.initialized !== false) {
922
+ throw new Error('恢复后管理员会话没有进入强制重置状态');
923
+ }
924
+ restored.commit();
925
+ return {
926
+ command: 'restore',
927
+ restored: true,
928
+ backupDirectory: validated.backupDirectory,
929
+ safetyBackupDirectory: safety.backupDirectory,
930
+ previousSessionsRevoked: true,
931
+ initializationUrl: reset.initializationUrl,
932
+ initializationExpiresAt: new Date(reset.expiresAt).toISOString(),
933
+ service: 'active',
934
+ };
935
+ } catch (error) {
936
+ runSystem('systemctl', ['stop', 'chat-hub.service'], true);
937
+ if (restored) {
938
+ restored.rollback();
939
+ runSystem('chown', ['-R', 'chat-hub:chat-hub', deploymentPaths.data], true);
940
+ if (previousBootstrap) {
941
+ writeFileSync(deploymentPaths.adminBootstrap, previousBootstrap, { mode: 0o600 });
942
+ chmodSync(deploymentPaths.adminBootstrap, 0o600);
943
+ } else if (existsSync(deploymentPaths.adminBootstrap)) {
944
+ unlinkSync(deploymentPaths.adminBootstrap);
945
+ }
946
+ }
947
+ if (wasActive) runSystem('systemctl', ['start', 'chat-hub.service'], true);
948
+ throw error;
949
+ }
950
+ }
951
+
952
+ async function upgradeServer() {
953
+ assertSupportedDeploymentHost({ commandName: 'upgrade' });
954
+ assertPackagedRuntime();
955
+ const manifest = readDeploymentManifest();
956
+ const currentVersion = readCurrentPackageVersion();
957
+ const requestedVersion = options['--to'];
958
+ if (requestedVersion) {
959
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requestedVersion)) {
960
+ throw new Error('--to 必须是精确的语义版本,不能使用 latest、next 或版本范围');
961
+ }
962
+ if (requestedVersion !== currentVersion) {
963
+ throw new Error(
964
+ `当前执行包版本是 ${currentVersion},不是请求的 ${requestedVersion};请先通过 npm 安装目标版本,再运行 upgrade`,
965
+ );
966
+ }
967
+ }
968
+ for (const executable of ['systemctl', 'systemd-analyze', 'caddy', 'chown', 'chmod']) {
969
+ if (!findExecutable(executable)) throw new Error(`升级依赖不可用:${executable}`);
970
+ }
971
+ if (
972
+ resolve(packageRoot).startsWith('/root/') ||
973
+ realpathSync(process.execPath).startsWith('/root/')
974
+ ) {
975
+ throw new Error('npm 包或 Node.js 安装在 /root 下,专用服务用户无法安全读取');
976
+ }
977
+ const larkCliPath = compatibleCli('lark-cli', verifiedPlatformCliVersionPatterns['lark-cli']);
978
+ const dingtalkCliPath = compatibleCli('dws', verifiedPlatformCliVersionPatterns.dws);
979
+ const wasActive = systemServiceActive('chat-hub.service');
980
+ if (!wasActive) throw new Error('Chat Hub 服务未运行;先恢复现有服务再执行升级');
981
+ runSystem('systemctl', ['stop', 'chat-hub.service']);
982
+ let backup;
983
+ let staged;
984
+ try {
985
+ backup = createStoppedBackup({ kind: 'upgrade' });
986
+ await validateSqliteBackup(backup.backupDirectory);
987
+ staged = stageDeployment({
988
+ publicHost: manifest.publicHost,
989
+ publicScheme: manifest.publicScheme,
990
+ packageRoot,
991
+ nodePath: realpathSync(process.execPath),
992
+ larkCliPath,
993
+ dingtalkCliPath,
994
+ preserveAdministratorCredential: true,
995
+ });
996
+ runSystem('chown', ['-R', 'chat-hub:chat-hub', deploymentPaths.data, deploymentPaths.backups]);
997
+ runSystem('chown', ['root:chat-hub', deploymentPaths.configuration]);
998
+ runSystem('chmod', ['0750', deploymentPaths.configuration]);
999
+ runSystem('chown', ['root:chat-hub', deploymentPaths.runtimeConfig]);
1000
+ runSystem('chmod', ['0640', deploymentPaths.runtimeConfig]);
1001
+ runSystem('systemd-analyze', ['verify', deploymentPaths.service]);
1002
+ runSystem('caddy', [
1003
+ 'validate',
1004
+ '--config',
1005
+ deploymentPaths.caddyfile,
1006
+ '--adapter',
1007
+ 'caddyfile',
1008
+ ]);
1009
+ runSystem('systemctl', ['daemon-reload']);
1010
+ runSystem('systemctl', ['start', 'chat-hub.service']);
1011
+ await waitForPublicGateway(manifest);
1012
+ if (systemServiceActive('caddy.service')) runSystem('systemctl', ['reload', 'caddy.service']);
1013
+ const removedBackups = pruneUpgradeBackups('/', 3);
1014
+ return {
1015
+ command: 'upgrade',
1016
+ upgraded: true,
1017
+ version: currentVersion,
1018
+ previousPackageRoot: manifest.packageRoot,
1019
+ packageRoot,
1020
+ backupDirectory: backup.backupDirectory,
1021
+ prunedUpgradeBackups: removedBackups,
1022
+ service: 'active',
1023
+ };
1024
+ } catch (error) {
1025
+ runSystem('systemctl', ['stop', 'chat-hub.service'], true);
1026
+ try {
1027
+ if (backup && staged) {
1028
+ try {
1029
+ const restored = stageRestore({ backup: backup.backupDirectory });
1030
+ restored.commit();
1031
+ runSystem('chown', ['-R', 'chat-hub:chat-hub', deploymentPaths.data], true);
1032
+ } finally {
1033
+ staged.rollback();
1034
+ }
1035
+ }
1036
+ } finally {
1037
+ runSystem('systemctl', ['daemon-reload'], true);
1038
+ runSystem('systemctl', ['start', 'chat-hub.service'], true);
1039
+ }
1040
+ throw error;
1041
+ }
1042
+ }
1043
+
1044
+ async function uninstallServer() {
1045
+ assertSupportedDeploymentHost({ commandName: 'uninstall' });
1046
+ const manifest = existsSync(deploymentPaths.manifest) ? readDeploymentManifest() : undefined;
1047
+ const wasActive = systemServiceActive('chat-hub.service');
1048
+ const hadCertificateTimer = manifest?.certificateMode === 'ip-shortlived';
1049
+ runSystem('systemctl', ['stop', 'chat-hub-cert-renew.timer'], true);
1050
+ runSystem('systemctl', ['disable', 'chat-hub-cert-renew.timer'], true);
1051
+ runSystem('systemctl', ['stop', 'chat-hub-cert-renew.service'], true);
1052
+ runSystem('systemctl', ['stop', 'chat-hub.service'], true);
1053
+ runSystem('systemctl', ['disable', 'chat-hub.service'], true);
1054
+ let staged;
1055
+ try {
1056
+ staged = stageUninstall();
1057
+ runSystem('systemctl', ['daemon-reload']);
1058
+ if (findExecutable('caddy')) {
1059
+ runSystem('caddy', [
1060
+ 'validate',
1061
+ '--config',
1062
+ deploymentPaths.caddyfile,
1063
+ '--adapter',
1064
+ 'caddyfile',
1065
+ ]);
1066
+ if (systemServiceActive('caddy.service')) runSystem('systemctl', ['reload', 'caddy.service']);
1067
+ }
1068
+ staged.commit();
1069
+ return {
1070
+ command: 'uninstall',
1071
+ uninstalled: true,
1072
+ previousDomain: manifest?.domain,
1073
+ serviceWasActive: wasActive,
1074
+ preservedPaths: staged.preservedPaths,
1075
+ packageRemoval: '请使用安装该版本时采用的 npm 方式移除全局包;数据不会随之删除',
1076
+ };
1077
+ } catch (error) {
1078
+ staged?.rollback();
1079
+ runSystem('systemctl', ['daemon-reload'], true);
1080
+ if (wasActive) {
1081
+ runSystem('systemctl', ['enable', 'chat-hub.service'], true);
1082
+ runSystem('systemctl', ['start', 'chat-hub.service'], true);
1083
+ }
1084
+ if (hadCertificateTimer) {
1085
+ runSystem('systemctl', ['enable', '--now', 'chat-hub-cert-renew.timer'], true);
1086
+ }
1087
+ throw error;
1088
+ }
1089
+ }
1090
+
1091
+ async function purgeServer() {
1092
+ assertSupportedDeploymentHost({ commandName: 'purge' });
1093
+ const confirmationToken = options['--confirm'];
1094
+ if (!confirmationToken) {
1095
+ const confirmation = createPurgeConfirmation();
1096
+ return {
1097
+ command: 'purge',
1098
+ purged: false,
1099
+ confirmationRequired: true,
1100
+ warning: '以下数据、凭证和备份将永久删除,且不会自动创建备份',
1101
+ paths: confirmation.paths,
1102
+ confirmBefore: new Date(confirmation.expiresAt).toISOString(),
1103
+ confirmCommand: `sudo chat-hub purge --confirm ${confirmation.token}`,
1104
+ };
1105
+ }
1106
+
1107
+ validatePurgeConfirmation({ token: confirmationToken });
1108
+ const wasActive = systemServiceActive('chat-hub.service');
1109
+ const manifest = existsSync(deploymentPaths.manifest) ? readDeploymentManifest() : undefined;
1110
+ const hadCertificateTimer = manifest?.certificateMode === 'ip-shortlived';
1111
+ runSystem('systemctl', ['stop', 'chat-hub-cert-renew.timer'], true);
1112
+ runSystem('systemctl', ['disable', 'chat-hub-cert-renew.timer'], true);
1113
+ runSystem('systemctl', ['stop', 'chat-hub-cert-renew.service'], true);
1114
+ runSystem('systemctl', ['stop', 'chat-hub.service'], true);
1115
+ runSystem('systemctl', ['disable', 'chat-hub.service'], true);
1116
+ let staged;
1117
+ try {
1118
+ staged = stageUninstall();
1119
+ runSystem('systemctl', ['daemon-reload']);
1120
+ if (findExecutable('caddy')) {
1121
+ runSystem('caddy', [
1122
+ 'validate',
1123
+ '--config',
1124
+ deploymentPaths.caddyfile,
1125
+ '--adapter',
1126
+ 'caddyfile',
1127
+ ]);
1128
+ if (systemServiceActive('caddy.service')) runSystem('systemctl', ['reload', 'caddy.service']);
1129
+ }
1130
+ const result = consumePurgeConfirmation({ token: confirmationToken });
1131
+ staged.commit();
1132
+ if (findExecutable('userdel')) runSystem('userdel', ['chat-hub'], true);
1133
+ return {
1134
+ command: 'purge',
1135
+ purged: true,
1136
+ removedPaths: result.removedPaths,
1137
+ serviceWasActive: wasActive,
1138
+ };
1139
+ } catch (error) {
1140
+ staged?.rollback();
1141
+ runSystem('systemctl', ['daemon-reload'], true);
1142
+ if (wasActive) {
1143
+ runSystem('systemctl', ['enable', 'chat-hub.service'], true);
1144
+ runSystem('systemctl', ['start', 'chat-hub.service'], true);
1145
+ }
1146
+ if (hadCertificateTimer) {
1147
+ runSystem('systemctl', ['enable', '--now', 'chat-hub-cert-renew.timer'], true);
1148
+ }
1149
+ throw error;
1150
+ }
1151
+ }
1152
+
1153
+ function readCurrentPackageVersion() {
1154
+ const value = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
1155
+ if (!value || typeof value.version !== 'string') throw new Error('安装包版本信息无效');
1156
+ return value.version;
1157
+ }
1158
+
1159
+ function readDeploymentManifest() {
1160
+ if (!existsSync(deploymentPaths.manifest)) {
1161
+ throw new Error('Chat Hub 尚未通过 deploy --apply 安装');
1162
+ }
1163
+ const manifest = JSON.parse(readFileSync(deploymentPaths.manifest, 'utf8'));
1164
+ if (!manifest || typeof manifest.packageRoot !== 'string' || !isAbsolute(manifest.packageRoot)) {
1165
+ throw new Error('Chat Hub 安装清单无效');
1166
+ }
1167
+ if (manifest.version === 1) {
1168
+ const publicHost = normalizedDomain(manifest.domain);
1169
+ return { ...manifest, domain: publicHost, publicHost, publicScheme: 'https' };
1170
+ }
1171
+ if (manifest.version !== 2 || !['http', 'https'].includes(manifest.publicScheme)) {
1172
+ throw new Error('Chat Hub 安装清单无效');
1173
+ }
1174
+ const publicHost =
1175
+ manifest.publicScheme === 'https'
1176
+ ? normalizedDomain(manifest.publicHost)
1177
+ : normalizedPublicIpv4(manifest.publicHost);
1178
+ return { ...manifest, domain: publicHost, publicHost };
1179
+ }
1180
+
1181
+ async function validateSqliteBackup(backupDirectory) {
1182
+ const validated = validateBackup(backupDirectory);
1183
+ if (!validated.manifest.database) return;
1184
+ const databasePath = join(backupDirectory, validated.manifest.database.relativePath);
1185
+ const { default: Database } = await import('better-sqlite3');
1186
+ const database = new Database(databasePath, { readonly: true, fileMustExist: true });
1187
+ try {
1188
+ const result = database.pragma('quick_check', { simple: true });
1189
+ if (result !== 'ok') throw new Error(`SQLite 快速完整性检查失败:${String(result)}`);
1190
+ } finally {
1191
+ database.close();
1192
+ }
1193
+ }
1194
+
1195
+ function ensureSystemUser() {
1196
+ try {
1197
+ execFileSync('id', ['-u', 'chat-hub'], { stdio: 'ignore' });
1198
+ } catch {
1199
+ runSystem('useradd', [
1200
+ '--system',
1201
+ '--user-group',
1202
+ '--home-dir',
1203
+ deploymentPaths.data,
1204
+ '--shell',
1205
+ '/usr/sbin/nologin',
1206
+ 'chat-hub',
1207
+ ]);
1208
+ }
1209
+ }
1210
+
1211
+ function compatibleCli(executable, pattern) {
1212
+ const path = selectedPlatformCli(executable);
1213
+ if (!path) throw new Error(`缺少固定版本平台 CLI:${executable}`);
1214
+ const invocation = executableInvocation(path);
1215
+ let version;
1216
+ try {
1217
+ version = execFileSync(invocation.executable, [...invocation.prefixArgs, '--version'], {
1218
+ encoding: 'utf8',
1219
+ timeout: 10_000,
1220
+ stdio: 'pipe',
1221
+ }).trim();
1222
+ } catch {
1223
+ throw new Error(`固定版本平台 CLI 不可执行:${executable}`);
1224
+ }
1225
+ if (!pattern.test(version)) throw new Error(`${executable} 版本未经验证:${version}`);
1226
+ return path;
1227
+ }
1228
+
1229
+ function compatibleCertbot() {
1230
+ const selected = process.env.CHAT_HUB_CERTBOT_PATH
1231
+ ? realpathSync(process.env.CHAT_HUB_CERTBOT_PATH)
1232
+ : findExecutable('certbot');
1233
+ if (!selected) {
1234
+ throw new Error(
1235
+ 'IP HTTPS 需要 Certbot 5.4+;当前未找到。请通过受控安装器安装后重试,不会降级为 HTTP',
1236
+ );
1237
+ }
1238
+ let version;
1239
+ try {
1240
+ version = execFileSync(selected, ['--version'], {
1241
+ encoding: 'utf8',
1242
+ timeout: 10_000,
1243
+ stdio: 'pipe',
1244
+ }).trim();
1245
+ } catch {
1246
+ throw new Error('Certbot 不可执行;请修复受控安装后重试');
1247
+ }
1248
+ if (!certbotVersionSupported(version)) {
1249
+ throw new Error(`IP HTTPS 需要 Certbot 5.4+;当前为 ${version || '未知版本'}`);
1250
+ }
1251
+ return selected;
1252
+ }
1253
+
1254
+ function selectedPlatformCli(executable) {
1255
+ const override =
1256
+ executable === 'lark-cli' ? process.env.LARK_CLI_PATH : process.env.DINGTALK_CLI_PATH;
1257
+ if (override)
1258
+ return isAbsolute(override) && existsSync(override) ? override : findExecutable(override);
1259
+ return packagedPlatformCliEntry(executable) ?? findExecutable(executable);
1260
+ }
1261
+
1262
+ function packagedPlatformCliEntry(executable) {
1263
+ const specification =
1264
+ executable === 'lark-cli'
1265
+ ? { packageJson: '@larksuite/cli/package.json', entry: ['scripts', 'run.js'] }
1266
+ : executable === 'dws'
1267
+ ? { packageJson: 'dingtalk-workspace-cli/package.json', entry: ['bin', 'dws.js'] }
1268
+ : undefined;
1269
+ if (!specification) return undefined;
1270
+ try {
1271
+ const packageJson = packageRequire.resolve(specification.packageJson);
1272
+ const entry = join(dirname(packageJson), ...specification.entry);
1273
+ return existsSync(entry) ? entry : undefined;
1274
+ } catch {
1275
+ return undefined;
1276
+ }
1277
+ }
1278
+
1279
+ function executableInvocation(executable) {
1280
+ if (!executable) throw new Error('平台 CLI 不可执行');
1281
+ return extname(executable).toLowerCase() === '.js'
1282
+ ? { executable: process.execPath, prefixArgs: [executable] }
1283
+ : { executable, prefixArgs: [] };
1284
+ }
1285
+
1286
+ function findExecutable(executable) {
1287
+ try {
1288
+ const result = execFileSync('which', [executable], {
1289
+ encoding: 'utf8',
1290
+ timeout: 10_000,
1291
+ stdio: 'pipe',
1292
+ }).trim();
1293
+ return result && isAbsolute(result) ? realpathSync(result) : undefined;
1294
+ } catch {
1295
+ return undefined;
1296
+ }
1297
+ }
1298
+
1299
+ function runSystem(executable, args, ignoreFailure = false) {
1300
+ try {
1301
+ execFileSync(executable, args, {
1302
+ timeout: 5 * 60_000,
1303
+ windowsHide: true,
1304
+ stdio: ['ignore', 'inherit', 'inherit'],
1305
+ });
1306
+ } catch (error) {
1307
+ if (!ignoreFailure) throw error;
1308
+ }
1309
+ }
1310
+
1311
+ function systemServiceActive(service) {
1312
+ try {
1313
+ execFileSync('systemctl', ['is-active', '--quiet', service], {
1314
+ timeout: 10_000,
1315
+ stdio: 'ignore',
1316
+ });
1317
+ return true;
1318
+ } catch {
1319
+ return false;
1320
+ }
1321
+ }
1322
+
1323
+ async function installOfficialCaddyPackage(host) {
1324
+ if (host.caddyInstaller === 'apt') return installOfficialCaddyAptPackage();
1325
+ if (host.caddyInstaller === 'verified-static') return installVerifiedStaticCaddy();
1326
+ throw new Error(`当前系统没有受支持的 Caddy 安装方式:${host.label}`);
1327
+ }
1328
+
1329
+ async function installOfficialCaddyAptPackage() {
1330
+ runSystem('apt-get', ['update']);
1331
+ runSystem('apt-get', [
1332
+ 'install',
1333
+ '-y',
1334
+ 'debian-keyring',
1335
+ 'debian-archive-keyring',
1336
+ 'apt-transport-https',
1337
+ 'curl',
1338
+ 'gpg',
1339
+ ]);
1340
+ const keyUrl = 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key';
1341
+ const listUrl = 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt';
1342
+ const keyTemporary = '/usr/share/keyrings/caddy-stable-archive-keyring.gpg.chat-hub.asc';
1343
+ const keyTarget = '/usr/share/keyrings/caddy-stable-archive-keyring.gpg';
1344
+ const keyNew = `${keyTarget}.chat-hub.new`;
1345
+ const listTarget = '/etc/apt/sources.list.d/caddy-stable.list';
1346
+ const listNew = `${listTarget}.chat-hub.new`;
1347
+ try {
1348
+ writeFileSync(keyTemporary, await downloadFixedDependency(keyUrl), { mode: 0o600 });
1349
+ runSystem('gpg', ['--batch', '--yes', '--dearmor', '--output', keyNew, keyTemporary]);
1350
+ writeFileSync(listNew, await downloadFixedDependency(listUrl), { mode: 0o644 });
1351
+ renameSync(keyNew, keyTarget);
1352
+ renameSync(listNew, listTarget);
1353
+ runSystem('chmod', ['o+r', keyTarget, listTarget]);
1354
+ } finally {
1355
+ for (const path of [keyTemporary, keyNew, listNew]) {
1356
+ if (existsSync(path)) unlinkSync(path);
1357
+ }
1358
+ }
1359
+ runSystem('apt-get', ['update']);
1360
+ runSystem('apt-get', ['install', '-y', 'caddy']);
1361
+ }
1362
+
1363
+ async function installVerifiedStaticCaddy() {
1364
+ if (!findExecutable('tar')) throw new Error('安装 Caddy 需要系统提供 tar');
1365
+ if (existsSync(alinuxCaddyRelease.binaryPath)) {
1366
+ throw new Error(`${alinuxCaddyRelease.binaryPath} 已存在但不在 PATH;不会覆盖未知文件`);
1367
+ }
1368
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'chat-hub-caddy-'));
1369
+ const archivePath = join(temporaryDirectory, 'caddy.tar.gz');
1370
+ const extractedBinary = join(temporaryDirectory, 'caddy');
1371
+ const stagedBinary = `${alinuxCaddyRelease.binaryPath}.chat-hub.new`;
1372
+ let installedBinary = false;
1373
+ try {
1374
+ const archive = await downloadFixedDependency(
1375
+ alinuxCaddyRelease.archiveUrl,
1376
+ 64 * 1024 * 1024,
1377
+ 120_000,
1378
+ );
1379
+ const digest = createHash('sha256').update(archive).digest('hex');
1380
+ if (digest !== alinuxCaddyRelease.archiveSha256) {
1381
+ throw new Error(`Caddy ${alinuxCaddyRelease.version} 安装包 SHA-256 校验失败;不会安装`);
1382
+ }
1383
+ writeFileSync(archivePath, archive, { flag: 'wx', mode: 0o600 });
1384
+ runSystem('tar', ['-xzf', archivePath, '-C', temporaryDirectory, 'caddy']);
1385
+ if (!existsSync(extractedBinary) || !statSync(extractedBinary).isFile()) {
1386
+ throw new Error('Caddy 官方安装包没有包含普通文件 caddy');
1387
+ }
1388
+ if (existsSync(stagedBinary)) unlinkSync(stagedBinary);
1389
+ writeFileSync(stagedBinary, readFileSync(extractedBinary), { flag: 'wx', mode: 0o755 });
1390
+ chmodSync(stagedBinary, 0o755);
1391
+ const version = execFileSync(stagedBinary, ['version'], {
1392
+ encoding: 'utf8',
1393
+ timeout: 10_000,
1394
+ stdio: 'pipe',
1395
+ }).trim();
1396
+ if (!version.startsWith(`v${alinuxCaddyRelease.version} `)) {
1397
+ throw new Error(`Caddy 可执行文件版本异常:${version}`);
1398
+ }
1399
+ renameSync(stagedBinary, alinuxCaddyRelease.binaryPath);
1400
+ installedBinary = true;
1401
+ ensureCaddySystemUser();
1402
+ installCaddySystemdUnit();
1403
+ } catch (error) {
1404
+ if (existsSync(stagedBinary)) unlinkSync(stagedBinary);
1405
+ if (installedBinary && existsSync(alinuxCaddyRelease.binaryPath)) {
1406
+ unlinkSync(alinuxCaddyRelease.binaryPath);
1407
+ }
1408
+ throw error;
1409
+ } finally {
1410
+ rmSync(temporaryDirectory, { recursive: true, force: true });
1411
+ }
1412
+ }
1413
+
1414
+ function ensureCaddySystemUser() {
1415
+ try {
1416
+ execFileSync('id', ['-u', 'caddy'], { stdio: 'ignore' });
1417
+ } catch {
1418
+ runSystem('useradd', [
1419
+ '--system',
1420
+ '--user-group',
1421
+ '--home-dir',
1422
+ '/var/lib/caddy',
1423
+ '--create-home',
1424
+ '--shell',
1425
+ '/usr/sbin/nologin',
1426
+ 'caddy',
1427
+ ]);
1428
+ }
1429
+ mkdirSync('/var/lib/caddy', { recursive: true, mode: 0o750 });
1430
+ mkdirSync('/etc/caddy', { recursive: true, mode: 0o755 });
1431
+ runSystem('chown', ['-R', 'caddy:caddy', '/var/lib/caddy']);
1432
+ runSystem('chmod', ['0750', '/var/lib/caddy']);
1433
+ runSystem('chmod', ['0755', '/etc/caddy']);
1434
+ }
1435
+
1436
+ function installCaddySystemdUnit() {
1437
+ const content = alinuxCaddySystemdUnit();
1438
+ if (existsSync(alinuxCaddyRelease.servicePath)) {
1439
+ if (readFileSync(alinuxCaddyRelease.servicePath, 'utf8') !== content) {
1440
+ throw new Error(
1441
+ `${alinuxCaddyRelease.servicePath} 已存在且不是 Chat Hub 管理的版本;不会覆盖`,
1442
+ );
1443
+ }
1444
+ return;
1445
+ }
1446
+ const staged = `${alinuxCaddyRelease.servicePath}.chat-hub.new`;
1447
+ try {
1448
+ writeFileSync(staged, content, { flag: 'wx', mode: 0o644 });
1449
+ chmodSync(staged, 0o644);
1450
+ renameSync(staged, alinuxCaddyRelease.servicePath);
1451
+ } finally {
1452
+ if (existsSync(staged)) unlinkSync(staged);
1453
+ }
1454
+ }
1455
+
1456
+ async function downloadFixedDependency(url, maximumBytes = 1024 * 1024, timeout = 30_000) {
1457
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeout) });
1458
+ if (!response.ok) throw new Error(`下载官方 Caddy 依赖失败:HTTP ${response.status}`);
1459
+ const declaredLength = Number(response.headers.get('content-length'));
1460
+ if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
1461
+ throw new Error(`下载官方 Caddy 依赖超过 ${maximumBytes} 字节限制`);
1462
+ }
1463
+ if (!response.body) throw new Error('下载官方 Caddy 依赖没有响应正文');
1464
+ const reader = response.body.getReader();
1465
+ const chunks = [];
1466
+ let total = 0;
1467
+ while (true) {
1468
+ const { done, value } = await reader.read();
1469
+ if (done) break;
1470
+ total += value.byteLength;
1471
+ if (total > maximumBytes) {
1472
+ await reader.cancel();
1473
+ throw new Error(`下载官方 Caddy 依赖超过 ${maximumBytes} 字节限制`);
1474
+ }
1475
+ chunks.push(Buffer.from(value));
1476
+ }
1477
+ return Buffer.concat(chunks, total);
1478
+ }
1479
+
1480
+ async function waitForPublicGateway(endpoint) {
1481
+ const deadline = Date.now() + 45_000;
1482
+ let lastError = '健康接口尚未就绪';
1483
+ while (Date.now() < deadline) {
1484
+ try {
1485
+ const result = await fetchPublicGateway(endpoint, '/api/health');
1486
+ if (result?.ok === true && result.data?.status === 'ready') return;
1487
+ lastError = `健康状态为 ${String(result?.data?.status ?? 'unknown')}`;
1488
+ } catch (error) {
1489
+ // systemd may still be starting SQLite, the app process, and the gateway.
1490
+ lastError = safeDiagnosticText(error instanceof Error ? error.message : String(error));
1491
+ }
1492
+ await delay(250);
1493
+ }
1494
+ const service = safeServiceFailureSummary();
1495
+ throw new Error(
1496
+ `systemd 服务未在 45 秒内通过内部健康检查;最后错误:${lastError}${service ? `;脱敏摘要:${service}` : ''}`,
1497
+ );
1498
+ }
1499
+
1500
+ function safeServiceFailureSummary() {
1501
+ const parts = [];
1502
+ try {
1503
+ const properties = execFileSync(
1504
+ 'systemctl',
1505
+ [
1506
+ 'show',
1507
+ 'chat-hub.service',
1508
+ '--property=ActiveState,SubState,Result,ExecMainCode,ExecMainStatus',
1509
+ ],
1510
+ { encoding: 'utf8', timeout: 10_000, stdio: 'pipe' },
1511
+ )
1512
+ .trim()
1513
+ .replace(/\r?\n/g, ',');
1514
+ if (properties) parts.push(properties);
1515
+ } catch {
1516
+ // Keep the primary health failure when systemd metadata is unavailable.
1517
+ }
1518
+ try {
1519
+ const journal = execFileSync(
1520
+ 'journalctl',
1521
+ ['-u', 'chat-hub.service', '-n', '5', '--no-pager', '--output=cat'],
1522
+ { encoding: 'utf8', timeout: 10_000, stdio: 'pipe', maxBuffer: 64 * 1024 },
1523
+ )
1524
+ .split(/\r?\n/)
1525
+ .map(safeDiagnosticText)
1526
+ .filter(Boolean)
1527
+ .slice(-5)
1528
+ .join(' | ');
1529
+ if (journal) parts.push(journal);
1530
+ } catch {
1531
+ // Journal access is optional; do not hide the original failure.
1532
+ }
1533
+ return parts.join(';').slice(0, 1_200);
1534
+ }
1535
+
1536
+ function safeDiagnosticText(value) {
1537
+ return String(value)
1538
+ .replace(/([?&](?:token|key|secret|code)=)[^\s&]+/giu, '$1[REDACTED]')
1539
+ .replace(/(authorization|api[_-]?key|secret|token)\s*[:=]\s*\S+/giu, '$1=[REDACTED]')
1540
+ .replace(/[A-Za-z0-9_-]{40,}/g, '[REDACTED]')
1541
+ .replace(/[\r\n]+/g, ' ')
1542
+ .trim()
1543
+ .slice(0, 240);
1544
+ }
1545
+
1546
+ async function fetchPublicGateway(endpoint, path) {
1547
+ const publicHost = typeof endpoint === 'string' ? endpoint : endpoint.publicHost;
1548
+ const publicScheme = typeof endpoint === 'string' ? 'https' : endpoint.publicScheme;
1549
+ const response = await fetchInternalGateway(`http://127.0.0.1:8080${path}`, {
1550
+ headers: {
1551
+ host: publicHost,
1552
+ 'x-forwarded-proto': publicScheme,
1553
+ 'x-forwarded-for': '127.0.0.1',
1554
+ },
1555
+ signal: AbortSignal.timeout(2_000),
1556
+ });
1557
+ if (!response.ok) throw new Error(`内部安全网关返回 HTTP ${response.status}`);
1558
+ return response.json();
1559
+ }
1560
+
1561
+ function normalizedDomain(value) {
1562
+ if (!value || typeof value !== 'string') throw new Error('deploy 必须提供 --domain');
1563
+ const domain = value.trim().toLowerCase();
1564
+ if (
1565
+ domain.length > 253 ||
1566
+ !domain.includes('.') ||
1567
+ !/^[a-z0-9.-]+$/.test(domain) ||
1568
+ domain
1569
+ .split('.')
1570
+ .some(
1571
+ (label) => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'),
1572
+ ) ||
1573
+ domain === 'localhost' ||
1574
+ /^\d+(?:\.\d+){3}$/.test(domain)
1575
+ ) {
1576
+ throw new Error('domain 必须是有效的公网 DNS 域名,不能使用 IP 或 localhost');
1577
+ }
1578
+ return domain;
1579
+ }
1580
+
1581
+ function normalizedPublicIpv4(value) {
1582
+ if (!value || typeof value !== 'string') throw new Error('必须提供有效的公网 IPv4');
1583
+ const address = value.trim();
1584
+ if (isIP(address) !== 4) throw new Error('--http-ip 必须是有效的公网 IPv4');
1585
+ const [first, second, third] = address.split('.').map(Number);
1586
+ const nonPublic =
1587
+ first === 0 ||
1588
+ first === 10 ||
1589
+ first === 127 ||
1590
+ (first === 100 && second >= 64 && second <= 127) ||
1591
+ (first === 169 && second === 254) ||
1592
+ (first === 172 && second >= 16 && second <= 31) ||
1593
+ (first === 192 && second === 168) ||
1594
+ (first === 192 && second === 0 && third === 2) ||
1595
+ (first === 198 && (second === 18 || second === 19 || (second === 51 && third === 100))) ||
1596
+ (first === 203 && second === 0 && third === 113) ||
1597
+ first >= 224;
1598
+ if (nonPublic) throw new Error('--http-ip 必须是可路由的公网 IPv4,不能使用内网或保留地址');
1599
+ return address;
1600
+ }
1601
+
1602
+ function normalizedPublicEndpoint(optionValues) {
1603
+ const hasDomain = typeof optionValues['--domain'] === 'string';
1604
+ const hasHttpsIp = typeof optionValues['--ip'] === 'string';
1605
+ const hasHttpIp = typeof optionValues['--http-ip'] === 'string';
1606
+ if ([hasDomain, hasHttpsIp, hasHttpIp].filter(Boolean).length !== 1) {
1607
+ throw new Error('必须且只能提供 --domain、--ip 或 --http-ip 其中一个');
1608
+ }
1609
+ if (hasDomain) {
1610
+ return {
1611
+ publicHost: normalizedDomain(optionValues['--domain']),
1612
+ publicScheme: 'https',
1613
+ certificateMode: 'managed-domain',
1614
+ };
1615
+ }
1616
+ if (hasHttpsIp) {
1617
+ return {
1618
+ publicHost: normalizedPublicIpv4(optionValues['--ip']),
1619
+ publicScheme: 'https',
1620
+ certificateMode: 'ip-shortlived',
1621
+ };
1622
+ }
1623
+ return {
1624
+ publicHost: normalizedPublicIpv4(optionValues['--http-ip']),
1625
+ publicScheme: 'http',
1626
+ certificateMode: 'none',
1627
+ };
1628
+ }
1629
+
1630
+ async function currentStatus(config, statePath) {
1631
+ const state = readJson(statePath);
1632
+ const processExists = Boolean(state?.pid && isProcessAlive(state.pid));
1633
+ const healthy = Boolean(config && processExists && (await probe(config)));
1634
+ return {
1635
+ installed: Boolean(config),
1636
+ running: processExists,
1637
+ healthy,
1638
+ localUrl: config ? `http://127.0.0.1:${config.httpPort}` : undefined,
1639
+ phoneUrl: phoneUrl(config),
1640
+ state,
1641
+ };
1642
+ }
1643
+
1644
+ async function stop(config, statePath) {
1645
+ if (!config) return { stopped: true, alreadyStopped: true, dataPreserved: true };
1646
+ const state = readJson(statePath);
1647
+ if (!state?.pid || !isProcessAlive(state.pid)) {
1648
+ return { stopped: true, alreadyStopped: true, dataPreserved: true };
1649
+ }
1650
+ if (state.id !== config.id || state.runId !== config.runId || !(await probe(config))) {
1651
+ throw new Error('实例身份或健康状态无法确认;不会终止可能不属于 Chat Hub 的进程');
1652
+ }
1653
+ if (process.platform === 'win32' && state.appPid && isProcessAlive(state.appPid)) {
1654
+ process.kill(state.appPid, 'SIGTERM');
1655
+ }
1656
+ process.kill(state.pid, 'SIGTERM');
1657
+ const deadline = Date.now() + 15_000;
1658
+ while (
1659
+ Date.now() < deadline &&
1660
+ (isProcessAlive(state.pid) ||
1661
+ (process.platform === 'win32' && state.appPid && isProcessAlive(state.appPid)))
1662
+ ) {
1663
+ await delay(200);
1664
+ }
1665
+ if (
1666
+ isProcessAlive(state.pid) ||
1667
+ (process.platform === 'win32' && state.appPid && isProcessAlive(state.appPid))
1668
+ ) {
1669
+ throw new Error('服务未在超时内停止,请检查日志');
1670
+ }
1671
+ writeJson(statePath, {
1672
+ ...state,
1673
+ status: 'stopped',
1674
+ code: 'stopped_by_cli',
1675
+ updatedAt: new Date().toISOString(),
1676
+ });
1677
+ return { stopped: true, dataPreserved: true };
1678
+ }
1679
+
1680
+ function hasRuntimeChanges(saved) {
1681
+ return (
1682
+ (options['--data-dir'] && resolve(options['--data-dir']) !== saved.dataDirectory) ||
1683
+ (options['--api-port'] && Number(options['--api-port']) !== saved.apiPort) ||
1684
+ (options['--http-port'] && Number(options['--http-port']) !== saved.httpPort) ||
1685
+ (options['--lan-ip'] && options['--lan-ip'] !== saved.lanIp) ||
1686
+ (options['--lan'] && !saved.lanIp)
1687
+ );
1688
+ }
1689
+
1690
+ function selectLan(saved) {
1691
+ const requested = options['--lan-ip'] ?? (options['--lan'] ? undefined : saved?.lanIp);
1692
+ const candidates = lanAddresses();
1693
+ if (requested) {
1694
+ const selected = candidates.find((entry) => entry.address === requested);
1695
+ if (!selected) throw new Error('指定或已保存的局域网地址当前不可用,请运行 doctor');
1696
+ return selected;
1697
+ }
1698
+ if (!options['--lan']) return undefined;
1699
+ if (candidates.length !== 1) throw new Error('局域网地址不唯一,请用 --lan-ip 明确指定');
1700
+ return candidates[0];
1701
+ }
1702
+
1703
+ function lanAddresses() {
1704
+ return Object.entries(networkInterfaces()).flatMap(([name, entries]) =>
1705
+ (entries ?? [])
1706
+ .filter(
1707
+ (entry) =>
1708
+ !entry.internal &&
1709
+ entry.family === 'IPv4' &&
1710
+ /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(entry.address),
1711
+ )
1712
+ .map((entry) => ({ name, address: entry.address, netmask: entry.netmask })),
1713
+ );
1714
+ }
1715
+
1716
+ function ensureLocalLinuxMasterKey(runtimeDirectory) {
1717
+ const path = join(runtimeDirectory, 'local-master-key');
1718
+ if (!existsSync(path)) writeFileSync(path, randomBytes(32), { flag: 'wx', mode: 0o600 });
1719
+ chmodSync(path, 0o600);
1720
+ return path;
1721
+ }
1722
+
1723
+ function mkdirSecure(directory) {
1724
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
1725
+ if (process.platform !== 'win32') chmodSync(directory, 0o700);
1726
+ }
1727
+
1728
+ function writeJson(path, value) {
1729
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
1730
+ if (process.platform !== 'win32') chmodSync(path, 0o600);
1731
+ }
1732
+
1733
+ function readJson(path) {
1734
+ if (!existsSync(path)) return undefined;
1735
+ try {
1736
+ return JSON.parse(readFileSync(path, 'utf8'));
1737
+ } catch {
1738
+ throw new Error(`运行状态文件损坏:${path}`);
1739
+ }
1740
+ }
1741
+
1742
+ function parsePort(name, fallback) {
1743
+ const value = Number(options[name] ?? fallback);
1744
+ if (!Number.isInteger(value) || value < 1024 || value > 65_535) {
1745
+ throw new Error(`${name} 必须是 1024–65535 的整数`);
1746
+ }
1747
+ return value;
1748
+ }
1749
+
1750
+ async function freePort(port, host) {
1751
+ await new Promise((resolvePromise, rejectPromise) => {
1752
+ const server = createServer();
1753
+ server.once('error', () => rejectPromise(new Error(`${host}:${port} 已被占用或不可绑定`)));
1754
+ server.listen(port, host, () => server.close(resolvePromise));
1755
+ });
1756
+ }
1757
+
1758
+ async function probe(config) {
1759
+ try {
1760
+ const setup = await fetch(`http://127.0.0.1:${config.httpPort}/__setup/status`, {
1761
+ signal: AbortSignal.timeout(1_000),
1762
+ });
1763
+ const setupBody = await setup.json();
1764
+ if (!setup.ok || setupBody.id !== config.id) return false;
1765
+ const health = await fetch(`http://127.0.0.1:${config.httpPort}/api/health`, {
1766
+ signal: AbortSignal.timeout(1_000),
1767
+ });
1768
+ const healthBody = await health.json();
1769
+ return health.ok && healthBody.ok === true && healthBody.data?.status === 'ready';
1770
+ } catch {
1771
+ return false;
1772
+ }
1773
+ }
1774
+
1775
+ function phoneUrl(config) {
1776
+ return config?.lanIp ? `http://${config.lanIp}:${config.httpPort}` : undefined;
1777
+ }
1778
+
1779
+ function isProcessAlive(pid) {
1780
+ try {
1781
+ process.kill(pid, 0);
1782
+ return true;
1783
+ } catch {
1784
+ return false;
1785
+ }
1786
+ }
1787
+
1788
+ function delay(milliseconds) {
1789
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
1790
+ }
1791
+
1792
+ function report(value) {
1793
+ console.log(JSON.stringify({ ok: true, ...value }, null, 2));
1794
+ }