@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/lib/config.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * 云端 server 配置加载与校验。
3
+ *
4
+ * 纯 CLI 形态:任何动作都走子命令,裸 kites(无参数)只打印用法、绝不启动服务。
5
+ * 配置只认 YAML 文件(不再读取任何环境变量),--config 显式指定,
6
+ * 不传时回落默认路径 ~/.kites/conf/server.yaml:
7
+ * kites start --config /path/to/server.yaml
8
+ * 结构与类型校验由 zod schema 完成(CONFIG_SCHEMA):未知配置项、
9
+ * 类型错误、缺必填项一次性报全后 exit 1;dataDir 的可创建/可写校验
10
+ * 是运行时行为,仍在校验通过后单独执行。模板见包内 config.example.yaml。
11
+ *
12
+ * 各子命令实现拆分在 lib/cli/ 目录:
13
+ * kites start 启动云端服务(--config 缺省回落 ~/.kites/conf/server.yaml);裸 kites 不带子命令只打印用法,不启动
14
+ * kites keygen 生成强随机密钥并打印配置片段(lib/cli/keygen.js)
15
+ * kites init [路径] 在目标位置(默认 ~/.kites/conf/server.yaml)生成填好随机密钥的配置文件;
16
+ * 终端(TTY)下进入交互问答(port/dataDir,回车用默认值),
17
+ * --yes 或非终端环境按默认值直接生成(lib/cli/init.js)
18
+ * kites check 只校验配置文件(--config 指定或默认路径),通过打印摘要退出,不启动服务
19
+ * kites deploy 基于包内 systemd 模板生成占位符已按本机替换好的 unit 文件(lib/cli/deploy.js)
20
+ * kites --help 打印用法说明(-h 等效,lib/cli/meta.js)
21
+ * kites --version 打印版本号(-V 等效,lib/cli/meta.js)
22
+ * apiSecret 若仍是配置模板的占位符文本,启动时直接拦截,避免裸奔。
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import YAML from 'yaml';
29
+ import { z } from 'zod';
30
+ import { maybeMeta } from './cli/meta.js';
31
+ import { dispatch, isCheckMode, parseArgs, fail } from './cli/args.js';
32
+ import { maybeKeygen } from './cli/keygen.js';
33
+ import { maybeInit } from './cli/init.js';
34
+ import { maybeDeploy } from './cli/deploy.js';
35
+ import { isPlainObject, PLACEHOLDER_SECRET, optStr, formatIssues, expandHome, SECRET_HINT } from './cli/util.js';
36
+
37
+ // 交互问答入口保持从 lib/config.js 导出(测试约定的导入路径)
38
+ export { collectServerInitAnswers } from './cli/init.js';
39
+
40
+ const CONFIG_SCHEMA = z.strictObject({
41
+ apiSecret: z
42
+ .string({
43
+ error: (iss) =>
44
+ iss.input === undefined || iss.input === null
45
+ ? `缺少 apiSecret${SECRET_HINT}`
46
+ : 'apiSecret 必须是字符串(建议加引号,避免被 YAML 解析为数字)',
47
+ })
48
+ .trim()
49
+ .min(1, `缺少 apiSecret${SECRET_HINT}`),
50
+ // Worker 专用密钥(可选):配置后 Worker 侧 API(claim/heartbeat/logs/finish)
51
+ // 与 /worker/ws 只认该密钥,控制台密钥(apiSecret)泄露不再等于 Worker 执行权。
52
+ // 留空则两端共用 apiSecret(向后兼容)
53
+ workerSecret: optStr('workerSecret 必须是字符串'),
54
+ // 多 Worker 独立密钥(可选):workerId -> 密钥映射,每台 Worker 一密钥。
55
+ // 配置后 Worker 侧验签命中该映射的密钥即绑定其 workerId 身份(请求自报的
56
+ // workerId 必须一致),Worker 之间无法互相冒充;与 workerSecret 可并存
57
+ // (命中共享密钥时身份仍自报,兼容旧部署)。均未配置时回落共用 apiSecret
58
+ workerSecrets: z
59
+ .record(z.string(), z.string({ error: 'workerSecrets 的密钥必须是字符串' }).trim(), {
60
+ error: 'workerSecrets 必须是「workerId: 密钥」的映射',
61
+ })
62
+ .optional(),
63
+ port: z.coerce
64
+ .number({ error: 'port 必须是数字' })
65
+ .int('port 必须是整数')
66
+ .min(1, 'port 超出范围 [1, 65535]')
67
+ .max(65535, 'port 超出范围 [1, 65535]')
68
+ .default(8787),
69
+ // 语义(~ 展开 / 相对启动工作目录 / 缺省 ~/.kites/data / 可写校验)
70
+ // 无法纯声明式表达,schema 只约束类型,解析后命令式处理
71
+ dataDir: optStr('dataDir 必须是字符串'),
72
+ // 程序自写日志文件(logs/ 标准目录);落盘逻辑见 lib/logger.js,
73
+ // 只包装 console 追加写文件,前台输出与 journal 捕获不受影响
74
+ logFile: optStr('logFile 必须是字符串'),
75
+ // 落盘最低级别(只影响日志文件,前台输出不过滤);大小写归一化后比枚举
76
+ logLevel: z.coerce
77
+ .string({ error: 'logLevel 必须是字符串' })
78
+ .trim()
79
+ .toLowerCase()
80
+ .pipe(
81
+ z.enum(['debug', 'info', 'warn', 'error'], {
82
+ error: (iss) => `logLevel 值 "${iss.input}" 非法(可用:debug|info|warn|error)`,
83
+ }),
84
+ )
85
+ .default('info'),
86
+ // 轮转保留代数(.1 最新,超出自动删除最旧一代)
87
+ logKeep: z.coerce
88
+ .number({ error: 'logKeep 必须是数字' })
89
+ .int('logKeep 必须是整数')
90
+ .min(1, 'logKeep 至少保留 1 代')
91
+ .max(100, 'logKeep 超出范围 [1, 100]')
92
+ .default(5),
93
+ maxTasks: z.coerce.number({ error: 'maxTasks 必须是数字' }).positive('maxTasks 必须是正数').default(200),
94
+ staleRunningSec: z.coerce
95
+ .number({ error: 'staleRunningSec 必须是数字' })
96
+ .positive('staleRunningSec 必须是正数')
97
+ .default(600),
98
+ sigWindowSec: z.coerce
99
+ .number({ error: 'sigWindowSec 必须是数字' })
100
+ .positive('sigWindowSec 必须是正数')
101
+ .default(300),
102
+ ptyIdleSec: z.coerce
103
+ .number({ error: 'ptyIdleSec 必须是数字' })
104
+ .positive('ptyIdleSec 必须是正数')
105
+ .default(90),
106
+ imWebhook: optStr('imWebhook 必须是字符串'),
107
+ imSecret: optStr('imSecret 必须是字符串'),
108
+ imNotifyEvents: z
109
+ .array(
110
+ // 与原实现一致:元素宽容转字符串后归一化小写再比枚举
111
+ z.coerce.string({ error: 'imNotifyEvents 元素必须是字符串' }).trim().toLowerCase().pipe(
112
+ z.enum(['done', 'failed', 'canceled'], {
113
+ error: (iss) => `imNotifyEvents 含非法事件 "${iss.input}"(可用:done|failed|canceled)`,
114
+ }),
115
+ ),
116
+ { error: 'imNotifyEvents 必须是数组(如 [failed, done])' },
117
+ )
118
+ .default(['failed']),
119
+ });
120
+
121
+ /** 加载并校验配置,返回归一化后的常量对象 */
122
+ export async function loadServerConfig() {
123
+ const tag = 'server';
124
+ await maybeMeta();
125
+ dispatch(); // 裸调用/旧写法/未知子命令在此拦截;能走到下面的必是五个子命令之一
126
+ maybeKeygen();
127
+ await maybeInit();
128
+ await maybeDeploy();
129
+ const configFile = parseArgs();
130
+
131
+ let raw;
132
+ try {
133
+ raw = await fs.promises.readFile(configFile, 'utf8');
134
+ } catch (err) {
135
+ console.error(`[${tag}] 无法读取配置文件 ${configFile}: ${err.message}`);
136
+ process.exit(1);
137
+ }
138
+
139
+ const doc = YAML.parseDocument(raw);
140
+ if (doc.errors.length) {
141
+ console.error(`[${tag}] 配置文件 YAML 语法错误(${configFile}):`);
142
+ for (const e of doc.errors) console.error(' - ' + e.message);
143
+ process.exit(1);
144
+ }
145
+ const cfg = doc.toJS();
146
+ if (!isPlainObject(cfg)) {
147
+ console.error(`[${tag}] 配置文件内容必须是一个键值映射(${configFile})`);
148
+ process.exit(1);
149
+ }
150
+
151
+ const parsed = CONFIG_SCHEMA.safeParse(cfg);
152
+ if (!parsed.success) {
153
+ fail(formatIssues(parsed.error.issues, { '': Object.keys(CONFIG_SCHEMA.shape) }), tag);
154
+ }
155
+ const v = parsed.data;
156
+ const errors = [];
157
+
158
+ // 模板占位符原样未改:此时启动等于用公开"密钥"裸奔,显式拦下并指路 keygen
159
+ if (v.apiSecret === PLACEHOLDER_SECRET) {
160
+ errors.push('apiSecret 仍是配置模板的占位符文本,请生成真实密钥后填入:kites keygen');
161
+ }
162
+ if (v.workerSecret === PLACEHOLDER_SECRET) {
163
+ errors.push('workerSecret 仍是配置模板的占位符文本,请生成真实密钥后填入(或留空共用 apiSecret):kites keygen');
164
+ }
165
+
166
+ // workerSecrets:键即 workerId(与 worker.yaml 的 workerId 同规则);
167
+ // 密钥非空/非占位符,且两两不同(同密钥会让服务端无法区分身份)
168
+ const workerSecrets = {};
169
+ if (v.workerSecrets) {
170
+ const seen = new Map(); // secret -> workerId
171
+ for (const [id, secret] of Object.entries(v.workerSecrets)) {
172
+ if (!/^[\w.-]{1,128}$/.test(id)) {
173
+ errors.push(`workerSecrets 的 Worker 标识 "${id}" 非法(只能含字母/数字/._-,不超过 128 字符)`);
174
+ continue;
175
+ }
176
+ if (!secret) {
177
+ errors.push(`workerSecrets.${id} 密钥不能为空(kites keygen 生成)`);
178
+ continue;
179
+ }
180
+ if (secret === PLACEHOLDER_SECRET) {
181
+ errors.push(`workerSecrets.${id} 仍是配置模板的占位符文本,请填入真实密钥:kites keygen`);
182
+ continue;
183
+ }
184
+ if (seen.has(secret)) {
185
+ errors.push(`workerSecrets 中 "${id}" 与 "${seen.get(secret)}" 配置了相同密钥,服务端无法区分两者身份`);
186
+ continue;
187
+ }
188
+ seen.set(secret, id);
189
+ workerSecrets[id] = secret;
190
+ }
191
+ if (v.workerSecret && seen.has(v.workerSecret)) {
192
+ errors.push(
193
+ `workerSecret 与 workerSecrets.${seen.get(v.workerSecret)} 的密钥相同,身份绑定会有歧义,请配置不同密钥`,
194
+ );
195
+ }
196
+ }
197
+
198
+ // 未配置时默认主目录下的固定位置(~/.kites 标准树),与配置文件位置、启动目录都解耦;
199
+ // 相对路径相对启动工作目录解析(path.resolve 默认行为),建议写绝对路径
200
+ const dataDir = path.resolve(
201
+ expandHome(v.dataDir || path.join(os.homedir(), '.kites', 'data')),
202
+ );
203
+ const logFile = path.resolve(
204
+ expandHome(v.logFile || path.join(os.homedir(), '.kites', 'logs', 'server.log')),
205
+ );
206
+ // 启动前校验可创建、可写:误配在这里一次性报清,
207
+ // 而不是运行中落盘失败才静默降级(只打日志、数据留在内存)
208
+ try {
209
+ await fs.promises.mkdir(dataDir, { recursive: true });
210
+ const probe = path.join(dataDir, '.rw-write-probe');
211
+ await fs.promises.writeFile(probe, '');
212
+ await fs.promises.rm(probe);
213
+ } catch (err) {
214
+ if (err.code === 'EEXIST' || err.code === 'ENOTDIR') {
215
+ errors.push(`dataDir 指向的路径已存在但不是目录:${dataDir}`);
216
+ } else {
217
+ errors.push(`dataDir 无法创建或不可写(${dataDir}):${err.message};请确认运行用户对该目录有读写权限`);
218
+ }
219
+ }
220
+ // logFile 探测父目录可创建、可写(文件本身由 logger 追加打开):
221
+ // 与 dataDir 同等对待,误配在启动时一次性报清
222
+ try {
223
+ await fs.promises.mkdir(path.dirname(logFile), { recursive: true });
224
+ const probe = path.join(path.dirname(logFile), '.rw-write-probe');
225
+ await fs.promises.writeFile(probe, '');
226
+ await fs.promises.rm(probe);
227
+ } catch (err) {
228
+ if (err.code === 'EEXIST' || err.code === 'ENOTDIR') {
229
+ errors.push(`logFile 的父目录已存在但不是目录:${path.dirname(logFile)}`);
230
+ } else {
231
+ errors.push(`logFile 父目录无法创建或不可写(${path.dirname(logFile)}):${err.message};请确认运行用户对该目录有读写权限`);
232
+ }
233
+ }
234
+ if (errors.length) fail(errors, tag);
235
+
236
+ // 典型误配提示:配了加签密钥但没配 webhook,secret 会被静默忽略
237
+ if (v.imSecret && !v.imWebhook) {
238
+ console.warn(`[${tag}] 已配置 imSecret 但 imWebhook 为空,加签密钥将被忽略`);
239
+ }
240
+
241
+ // check 子命令(kites check --config x):与启动完全相同的校验已通过,
242
+ // 打印归一化摘要后直接退出,改完配置不用真启动服务(占端口)就能验证。
243
+ // 密钥只报长度不回显内容
244
+ if (isCheckMode()) {
245
+ console.log(`[${tag}] 配置校验通过:${configFile}`);
246
+ console.log(` port: ${v.port}`);
247
+ console.log(` dataDir: ${dataDir}`);
248
+ console.log(` logFile: ${logFile}(级别 ${v.logLevel},轮转保留 ${v.logKeep} 代)`);
249
+ console.log(` apiSecret: 已配置(${v.apiSecret.length} 字符)`);
250
+ console.log(` workerSecret: ${v.workerSecret ? '已单独配置(Worker 专用密钥)' : '未配置,与 apiSecret 共用'}`);
251
+ console.log(` IM 通知: ${v.imWebhook ? `已配置(事件:${v.imNotifyEvents.join(', ')})` : '未配置'}`);
252
+ console.log(`[${tag}] 可以启动服务:kites start --config ${configFile}`);
253
+ process.exit(0);
254
+ }
255
+
256
+ // Worker 侧可接受的密钥及身份绑定:workerSecrets 条目验签命中即绑定 workerId;
257
+ // workerSecret 为共享兼容路径(workerId 为 null 表示身份自报);
258
+ // 两者都未配置时回落共用 apiSecret(单密钥模式)
259
+ const workerAuth = Object.entries(workerSecrets).map(([workerId, secret]) => ({ secret, workerId }));
260
+ if (v.workerSecret) workerAuth.push({ secret: v.workerSecret, workerId: null });
261
+ if (!workerAuth.length) workerAuth.push({ secret: v.apiSecret, workerId: null });
262
+
263
+ return {
264
+ configFile,
265
+ apiSecret: v.apiSecret,
266
+ workerAuth,
267
+ port: v.port,
268
+ dataDir,
269
+ logFile,
270
+ logLevel: v.logLevel,
271
+ logKeep: v.logKeep,
272
+ maxTasks: v.maxTasks,
273
+ staleRunningMs: v.staleRunningSec * 1000,
274
+ sigWindowSec: v.sigWindowSec,
275
+ ptyIdleMs: v.ptyIdleSec * 1000,
276
+ imWebhook: v.imWebhook,
277
+ imSecret: v.imSecret,
278
+ imNotifyEvents: new Set(v.imNotifyEvents),
279
+ };
280
+ }
package/lib/im.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * IM 群通知:任务终态事件推送钉钉群机器人(markdown),走 dingbot SDK;
3
+ * 机器人启用“加签”安全设置时由 imSecret 自动完成 timestamp+sign 拼接。
4
+ * 未配置 imWebhook 完全静默;发送失败只记日志,绝不抛出影响主链路。
5
+ */
6
+
7
+ import DingBot from 'dingbot';
8
+
9
+ const IM_STATUS_META = {
10
+ done: { icon: '✅', label: '完成' },
11
+ failed: { icon: '❌', label: '失败' },
12
+ canceled: { icon: '🚫', label: '已取消' },
13
+ };
14
+
15
+ export function createImNotifier(ctx) {
16
+ const { imWebhook: IM_WEBHOOK, imSecret: IM_SECRET, imNotifyEvents: IM_NOTIFY_EVENTS } = ctx.cfg;
17
+
18
+ const IM_BOT = IM_WEBHOOK ? new DingBot(IM_WEBHOOK, { timeout: 10_000 }, IM_SECRET || undefined) : null;
19
+
20
+ // 推送摘要:pty 任务只有项目目录
21
+ function imPayloadBrief(task) {
22
+ const p = task.payload || {};
23
+ if (task.type === 'pty') {
24
+ return p.project ? `交互终端(项目:${p.project})` : '交互终端';
25
+ }
26
+ return '';
27
+ }
28
+
29
+ function notifyIm(task, event) {
30
+ if (!IM_BOT || !IM_NOTIFY_EVENTS.has(event)) return;
31
+ const meta = IM_STATUS_META[event] || { icon: 'ℹ️', label: event };
32
+ const fmtTime = (ms) => new Date(ms).toLocaleString('zh-CN', { hour12: false });
33
+ const fmtDur = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)}分钟` : `${Math.round(ms / 1000)}s`);
34
+ // 超长内容中间截断,保留首尾
35
+ const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 8)} … ${s.slice(-4)}` : s);
36
+
37
+ const lines = [
38
+ `### ${meta.icon} 任务${meta.label}:${task.title || task.type}`,
39
+ `- 类型:${task.type}`,
40
+ ];
41
+ const hb = task.workerId ? ctx.state.heartbeats[task.workerId] : null;
42
+ if (task.workerId) {
43
+ lines.push(`- Worker:${hb?.hostname ? `${hb.hostname}(${task.workerId})` : task.workerId}`);
44
+ }
45
+ const brief = imPayloadBrief(task);
46
+ if (brief) lines.push(`- 内容:${truncate(brief, 200)}`);
47
+ if (task.createdAt) lines.push(`- 创建:${fmtTime(task.createdAt)}`);
48
+ if (task.startedAt) lines.push(`- 开始:${fmtTime(task.startedAt)}`);
49
+ if (task.finishedAt) lines.push(`- 结束:${fmtTime(task.finishedAt)}`);
50
+ if (task.startedAt && task.finishedAt) {
51
+ lines.push(`- 耗时:${fmtDur(task.finishedAt - task.startedAt)}`);
52
+ }
53
+ if (task.result) {
54
+ const detail = String(task.result.error || JSON.stringify(task.result)).slice(0, 300);
55
+ if (detail !== '{}') lines.push(`- 结果:${detail}`);
56
+ }
57
+ // 日志末尾几行,失败时可直接看到报错上下文
58
+ const tail = (task.logs || [])
59
+ .filter((l) => !/^\[(server|worker)\]/.test(l) && l.trim())
60
+ .slice(-3)
61
+ .map((l) => truncate(String(l), 150));
62
+ if (tail.length) lines.push('**日志末尾:**', ...tail.map((l) => `> ${l}`));
63
+ lines.push(`任务 ID:\`${task.id}\``);
64
+ // SDK 内部校验钉钉返回的 errcode(关键词/加签拦截、限流等业务错误)并抛错
65
+ IM_BOT.markdown(`kite 任务${meta.label}`, lines.join('\n'))
66
+ .catch((err) => console.error(`[server] IM 推送失败(${task.id}):${err.message}`));
67
+ }
68
+
69
+ return notifyIm;
70
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 日志落盘:start 启动时把 console.log/warn/error 同时追加写到 logFile
3
+ * (logs/ 标准目录),前台输出与托管层(systemd journal / launchd 捕获)不受影响。
4
+ * 只在 start 路径接线(bin 里配置加载完成后调用),check/init/keygen/deploy
5
+ * 这类一次性子命令不写日志文件。
6
+ *
7
+ * 格式与轮转:
8
+ * - 每条一行:「[ISO 时间] [级别 ] [pid] 内容」;内容里的换行统一转义成 \n,
9
+ * 保证一条记录一行,grep/tail 友好,也防止换行字符伪造日志行
10
+ * - 落盘最低级别由 logLevel 配置(debug|info|warn|error,默认 info);
11
+ * 低于阈值的条目只走前台输出、不落盘(前台输出行为完全不受影响)
12
+ * - 超过 maxBytes(默认 10MB)轮转:当前文件整体顺延一代(.1 最新),
13
+ * 超出 logKeep 代(默认 5)的最旧一代自动删除;
14
+ * 更复杂的保留策略可交给 logrotate 等系统工具
15
+ *
16
+ * 实现:所有落盘动作(打开/写入/轮转/关闭)排进一条异步串行队列依次执行,
17
+ * 全程不用同步 IO 阻塞事件循环,同时保持「本行写完才判轮转、轮转完才续写」
18
+ * 的严格顺序;写入失败即降级为不落盘并告警,不阻断服务
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const MAX_BYTES = 10 * 1024 * 1024;
25
+ const KEEP_FILES = 5;
26
+ const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
27
+
28
+ /** 把 console 参数序列转成单行文本(近似 console 的拼接语义);
29
+ * 换行转义成字面 \n,保证一条记录一行 */
30
+ function fmt(args) {
31
+ return args
32
+ .map((a) => (typeof a === 'string' ? a : a instanceof Error ? a.stack || a.message : JSON.stringify(a)))
33
+ .join(' ')
34
+ .replace(/\r?\n/g, '\\n');
35
+ }
36
+
37
+ /** 创建文件日志写入器(不触碰 console 全局,enableLogFile 拿它接线;
38
+ * 单测可直接用它验证格式/过滤/轮转)。返回 { append(level, args), close() };
39
+ * append 入队即返回(fire-and-forget),不阻塞前台输出 */
40
+ export function createFileLogger(logFile, { level = 'info', keep = KEEP_FILES, maxBytes = MAX_BYTES } = {}) {
41
+ const minRank = LEVEL_RANK[level] ?? LEVEL_RANK.info;
42
+ let stream = null;
43
+ let written = 0;
44
+ let disabled = false; // 写入失败后本次启动不再落盘(前台输出不受影响)
45
+
46
+ const open = async () => {
47
+ try {
48
+ await fs.promises.mkdir(path.dirname(logFile), { recursive: true });
49
+ const st = await fs.promises.stat(logFile).catch(() => null);
50
+ written = st ? st.size : 0;
51
+ stream = fs.createWriteStream(logFile, { flags: 'a' });
52
+ // 兜住 error 事件(如 ENOSPC):未监听会让进程崩溃;
53
+ // 流置空后队列里的下一次写入会走降级路径
54
+ stream.on('error', () => { stream = null; });
55
+ } catch (err) {
56
+ stream = null;
57
+ console.warn(`[server] 日志文件无法打开,本次启动不落盘日志(${logFile}):${err.message}`);
58
+ }
59
+ };
60
+
61
+ const rotate = async () => {
62
+ // end 会等缓冲冲刷完再关闭,轮转不丢行;
63
+ // 再删最旧一代、逐代后移,最后把当前文件改名为 .1
64
+ const cur = stream;
65
+ stream = null;
66
+ await new Promise((resolve) => cur.end(resolve));
67
+ try {
68
+ await fs.promises.unlink(`${logFile}.${keep}`).catch(() => {}); // 最旧一代不存在则跳过
69
+ for (let i = keep - 1; i >= 1; i--) {
70
+ await fs.promises.rename(`${logFile}.${i}`, `${logFile}.${i + 1}`).catch(() => {}); // 缺代跳过
71
+ }
72
+ await fs.promises.rename(logFile, `${logFile}.1`);
73
+ } catch { /* 轮转失败交给下方重开兜底,避免无限增长 */ }
74
+ await open();
75
+ };
76
+
77
+ const writeLine = async (line) => {
78
+ // 先写后查:本行落盘完成才判超限,触发行不丢;超限则整体轮转
79
+ await new Promise((resolve, reject) => {
80
+ stream.write(line, (err) => (err ? reject(err) : resolve()));
81
+ });
82
+ written += Buffer.byteLength(line);
83
+ if (written > maxBytes) await rotate();
84
+ };
85
+
86
+ // 异步串行队列:打开/写入/轮转/关闭按调用顺序逐个执行;
87
+ // 队列内部消化所有异常,永不 reject(否则后续任务会被跳过)
88
+ let queue = open();
89
+
90
+ const append = (lvl, args) => {
91
+ if (disabled || LEVEL_RANK[lvl] < minRank) return;
92
+ const line = `[${new Date().toISOString()}] [${lvl.toUpperCase().padEnd(5)}] [${process.pid}] ${fmt(args)}\n`;
93
+ queue = queue.then(async () => {
94
+ if (!stream) return;
95
+ try {
96
+ await writeLine(line);
97
+ } catch {
98
+ // 写失败(磁盘满/文件被外部移除等):关流降级,不影响前台输出
99
+ const cur = stream;
100
+ stream = null;
101
+ if (cur) try { cur.end(); } catch { /* 流可能已失效 */ }
102
+ disabled = true;
103
+ console.warn(`[server] 日志文件写入失败,本次启动不再落盘日志(${logFile})`);
104
+ }
105
+ });
106
+ };
107
+
108
+ /** 等队列里的写入全部落盘后关闭流(供单测等待异步写入完成) */
109
+ const close = () =>
110
+ queue.then(async () => {
111
+ disabled = true;
112
+ const cur = stream;
113
+ stream = null;
114
+ if (cur) await new Promise((resolve) => cur.end(resolve));
115
+ });
116
+
117
+ return { append, close };
118
+ }
119
+
120
+ /** 启用日志文件:包装 console.log/warn/error,原输出保留,另按
121
+ * 「[ISO 时间] [级别 ] [pid] 内容」追加写入 logFile(父目录自动创建);
122
+ * 打开失败只降级告警,不阻断服务启动 */
123
+ export function enableLogFile(logFile, opts) {
124
+ const fileLog = createFileLogger(logFile, opts);
125
+ const { log, warn, error } = console;
126
+ console.log = (...args) => { log(...args); fileLog.append('info', args); };
127
+ console.warn = (...args) => { warn(...args); fileLog.append('warn', args); };
128
+ console.error = (...args) => { error(...args); fileLog.append('error', args); };
129
+ }
package/lib/pty.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * pty 会话中继:taskId -> { ws, tail, lastActiveAt }
3
+ * Worker 上行 session.output -> 追加回看缓冲并广播控制台;
4
+ * 控制台下行 session.input/resize/close -> 定向转发给持有会话的 Worker。
5
+ * 回看缓冲只留末尾 16KB,手机切后台重连终端不至于白屏。
6
+ */
7
+
8
+ export const PTY_TAIL_BYTES = 16 * 1024;
9
+
10
+ export function createPtyHub(ctx) {
11
+ const PTY_IDLE_MS = ctx.cfg.ptyIdleMs;
12
+
13
+ const ptySessions = new Map();
14
+
15
+ function wsSendJson(ws, msg) {
16
+ if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
17
+ }
18
+
19
+ // 关闭会话并(可选)把 running 的 pty 任务落为终态;reason 为空表示 Worker
20
+ // 正常上报退出,任务终态由 Worker 的 finish 上报负责,这里只清会话
21
+ function closePtySession(taskId, { status, error, reason }) {
22
+ const s = ptySessions.get(taskId);
23
+ if (s) ptySessions.delete(taskId);
24
+ if (status) {
25
+ const task = ctx.state.tasks.find((t) => t.id === taskId);
26
+ if (task && task.status === 'running') {
27
+ task.status = status;
28
+ task.finishedAt = Date.now();
29
+ task.result = { error };
30
+ if (reason) task.logs.push(reason);
31
+ ctx.scheduleSave();
32
+ ctx.broadcast({ type: 'task.updated', task: ctx.taskSummary(task), result: task.result });
33
+ }
34
+ }
35
+ ctx.broadcast({ type: 'pty.exited', id: taskId, reason: error || 'closed' });
36
+ }
37
+
38
+ // Worker 通道断开或会话长时间无活动(Worker 掉线且无控制台输入)时回收,
39
+ // 防止忘关的终端在 Worker 上常驻
40
+ function sweepPtyIdle() {
41
+ const now = Date.now();
42
+ for (const [taskId, s] of ptySessions) {
43
+ const workerGone = !s.ws || s.ws.readyState !== s.ws.OPEN;
44
+ if (workerGone && now - s.lastActiveAt > PTY_IDLE_MS) {
45
+ closePtySession(taskId, {
46
+ status: 'failed',
47
+ error: '终端会话空闲超时,已被服务端回收',
48
+ reason: '[server] 终端会话空闲超时(Worker 断开),已回收',
49
+ });
50
+ }
51
+ }
52
+ }
53
+
54
+ return { ptySessions, wsSendJson, closePtySession, sweepPtyIdle };
55
+ }
package/lib/static.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 静态文件(Web 控制台:移动端 index.html / PC 端 pc.html,按 UA 分发)
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const MIME = {
9
+ '.html': 'text/html; charset=utf-8',
10
+ '.js': 'text/javascript; charset=utf-8',
11
+ '.css': 'text/css; charset=utf-8',
12
+ '.svg': 'image/svg+xml',
13
+ '.png': 'image/png',
14
+ };
15
+
16
+ // 移动端 UA 特征词;命中走手机版,其余(桌面浏览器)走 PC 版。
17
+ // iPadOS 13+ 默认伪装 Mac UA,会落到 PC 版——需要时可显式访问 /index.html
18
+ const MOBILE_UA = /android|iphone|ipod|ipad|mobile|harmonyos/i;
19
+
20
+ export function createStaticServer(WEB_DIR) {
21
+ return function serveStatic(req, res, pathname) {
22
+ let rel = pathname.replace(/^\/+/, '');
23
+ if (!rel) {
24
+ rel = MOBILE_UA.test(req.headers['user-agent'] || '') ? 'index.html' : 'pc.html';
25
+ }
26
+ // resolve 归一化 ../ 后按「目录 + 分隔符」前缀判断,避免同级前缀目录(web-xxx)绕过
27
+ const filePath = path.resolve(WEB_DIR, rel);
28
+ if (filePath !== WEB_DIR && !filePath.startsWith(WEB_DIR + path.sep)) {
29
+ res.writeHead(403);
30
+ return res.end('forbidden');
31
+ }
32
+ fs.readFile(filePath, (err, data) => {
33
+ if (err) {
34
+ res.writeHead(404);
35
+ return res.end('not found');
36
+ }
37
+ res.writeHead(200, {
38
+ 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
39
+ 'Cache-Control': 'no-store',
40
+ // 纵深防御:页面资源一律限同源,阻断潜在注入脚本把密钥外传(connect-src
41
+ // 的 'self' 按 CSP3 含同源 ws/wss,实时通道不受影响);页面含内联 <script>
42
+ // 与行内样式操作,script/style 需放行 'unsafe-inline'
43
+ 'Content-Security-Policy':
44
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
45
+ "img-src 'self' data:; connect-src 'self'",
46
+ 'X-Content-Type-Options': 'nosniff',
47
+ });
48
+ res.end(data);
49
+ });
50
+ };
51
+ }