@jacksontian/kite-server 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config.js DELETED
@@ -1,540 +0,0 @@
1
- /**
2
- * 云端 server 配置加载与校验。
3
- *
4
- * 纯 CLI 形态:任何动作都走子命令,裸 kites(无参数)只打印用法、绝不启动服务。
5
- * 只认 --config 指定的 YAML 配置文件(不再读取任何环境变量):
6
- * kites start --config /path/to/server.yaml
7
- * 结构与类型校验由 zod schema 完成(CONFIG_SCHEMA):未知配置项、
8
- * 类型错误、缺必填项一次性报全后 exit 1;dataDir 的可创建/可写校验
9
- * 是运行时行为,仍在校验通过后单独执行。模板见包内 config.example.yaml。
10
- *
11
- * 另支持子命令与常规 CLI 选项:
12
- * kites start 启动云端服务(--config 必填);裸 kites 不带子命令只打印用法,不启动
13
- * kites keygen 生成强随机密钥并打印配置片段
14
- * kites init [路径] 在目标位置(默认当前目录 server.yaml)生成填好随机密钥的配置文件
15
- * kites check 只校验 --config 指定的配置文件,通过打印摘要退出,不启动服务
16
- * kites deploy 基于包内 systemd 模板生成占位符已按本机替换好的 unit 文件
17
- * kites --help 打印用法说明(-h 等效)
18
- * kites --version 打印版本号(-V 等效)
19
- * apiSecret 若仍是配置模板的占位符文本,启动时直接拦截,避免裸奔。
20
- */
21
-
22
- import fs from 'node:fs';
23
- import os from 'node:os';
24
- import path from 'node:path';
25
- import crypto from 'node:crypto';
26
- import cp from 'node:child_process';
27
- import { fileURLToPath } from 'node:url';
28
- import YAML from 'yaml';
29
- import { z } from 'zod';
30
-
31
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
-
33
- /** 用法说明:--help/-h 输出到 stdout 并 exit 0(符合 CLI 惯例);
34
- * 裸 kites(不带任何子命令)同样只打印用法,不启动服务 */
35
- function printHelp() {
36
- console.log('用法:kites <子命令> [选项]');
37
- console.log('');
38
- console.log('子命令:');
39
- console.log(' kites start 启动云端服务(前台运行;常驻托管交给 systemd,见 deploy)');
40
- console.log(' -c, --config <路径> 指定 YAML 配置文件(必填,模板见包内 config.example.yaml)');
41
- console.log(' kites check 只校验 --config 指定的配置文件(改配置后干跑验证),通过即退出,不启动服务');
42
- console.log(' kites keygen 生成强随机密钥并打印配置片段');
43
- console.log(' kites init [路径] 基于包内模板生成配置文件(默认当前目录 server.yaml),自动填好随机密钥');
44
- console.log(' kites deploy [输出] 基于包内 systemd 模板生成占位符已替换好的 unit 文件(默认当前目录 kiteserver.service),');
45
- console.log(' 可选 --config <路径> 指定 unit 里的配置文件路径(默认 /opt/kite/server.yaml)');
46
- console.log('');
47
- console.log('选项:');
48
- console.log(' -h, --help 打印本帮助');
49
- console.log(' -V, --version 打印版本号');
50
- console.log('');
51
- console.log('不带任何子命令时只打印本用法,不会启动服务。');
52
- }
53
-
54
- /** --version/-V:读取包内 package.json 输出版本号(与 npm 发布版本一致) */
55
- function printVersion() {
56
- try {
57
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
58
- console.log(`kites (${pkg.name}) ${pkg.version}`);
59
- } catch (err) {
60
- console.error(`[server] 无法读取包内 package.json:${err.message}`);
61
- process.exit(1);
62
- }
63
- }
64
-
65
- /** 常规 CLI 选项:--help/-h 与 --version/-V 优先于子命令/配置加载,识别到即退出 */
66
- function maybeMeta() {
67
- const args = process.argv.slice(2);
68
- if (args.includes('--help') || args.includes('-h')) {
69
- printHelp();
70
- process.exit(0);
71
- }
72
- if (args.includes('--version') || args.includes('-V')) {
73
- printVersion();
74
- process.exit(0);
75
- }
76
- }
77
-
78
- const SUBCOMMANDS = ['start', 'check', 'keygen', 'init', 'deploy'];
79
-
80
- /** 子命令分发:返回 argv[2] 的子命令名。CLI 化的核心约定——启动服务必须显式
81
- * 走 start 子命令;裸 kites(不带任何参数)只打印用法退出,绝不启动服务 */
82
- function dispatch() {
83
- const cmd = process.argv[2];
84
- if (cmd === undefined) {
85
- printHelp();
86
- process.exit(0);
87
- }
88
- if (SUBCOMMANDS.includes(cmd)) return cmd;
89
- if (cmd === '--config' || cmd === '-c' || cmd.startsWith('--config=')) {
90
- // 旧版写法(kites --config x):明确告知启动已改为 start 子命令,不静默兼容
91
- console.error('[server] 启动服务请显式使用 start 子命令:kites start --config <配置文件路径>');
92
- console.error('[server] 运行 kites --help 查看用法');
93
- process.exit(1);
94
- }
95
- console.error(`[server] 未知子命令 "${cmd}";运行 kites --help 查看用法`);
96
- process.exit(1);
97
- }
98
-
99
- /** check 子命令位参数(argv[2] === 'check'):其余照常解析,校验通过后在
100
- * loadServerConfig 末尾打印摘要退出,不真正启动服务 */
101
- function isCheckMode() {
102
- return process.argv[2] === 'check';
103
- }
104
-
105
- /** 解析 start/check 子命令后的参数;--config 必填,缺失/重复/取不到值/出现未知参数立即退出 */
106
- function parseArgs() {
107
- const sub = process.argv[2]; // dispatch 已保证是 start 或 check
108
- const args = process.argv.slice(3);
109
- const values = [];
110
- for (let i = 0; i < args.length; i++) {
111
- if (args[i] === '--config' || args[i] === '-c') {
112
- values.push(args[i + 1]);
113
- i++;
114
- } else if (args[i].startsWith('--config=')) {
115
- values.push(args[i].slice('--config='.length));
116
- } else {
117
- // 输错参数不再静默忽略:报出具体参数并指路 --help,避免误以为已生效
118
- console.error(`[server] 未知参数 "${args[i]}";运行 kites --help 查看用法`);
119
- process.exit(1);
120
- }
121
- }
122
- if (values.length === 0) {
123
- console.error(`[server] 缺少 --config:kites ${sub} --config <配置文件路径>`);
124
- console.error('[server] 运行 kites --help 查看用法;配置模板见包内 config.example.yaml');
125
- process.exit(1);
126
- }
127
- if (values.length > 1) {
128
- console.error('[server] --config 只能指定一次');
129
- process.exit(1);
130
- }
131
- const file = String(values[0] ?? '').trim();
132
- if (!file || file.startsWith('-')) {
133
- console.error('[server] --config 后需要给出配置文件路径');
134
- process.exit(1);
135
- }
136
- return path.resolve(file);
137
- }
138
-
139
- function fail(errors, tag) {
140
- console.error(`[${tag}] 配置错误,无法启动:`);
141
- for (const e of errors) console.error(' - ' + e);
142
- console.error(`[${tag}] 请检查 --config 指定的 YAML 文件,模板见包内 config.example.yaml`);
143
- process.exit(1);
144
- }
145
-
146
- function isPlainObject(v) {
147
- return v !== null && typeof v === 'object' && !Array.isArray(v);
148
- }
149
-
150
- /** config.example.yaml 里的占位文本:原样未改就启动等于用公开"密钥"裸奔,必须拦下 */
151
- const PLACEHOLDER_SECRET = '换成你生成的密钥';
152
-
153
- /** keygen 子命令(kites keygen):生成强随机密钥并打印配置片段,免去手工生成/搬运 hex */
154
- function maybeKeygen() {
155
- if (process.argv[2] !== 'keygen') return;
156
- const secret = crypto.randomBytes(32).toString('hex');
157
- console.log('[server] 已生成签名密钥(永不在网络上传输,请妥善保管):');
158
- console.log('');
159
- console.log(' ' + secret);
160
- console.log('');
161
- console.log('[server] 粘贴到 server.yaml(手机浏览器控制台使用同一密钥):');
162
- console.log('');
163
- console.log(' apiSecret: ' + secret);
164
- console.log('');
165
- console.log('[server] 建议再执行一次 kites keygen 生成不同的 workerSecret(分权见 README「安全说明」),');
166
- console.log('[server] Worker 端 worker.yaml 的 apiSecret 与 workerSecret 保持一致');
167
- process.exit(0);
168
- }
169
-
170
- /** init/deploy 就跑在目标环境里,顺手解析 kites 可执行路径(等效 `which kites`),
171
- * 好把 unit 安装命令里的 __KITES_BIN__ 占位符换成现成值;
172
- * 取不到(如仓库源码里直接跑)则返回 null 由调用方决定回退方式 */
173
- function resolveKitesBin() {
174
- try {
175
- const p = cp.execSync('which kites', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
176
- if (p) return p;
177
- } catch { /* PATH 里没有 kites */ }
178
- return null;
179
- }
180
-
181
- /** init 子命令(kites init [配置文件路径]):基于包内模板一步生成配置文件,
182
- * 自动填好随机 apiSecret/workerSecret,免去「复制模板 + keygen + 粘贴」三步;
183
- * 默认生成在当前目录(好找),生产部署建议显式 init 到 /opt/kite/server.yaml;
184
- * 已存在的目标绝不覆盖 */
185
- function maybeInit() {
186
- if (process.argv[2] !== 'init') return;
187
- const rawPath = process.argv[3] || 'server.yaml';
188
- if (!rawPath.trim() || rawPath.startsWith('-')) {
189
- console.error('[server] init 用法:kites init [配置文件路径](默认当前目录 server.yaml)');
190
- process.exit(1);
191
- }
192
- const target = path.resolve(expandHome(rawPath));
193
- if (fs.existsSync(target)) {
194
- console.error(`[server] ${target} 已存在,init 不会覆盖;如需重新生成请先备份删除或换路径`);
195
- process.exit(1);
196
- }
197
- let tpl;
198
- try {
199
- tpl = fs.readFileSync(path.join(__dirname, 'config.example.yaml'), 'utf8');
200
- } catch (err) {
201
- console.error(`[server] 无法读取包内配置模板 config.example.yaml:${err.message}`);
202
- process.exit(1);
203
- }
204
- const apiSecret = crypto.randomBytes(32).toString('hex');
205
- const workerSecret = crypto.randomBytes(32).toString('hex');
206
- // dataDir 固化为配置文件同目录下的绝对路径,与启动工作目录解耦
207
- const dataDir = path.join(path.dirname(target), 'data');
208
- const content = tpl
209
- .replace(`apiSecret: ${PLACEHOLDER_SECRET}`, `apiSecret: ${apiSecret}`)
210
- .replace('workerSecret: ""', `workerSecret: ${workerSecret}`)
211
- .replace('dataDir: ./data', `dataDir: ${dataDir}`);
212
- if (!content.includes(apiSecret)) {
213
- console.error('[server] 包内配置模板结构与预期不符(找不到占位符),请改用手工方式:复制 config.example.yaml 后填写');
214
- process.exit(1);
215
- }
216
- try {
217
- fs.mkdirSync(path.dirname(target), { recursive: true });
218
- fs.writeFileSync(target, content, { mode: 0o600 });
219
- } catch (err) {
220
- console.error(`[server] 无法写入 ${target}:${err.message}`);
221
- console.error('[server] 目标目录需要 root 权限时请改用 sudo,或 init 到自定义路径');
222
- process.exit(1);
223
- }
224
- console.log(`[server] 已生成配置:${target}(权限 0600)`);
225
- console.log('');
226
- console.log(` apiSecret(控制台/手机浏览器粘贴这个):${apiSecret}`);
227
- console.log(` workerSecret(Worker 专用,填入 worker.yaml 的 apiSecret):${workerSecret}`);
228
- console.log('');
229
- console.log('[server] 密钥已写入配置文件,请另行妥善保存(永不在网络上传输)');
230
- console.log(`[server] 干跑验证:kites check --config ${target}`);
231
- console.log(`[server] 启动:kites start --config ${target}`);
232
- if (target.startsWith('/opt/kite')) {
233
- console.log('[server] 生产部署后续步骤(systemd 以 www-data 运行):');
234
- console.log(` sudo mkdir -p ${dataDir} && sudo chown -R www-data:www-data ${dataDir}`);
235
- console.log(` sudo chown www-data:www-data ${target}`);
236
- console.log(' 安装 systemd unit(kites deploy 自动按本机环境替换占位符,无需手工 sed):');
237
- console.log(` kites deploy --config ${target}`);
238
- console.log(' sudo install -m 644 kiteserver.service /etc/systemd/system/kiteserver.service');
239
- console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
240
- } else {
241
- console.log('[server] 生产部署建议放 /opt/kite:sudo kites init /opt/kite/server.yaml,');
242
- console.log('[server] 再按 README「云端部署」做 www-data 授权与 systemd unit 安装(kites deploy 一步生成)');
243
- }
244
- process.exit(0);
245
- }
246
-
247
- /** deploy 子命令(kites deploy [输出路径] [--config <配置文件路径>]):
248
- * 读取包内 systemd 模板,把 __KITES_BIN__ / __CONFIG_FILE__ 按本机环境替换后
249
- * 写出可直接安装的 unit 文件,免去手工 sed;默认输出到当前目录 kiteserver.service。
250
- * 产物可随升级重复生成(覆盖写),安装命令打印在输出里 */
251
- function maybeDeploy() {
252
- if (process.argv[2] !== 'deploy') return;
253
- const args = process.argv.slice(3);
254
- let rawOut = '';
255
- let rawConfig = '/opt/kite/server.yaml';
256
- for (let i = 0; i < args.length; i++) {
257
- if (args[i] === '--config' || args[i] === '-c') {
258
- rawConfig = String(args[++i] ?? '');
259
- } else if (args[i].startsWith('--config=')) {
260
- rawConfig = args[i].slice('--config='.length);
261
- } else if (!args[i].startsWith('-') && !rawOut) {
262
- rawOut = args[i];
263
- } else {
264
- console.error(`[server] deploy 用法:kites deploy [unit 输出路径] [--config <配置文件路径>]`);
265
- console.error(`[server] 无法识别的参数:"${args[i]}"`);
266
- process.exit(1);
267
- }
268
- }
269
- if (!rawConfig.trim() || rawConfig.startsWith('-')) {
270
- console.error('[server] deploy 的 --config 后需要给出配置文件路径');
271
- process.exit(1);
272
- }
273
- const configFile = path.resolve(expandHome(rawConfig));
274
- const outPath = path.resolve(expandHome(rawOut || 'kiteserver.service'));
275
-
276
- const kitesBin = resolveKitesBin();
277
- if (!kitesBin) {
278
- console.error('[server] PATH 里找不到 kites 可执行文件(which kites 失败),无法填写 unit 的 ExecStart');
279
- console.error('[server] 请在 npm 全局安装本包的机器上运行:npm i -g @jacksontian/kite-server');
280
- process.exit(1);
281
- }
282
- let tpl;
283
- try {
284
- tpl = fs.readFileSync(path.join(__dirname, 'deploy', 'kiteserver.service'), 'utf8');
285
- } catch (err) {
286
- console.error(`[server] 无法读取包内 unit 模板 deploy/kiteserver.service:${err.message}`);
287
- process.exit(1);
288
- }
289
- const content = tpl.replaceAll('__KITES_BIN__', kitesBin).replaceAll('__CONFIG_FILE__', configFile);
290
- if (/__[A-Z_]+__/.test(content)) {
291
- console.error('[server] 包内 unit 模板结构与预期不符(仍有未替换的占位符),请改用手工方式安装');
292
- process.exit(1);
293
- }
294
- try {
295
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
296
- fs.writeFileSync(outPath, content, { mode: 0o644 });
297
- } catch (err) {
298
- console.error(`[server] 无法写入 ${outPath}:${err.message}`);
299
- process.exit(1);
300
- }
301
- console.log(`[server] 已生成 systemd unit:${outPath}`);
302
- console.log(` ExecStart: ${kitesBin} start --config ${configFile}`);
303
- if (!fs.existsSync(configFile)) {
304
- console.warn(`[server] 注意:${configFile} 尚不存在,安装 unit 前请先生成:kites init ${configFile}`);
305
- }
306
- console.log('[server] 安装(unit 以 www-data 运行,注意先完成 README「云端部署」的目录授权):');
307
- console.log(` sudo install -m 644 ${outPath} /etc/systemd/system/kiteserver.service`);
308
- console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
309
- process.exit(0);
310
- }
311
-
312
- /** 可选字符串:缺省/给 null 都按空串处理,统一 trim
313
- * (zod v4 中 union(z.undefined()) 不能让对象键变可选,须用 .optional()) */
314
- const optStr = (errMsg) =>
315
- z.string({ error: errMsg }).trim().optional().nullable().transform((v) => v ?? '');
316
-
317
- /** 把 zod issues 转成错误行:未知配置项逐条展开并附该层级可用项清单;
318
- * custom/各叶子的报错文案均已自带字段名,直接透传 */
319
- function formatIssues(issues, knownByPath) {
320
- const out = [];
321
- for (const iss of issues) {
322
- if (iss.code === 'unrecognized_keys') {
323
- const known = knownByPath[iss.path.join('.')] || [];
324
- for (const key of iss.keys) out.push(`未知配置项 "${key}"(可用:${known.join(', ')})`);
325
- continue;
326
- }
327
- out.push(iss.message);
328
- }
329
- return out;
330
- }
331
-
332
- /** 展开开头的 ~ 为用户主目录(YAML 不会替我们做这件事) */
333
- function expandHome(p) {
334
- if (p === '~') return os.homedir();
335
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
336
- return p;
337
- }
338
-
339
- const SECRET_HINT =
340
- '(两端共享的签名密钥;生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))")';
341
-
342
- const CONFIG_SCHEMA = z.strictObject({
343
- apiSecret: z
344
- .string({
345
- error: (iss) =>
346
- iss.input === undefined || iss.input === null
347
- ? `缺少 apiSecret${SECRET_HINT}`
348
- : 'apiSecret 必须是字符串(建议加引号,避免被 YAML 解析为数字)',
349
- })
350
- .trim()
351
- .min(1, `缺少 apiSecret${SECRET_HINT}`),
352
- // Worker 专用密钥(可选):配置后 Worker 侧 API(claim/heartbeat/logs/finish)
353
- // 与 /worker/ws 只认该密钥,控制台密钥(apiSecret)泄露不再等于 Worker 执行权。
354
- // 留空则两端共用 apiSecret(向后兼容)
355
- workerSecret: optStr('workerSecret 必须是字符串'),
356
- // 多 Worker 独立密钥(可选):workerId -> 密钥映射,每台 Worker 一密钥。
357
- // 配置后 Worker 侧验签命中该映射的密钥即绑定其 workerId 身份(请求自报的
358
- // workerId 必须一致),Worker 之间无法互相冒充;与 workerSecret 可并存
359
- // (命中共享密钥时身份仍自报,兼容旧部署)。均未配置时回落共用 apiSecret
360
- workerSecrets: z
361
- .record(z.string(), z.string({ error: 'workerSecrets 的密钥必须是字符串' }).trim(), {
362
- error: 'workerSecrets 必须是「workerId: 密钥」的映射',
363
- })
364
- .optional(),
365
- port: z.coerce
366
- .number({ error: 'port 必须是数字' })
367
- .int('port 必须是整数')
368
- .min(1, 'port 超出范围 [1, 65535]')
369
- .max(65535, 'port 超出范围 [1, 65535]')
370
- .default(8787),
371
- // 语义(~ 展开 / 相对启动工作目录 / 缺省 ~/.kite/data / 可写校验)
372
- // 无法纯声明式表达,schema 只约束类型,解析后命令式处理
373
- dataDir: optStr('dataDir 必须是字符串'),
374
- maxTasks: z.coerce.number({ error: 'maxTasks 必须是数字' }).positive('maxTasks 必须是正数').default(200),
375
- staleRunningSec: z.coerce
376
- .number({ error: 'staleRunningSec 必须是数字' })
377
- .positive('staleRunningSec 必须是正数')
378
- .default(600),
379
- sigWindowSec: z.coerce
380
- .number({ error: 'sigWindowSec 必须是数字' })
381
- .positive('sigWindowSec 必须是正数')
382
- .default(300),
383
- ptyIdleSec: z.coerce
384
- .number({ error: 'ptyIdleSec 必须是数字' })
385
- .positive('ptyIdleSec 必须是正数')
386
- .default(90),
387
- imWebhook: optStr('imWebhook 必须是字符串'),
388
- imSecret: optStr('imSecret 必须是字符串'),
389
- imNotifyEvents: z
390
- .array(
391
- // 与原实现一致:元素宽容转字符串后归一化小写再比枚举
392
- z.coerce.string({ error: 'imNotifyEvents 元素必须是字符串' }).trim().toLowerCase().pipe(
393
- z.enum(['done', 'failed', 'canceled'], {
394
- error: (iss) => `imNotifyEvents 含非法事件 "${iss.input}"(可用:done|failed|canceled)`,
395
- }),
396
- ),
397
- { error: 'imNotifyEvents 必须是数组(如 [failed, done])' },
398
- )
399
- .default(['failed']),
400
- });
401
-
402
- /** 加载并校验配置,返回归一化后的常量对象 */
403
- export async function loadServerConfig() {
404
- const tag = 'server';
405
- maybeMeta();
406
- dispatch(); // 裸调用/旧写法/未知子命令在此拦截;能走到下面的必是五个子命令之一
407
- maybeKeygen();
408
- maybeInit();
409
- maybeDeploy();
410
- const configFile = parseArgs();
411
-
412
- let raw;
413
- try {
414
- raw = await fs.promises.readFile(configFile, 'utf8');
415
- } catch (err) {
416
- console.error(`[${tag}] 无法读取配置文件 ${configFile}: ${err.message}`);
417
- process.exit(1);
418
- }
419
-
420
- const doc = YAML.parseDocument(raw);
421
- if (doc.errors.length) {
422
- console.error(`[${tag}] 配置文件 YAML 语法错误(${configFile}):`);
423
- for (const e of doc.errors) console.error(' - ' + e.message);
424
- process.exit(1);
425
- }
426
- const cfg = doc.toJS();
427
- if (!isPlainObject(cfg)) {
428
- console.error(`[${tag}] 配置文件内容必须是一个键值映射(${configFile})`);
429
- process.exit(1);
430
- }
431
-
432
- const parsed = CONFIG_SCHEMA.safeParse(cfg);
433
- if (!parsed.success) {
434
- fail(formatIssues(parsed.error.issues, { '': Object.keys(CONFIG_SCHEMA.shape) }), tag);
435
- }
436
- const v = parsed.data;
437
- const errors = [];
438
-
439
- // 模板占位符原样未改:此时启动等于用公开"密钥"裸奔,显式拦下并指路 keygen
440
- if (v.apiSecret === PLACEHOLDER_SECRET) {
441
- errors.push('apiSecret 仍是配置模板的占位符文本,请生成真实密钥后填入:kites keygen');
442
- }
443
- if (v.workerSecret === PLACEHOLDER_SECRET) {
444
- errors.push('workerSecret 仍是配置模板的占位符文本,请生成真实密钥后填入(或留空共用 apiSecret):kites keygen');
445
- }
446
-
447
- // workerSecrets:键即 workerId(与 worker.yaml 的 workerId 同规则);
448
- // 密钥非空/非占位符,且两两不同(同密钥会让服务端无法区分身份)
449
- const workerSecrets = {};
450
- if (v.workerSecrets) {
451
- const seen = new Map(); // secret -> workerId
452
- for (const [id, secret] of Object.entries(v.workerSecrets)) {
453
- if (!/^[\w.-]{1,128}$/.test(id)) {
454
- errors.push(`workerSecrets 的 Worker 标识 "${id}" 非法(只能含字母/数字/._-,不超过 128 字符)`);
455
- continue;
456
- }
457
- if (!secret) {
458
- errors.push(`workerSecrets.${id} 密钥不能为空(kites keygen 生成)`);
459
- continue;
460
- }
461
- if (secret === PLACEHOLDER_SECRET) {
462
- errors.push(`workerSecrets.${id} 仍是配置模板的占位符文本,请填入真实密钥:kites keygen`);
463
- continue;
464
- }
465
- if (seen.has(secret)) {
466
- errors.push(`workerSecrets 中 "${id}" 与 "${seen.get(secret)}" 配置了相同密钥,服务端无法区分两者身份`);
467
- continue;
468
- }
469
- seen.set(secret, id);
470
- workerSecrets[id] = secret;
471
- }
472
- if (v.workerSecret && seen.has(v.workerSecret)) {
473
- errors.push(
474
- `workerSecret 与 workerSecrets.${seen.get(v.workerSecret)} 的密钥相同,身份绑定会有歧义,请配置不同密钥`,
475
- );
476
- }
477
- }
478
-
479
- // 未配置时默认主目录下的固定位置,与配置文件位置、启动目录都解耦;
480
- // 相对路径相对启动工作目录解析(path.resolve 默认行为),建议写绝对路径
481
- const dataDir = path.resolve(
482
- expandHome(v.dataDir || path.join(os.homedir(), '.kite', 'data')),
483
- );
484
- // 启动前校验可创建、可写:误配在这里一次性报清,
485
- // 而不是运行中落盘失败才静默降级(只打日志、数据留在内存)
486
- try {
487
- await fs.promises.mkdir(dataDir, { recursive: true });
488
- const probe = path.join(dataDir, '.rw-write-probe');
489
- await fs.promises.writeFile(probe, '');
490
- await fs.promises.rm(probe);
491
- } catch (err) {
492
- if (err.code === 'EEXIST' || err.code === 'ENOTDIR') {
493
- errors.push(`dataDir 指向的路径已存在但不是目录:${dataDir}`);
494
- } else {
495
- errors.push(`dataDir 无法创建或不可写(${dataDir}):${err.message};请确认运行用户对该目录有读写权限`);
496
- }
497
- }
498
- if (errors.length) fail(errors, tag);
499
-
500
- // 典型误配提示:配了加签密钥但没配 webhook,secret 会被静默忽略
501
- if (v.imSecret && !v.imWebhook) {
502
- console.warn(`[${tag}] 已配置 imSecret 但 imWebhook 为空,加签密钥将被忽略`);
503
- }
504
-
505
- // check 子命令(kites check --config x):与启动完全相同的校验已通过,
506
- // 打印归一化摘要后直接退出,改完配置不用真启动服务(占端口)就能验证。
507
- // 密钥只报长度不回显内容
508
- if (isCheckMode()) {
509
- console.log(`[${tag}] 配置校验通过:${configFile}`);
510
- console.log(` port: ${v.port}`);
511
- console.log(` dataDir: ${dataDir}`);
512
- console.log(` apiSecret: 已配置(${v.apiSecret.length} 字符)`);
513
- console.log(` workerSecret: ${v.workerSecret ? '已单独配置(Worker 专用密钥)' : '未配置,与 apiSecret 共用'}`);
514
- console.log(` IM 通知: ${v.imWebhook ? `已配置(事件:${v.imNotifyEvents.join(', ')})` : '未配置'}`);
515
- console.log(`[${tag}] 可以启动服务:kites start --config ${configFile}`);
516
- process.exit(0);
517
- }
518
-
519
- // Worker 侧可接受的密钥及身份绑定:workerSecrets 条目验签命中即绑定 workerId;
520
- // workerSecret 为共享兼容路径(workerId 为 null 表示身份自报);
521
- // 两者都未配置时回落共用 apiSecret(单密钥模式)
522
- const workerAuth = Object.entries(workerSecrets).map(([workerId, secret]) => ({ secret, workerId }));
523
- if (v.workerSecret) workerAuth.push({ secret: v.workerSecret, workerId: null });
524
- if (!workerAuth.length) workerAuth.push({ secret: v.apiSecret, workerId: null });
525
-
526
- return {
527
- configFile,
528
- apiSecret: v.apiSecret,
529
- workerAuth,
530
- port: v.port,
531
- dataDir,
532
- maxTasks: v.maxTasks,
533
- staleRunningMs: v.staleRunningSec * 1000,
534
- sigWindowSec: v.sigWindowSec,
535
- ptyIdleMs: v.ptyIdleSec * 1000,
536
- imWebhook: v.imWebhook,
537
- imSecret: v.imSecret,
538
- imNotifyEvents: new Set(v.imNotifyEvents),
539
- };
540
- }