@jacksontian/kite-server 0.3.0 → 0.4.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,31 @@
1
+ /**
2
+ * WebSocket 实时推送:控制台通过 /ws 建立长连接,任务变更即时下发;
3
+ * Worker 通过 /worker/ws 建立长连接,仅接收任务到达通知。
4
+ * 本模块只维护已鉴权连接集合与广播函数,连接建立见 lib/websocket.js。
5
+ */
6
+
7
+ export function createChannels() {
8
+ // 已鉴权连接集合(未通过鉴权的 socket 不会进入此集合)
9
+ const wsClients = new Set();
10
+
11
+ function broadcast(msg) {
12
+ if (!wsClients.size) return;
13
+ const data = JSON.stringify(msg);
14
+ for (const ws of wsClients) ws.send(data);
15
+ }
16
+
17
+ // 已鉴权的 Worker 通知连接(见 lib/websocket.js 的 /worker/ws)。通知只是加速:
18
+ // Worker 收到后仍走 HTTP claim 原子领取,断线期间由轮询兜底
19
+ const wsWorkers = new Set();
20
+
21
+ // 显式指派模型:只唤醒指名 Worker 的连接,其他 Worker 完全不被打扰
22
+ function notifyWorkers(targetWorker) {
23
+ if (!wsWorkers.size || !targetWorker) return;
24
+ const data = JSON.stringify({ type: 'task.available', targetWorker });
25
+ for (const ws of wsWorkers) {
26
+ if (ws.workerId === targetWorker) ws.send(data);
27
+ }
28
+ }
29
+
30
+ return { wsClients, broadcast, wsWorkers, notifyWorkers };
31
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * kites 子命令分发与 start/check 参数解析。
3
+ */
4
+
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { printHelp } from './meta.js';
8
+
9
+ export const SUBCOMMANDS = ['start', 'check', 'keygen', 'init', 'deploy'];
10
+
11
+ /** 缺省配置文件路径(start/check 不传 --config、init 不传路径时都用它):
12
+ * ~/.kites 标准树的 conf/ 下,与 deploy 托管路径一致;运行时取 homedir,
13
+ * systemd 以 www-data 跑时即 /var/www/.kites/conf/server.yaml */
14
+ export function defaultConfigFile() {
15
+ return path.join(os.homedir(), '.kites', 'conf', 'server.yaml');
16
+ }
17
+
18
+ /** 子命令分发:返回 argv[2] 的子命令名。CLI 化的核心约定——启动服务必须显式
19
+ * 走 start 子命令;裸 kites(不带任何参数)只打印用法退出,绝不启动服务 */
20
+ export function dispatch() {
21
+ const cmd = process.argv[2];
22
+ if (cmd === undefined) {
23
+ printHelp();
24
+ process.exit(0);
25
+ }
26
+ if (SUBCOMMANDS.includes(cmd)) return cmd;
27
+ if (cmd === '--config' || cmd === '-c' || cmd.startsWith('--config=')) {
28
+ // 旧版写法(kites --config x):明确告知启动已改为 start 子命令,不静默兼容
29
+ console.error('[server] 启动服务请显式使用 start 子命令:kites start --config <配置文件路径>');
30
+ console.error('[server] 运行 kites --help 查看用法');
31
+ process.exit(1);
32
+ }
33
+ console.error(`[server] 未知子命令 "${cmd}";运行 kites --help 查看用法`);
34
+ process.exit(1);
35
+ }
36
+
37
+ /** check 子命令位参数(argv[2] === 'check'):其余照常解析,校验通过后在
38
+ * loadServerConfig 末尾打印摘要退出,不真正启动服务 */
39
+ export function isCheckMode() {
40
+ return process.argv[2] === 'check';
41
+ }
42
+
43
+ /** 解析 start/check 子命令后的参数;--config 可选(缺省回落 ~/.kites/conf/server.yaml),
44
+ * 重复/取不到值/出现未知参数立即退出 */
45
+ export function parseArgs() {
46
+ const args = process.argv.slice(3);
47
+ const values = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ if (args[i] === '--config' || args[i] === '-c') {
50
+ values.push(args[i + 1]);
51
+ i++;
52
+ } else if (args[i].startsWith('--config=')) {
53
+ values.push(args[i].slice('--config='.length));
54
+ } else {
55
+ // 输错参数不再静默忽略:报出具体参数并指路 --help,避免误以为已生效
56
+ console.error(`[server] 未知参数 "${args[i]}";运行 kites --help 查看用法`);
57
+ process.exit(1);
58
+ }
59
+ }
60
+ if (values.length === 0) {
61
+ // 未指定 --config:回落默认配置路径(与 init 默认生成位置一致),先报一行让去向可追溯
62
+ const file = defaultConfigFile();
63
+ console.log(`[server] 未指定 --config,使用默认配置文件:${file}`);
64
+ return file;
65
+ }
66
+ if (values.length > 1) {
67
+ console.error('[server] --config 只能指定一次');
68
+ process.exit(1);
69
+ }
70
+ const file = String(values[0] ?? '').trim();
71
+ if (!file || file.startsWith('-')) {
72
+ console.error('[server] --config 后需要给出配置文件路径');
73
+ process.exit(1);
74
+ }
75
+ return path.resolve(file);
76
+ }
77
+
78
+ export function fail(errors, tag) {
79
+ console.error(`[${tag}] 配置错误,无法启动:`);
80
+ for (const e of errors) console.error(' - ' + e);
81
+ console.error(`[${tag}] 请检查 YAML 配置文件(--config 指定或默认路径),模板见包内 config.example.yaml`);
82
+ process.exit(1);
83
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * kites deploy 子命令:基于包内 systemd 模板生成托管 unit 文件。
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import cp from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { expandHome } from './util.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /** init/deploy 就跑在目标环境里,顺手解析 kites 可执行路径(等效 `which kites`),
14
+ * 好把 unit 安装命令里的 __KITES_BIN__ 占位符换成现成值;
15
+ * 取不到(如仓库源码里直接跑)则返回 null 由调用方决定回退方式 */
16
+ async function resolveKitesBin() {
17
+ try {
18
+ // 调用时经 cp 命名空间取 execFile,单测可 mock cp.execFile
19
+ const stdout = await new Promise((resolve, reject) => {
20
+ cp.execFile('which', ['kites'], { encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(out)));
21
+ });
22
+ const p = stdout.trim();
23
+ if (p) return p;
24
+ } catch { /* PATH 里没有 kites */ }
25
+ return null;
26
+ }
27
+
28
+ /** deploy 子命令(kites deploy [输出路径] [--config <配置文件路径>]):
29
+ * 读取包内 systemd 模板,把 __KITES_BIN__ / __CONFIG_FILE__ 按本机环境替换后
30
+ * 写出可直接安装的 unit 文件,免去手工 sed;默认输出到当前目录 kiteserver.service。
31
+ * 产物可随升级重复生成(覆盖写),安装命令打印在输出里 */
32
+ export async function maybeDeploy() {
33
+ if (process.argv[2] !== 'deploy') return;
34
+ const args = process.argv.slice(3);
35
+ let rawOut = '';
36
+ // 缺省指向 www-data 家目录的 ~/.kites 标准树(字面量给出:deploy 通常以 root
37
+ // 运行,~ 会展开成 /root 而非服务运行用户的家目录)
38
+ let rawConfig = '/var/www/.kites/conf/server.yaml';
39
+ for (let i = 0; i < args.length; i++) {
40
+ if (args[i] === '--config' || args[i] === '-c') {
41
+ rawConfig = String(args[++i] ?? '');
42
+ } else if (args[i].startsWith('--config=')) {
43
+ rawConfig = args[i].slice('--config='.length);
44
+ } else if (!args[i].startsWith('-') && !rawOut) {
45
+ rawOut = args[i];
46
+ } else {
47
+ console.error('[server] deploy 用法:kites deploy [unit 输出路径] [--config <配置文件路径>]');
48
+ console.error(`[server] 无法识别的参数:"${args[i]}"`);
49
+ process.exit(1);
50
+ }
51
+ }
52
+ if (!rawConfig.trim() || rawConfig.startsWith('-')) {
53
+ console.error('[server] deploy 的 --config 后需要给出配置文件路径');
54
+ process.exit(1);
55
+ }
56
+ const configFile = path.resolve(expandHome(rawConfig));
57
+ const outPath = path.resolve(expandHome(rawOut || 'kiteserver.service'));
58
+
59
+ const kitesBin = await resolveKitesBin();
60
+ if (!kitesBin) {
61
+ console.error('[server] PATH 里找不到 kites 可执行文件(which kites 失败),无法填写 unit 的 ExecStart');
62
+ console.error('[server] 请在 npm 全局安装本包的机器上运行:npm i -g @jacksontian/kite-server');
63
+ process.exit(1);
64
+ }
65
+ let tpl;
66
+ try {
67
+ tpl = await fs.promises.readFile(path.join(__dirname, '..', '..', 'deploy', 'kiteserver.service'), 'utf8');
68
+ } catch (err) {
69
+ console.error(`[server] 无法读取包内 unit 模板 deploy/kiteserver.service:${err.message}`);
70
+ process.exit(1);
71
+ }
72
+ const content = tpl.replaceAll('__KITES_BIN__', kitesBin).replaceAll('__CONFIG_FILE__', configFile);
73
+ if (/__[A-Z_]+__/.test(content)) {
74
+ console.error('[server] 包内 unit 模板结构与预期不符(仍有未替换的占位符),请改用手工方式安装');
75
+ process.exit(1);
76
+ }
77
+ try {
78
+ await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
79
+ await fs.promises.writeFile(outPath, content, { mode: 0o644 });
80
+ } catch (err) {
81
+ console.error(`[server] 无法写入 ${outPath}:${err.message}`);
82
+ process.exit(1);
83
+ }
84
+ console.log(`[server] 已生成 systemd unit:${outPath}`);
85
+ console.log(` ExecStart: ${kitesBin} start --config ${configFile}`);
86
+ const configExists = await fs.promises.access(configFile).then(() => true, () => false);
87
+ if (!configExists) {
88
+ console.warn(`[server] 注意:${configFile} 尚不存在,安装 unit 前请先生成:kites init ${configFile}`);
89
+ }
90
+ console.log('[server] 安装(unit 以 www-data 运行,注意先完成 README「云端部署」的目录授权):');
91
+ console.log(` sudo install -m 644 ${outPath} /etc/systemd/system/kiteserver.service`);
92
+ console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
93
+ process.exit(0);
94
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * kites init 子命令:交互式问答与配置文件生成。
3
+ */
4
+
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import readline from 'node:readline/promises';
10
+ import { fileURLToPath } from 'node:url';
11
+ import YAML from 'yaml';
12
+ import { expandHome, PLACEHOLDER_SECRET } from './util.js';
13
+ import { defaultConfigFile } from './args.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ /** 建 readline 并包装成问答队列:突发到达的行(单测管道注入)不丢失;
18
+ * Ctrl+C / 输入流结束(Ctrl+D)统一取消退出 130 */
19
+ function createPrompter(tag, { input, output }) {
20
+ const rl = readline.createInterface({ input, output, terminal: input.isTTY === true });
21
+ const buffered = [];
22
+ let waiter = null;
23
+ const cancel = () => {
24
+ console.log('');
25
+ console.log(`[${tag}] 输入已结束,取消 init,未生成配置文件`);
26
+ process.exit(130);
27
+ };
28
+ rl.on('line', (line) => {
29
+ if (waiter) {
30
+ const w = waiter;
31
+ waiter = null;
32
+ w(line);
33
+ } else {
34
+ buffered.push(line);
35
+ }
36
+ });
37
+ rl.on('SIGINT', () => {
38
+ rl.close();
39
+ cancel();
40
+ });
41
+ rl.on('close', () => {
42
+ if (waiter) {
43
+ waiter = null;
44
+ cancel();
45
+ }
46
+ });
47
+ const ask = (prompt) => {
48
+ output.write(prompt);
49
+ if (buffered.length) return Promise.resolve(buffered.shift());
50
+ return new Promise((resolve) => {
51
+ waiter = resolve;
52
+ });
53
+ };
54
+ return { ask, close: () => rl.close() };
55
+ }
56
+
57
+ /** init 交互问答(终端下启用):逐项确认可调配置,直接回车接受 [...] 里的默认值,
58
+ * Ctrl+C 取消退出。input/output 可注入(默认真实终端),便于单测以管道模拟 */
59
+ export async function collectServerInitAnswers(defaults, { input = process.stdin, output = process.stdout } = {}) {
60
+ const { ask, close } = createPrompter('server', { input, output });
61
+ const answers = { port: defaults.port, dataDir: defaults.dataDir };
62
+ try {
63
+ for (;;) {
64
+ const ans = (await ask(`监听端口 port [${defaults.port}]:`)).trim();
65
+ if (!ans) break;
66
+ const n = Number(ans);
67
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) {
68
+ answers.port = n;
69
+ break;
70
+ }
71
+ console.log('[server] 端口需为 1-65535 的整数,请重新输入');
72
+ }
73
+ const dirAns = (await ask(`持久化目录 dataDir [${defaults.dataDir}]:`)).trim();
74
+ if (dirAns) answers.dataDir = path.resolve(expandHome(dirAns));
75
+ } finally {
76
+ close();
77
+ }
78
+ return answers;
79
+ }
80
+
81
+ /** init 子命令(kites init [配置文件路径] [--yes]):基于包内模板一步生成配置文件,
82
+ * 自动填好随机 apiSecret/workerSecret,免去「复制模板 + keygen + 粘贴」三步;
83
+ * 终端(TTY)下进入交互问答确认 port/dataDir,--yes(或非终端)按默认值直接生成;
84
+ * 不带路径时默认生成到 ~/.kites/conf/server.yaml(与 start/check 缺省读取的
85
+ * --config 路径一致,init 完即可直接 kites start);
86
+ * 目标已存在时模仿 npm init:现有值作默认答案重新确认,确认后重写该文件
87
+ * (额外字段保留,密钥不轮换);无任何变化则只确认不落盘 */
88
+ export async function maybeInit() {
89
+ if (process.argv[2] !== 'init') return;
90
+ const args = process.argv.slice(3);
91
+ let rawPath = '';
92
+ let skipPrompts = false;
93
+ for (const a of args) {
94
+ if (a === '--yes' || a === '-y') {
95
+ skipPrompts = true;
96
+ } else if (a.startsWith('-')) {
97
+ console.error(`[server] 无法识别的参数 "${a}";init 用法:kites init [配置文件路径] [--yes]`);
98
+ process.exit(1);
99
+ } else if (rawPath) {
100
+ console.error(`[server] init 只接受一个配置文件路径(多余:"${a}")`);
101
+ process.exit(1);
102
+ } else {
103
+ rawPath = a;
104
+ }
105
+ }
106
+ if (rawPath && !rawPath.trim()) {
107
+ console.error('[server] init 用法:kites init [配置文件路径] [--yes](默认 ~/.kites/conf/server.yaml)');
108
+ process.exit(1);
109
+ }
110
+ const target = path.resolve(expandHome(rawPath.trim() || defaultConfigFile()));
111
+ // 目标已存在:读出并解析作为默认值来源(模仿 npm init 重复确认语义);
112
+ // 无法解析成 YAML 映射时才报错退出,其余读失败(如 ENOENT)按新文件处理
113
+ let existing = null;
114
+ try {
115
+ existing = YAML.parse(await fs.promises.readFile(target, 'utf8'));
116
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
117
+ throw new Error('内容不是一个键值映射');
118
+ }
119
+ console.log(`[server] 发现已有配置 ${target}`);
120
+ } catch (err) {
121
+ if (err.code !== 'ENOENT') {
122
+ console.error(`[server] ${target} 已存在但无法按 YAML 配置解析:${err.message}`);
123
+ console.error('[server] 请先手工修复,或备份删除后重新 init');
124
+ process.exit(1);
125
+ }
126
+ }
127
+ let tpl = null;
128
+ if (!existing) {
129
+ try {
130
+ tpl = await fs.promises.readFile(path.join(__dirname, '..', '..', 'config.example.yaml'), 'utf8');
131
+ } catch (err) {
132
+ console.error(`[server] 无法读取包内配置模板 config.example.yaml:${err.message}`);
133
+ process.exit(1);
134
+ }
135
+ }
136
+ // 密钥策略:已有配置保留现值不轮换(占位符视为未设置,生成新的);全新文件生成随机密钥。
137
+ // 自动生成的密钥带 kites_ 前缀,便于与 kitew keygen 产物(kitew_ 前缀)目视区分,防粘贴错端
138
+ let apiSecret = String(existing?.apiSecret ?? '');
139
+ if (!apiSecret || apiSecret === PLACEHOLDER_SECRET) apiSecret = 'kites_' + crypto.randomBytes(32).toString('hex');
140
+ const workerSecret = existing ? String(existing.workerSecret ?? '') : 'kites_' + crypto.randomBytes(32).toString('hex');
141
+ // dataDir 默认落 ~/.kites 标准树(与配置文件位置、启动工作目录都解耦);
142
+ // 已有配置则沿用现值作默认
143
+ const defaults = {
144
+ port: Number.isInteger(existing?.port) ? existing.port : 8787,
145
+ dataDir: existing?.dataDir ? String(existing.dataDir) : path.join(os.homedir(), '.kites', 'data'),
146
+ };
147
+ let answers = defaults;
148
+ if (!skipPrompts && process.stdin.isTTY === true && process.stdout.isTTY === true) {
149
+ // 头部说明与问答区各留一行空隙,避免提示行挤在一起
150
+ if (existing) console.log('');
151
+ console.log('[server] 逐项确认,回车接受 [...] 里的默认值(Ctrl+C 取消):');
152
+ console.log('');
153
+ answers = await collectServerInitAnswers(defaults);
154
+ console.log('');
155
+ }
156
+ let content;
157
+ if (existing) {
158
+ // 已有配置:合并确认结果后整体重写;额外字段(如 imWebhook)原样保留
159
+ const unchanged = existing.port === answers.port
160
+ && String(existing.dataDir ?? '') === answers.dataDir
161
+ && String(existing.apiSecret ?? '') === apiSecret
162
+ && String(existing.workerSecret ?? '') === workerSecret;
163
+ if (unchanged) {
164
+ console.log(`[server] 配置确认完毕,与现有内容一致,不改动文件:${target}`);
165
+ process.exit(0);
166
+ }
167
+ content = YAML.stringify({ ...existing, apiSecret, workerSecret, port: answers.port, dataDir: answers.dataDir });
168
+ } else {
169
+ content = tpl
170
+ .replace(`apiSecret: ${PLACEHOLDER_SECRET}`, `apiSecret: ${apiSecret}`)
171
+ .replace('workerSecret: ""', `workerSecret: ${workerSecret}`)
172
+ .replace('port: 8787', `port: ${answers.port}`)
173
+ .replace('dataDir: ~/.kites/data', `dataDir: ${answers.dataDir}`);
174
+ if (!content.includes(apiSecret)) {
175
+ console.error('[server] 包内配置模板结构与预期不符(找不到占位符),请改用手工方式:复制 config.example.yaml 后填写');
176
+ process.exit(1);
177
+ }
178
+ }
179
+ try {
180
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
181
+ await fs.promises.writeFile(target, content, { mode: 0o600 });
182
+ } catch (err) {
183
+ console.error(`[server] 无法写入 ${target}:${err.message}`);
184
+ console.error('[server] 目标目录需要 root 权限时请改用 sudo,或 init 到自定义路径');
185
+ process.exit(1);
186
+ }
187
+ const dataDir = answers.dataDir;
188
+ console.log(`[server] ${existing ? '已确认并更新配置' : '已生成配置'}:${target}(权限 0600;port ${answers.port},dataDir ${dataDir})`);
189
+ console.log('');
190
+ console.log('密钥(已写入配置文件,请妥善保存;永不在网络上传输):');
191
+ console.log(` ${'apiSecret'.padEnd(12)}(控制台/手机浏览器用):${apiSecret}`);
192
+ console.log(` ${'workerSecret'.padEnd(12)}(填入 worker.yaml 的 apiSecret):${workerSecret || '(未设置,Worker 与控制台共用 apiSecret)'}`);
193
+ console.log('');
194
+ console.log('后续步骤:');
195
+ console.log(` kites check --config ${target} # 干跑验证`);
196
+ console.log(` kites start --config ${target} # 启动`);
197
+ console.log('');
198
+ if (target.startsWith('/var/www/.kites')) {
199
+ console.log('生产部署(systemd 以 www-data 运行):');
200
+ console.log(` sudo chown -R www-data:www-data ${path.dirname(path.dirname(target))}`);
201
+ console.log(` kites deploy --config ${target}`);
202
+ console.log(' sudo install -m 644 kiteserver.service /etc/systemd/system/kiteserver.service');
203
+ console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
204
+ } else {
205
+ console.log('生产部署:见 README「云端部署」(systemd unit 用 kites deploy 生成)');
206
+ }
207
+ process.exit(0);
208
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * kites keygen 子命令:生成强随机密钥并打印配置片段。
3
+ */
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ /** keygen 子命令(kites keygen):生成强随机密钥并打印配置片段,免去手工生成/搬运 hex;
8
+ * 带 kites_ 前缀,便于与 kitew keygen 产物(kitew_ 前缀)目视区分,防粘贴错端 */
9
+ export function maybeKeygen() {
10
+ if (process.argv[2] !== 'keygen') return;
11
+ const secret = 'kites_' + crypto.randomBytes(32).toString('hex');
12
+ console.log('[server] 已生成签名密钥(永不在网络上传输,请妥善保管):');
13
+ console.log('');
14
+ console.log(' ' + secret);
15
+ console.log('');
16
+ console.log('[server] 粘贴到 server.yaml(手机浏览器控制台使用同一密钥):');
17
+ console.log('');
18
+ console.log(' apiSecret: ' + secret);
19
+ console.log('');
20
+ console.log('[server] 建议再执行一次 kites keygen 生成不同的 workerSecret(分权见 README「安全说明」),');
21
+ console.log('[server] Worker 端 worker.yaml 的 apiSecret 与 workerSecret 保持一致');
22
+ process.exit(0);
23
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * kites --help / --version 与 meta 选项处理。
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ /** 用法说明:--help/-h 输出到 stdout 并 exit 0(符合 CLI 惯例);
12
+ * 裸 kites(不带任何子命令)同样只打印用法,不启动服务 */
13
+ export function printHelp() {
14
+ console.log('用法:kites <子命令> [选项]');
15
+ console.log('');
16
+ console.log('子命令:');
17
+ console.log(' kites start 启动云端服务(前台运行;常驻托管交给 systemd,见 deploy)');
18
+ console.log(' -c, --config <路径> 指定 YAML 配置文件(缺省用 ~/.kites/conf/server.yaml,模板见包内 config.example.yaml)');
19
+ console.log(' kites check 只校验配置文件(--config 指定或默认路径,改配置后干跑验证),通过即退出,不启动服务');
20
+ console.log(' kites keygen 生成强随机密钥并打印配置片段');
21
+ console.log(' kites init [路径] 基于包内模板生成配置文件(默认 ~/.kites/conf/server.yaml),自动填好随机密钥;');
22
+ console.log(' 终端下交互确认 port/dataDir(回车用默认值),--yes 跳过提问按默认生成;');
23
+ console.log(' 目标已存在时重复运行只是重新确认(仿 npm init),密钥不轮换');
24
+ console.log(' kites deploy [输出] 基于包内 systemd 模板生成占位符已替换好的 unit 文件(默认当前目录 kiteserver.service),');
25
+ console.log(' 可选 --config <路径> 指定 unit 里的配置文件路径(默认 /var/www/.kites/conf/server.yaml)');
26
+ console.log('');
27
+ console.log('选项:');
28
+ console.log(' -h, --help 打印本帮助');
29
+ console.log(' -V, --version 打印版本号');
30
+ console.log('');
31
+ console.log('不带任何子命令时只打印本用法,不会启动服务。');
32
+ }
33
+
34
+ /** --version/-V:读取包内 package.json 输出版本号(与 npm 发布版本一致) */
35
+ export async function printVersion() {
36
+ try {
37
+ const pkg = JSON.parse(await fs.promises.readFile(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
38
+ console.log(`kites (${pkg.name}) ${pkg.version}`);
39
+ } catch (err) {
40
+ console.error(`[server] 无法读取包内 package.json:${err.message}`);
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ /** 常规 CLI 选项:--help/-h 与 --version/-V 优先于子命令/配置加载,识别到即退出 */
46
+ export async function maybeMeta() {
47
+ const args = process.argv.slice(2);
48
+ if (args.includes('--help') || args.includes('-h')) {
49
+ printHelp();
50
+ process.exit(0);
51
+ }
52
+ if (args.includes('--version') || args.includes('-V')) {
53
+ await printVersion();
54
+ process.exit(0);
55
+ }
56
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * kites CLI 通用小工具:对象判断、占位符常量、~ 展开、schema 辅助。
3
+ */
4
+
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { z } from 'zod';
8
+
9
+ export function isPlainObject(v) {
10
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
11
+ }
12
+
13
+ /** config.example.yaml 里的占位文本:原样未改就启动等于用公开"密钥"裸奔,必须拦下 */
14
+ export const PLACEHOLDER_SECRET = '换成你生成的密钥';
15
+
16
+ /** 可选字符串:缺省/给 null 都按空串处理,统一 trim
17
+ * (zod v4 中 union(z.undefined()) 不能让对象键变可选,须用 .optional()) */
18
+ export const optStr = (errMsg) =>
19
+ z.string({ error: errMsg }).trim().optional().nullable().transform((v) => v ?? '');
20
+
21
+ /** 把 zod issues 转成错误行:未知配置项逐条展开并附该层级可用项清单;
22
+ * custom/各叶子的报错文案均已自带字段名,直接透传 */
23
+ export function formatIssues(issues, knownByPath) {
24
+ const out = [];
25
+ for (const iss of issues) {
26
+ if (iss.code === 'unrecognized_keys') {
27
+ const known = knownByPath[iss.path.join('.')] || [];
28
+ for (const key of iss.keys) out.push(`未知配置项 "${key}"(可用:${known.join(', ')})`);
29
+ continue;
30
+ }
31
+ out.push(iss.message);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** 展开开头的 ~ 为用户主目录(YAML 不会替我们做这件事) */
37
+ export function expandHome(p) {
38
+ if (p === '~') return os.homedir();
39
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
40
+ return p;
41
+ }
42
+
43
+ export const SECRET_HINT =
44
+ '(两端共享的签名密钥;生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))")';