@napuketto/cli 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/dist/index.mjs +775 -684
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -3,172 +3,16 @@ import process from "node:process";
|
|
|
3
3
|
import { ConfigBase, ConfigError, EXIT_CONFIG_INVALID, configError, createLogger, kernelError, parseLogConfig, parseToml, resolveConfigPath, resolveDataRoot, stringifyToml } from "@napuketto/kernel";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
6
7
|
import path, { dirname, extname, isAbsolute, join } from "node:path";
|
|
8
|
+
import { checkInstanceLock, defaultStubDir, isNativeNoiseLine, launchSelfHost, removeStopSentinel, resolveQqFiles, watchStopSentinel, writeStopSentinel } from "@napuketto/loader";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
7
10
|
import { createInterface } from "node:readline";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { checkInstanceLock, defaultStubDir, isNativeNoiseLine, launchSelfHost, removeStopSentinel, resolveQqFiles, watchStopSentinel, writeStopSentinel } from "@napuketto/loader";
|
|
10
12
|
import QRCode from "qrcode";
|
|
11
|
-
|
|
12
|
-
import { access, readFile, writeFile } from "node:fs/promises";
|
|
13
|
-
//#region src/config-template.ts
|
|
14
|
-
/**
|
|
15
|
-
* 配置模板(TOML 注释版,从 config-cmds.ts 拆分,2026-08-08 FTA 优化)。
|
|
16
|
-
* `config init` 与首次启动(loadCliConfig)缺失时生成。
|
|
17
|
-
*
|
|
18
|
-
* 2026-08-08 结构拍板:**一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在
|
|
19
|
-
* 账号内**([accounts.onebot11] / [accounts.satori]);账号必填(至少一个)。
|
|
20
|
-
* dataDir 不再写死(缺省 = 项目根/.napuketto,跨平台),模板保持可移植。
|
|
21
|
-
*/
|
|
22
|
-
/** 保留占位 QQ 号(模板默认值):必须替换为真实 QQ 号,否则启动校验报错。 */
|
|
23
|
-
const RESERVED_PLACEHOLDER_QINS = ["123456", "654321"];
|
|
24
|
-
function configTemplate() {
|
|
25
|
-
return `# ============================================================
|
|
26
|
-
# NapukettoQQ 全局配置文件
|
|
27
|
-
# ============================================================
|
|
28
|
-
# 本文件由 \`napuketto config init\` 生成(或首次启动自动生成)。
|
|
29
|
-
# 本机配置不入库(.gitignore);修改后重启生效。
|
|
30
|
-
#
|
|
31
|
-
# 配置组织:一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在账号内
|
|
32
|
-
# ([accounts.onebot11] / [accounts.satori] / [accounts.kurobot])。账号没有写
|
|
33
|
-
# 某协议段 = 该账号不启用该协议。账号必填(至少一个,qq 必填)。
|
|
34
|
-
|
|
35
|
-
# ------------------------------------------------------------
|
|
36
|
-
# 全局配置(跨账号)
|
|
37
|
-
# ------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
# 数据根目录:账号目录/日志/缓存/QQ 数据存放位置。
|
|
40
|
-
# 缺省 = <项目根>/.napuketto(部署场景,跨平台,无需修改)。
|
|
41
|
-
# 支持绝对路径、"~/" 前缀(用户主目录)、相对路径(相对启动目录)。
|
|
42
|
-
# dataDir = ".napuketto"
|
|
43
|
-
|
|
44
|
-
# 多账号(supervisor)模式下,账号进程崩溃是否自动重启
|
|
45
|
-
autoRestart = true
|
|
46
|
-
|
|
47
|
-
# 崩溃后自动重启的延迟(毫秒)
|
|
48
|
-
restartDelayMs = 2000
|
|
49
|
-
|
|
50
|
-
# ------------------------------------------------------------
|
|
51
|
-
# 日志([log],可选——不配置即用缺省:终端 pretty 多行 / 管道 single-line)
|
|
52
|
-
# ------------------------------------------------------------
|
|
53
|
-
# 嵌入式宿主(把 napuketto 打进服务端等场景)推荐:
|
|
54
|
-
# [log]
|
|
55
|
-
# format = "single-line" # 或 "json"(NDJSON,宿主按 level/module 精确路由)
|
|
56
|
-
# level = "warn" # 控制台只留警告以上,细节进文件(配 [log.file])
|
|
57
|
-
# banner = false
|
|
58
|
-
#
|
|
59
|
-
# [log]
|
|
60
|
-
# format = "pretty" # pretty | single-line | json(缺省按 TTY 自动)
|
|
61
|
-
# level = "info" # trace|debug|info|warn|error|fatal(控制台级别)
|
|
62
|
-
# banner = true # 启动字符画
|
|
63
|
-
# timestamp = "local" # local | iso8601 | elapsed
|
|
64
|
-
# unboundReceive = true # false = 未绑定频道的接收行不打(kurobot 握手后生效)
|
|
65
|
-
# [log.levels] # 按模块覆盖级别:cli / kernel / loader / ob11
|
|
66
|
-
# # loader = "debug" # 例:终端上看接收行(默认已降 debug)
|
|
67
|
-
# # ob11 = "debug" # 例:协议校准数据(含原始 hex)
|
|
68
|
-
# [log.file] # 独立文件 sink(可选;级别可低于控制台)
|
|
69
|
-
# # path = "logs/napuketto-console.log"
|
|
70
|
-
# # level = "debug"
|
|
71
|
-
#
|
|
72
|
-
# 环境变量覆盖(优先级高于本文件):NAPUKETTO_LOG_LEVEL / NAPUKETTO_LOG_FORMAT /
|
|
73
|
-
# NAPUKETTO_NO_BANNER(兼容旧拼写 NAPKETTO_LOG_LEVEL)。
|
|
74
|
-
|
|
75
|
-
# ------------------------------------------------------------
|
|
76
|
-
# 账号(至少一个,qq 必填)
|
|
77
|
-
# ------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
[[accounts]]
|
|
80
|
-
qq = "${RESERVED_PLACEHOLDER_QINS[0]}" # 占位值,必须替换为你的 QQ 号,否则启动报错
|
|
81
|
-
enabled = true # 是否启用(可选,缺省 true)
|
|
82
|
-
|
|
83
|
-
[accounts.onebot11] # 该账号的 OneBot 11 配置(无此段 = 不启用 OB11)
|
|
84
|
-
|
|
85
|
-
# 鉴权 token(全局默认):HTTP/WS 连接必须携带。
|
|
86
|
-
# 校验方式:Authorization: Bearer <token> / 裸 token / URL 参数 ?access_token=<token>
|
|
87
|
-
# 每个网络实例可单独覆盖(见下文各实例的 token 字段)。
|
|
88
|
-
# 留空 = 不鉴权(仅本机调试推荐)。
|
|
89
|
-
# token = ""
|
|
90
|
-
|
|
91
|
-
# 心跳 meta 事件间隔(毫秒);0 = 关闭心跳事件
|
|
92
|
-
heartbeatInterval = 3000
|
|
93
|
-
|
|
94
|
-
# 是否上报机器人自己发的消息(OB11 规范默认不上报)
|
|
95
|
-
reportSelfMessage = false
|
|
96
|
-
|
|
97
|
-
# 消息内容格式:array = 消息段数组(标准 OneBot 11),string = CQ 码字符串
|
|
98
|
-
messagePostFormat = "array"
|
|
99
|
-
|
|
100
|
-
# 群文件报形式:false(默认)= message 事件 + file 段(go-cqhttp 兼容);
|
|
101
|
-
# true = 群文件消息改报 group_upload notice 事件(替代 message 事件)
|
|
102
|
-
groupUploadAsNotice = false
|
|
103
|
-
|
|
104
|
-
# ---- HTTP 反向服务器(接收第三方 POST 指令),可配置多个 ----
|
|
105
|
-
[[accounts.onebot11.httpServers]]
|
|
106
|
-
enabled = true
|
|
107
|
-
host = "127.0.0.1" # 监听地址;0.0.0.0 = 监听所有网卡(局域网可连)
|
|
108
|
-
port = 3000
|
|
109
|
-
# token = "abc" # 可选:覆盖全局 token
|
|
110
|
-
|
|
111
|
-
# ---- HTTP 正向上报(Webhook:把事件 POST 到第三方地址),可配置多个 ----
|
|
112
|
-
# [[accounts.onebot11.httpPostUrls]]
|
|
113
|
-
# enabled = true
|
|
114
|
-
# url = "http://127.0.0.1:8080/onebot" # 第三方接收地址
|
|
115
|
-
# token = "abc" # 可选:覆盖全局 token
|
|
116
|
-
# timeoutMs = 5000 # 可选:上报超时(毫秒),缺省不超时
|
|
117
|
-
|
|
118
|
-
# ---- WS 反向服务器(外部 WS 客户端主动连入),可配置多个 ----
|
|
119
|
-
[[accounts.onebot11.wsServers]]
|
|
120
|
-
enabled = true
|
|
121
|
-
host = "127.0.0.1"
|
|
122
|
-
port = 3001
|
|
123
|
-
# token = "abc" # 可选:覆盖全局 token
|
|
124
|
-
# heartbeatInterval = 30000 # 可选:WS ping 间隔(毫秒)
|
|
125
|
-
|
|
126
|
-
# ---- WS 正向客户端(主动连接外部 WS,双向),可配置多个 ----
|
|
127
|
-
# [[accounts.onebot11.wsReverseUrls]]
|
|
128
|
-
# enabled = true
|
|
129
|
-
# url = "ws://127.0.0.1:8081/ws" # 第三方 WS 地址(ws:// 或 wss://)
|
|
130
|
-
# token = "abc" # 可选:覆盖全局 token
|
|
131
|
-
# reconnectDelayMs = 5000 # 可选:断线重连延迟(毫秒)
|
|
132
|
-
# maxReconnectAttempts = 10 # 可选:最大重连次数(缺省无限)
|
|
133
|
-
# rejectUnauthorized = true # 可选:wss:// 是否校验证书(自签证书设 false)
|
|
134
|
-
# heartbeatInterval = 30000 # 可选:WS ping 间隔(毫秒)
|
|
135
|
-
|
|
136
|
-
# 第二个账号示例(只开 Satori):
|
|
137
|
-
# [[accounts]]
|
|
138
|
-
# qq = "${RESERVED_PLACEHOLDER_QINS[1]}"
|
|
139
|
-
# [accounts.satori]
|
|
140
|
-
# # token = "" # 可选:Satori 鉴权 token
|
|
141
|
-
# [[accounts.satori.httpServers]] # HTTP RPC(POST /v1/{resource}.{method})
|
|
142
|
-
# enabled = true
|
|
143
|
-
# host = "127.0.0.1"
|
|
144
|
-
# port = 5500
|
|
145
|
-
# [[accounts.satori.wsServers]] # WS 事件服务(/v1/events)
|
|
146
|
-
# enabled = true
|
|
147
|
-
# host = "127.0.0.1"
|
|
148
|
-
# port = 5501
|
|
149
|
-
|
|
150
|
-
# ------------------------------------------------------------
|
|
151
|
-
# KuroBot 群服互通(kurobridge-ws 客户端,MVP-3)
|
|
152
|
-
# ------------------------------------------------------------
|
|
153
|
-
# 账号内写 [accounts.kurobot] 段即启用;不写 = 不启用。作为 WS 客户端
|
|
154
|
-
# 主动连入 KuroBot 服务端(Minecraft 服务器 kurobot 插件)。
|
|
155
|
-
# QQ 群文字 ↔ 游戏聊天双向互通;群命令(前缀开头)→ 游戏命令。
|
|
156
|
-
# [accounts.kurobot]
|
|
157
|
-
# url = "ws://127.0.0.1:25580" # 必填:KuroBot 服务端地址(ws:// 或 wss://)
|
|
158
|
-
# # token = "" # 可选:鉴权 token(服务端配了则必填,错 token 拒连)
|
|
159
|
-
# # client = "napukettoqq" # 可选:自报身份(缺省 napukettoqq/<版本>)
|
|
160
|
-
# # commandPrefix = "/" # 可选:群命令前缀
|
|
161
|
-
# # pingIntervalMs = 15000 # 可选:应用层心跳间隔(毫秒,须 < 30000)
|
|
162
|
-
# # chatTemplate = "[{player}] {content}" # 游戏聊天渲染模板
|
|
163
|
-
# # joinTemplate = "{player} 加入了服务器" # 玩家进服模板
|
|
164
|
-
# # leaveTemplate = "{player} 离开了服务器" # 玩家退服模板
|
|
165
|
-
# # deathTemplate = "{player} {message}" # 玩家死亡模板(空消息显示「死亡了」)
|
|
166
|
-
`;
|
|
167
|
-
}
|
|
168
|
-
//#endregion
|
|
169
|
-
//#region src/config-parse.ts
|
|
13
|
+
//#region src/config/schema.ts
|
|
170
14
|
/**
|
|
171
|
-
*
|
|
15
|
+
* 主配置类型与手写校验器(原 config-cmds.ts 拆分,2026-08-08 FTA 优化)
|
|
172
16
|
*
|
|
173
17
|
* 主配置 parse(不引入 zod):数据根 / 自动重启 / 重启延迟 / 账号列表。
|
|
174
18
|
* 2026-08-08 结构拍板:**一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在
|
|
@@ -177,10 +21,12 @@ port = 3001
|
|
|
177
21
|
* 全部校验错误为 ConfigError(2026-10-25 嵌入式退出码契约:CLI exit 78 一族,
|
|
178
22
|
* 内联与文件两形态共用本校验器)。
|
|
179
23
|
*/
|
|
24
|
+
/** 保留占位 QQ 号(模板默认值):必须替换为真实 QQ 号,否则启动校验报错。 */
|
|
25
|
+
const RESERVED_PLACEHOLDER_QINS = ["123456", "654321"];
|
|
180
26
|
/** 默认自动重启。 */
|
|
181
27
|
const DEFAULT_AUTO_RESTART = true;
|
|
182
|
-
/**
|
|
183
|
-
const DEFAULT_RESTART_DELAY_MS
|
|
28
|
+
/** 默认重启延迟(毫秒;supervisor 显式 -q 场景复用,避免两处常量漂移)。 */
|
|
29
|
+
const DEFAULT_RESTART_DELAY_MS = 2e3;
|
|
184
30
|
/** QQ 号格式:纯数字 5-11 位(QQ 号合法范围,勿写死 11 位)。 */
|
|
185
31
|
const QQ_IN_FORMAT_RE = /^\d{5,11}$/;
|
|
186
32
|
/** 解析账号内协议段(宽松对象,装配时由 zod schema 严格校验)。 */
|
|
@@ -208,7 +54,7 @@ function parseAutoRestart(raw) {
|
|
|
208
54
|
}
|
|
209
55
|
/** 解析 restartDelayMs(毫秒)。 */
|
|
210
56
|
function parseRestartDelayMs(raw) {
|
|
211
|
-
if (raw["restartDelayMs"] === void 0) return DEFAULT_RESTART_DELAY_MS
|
|
57
|
+
if (raw["restartDelayMs"] === void 0) return DEFAULT_RESTART_DELAY_MS;
|
|
212
58
|
if (typeof raw["restartDelayMs"] !== "number" || !Number.isInteger(raw["restartDelayMs"]) || raw["restartDelayMs"] <= 0) throw configError("主配置 restartDelayMs 必须是正整数");
|
|
213
59
|
return raw["restartDelayMs"];
|
|
214
60
|
}
|
|
@@ -238,7 +84,7 @@ function applyAccountEnabled(out, enabled) {
|
|
|
238
84
|
if (typeof enabled !== "boolean") throw configError("主配置账号 enabled 必须是布尔值");
|
|
239
85
|
out.enabled = enabled;
|
|
240
86
|
}
|
|
241
|
-
/** 账号内协议段装配(onebot11/satori/kurobot 缺省 = 不启用该协议)。 */
|
|
87
|
+
/** 账号内协议段装配(onebot11/satori/kurobot/milky 缺省 = 不启用该协议)。 */
|
|
242
88
|
function applyProtocolSections(raw, out) {
|
|
243
89
|
const onebot11 = parseProtocolSection(raw, "onebot11");
|
|
244
90
|
if (onebot11 !== void 0) out.onebot11 = onebot11;
|
|
@@ -246,6 +92,8 @@ function applyProtocolSections(raw, out) {
|
|
|
246
92
|
if (satori !== void 0) out.satori = satori;
|
|
247
93
|
const kurobot = parseProtocolSection(raw, "kurobot");
|
|
248
94
|
if (kurobot !== void 0) out.kurobot = kurobot;
|
|
95
|
+
const milky = parseProtocolSection(raw, "milky");
|
|
96
|
+
if (milky !== void 0) out.milky = milky;
|
|
249
97
|
}
|
|
250
98
|
/** 解析单个账号项(qq 必填;协议段缺省 = 不启用)。 */
|
|
251
99
|
function parseAccount(item) {
|
|
@@ -297,7 +145,7 @@ function parseCliConfig(input) {
|
|
|
297
145
|
};
|
|
298
146
|
}
|
|
299
147
|
//#endregion
|
|
300
|
-
//#region src/config
|
|
148
|
+
//#region src/config/inline.ts
|
|
301
149
|
/**
|
|
302
150
|
* 内联配置(NAPKETTO_CONFIG_JSON,2026-10-25 嵌入式宿主契约)。
|
|
303
151
|
*
|
|
@@ -307,8 +155,15 @@ function parseCliConfig(input) {
|
|
|
307
155
|
* 内联生效时不读 / 不写 / 不校验任何 toml 文件。Windows 单环境变量上限 32767
|
|
308
156
|
* 字符(约 32KB),嵌入式配置形状远小于该限(超限宿主退回文件形态)。
|
|
309
157
|
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
158
|
+
* 读取口径分两档(有意为之):
|
|
159
|
+
* - parseInlineConfig:严格,主配置加载 / config validate 用;JSON 解析失败、
|
|
160
|
+
* 非对象、字段校验失败一律 ConfigError(exit 78 一族)。
|
|
161
|
+
* - readInlineConfigDoc / readInlineConfigSection:宽松,logger 读 [log] 段用;
|
|
162
|
+
* 坏文档返回 undefined(日志配置损坏不阻塞启动,严格校验由主配置路径负责)。
|
|
163
|
+
* loader 引导进程另有自己的宽松读取(编译期不静态依赖 kernel),两套不合并。
|
|
164
|
+
*
|
|
165
|
+
* 错误文案指明来源为「内联配置」且不含任何 toml 文件路径。本模块不 import
|
|
166
|
+
* logger(logger.ts 反向依赖本模块判内联来源)。
|
|
312
167
|
*/
|
|
313
168
|
/** 内联配置 env 键(读取为精确键访问;Windows 上 process.env 本身大小写不敏感)。 */
|
|
314
169
|
const INLINE_CONFIG_ENV = "NAPKETTO_CONFIG_JSON";
|
|
@@ -324,11 +179,31 @@ function readInlineConfigText() {
|
|
|
324
179
|
function isInlineConfigActive() {
|
|
325
180
|
return readInlineConfigText() !== void 0;
|
|
326
181
|
}
|
|
182
|
+
/** JSON.parse 产物 → 配置对象(数组 / null / 原始值 → undefined)。 */
|
|
183
|
+
function asConfigObject(value) {
|
|
184
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
327
187
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
188
|
+
* 读内联配置文档(宽松口径:未设置 / 空串 / JSON 解析失败 / 非对象 → undefined)。
|
|
189
|
+
* 供日志段等「坏配置不阻塞启动」的消费方使用。
|
|
330
190
|
*/
|
|
331
|
-
function
|
|
191
|
+
function readInlineConfigDoc() {
|
|
192
|
+
const text = readInlineConfigText();
|
|
193
|
+
if (text === void 0) return;
|
|
194
|
+
try {
|
|
195
|
+
return asConfigObject(JSON.parse(text));
|
|
196
|
+
} catch {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** 读内联配置的单个顶层段(坏文档 → undefined;logger 读 [log] 用)。 */
|
|
201
|
+
function readInlineConfigSection(key) {
|
|
202
|
+
const doc = readInlineConfigDoc();
|
|
203
|
+
return doc === void 0 ? void 0 : doc[key];
|
|
204
|
+
}
|
|
205
|
+
/** 严格解析内联 JSON 对象(主配置校验入口;错误一概 ConfigError)。 */
|
|
206
|
+
function parseJsonObject(text) {
|
|
332
207
|
let doc;
|
|
333
208
|
try {
|
|
334
209
|
doc = JSON.parse(text);
|
|
@@ -336,8 +211,16 @@ function parseInlineConfig(text) {
|
|
|
336
211
|
const detail = err instanceof Error ? err.message : String(err);
|
|
337
212
|
throw configError(`内联配置 JSON 解析失败: ${detail}`, { cause: err });
|
|
338
213
|
}
|
|
339
|
-
|
|
340
|
-
|
|
214
|
+
const object = asConfigObject(doc);
|
|
215
|
+
if (object === void 0) throw configError("内联配置必须是 JSON 对象(与 napuketto.toml 同构)");
|
|
216
|
+
return object;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 内联 JSON 文本 → 校验后的主配置。JSON 解析失败 / 非对象 / 字段校验失败
|
|
220
|
+
* 均抛 ConfigError(宿主消费 exit 78,不重试)。
|
|
221
|
+
*/
|
|
222
|
+
function parseInlineConfig(text) {
|
|
223
|
+
return parseCliConfig(parseJsonObject(text));
|
|
341
224
|
}
|
|
342
225
|
//#endregion
|
|
343
226
|
//#region src/logger.ts
|
|
@@ -364,12 +247,7 @@ function resolveCliLogConfig(dataDir) {
|
|
|
364
247
|
if (cachedConfig !== null) return cachedConfig;
|
|
365
248
|
cachedDataRoot = resolveDataRoot(dataDir);
|
|
366
249
|
let section;
|
|
367
|
-
|
|
368
|
-
if (inlineText !== void 0) try {
|
|
369
|
-
section = JSON.parse(inlineText)["log"];
|
|
370
|
-
} catch {
|
|
371
|
-
section = void 0;
|
|
372
|
-
}
|
|
250
|
+
if (isInlineConfigActive()) section = readInlineConfigSection("log");
|
|
373
251
|
else try {
|
|
374
252
|
const configPath = resolveConfigPath({ dataRoot: cachedDataRoot });
|
|
375
253
|
section = parseToml(readFileSync(configPath, "utf8"))["log"];
|
|
@@ -452,381 +330,183 @@ var LazyCliLogger = class {
|
|
|
452
330
|
/** cli 进程日志(懒构造,配置见 resolveCliLogConfig)。 */
|
|
453
331
|
const logger = new LazyCliLogger();
|
|
454
332
|
//#endregion
|
|
455
|
-
//#region src/
|
|
456
|
-
/**
|
|
457
|
-
* CLI 动作错误出口(2026-10-25 退出码契约):
|
|
458
|
-
* 0 = 请求式优雅关停(0.2.0 已有,不经此路径);78 = 配置校验失败
|
|
459
|
-
* (ConfigError 一族,宿主据此区分「配置未就绪」与运行时崩溃,免重启);
|
|
460
|
-
* 其余 = 1(运行时崩溃,现状不变)。
|
|
461
|
-
*
|
|
462
|
-
* 退出码只认错误类型(instanceof ConfigError),与日志文案完全解耦——
|
|
463
|
-
* 文案是人读的,退出码是宿主消费的机器契约。
|
|
464
|
-
*/
|
|
465
|
-
/** 统一错误输出(结构化日志,错误对象经 pino serializer 带堆栈)。 */
|
|
466
|
-
function reportError(err) {
|
|
467
|
-
let message = String(err);
|
|
468
|
-
if (err instanceof Error) {
|
|
469
|
-
const { message: errMessage } = err;
|
|
470
|
-
message = errMessage;
|
|
471
|
-
}
|
|
472
|
-
logger.error({ err }, message);
|
|
473
|
-
}
|
|
474
|
-
/** 统一 catch 出口:打印错误并按类型设置进程退出码(78 = 配置校验失败)。 */
|
|
475
|
-
function handleActionError(err) {
|
|
476
|
-
reportError(err);
|
|
477
|
-
process.exitCode = err instanceof ConfigError ? EXIT_CONFIG_INVALID : 1;
|
|
478
|
-
}
|
|
479
|
-
//#endregion
|
|
480
|
-
//#region src/runtime-state.ts
|
|
333
|
+
//#region src/config/template.ts
|
|
481
334
|
/**
|
|
482
|
-
*
|
|
335
|
+
* 配置模板(TOML 注释版,原 config-cmds.ts 拆分,2026-08-08 FTA 优化)。
|
|
336
|
+
* `config init` 与首次启动(loadCliConfig)缺失时生成。
|
|
483
337
|
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* `<数据根>/<uin>/runtime.json`,supervisor 自身写 `<数据根>/supervisor.json`;
|
|
488
|
-
* `napuketto status/stop/restart` 读文件 + liveness 检测 + 树杀(win32
|
|
489
|
-
* taskkill /T /F,杀 boot 进程树连带 self-host 孙进程)+ detached 重启。
|
|
338
|
+
* 2026-08-08 结构拍板:**一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在
|
|
339
|
+
* 账号内**([accounts.onebot11] / [accounts.satori]);账号必填(至少一个)。
|
|
340
|
+
* dataDir 不再写死(缺省 = 项目根/.napuketto,跨平台),模板保持可移植。
|
|
490
341
|
*/
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
return opts.stubDir ?? process.env["NAPUTO_STUB_DIR"] ?? defaultStubDir();
|
|
649
|
-
}
|
|
650
|
-
/** 错误文案提取(Error 用 message,其余字符串化——保留旧日志文案语义)。 */
|
|
651
|
-
function errorMessage(err) {
|
|
652
|
-
return err instanceof Error ? err.message : String(err);
|
|
653
|
-
}
|
|
654
|
-
/** QQ 原生文件多级来源 + 自动下载(本机未装 QQ 时走下载管线)。
|
|
655
|
-
* 失败记录可操作提示并置 exitCode 1,返回 undefined(调用方提前返回)。 */
|
|
656
|
-
async function locateQqFiles(dataRoot, opts) {
|
|
657
|
-
try {
|
|
658
|
-
return await resolveQqFiles({
|
|
659
|
-
dataRoot,
|
|
660
|
-
...opts.qqPath !== void 0 ? { qqPath: opts.qqPath } : {}
|
|
661
|
-
});
|
|
662
|
-
} catch (err) {
|
|
663
|
-
logger.error({ err }, `定位/下载 QQ 原生文件失败:${errorMessage(err)}\n(本机未装 QQ 时自动下载官方安装包;若下载失败请检查网络,或手动安装 QQ、设置 NAPUTO_QQ_PATH 指定已有安装、NAPUTO_QQ_FILES 指定文件根、NAPUTO_QQ_URL 覆盖下载地址)`);
|
|
664
|
-
process.exitCode = 1;
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* 单实例锁预检(2026-08-07 根治「多实例抢数据目录锁挂起」):同一账号数据
|
|
670
|
-
* 目录只允许一个实例(QQ 原生层 MMKV/登录单例有锁,第二个实例抢不到锁会卡死)。
|
|
671
|
-
* 占用 → 快速失败并提示占用 PID;残留(PID 已死 / PID 被复用,2026-09-10
|
|
672
|
-
* cmdline 校验根治)→ self-host 会自动接管。真正的锁由 self-host 子进程
|
|
673
|
-
* 获取/释放(它才是数据目录的实际持有者),cli 这里只做 spawn 前预检,
|
|
674
|
-
* 给用户友好提示(supervisor 等批量拉起同用)。
|
|
675
|
-
* @returns 可用 true;占用时记录错误并置 exitCode 1 返回 false
|
|
676
|
-
*/
|
|
677
|
-
function ensureInstanceLockFree(cfgDir) {
|
|
678
|
-
const { occupied, pid: holderPid } = checkInstanceLock(cfgDir);
|
|
679
|
-
if (!occupied) return true;
|
|
680
|
-
logger.error({
|
|
681
|
-
pid: holderPid,
|
|
682
|
-
dataDir: cfgDir
|
|
683
|
-
}, "数据目录已被其他实例占用,拒绝启动(同一账号数据目录仅允许一个实例;占用校验已含 pid 复用检测,如仍误报请删除该目录下的 instance.lock)");
|
|
684
|
-
process.exitCode = 1;
|
|
685
|
-
return false;
|
|
686
|
-
}
|
|
687
|
-
/** 启动信息走结构化日志(时间戳 + 级别 + pid + 元数据,与 kernel 子进程格式一致)。
|
|
688
|
-
* 2026-10-25 级别重分配:引导细节降 debug(banner 之外的定位/路径细节对运行
|
|
689
|
-
* 状态无信息量),致命错误与登录/退出生命周期保持 info 及以上。 */
|
|
690
|
-
function logBootInfo(qq, cfgDir) {
|
|
691
|
-
logger.debug({
|
|
692
|
-
qqVersion: qq.version,
|
|
693
|
-
qqPath: qq.qqPath,
|
|
694
|
-
source: qq.source
|
|
695
|
-
}, "QQ 文件来源");
|
|
696
|
-
logger.debug({ dataDir: cfgDir }, "数据目录");
|
|
697
|
-
logger.debug("自建宿主引导(标准 node + stub QQNT.dll)");
|
|
698
|
-
}
|
|
699
|
-
/** launchSelfHost 调用参数构造(唯一启动路径:自建宿主,2026-08-07 用户拍板)。
|
|
700
|
-
* configPath:全局配置文件(项目根 napuketto.toml),注入 NAPKETTO_CONFIG 供装配链读取;
|
|
701
|
-
* 内联配置生效(NAPKETTO_CONFIG_JSON)时不解析、不注入——子进程经 env 继承
|
|
702
|
-
* 内联文本,装配链按同一优先级读取;避免项目根探测出无关路径注入孙进程。
|
|
703
|
-
* extraEnv:传播有效控制台渲染决策(子进程 stdout 恒 pipe,TTY 判定失真)+
|
|
704
|
-
* 数据根(NAPUTO_DATA_ROOT,self-host 停止哨兵目录)。
|
|
705
|
-
* cwd:子进程 cwd 指向数据根——QQ 原生层 fallback 落盘(guild1.db 等)落在专门
|
|
706
|
-
* 的数据目录,不污染项目根(实测 08-07)。 */
|
|
707
|
-
function buildLaunchOptions(ctx) {
|
|
708
|
-
const { opts, qq, cfgDir, dataRoot, entries, stubDir } = ctx;
|
|
709
|
-
return {
|
|
710
|
-
qq,
|
|
711
|
-
kernelEntry: entries.kernelEntry,
|
|
712
|
-
adapterEntry: entries.adapterEntry,
|
|
713
|
-
networkEntry: entries.networkEntry,
|
|
714
|
-
cfgDir,
|
|
715
|
-
cwd: dataRoot,
|
|
716
|
-
...isInlineConfigActive() ? {} : { configPath: resolveConfigPath({ dataRoot }) },
|
|
717
|
-
selfHost: true,
|
|
718
|
-
stdio: [
|
|
719
|
-
"inherit",
|
|
720
|
-
"pipe",
|
|
721
|
-
"pipe"
|
|
722
|
-
],
|
|
723
|
-
extraEnv: {
|
|
724
|
-
...effectiveConsoleEnv(resolveCliLogConfig(opts.dataDir)),
|
|
725
|
-
NAPUTO_DATA_ROOT: dataRoot
|
|
726
|
-
},
|
|
727
|
-
...opts.qq !== void 0 ? { quickUin: opts.qq } : {},
|
|
728
|
-
stubDir
|
|
729
|
-
};
|
|
730
|
-
}
|
|
731
|
-
/** 启动自建宿主子进程(包入口解析 → stub 目录 → 启动信息 → launchSelfHost)。
|
|
732
|
-
* ⚠️ P2(2026-08-12):launchSelfHost 变 async(linux 场景需下载 win-node)。 */
|
|
733
|
-
async function startSelfHost(opts, qq, cfgDir, dataRoot) {
|
|
734
|
-
const entries = await resolvePackageEntries();
|
|
735
|
-
const stubDir = resolveStubDir(opts);
|
|
736
|
-
logBootInfo(qq, cfgDir);
|
|
737
|
-
return await launchSelfHost(buildLaunchOptions({
|
|
738
|
-
opts,
|
|
739
|
-
qq,
|
|
740
|
-
cfgDir,
|
|
741
|
-
dataRoot,
|
|
742
|
-
entries,
|
|
743
|
-
stubDir
|
|
744
|
-
}));
|
|
745
|
-
}
|
|
746
|
-
/** 接管子进程 stdout/stderr:过滤 MMKV / 符号查找失败等原生噪音,其余转发。 */
|
|
747
|
-
function forwardChildOutput(child) {
|
|
748
|
-
if (child.stdout !== null) forwardFiltered(child.stdout, process.stdout);
|
|
749
|
-
if (child.stderr !== null) forwardFiltered(child.stderr, process.stderr);
|
|
750
|
-
}
|
|
751
|
-
/** 单账号运行时状态落盘(napuketto status/stop 消费,2026-09-08 T8;
|
|
752
|
-
* pid 记 boot 进程——树杀连带 self-host 孙进程)。 */
|
|
753
|
-
function recordRuntimeState(dataRoot, account, child, status, exitCode) {
|
|
754
|
-
writeAccountRuntime(dataRoot, {
|
|
755
|
-
uin: account,
|
|
756
|
-
pid: process.pid,
|
|
757
|
-
...child.pid !== void 0 ? { selfHostPid: child.pid } : {},
|
|
758
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
759
|
-
kind: "single-boot",
|
|
760
|
-
status,
|
|
761
|
-
...exitCode !== void 0 ? { exitCode } : {}
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
/** 优雅关停编排(2026-10-25 生命周期契约):触发后等 self-host 经自身哨兵/信号
|
|
765
|
-
* 处理自行退出(exit 0),10s 未退则 kill 兜底(Windows 下为硬杀)——
|
|
766
|
-
* boot 进程随后随 child 退出自然返回(退出码 0 = 请求式正常退出)。
|
|
767
|
-
* @returns 「是否已收到停止请求」查询(退出日志 graceful 标注) */
|
|
768
|
-
function installGracefulStop(child, dataRoot, account) {
|
|
769
|
-
let stopArmed = false;
|
|
770
|
-
const killIfHung = () => {
|
|
771
|
-
if (child.exitCode !== null || child.killed) return;
|
|
772
|
-
logger.warn({ account }, "自建宿主未在限时内退出,强制结束");
|
|
773
|
-
child.kill();
|
|
774
|
-
};
|
|
775
|
-
const armGracefulStop = (reason) => {
|
|
776
|
-
if (stopArmed) return;
|
|
777
|
-
stopArmed = true;
|
|
778
|
-
logger.info({
|
|
779
|
-
account,
|
|
780
|
-
reason
|
|
781
|
-
}, "收到停止请求,等待自建宿主退出");
|
|
782
|
-
setTimeout(killIfHung, GRACEFUL_STOP_TIMEOUT_MS).unref?.();
|
|
783
|
-
};
|
|
784
|
-
watchStopSentinel({
|
|
785
|
-
dataRoot,
|
|
786
|
-
account,
|
|
787
|
-
onTrigger: () => armGracefulStop("停止哨兵")
|
|
788
|
-
});
|
|
789
|
-
const onSignal = (signal) => {
|
|
790
|
-
writeStopSentinel(dataRoot, account);
|
|
791
|
-
armGracefulStop(`信号 ${signal}`);
|
|
792
|
-
};
|
|
793
|
-
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
794
|
-
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
795
|
-
process.on("SIGBREAK", () => onSignal("SIGBREAK"));
|
|
796
|
-
return () => stopArmed;
|
|
797
|
-
}
|
|
798
|
-
/** 常驻:等待自建宿主进程退出(exit / error 两路径均 resolve,去留由 self-host 决定)。 */
|
|
799
|
-
async function waitSelfHostExit(child, dataRoot, account, isStopping) {
|
|
800
|
-
await new Promise((resolve) => {
|
|
801
|
-
child.on("exit", (code) => {
|
|
802
|
-
logger.info({
|
|
803
|
-
code,
|
|
804
|
-
graceful: isStopping()
|
|
805
|
-
}, "自建宿主进程退出");
|
|
806
|
-
recordRuntimeState(dataRoot, account, child, "exited", code);
|
|
807
|
-
resolve();
|
|
808
|
-
});
|
|
809
|
-
child.on("error", (err) => {
|
|
810
|
-
logger.error({ err }, "自建宿主进程启动失败");
|
|
811
|
-
resolve();
|
|
812
|
-
});
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
/** 启动单个账号(自建宿主 + 常驻)。 */
|
|
816
|
-
async function runSingleAccount(opts = {}) {
|
|
817
|
-
const dataRoot = resolveDataRoot(opts.dataDir);
|
|
818
|
-
const qq = await locateQqFiles(dataRoot, opts);
|
|
819
|
-
if (qq === void 0) return;
|
|
820
|
-
const account = opts.qq ?? "default";
|
|
821
|
-
const cfgDir = path.join(dataRoot, account);
|
|
822
|
-
if (!ensureInstanceLockFree(cfgDir)) return;
|
|
823
|
-
const { child } = await startSelfHost(opts, qq, cfgDir, dataRoot);
|
|
824
|
-
forwardChildOutput(child);
|
|
825
|
-
recordRuntimeState(dataRoot, account, child, "running");
|
|
826
|
-
await waitSelfHostExit(child, dataRoot, account, installGracefulStop(child, dataRoot, account));
|
|
342
|
+
function configTemplate() {
|
|
343
|
+
return `# ============================================================
|
|
344
|
+
# NapukettoQQ 全局配置文件
|
|
345
|
+
# ============================================================
|
|
346
|
+
# 本文件由 \`napuketto config init\` 生成(或首次启动自动生成)。
|
|
347
|
+
# 本机配置不入库(.gitignore);修改后重启生效。
|
|
348
|
+
#
|
|
349
|
+
# 配置组织:一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在账号内
|
|
350
|
+
# ([accounts.onebot11] / [accounts.satori] / [accounts.kurobot])。账号没有写
|
|
351
|
+
# 某协议段 = 该账号不启用该协议。账号必填(至少一个,qq 必填)。
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------
|
|
354
|
+
# 全局配置(跨账号)
|
|
355
|
+
# ------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
# 数据根目录:账号目录/日志/缓存/QQ 数据存放位置。
|
|
358
|
+
# 缺省 = <项目根>/.napuketto(部署场景,跨平台,无需修改)。
|
|
359
|
+
# 支持绝对路径、"~/" 前缀(用户主目录)、相对路径(相对启动目录)。
|
|
360
|
+
# dataDir = ".napuketto"
|
|
361
|
+
|
|
362
|
+
# 多账号(supervisor)模式下,账号进程崩溃是否自动重启
|
|
363
|
+
autoRestart = true
|
|
364
|
+
|
|
365
|
+
# 崩溃后自动重启的延迟(毫秒)
|
|
366
|
+
restartDelayMs = 2000
|
|
367
|
+
|
|
368
|
+
# ------------------------------------------------------------
|
|
369
|
+
# 日志([log],可选——不配置即用缺省:终端 pretty 多行 / 管道 single-line)
|
|
370
|
+
# ------------------------------------------------------------
|
|
371
|
+
# 嵌入式宿主(把 napuketto 打进服务端等场景)推荐:
|
|
372
|
+
# [log]
|
|
373
|
+
# format = "single-line" # 或 "json"(NDJSON,宿主按 level/module 精确路由)
|
|
374
|
+
# level = "warn" # 控制台只留警告以上,细节进文件(配 [log.file])
|
|
375
|
+
# banner = false
|
|
376
|
+
#
|
|
377
|
+
# [log]
|
|
378
|
+
# format = "pretty" # pretty | single-line | json(缺省按 TTY 自动)
|
|
379
|
+
# level = "info" # trace|debug|info|warn|error|fatal(控制台级别)
|
|
380
|
+
# banner = true # 启动字符画
|
|
381
|
+
# timestamp = "local" # local | iso8601 | elapsed
|
|
382
|
+
# unboundReceive = true # false = 未绑定频道的接收行不打(kurobot 握手后生效)
|
|
383
|
+
# [log.levels] # 按模块覆盖级别:cli / kernel / loader / ob11
|
|
384
|
+
# # loader = "debug" # 例:终端上看接收行(默认已降 debug)
|
|
385
|
+
# # ob11 = "debug" # 例:协议校准数据(含原始 hex)
|
|
386
|
+
# [log.file] # 独立文件 sink(可选;级别可低于控制台)
|
|
387
|
+
# # path = "logs/napuketto-console.log"
|
|
388
|
+
# # level = "debug"
|
|
389
|
+
#
|
|
390
|
+
# 环境变量覆盖(优先级高于本文件):NAPUKETTO_LOG_LEVEL / NAPUKETTO_LOG_FORMAT /
|
|
391
|
+
# NAPUKETTO_NO_BANNER(兼容旧拼写 NAPKETTO_LOG_LEVEL)。
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------
|
|
394
|
+
# 账号(至少一个,qq 必填)
|
|
395
|
+
# ------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
[[accounts]]
|
|
398
|
+
qq = "${RESERVED_PLACEHOLDER_QINS[0]}" # 占位值,必须替换为你的 QQ 号,否则启动报错
|
|
399
|
+
enabled = true # 是否启用(可选,缺省 true)
|
|
400
|
+
|
|
401
|
+
[accounts.onebot11] # 该账号的 OneBot 11 配置(无此段 = 不启用 OB11)
|
|
402
|
+
|
|
403
|
+
# 鉴权 token(全局默认):HTTP/WS 连接必须携带。
|
|
404
|
+
# 校验方式:Authorization: Bearer <token> / 裸 token / URL 参数 ?access_token=<token>
|
|
405
|
+
# 每个网络实例可单独覆盖(见下文各实例的 token 字段)。
|
|
406
|
+
# 留空 = 不鉴权(仅本机调试推荐)。
|
|
407
|
+
# token = ""
|
|
408
|
+
|
|
409
|
+
# 心跳 meta 事件间隔(毫秒);0 = 关闭心跳事件
|
|
410
|
+
heartbeatInterval = 3000
|
|
411
|
+
|
|
412
|
+
# 是否上报机器人自己发的消息(OB11 规范默认不上报)
|
|
413
|
+
reportSelfMessage = false
|
|
414
|
+
|
|
415
|
+
# 消息内容格式:array = 消息段数组(标准 OneBot 11),string = CQ 码字符串
|
|
416
|
+
messagePostFormat = "array"
|
|
417
|
+
|
|
418
|
+
# 群文件报形式:false(默认)= message 事件 + file 段(go-cqhttp 兼容);
|
|
419
|
+
# true = 群文件消息改报 group_upload notice 事件(替代 message 事件)
|
|
420
|
+
groupUploadAsNotice = false
|
|
421
|
+
|
|
422
|
+
# ---- HTTP 反向服务器(接收第三方 POST 指令),可配置多个 ----
|
|
423
|
+
[[accounts.onebot11.httpServers]]
|
|
424
|
+
enabled = true
|
|
425
|
+
host = "127.0.0.1" # 监听地址;0.0.0.0 = 监听所有网卡(局域网可连)
|
|
426
|
+
port = 3000
|
|
427
|
+
# token = "abc" # 可选:覆盖全局 token
|
|
428
|
+
|
|
429
|
+
# ---- HTTP 正向上报(Webhook:把事件 POST 到第三方地址),可配置多个 ----
|
|
430
|
+
# [[accounts.onebot11.httpPostUrls]]
|
|
431
|
+
# enabled = true
|
|
432
|
+
# url = "http://127.0.0.1:8080/onebot" # 第三方接收地址
|
|
433
|
+
# token = "abc" # 可选:覆盖全局 token
|
|
434
|
+
# timeoutMs = 5000 # 可选:上报超时(毫秒),缺省不超时
|
|
435
|
+
|
|
436
|
+
# ---- WS 反向服务器(外部 WS 客户端主动连入),可配置多个 ----
|
|
437
|
+
[[accounts.onebot11.wsServers]]
|
|
438
|
+
enabled = true
|
|
439
|
+
host = "127.0.0.1"
|
|
440
|
+
port = 3001
|
|
441
|
+
# token = "abc" # 可选:覆盖全局 token
|
|
442
|
+
# heartbeatInterval = 30000 # 可选:WS ping 间隔(毫秒)
|
|
443
|
+
|
|
444
|
+
# ---- WS 正向客户端(主动连接外部 WS,双向),可配置多个 ----
|
|
445
|
+
# [[accounts.onebot11.wsReverseUrls]]
|
|
446
|
+
# enabled = true
|
|
447
|
+
# url = "ws://127.0.0.1:8081/ws" # 第三方 WS 地址(ws:// 或 wss://)
|
|
448
|
+
# token = "abc" # 可选:覆盖全局 token
|
|
449
|
+
# reconnectDelayMs = 5000 # 可选:断线重连延迟(毫秒)
|
|
450
|
+
# maxReconnectAttempts = 10 # 可选:最大重连次数(缺省无限)
|
|
451
|
+
# rejectUnauthorized = true # 可选:wss:// 是否校验证书(自签证书设 false)
|
|
452
|
+
# heartbeatInterval = 30000 # 可选:WS ping 间隔(毫秒)
|
|
453
|
+
|
|
454
|
+
# 第二个账号示例(只开 Satori):
|
|
455
|
+
# [[accounts]]
|
|
456
|
+
# qq = "${RESERVED_PLACEHOLDER_QINS[1]}"
|
|
457
|
+
# [accounts.satori]
|
|
458
|
+
# # token = "" # 可选:Satori 鉴权 token
|
|
459
|
+
# [[accounts.satori.httpServers]] # HTTP RPC(POST /v1/{resource}.{method})
|
|
460
|
+
# enabled = true
|
|
461
|
+
# host = "127.0.0.1"
|
|
462
|
+
# port = 5500
|
|
463
|
+
# [[accounts.satori.wsServers]] # WS 事件服务(/v1/events)
|
|
464
|
+
# enabled = true
|
|
465
|
+
# host = "127.0.0.1"
|
|
466
|
+
# port = 5501
|
|
467
|
+
|
|
468
|
+
# ------------------------------------------------------------
|
|
469
|
+
# KuroBot 群服互通(kurobridge-ws 客户端,MVP-3)
|
|
470
|
+
# ------------------------------------------------------------
|
|
471
|
+
# 账号内写 [accounts.kurobot] 段即启用;不写 = 不启用。作为 WS 客户端
|
|
472
|
+
# 主动连入 KuroBot 服务端(Minecraft 服务器 kurobot 插件)。
|
|
473
|
+
# QQ 群文字 ↔ 游戏聊天双向互通;群命令(前缀开头)→ 游戏命令。
|
|
474
|
+
# [accounts.kurobot]
|
|
475
|
+
# url = "ws://127.0.0.1:25580" # 必填:KuroBot 服务端地址(ws:// 或 wss://)
|
|
476
|
+
# # token = "" # 可选:鉴权 token(服务端配了则必填,错 token 拒连)
|
|
477
|
+
# # client = "napukettoqq" # 可选:自报身份(缺省 napukettoqq/<版本>)
|
|
478
|
+
# # commandPrefix = "/" # 可选:群命令前缀
|
|
479
|
+
# # pingIntervalMs = 15000 # 可选:应用层心跳间隔(毫秒,须 < 30000)
|
|
480
|
+
# # chatTemplate = "[{player}] {content}" # 游戏聊天渲染模板
|
|
481
|
+
# # joinTemplate = "{player} 加入了服务器" # 玩家进服模板
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------
|
|
484
|
+
# Milky 协议(P0)
|
|
485
|
+
# ------------------------------------------------------------
|
|
486
|
+
# 账号内写 [accounts.milky] 段且 enabled = true 即启用;不写 = 不启用。
|
|
487
|
+
# 单端口 HTTP:POST /api/:api 调用动作 + GET /event 事件推送(SSE / WS)。
|
|
488
|
+
# [accounts.milky]
|
|
489
|
+
# enabled = true # 启用开关(false = 不生效)
|
|
490
|
+
# host = "127.0.0.1" # 监听地址
|
|
491
|
+
# port = 3001 # 监听端口(/api 与 /event 同端口)
|
|
492
|
+
# # token = "" # 可选:access_token(空 = 不校验,仅限内网)
|
|
493
|
+
# # eventModes = ["sse", "ws"] # /event 允许的连接形态
|
|
494
|
+
# # webhooks = [] # 可选:额外事件推送地址(WebHook POST)
|
|
495
|
+
# # cacheDir = "cache" # 可选:发送远程媒体 URI 的下载缓存目录
|
|
496
|
+
# # leaveTemplate = "{player} 离开了服务器" # 玩家退服模板
|
|
497
|
+
# # deathTemplate = "{player} {message}" # 玩家死亡模板(空消息显示「死亡了」)
|
|
498
|
+
`;
|
|
827
499
|
}
|
|
828
500
|
//#endregion
|
|
829
|
-
//#region src/config
|
|
501
|
+
//#region src/config/store.ts
|
|
502
|
+
/**
|
|
503
|
+
* 主配置 store:默认值、ConfigBase 子类、模板落盘、内联/文件来源加载。
|
|
504
|
+
*
|
|
505
|
+
* 从 config-cmds.ts 拆出(2026-10-25 模块收拢):
|
|
506
|
+
* - `CliConfigStore` 只负责 napuketto.toml 的读写/校验(kernel ConfigBase);
|
|
507
|
+
* - `loadCliConfigWithSource` 负责来源决议(NAPKETTO_CONFIG_JSON 优先)与来源日志;
|
|
508
|
+
* - 4 个 config 子命令留在 commands/config.ts,不反向依赖本文件的私有实现。
|
|
509
|
+
*/
|
|
830
510
|
/** 主配置默认值(dataRoot 为当前解析的数据根 = 项目根/.napuketto)。 */
|
|
831
511
|
function defaultCliConfig(dataRoot) {
|
|
832
512
|
return {
|
|
@@ -863,6 +543,23 @@ async function loadCliConfig(dataRoot) {
|
|
|
863
543
|
await ensureConfigTemplate(store.path);
|
|
864
544
|
return await store.load();
|
|
865
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* 解析生效配置来源(loadCliConfigWithSource 与 config validate 无参分支共用):
|
|
548
|
+
* 内联非空 → inline;否则按 kernel resolveConfigPath 解析文件路径,并带回数据根。
|
|
549
|
+
*/
|
|
550
|
+
function resolveCliConfigSource(opts = {}) {
|
|
551
|
+
const inlineText = readInlineConfigText();
|
|
552
|
+
if (inlineText !== void 0) return {
|
|
553
|
+
source: "inline",
|
|
554
|
+
text: inlineText
|
|
555
|
+
};
|
|
556
|
+
const dataRoot = resolveDataRoot(opts.dataDir);
|
|
557
|
+
return {
|
|
558
|
+
source: "file",
|
|
559
|
+
path: resolveConfigPath({ dataRoot }),
|
|
560
|
+
dataRoot
|
|
561
|
+
};
|
|
562
|
+
}
|
|
866
563
|
/** 「配置来源」日志每进程至多一行(autoStart 与 runSupervisor 各加载一次)。 */
|
|
867
564
|
let sourceLogged = false;
|
|
868
565
|
/**
|
|
@@ -872,9 +569,9 @@ let sourceLogged = false;
|
|
|
872
569
|
* 同为 ConfigError(exit 78),文案区别只在来源标注。
|
|
873
570
|
*/
|
|
874
571
|
async function loadCliConfigWithSource(opts = {}) {
|
|
875
|
-
const
|
|
876
|
-
if (
|
|
877
|
-
const config = parseInlineConfig(
|
|
572
|
+
const resolved = resolveCliConfigSource(opts);
|
|
573
|
+
if (resolved.source === "inline") {
|
|
574
|
+
const config = parseInlineConfig(resolved.text);
|
|
878
575
|
if (!sourceLogged) {
|
|
879
576
|
sourceLogged = true;
|
|
880
577
|
logger.info("配置来源: inline(NAPKETTO_CONFIG_JSON)");
|
|
@@ -884,14 +581,14 @@ async function loadCliConfigWithSource(opts = {}) {
|
|
|
884
581
|
source: "inline"
|
|
885
582
|
};
|
|
886
583
|
}
|
|
887
|
-
const dataRoot = resolveDataRoot(opts.dataDir);
|
|
888
|
-
const configPath = resolveConfigPath({ dataRoot });
|
|
889
584
|
return {
|
|
890
|
-
config: await loadCliConfig(dataRoot),
|
|
585
|
+
config: await loadCliConfig(resolved.dataRoot),
|
|
891
586
|
source: "file",
|
|
892
|
-
path:
|
|
587
|
+
path: resolved.path
|
|
893
588
|
};
|
|
894
589
|
}
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region src/commands/config.ts
|
|
895
592
|
/** CliConfig → 普通对象(smol-toml stringify 入参)。 */
|
|
896
593
|
function toRecord(config) {
|
|
897
594
|
return config;
|
|
@@ -1013,36 +710,146 @@ async function validateConfigFile(path, dataRoot) {
|
|
|
1013
710
|
async function cmdConfigValidate(opts) {
|
|
1014
711
|
const { configPath, configJson } = opts;
|
|
1015
712
|
if (configPath !== void 0 && configJson !== void 0) throw kernelError("config validate:--config 与 --config-json 只能二选一", "INVALID_PARAM");
|
|
1016
|
-
const dataRoot = resolveDataRoot(opts.dataDir);
|
|
1017
713
|
if (configJson !== void 0) {
|
|
1018
714
|
parseInlineConfig(configJson);
|
|
1019
715
|
return { source: "inline" };
|
|
1020
716
|
}
|
|
1021
717
|
if (configPath !== void 0) {
|
|
1022
|
-
await validateConfigFile(configPath,
|
|
718
|
+
await validateConfigFile(configPath, resolveDataRoot(opts.dataDir));
|
|
1023
719
|
return {
|
|
1024
720
|
source: "file",
|
|
1025
721
|
path: configPath
|
|
1026
722
|
};
|
|
1027
723
|
}
|
|
1028
|
-
const
|
|
1029
|
-
if (
|
|
1030
|
-
parseInlineConfig(
|
|
724
|
+
const resolved = resolveCliConfigSource(opts);
|
|
725
|
+
if (resolved.source === "inline") {
|
|
726
|
+
parseInlineConfig(resolved.text);
|
|
1031
727
|
return { source: "inline" };
|
|
1032
728
|
}
|
|
1033
|
-
|
|
1034
|
-
await validateConfigFile(path, dataRoot);
|
|
729
|
+
await validateConfigFile(resolved.path, resolved.dataRoot);
|
|
1035
730
|
return {
|
|
1036
731
|
source: "file",
|
|
1037
|
-
path
|
|
732
|
+
path: resolved.path
|
|
1038
733
|
};
|
|
1039
734
|
}
|
|
1040
735
|
//#endregion
|
|
1041
|
-
//#region src/
|
|
736
|
+
//#region src/runtime/state.ts
|
|
737
|
+
/**
|
|
738
|
+
* state.ts:账号运行时状态文件 + 进程控制原语(2026-09-08 T8)。
|
|
739
|
+
*
|
|
740
|
+
* 跨进程通信选型(决策记录):现有 IPC 为 stdio JSON 行协议(NAPUTO_IPC=1,
|
|
741
|
+
* koishi 插件专用),supervisor 子进程不在此模式;引入独立通道(socket/pipe)
|
|
742
|
+
* 复杂度高。采用 **PID 状态文件**:supervisor/单账号 boot 启动时把进程信息写
|
|
743
|
+
* `<数据根>/<uin>/runtime.json`,supervisor 自身写 `<数据根>/supervisor.json`;
|
|
744
|
+
* `napuketto status/stop/restart` 读文件 + liveness 检测 + 树杀(win32
|
|
745
|
+
* taskkill /T /F,杀 boot 进程树连带 self-host 孙进程)+ detached 重启。
|
|
746
|
+
*/
|
|
747
|
+
/** runtime.json 文件名。 */
|
|
748
|
+
const RUNTIME_FILE = "runtime.json";
|
|
749
|
+
/** supervisor.json 文件名。 */
|
|
750
|
+
const SUPERVISOR_FILE = "supervisor.json";
|
|
751
|
+
/** 账号状态文件路径。 */
|
|
752
|
+
function runtimeStatePath(dataRoot, uin) {
|
|
753
|
+
return join(dataRoot, uin, RUNTIME_FILE);
|
|
754
|
+
}
|
|
755
|
+
/** supervisor 状态文件路径。 */
|
|
756
|
+
function supervisorStatePath(dataRoot) {
|
|
757
|
+
return join(dataRoot, SUPERVISOR_FILE);
|
|
758
|
+
}
|
|
759
|
+
/** 写账号运行时状态(原子覆盖)。 */
|
|
760
|
+
function writeAccountRuntime(dataRoot, state) {
|
|
761
|
+
const path = runtimeStatePath(dataRoot, state.uin);
|
|
762
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
763
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
764
|
+
}
|
|
765
|
+
/** 读账号运行时状态(缺失/损坏返回 null)。 */
|
|
766
|
+
function readAccountRuntime(dataRoot, uin) {
|
|
767
|
+
return readJson(runtimeStatePath(dataRoot, uin));
|
|
768
|
+
}
|
|
769
|
+
/** 写 supervisor 运行时状态。 */
|
|
770
|
+
function writeSupervisorRuntime(dataRoot, state) {
|
|
771
|
+
writeFileSync(supervisorStatePath(dataRoot), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
772
|
+
}
|
|
773
|
+
/** 读 supervisor 运行时状态(缺失返回 null)。 */
|
|
774
|
+
function readSupervisorRuntime(dataRoot) {
|
|
775
|
+
return readJson(supervisorStatePath(dataRoot));
|
|
776
|
+
}
|
|
777
|
+
/** 读 JSON 文件(缺失/解析失败返回 null)。 */
|
|
778
|
+
function readJson(path) {
|
|
779
|
+
try {
|
|
780
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/** 列出数据根下全部账号运行时状态(扫一级目录 runtime.json)。 */
|
|
786
|
+
function listAccountRuntimes(dataRoot) {
|
|
787
|
+
let entries = [];
|
|
788
|
+
try {
|
|
789
|
+
entries = readdirSync(dataRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
790
|
+
} catch {
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
const out = [];
|
|
794
|
+
for (const uin of entries) {
|
|
795
|
+
const state = readAccountRuntime(dataRoot, uin);
|
|
796
|
+
if (state !== null) out.push(state);
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
/** pid 存活检测(signal 0;EPERM 视为存活——进程存在但无权限)。 */
|
|
801
|
+
function isPidAlive(pid) {
|
|
802
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
803
|
+
try {
|
|
804
|
+
process.kill(pid, 0);
|
|
805
|
+
return true;
|
|
806
|
+
} catch (err) {
|
|
807
|
+
return err.code === "EPERM";
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/** 树杀命令构造(win32:taskkill /T /F 连孙进程;posix:kill 信号)。 */
|
|
811
|
+
function killProcessTree(pid) {
|
|
812
|
+
if (process.platform === "win32") {
|
|
813
|
+
spawn("taskkill", [
|
|
814
|
+
"/PID",
|
|
815
|
+
String(pid),
|
|
816
|
+
"/T",
|
|
817
|
+
"/F"
|
|
818
|
+
], { stdio: "ignore" });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
process.kill(pid, "SIGTERM");
|
|
823
|
+
} catch {}
|
|
824
|
+
}
|
|
825
|
+
/** detached 拉起 node 进程(restart 用;立即返回不等待)。 */
|
|
826
|
+
function spawnDetached(args, cwd) {
|
|
827
|
+
const child = spawn(process.execPath, args, {
|
|
828
|
+
detached: true,
|
|
829
|
+
stdio: "ignore",
|
|
830
|
+
...cwd !== void 0 ? { cwd } : {}
|
|
831
|
+
});
|
|
832
|
+
child.unref();
|
|
833
|
+
return child;
|
|
834
|
+
}
|
|
835
|
+
/** 聚合各账号状态(liveness 修正:文件 running 但 pid 已死 → not running)。 */
|
|
836
|
+
function aggregateStatus(runtimes, aliveFn = isPidAlive) {
|
|
837
|
+
return runtimes.map((r) => ({
|
|
838
|
+
uin: r.uin,
|
|
839
|
+
pid: r.pid,
|
|
840
|
+
alive: r.status === "running" ? aliveFn(r.pid) : false,
|
|
841
|
+
kind: r.kind,
|
|
842
|
+
startedAt: r.startedAt,
|
|
843
|
+
restartCount: r.restartCount ?? 0,
|
|
844
|
+
exitCode: r.exitCode
|
|
845
|
+
}));
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/commands/ops.ts
|
|
1042
849
|
/**
|
|
1043
|
-
* ops
|
|
850
|
+
* ops.ts:status / stop / restart 运维命令实现(2026-09-08 T8)。
|
|
1044
851
|
*
|
|
1045
|
-
* 数据源:runtime
|
|
852
|
+
* 数据源:runtime/state 状态文件(supervisor/单账号 boot 写入)+ liveness 检测。
|
|
1046
853
|
* stop/restart 语义(2026-10-25 优雅化):
|
|
1047
854
|
* - stop -q <uin>:写单账号停止哨兵 → 等该账号进程链优雅退出 → 超时树杀兜底。
|
|
1048
855
|
* ⚠️ supervisor 在跑且 autoRestart 时账号会被自动拉起——提示用户先 `napuketto stop`。
|
|
@@ -1122,57 +929,294 @@ async function stopSingleAccount(dataRoot, target) {
|
|
|
1122
929
|
const supervisor = readSupervisorRuntime(dataRoot);
|
|
1123
930
|
if (supervisor !== null) logger.warn({ supervisorPid: supervisor.pid }, "supervisor 仍在运行且 autoRestart 开启时会自动拉起该账号;如需彻底停止请执行 napuketto stop(停 supervisor)");
|
|
1124
931
|
}
|
|
1125
|
-
async function cmdStop(dataRoot, opts) {
|
|
1126
|
-
const runtimes = listAccountRuntimes(dataRoot);
|
|
1127
|
-
if (opts.qq !== void 0) {
|
|
1128
|
-
const target = runtimes.find((r) => r.uin === opts.qq);
|
|
1129
|
-
if (target === void 0) {
|
|
1130
|
-
logger.error({ uin: opts.qq }, "账号无运行时状态(未启动过)");
|
|
1131
|
-
process.exitCode = 1;
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
await stopSingleAccount(dataRoot, target);
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
const supervisor = readSupervisorRuntime(dataRoot);
|
|
1138
|
-
const supervisorPid = supervisor !== null && isPidAlive(supervisor.pid) ? supervisor.pid : 0;
|
|
1139
|
-
if (supervisor !== null) {
|
|
1140
|
-
logger.info({ pid: supervisor.pid }, "已发送停止请求(哨兵),等待 supervisor 退出");
|
|
1141
|
-
writeStopSentinel(dataRoot, "all");
|
|
1142
|
-
const alive = await waitForPidsExit([supervisorPid]);
|
|
1143
|
-
for (const pid of alive) {
|
|
1144
|
-
logger.warn({ pid }, "supervisor 限时内未退出,树杀兜底");
|
|
1145
|
-
killProcessTree(pid);
|
|
1146
|
-
}
|
|
1147
|
-
} else logger.warn("supervisor 未运行(无状态文件)");
|
|
1148
|
-
const stragglers = await waitForPidsExit(runtimes.map((r) => r.pid));
|
|
1149
|
-
for (const runtime of runtimes) if (stragglers.includes(runtime.pid)) stopAccount(runtime);
|
|
1150
|
-
removeStopSentinel(dataRoot);
|
|
932
|
+
async function cmdStop(dataRoot, opts) {
|
|
933
|
+
const runtimes = listAccountRuntimes(dataRoot);
|
|
934
|
+
if (opts.qq !== void 0) {
|
|
935
|
+
const target = runtimes.find((r) => r.uin === opts.qq);
|
|
936
|
+
if (target === void 0) {
|
|
937
|
+
logger.error({ uin: opts.qq }, "账号无运行时状态(未启动过)");
|
|
938
|
+
process.exitCode = 1;
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
await stopSingleAccount(dataRoot, target);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const supervisor = readSupervisorRuntime(dataRoot);
|
|
945
|
+
const supervisorPid = supervisor !== null && isPidAlive(supervisor.pid) ? supervisor.pid : 0;
|
|
946
|
+
if (supervisor !== null) {
|
|
947
|
+
logger.info({ pid: supervisor.pid }, "已发送停止请求(哨兵),等待 supervisor 退出");
|
|
948
|
+
writeStopSentinel(dataRoot, "all");
|
|
949
|
+
const alive = await waitForPidsExit([supervisorPid]);
|
|
950
|
+
for (const pid of alive) {
|
|
951
|
+
logger.warn({ pid }, "supervisor 限时内未退出,树杀兜底");
|
|
952
|
+
killProcessTree(pid);
|
|
953
|
+
}
|
|
954
|
+
} else logger.warn("supervisor 未运行(无状态文件)");
|
|
955
|
+
const stragglers = await waitForPidsExit(runtimes.map((r) => r.pid));
|
|
956
|
+
for (const runtime of runtimes) if (stragglers.includes(runtime.pid)) stopAccount(runtime);
|
|
957
|
+
removeStopSentinel(dataRoot);
|
|
958
|
+
}
|
|
959
|
+
/** restart:stop 同目标 + detached 拉起。 */
|
|
960
|
+
async function cmdRestart(dataRoot, opts) {
|
|
961
|
+
await cmdStop(dataRoot, opts);
|
|
962
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
963
|
+
const entry = process.argv[1] ?? "";
|
|
964
|
+
const args = opts.qq !== void 0 ? [
|
|
965
|
+
entry,
|
|
966
|
+
"-q",
|
|
967
|
+
opts.qq,
|
|
968
|
+
"--data-dir",
|
|
969
|
+
dataRoot
|
|
970
|
+
] : [
|
|
971
|
+
entry,
|
|
972
|
+
"--data-dir",
|
|
973
|
+
dataRoot
|
|
974
|
+
];
|
|
975
|
+
spawnDetached(args);
|
|
976
|
+
logger.info({
|
|
977
|
+
args: args.slice(1),
|
|
978
|
+
qq: opts.qq ?? "(supervisor 全量)"
|
|
979
|
+
}, "已 detached 拉起(日志不再接入当前终端,见数据目录 logs/)");
|
|
980
|
+
}
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/runtime/boot.ts
|
|
983
|
+
/** 解析 workspace 包的 dist 入口(ESM 解析:包是 ESM-only,exports 无 require 条件)。 */
|
|
984
|
+
async function packageEntry(pkg) {
|
|
985
|
+
const url = await import.meta.resolve(pkg);
|
|
986
|
+
return fileURLToPath(url);
|
|
987
|
+
}
|
|
988
|
+
/** QR 透出标记行前缀(loader bootstrap-core 非 IPC 模式输出,cli 解析终端渲染)。 */
|
|
989
|
+
const QR_LINE_PREFIX = "NAPUTO_QR ";
|
|
990
|
+
/** 优雅停等待上限(毫秒):超时 kill 兜底(Windows 下为硬杀)。 */
|
|
991
|
+
const GRACEFUL_STOP_TIMEOUT_MS = 1e4;
|
|
992
|
+
/**
|
|
993
|
+
* 逐行转发子进程输出到父进程,过滤原生噪音。
|
|
994
|
+
* readline 按 UTF-8 解码 pipe 字节流,再经 process.stdout(TTY 路径,
|
|
995
|
+
* WriteConsoleW UTF-16)输出——顺带修复 pino 中文在 cmd.exe/管道 936 转码
|
|
996
|
+
* 链路下的乱码(原生 printf 字节流无法从 JS 侧改编码,转 pipe 后统一解码)。
|
|
997
|
+
*
|
|
998
|
+
* NAPUTO_QR 标记行(loader bootstrap-core 输出,QR 登录二维码数据)不转发,
|
|
999
|
+
* 解析后用 qrcode 包渲染终端二维码;png 落盘与 URL 提示由 kernel 日志完成。
|
|
1000
|
+
* 噪音判定走 loader 共享单一来源(isNativeNoiseLine,与 koishi IPC 路径同源)。
|
|
1001
|
+
*/
|
|
1002
|
+
function forwardFiltered(input, out) {
|
|
1003
|
+
createInterface({ input }).on("line", (line) => {
|
|
1004
|
+
if (line.startsWith(QR_LINE_PREFIX)) {
|
|
1005
|
+
renderTerminalQr(line.slice(10));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (isNativeNoiseLine(line)) return;
|
|
1009
|
+
out.write(`${line}\n`);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* 渲染终端二维码(qrcode 包 terminal 模式,half-block 字符)。
|
|
1014
|
+
* 解析失败/无 url 时静默丢弃——标记行是内部协议,坏行不影响登录。
|
|
1015
|
+
*/
|
|
1016
|
+
function renderTerminalQr(raw) {
|
|
1017
|
+
try {
|
|
1018
|
+
const { qrcodeUrl } = JSON.parse(raw);
|
|
1019
|
+
if (qrcodeUrl === void 0 || qrcodeUrl === "") return;
|
|
1020
|
+
QRCode.toString(qrcodeUrl, {
|
|
1021
|
+
type: "terminal",
|
|
1022
|
+
small: true
|
|
1023
|
+
}).then((str) => {
|
|
1024
|
+
process.stdout.write(`${str}\n`);
|
|
1025
|
+
}).catch(() => {});
|
|
1026
|
+
} catch {}
|
|
1027
|
+
}
|
|
1028
|
+
/** 解析自建宿主装配面的三个包入口(ESM 解析,顺序同旧实现)。 */
|
|
1029
|
+
async function resolvePackageEntries() {
|
|
1030
|
+
return {
|
|
1031
|
+
kernelEntry: await packageEntry("@napuketto/kernel"),
|
|
1032
|
+
adapterEntry: await packageEntry("@napuketto/adapter"),
|
|
1033
|
+
networkEntry: await packageEntry("@napuketto/network")
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/** stub QQNT.dll 目录(--stub-dir > NAPUTO_STUB_DIR > loader 包内闭源 submodule 默认)。 */
|
|
1037
|
+
function resolveStubDir(opts) {
|
|
1038
|
+
return opts.stubDir ?? process.env["NAPUTO_STUB_DIR"] ?? defaultStubDir();
|
|
1039
|
+
}
|
|
1040
|
+
/** 错误文案提取(Error 用 message,其余字符串化——保留旧日志文案语义)。 */
|
|
1041
|
+
function errorMessage(err) {
|
|
1042
|
+
return err instanceof Error ? err.message : String(err);
|
|
1043
|
+
}
|
|
1044
|
+
/** QQ 原生文件多级来源 + 自动下载(本机未装 QQ 时走下载管线)。
|
|
1045
|
+
* 失败记录可操作提示并置 exitCode 1,返回 undefined(调用方提前返回)。 */
|
|
1046
|
+
async function locateQqFiles(dataRoot, opts) {
|
|
1047
|
+
try {
|
|
1048
|
+
return await resolveQqFiles({
|
|
1049
|
+
dataRoot,
|
|
1050
|
+
...opts.qqPath !== void 0 ? { qqPath: opts.qqPath } : {}
|
|
1051
|
+
});
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
logger.error({ err }, `定位/下载 QQ 原生文件失败:${errorMessage(err)}\n(本机未装 QQ 时自动下载官方安装包;若下载失败请检查网络,或手动安装 QQ、设置 NAPUTO_QQ_PATH 指定已有安装、NAPUTO_QQ_FILES 指定文件根、NAPUTO_QQ_URL 覆盖下载地址)`);
|
|
1054
|
+
process.exitCode = 1;
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* 单实例锁预检(2026-08-07 根治「多实例抢数据目录锁挂起」):同一账号数据
|
|
1060
|
+
* 目录只允许一个实例(QQ 原生层 MMKV/登录单例有锁,第二个实例抢不到锁会卡死)。
|
|
1061
|
+
* 占用 → 快速失败并提示占用 PID;残留(PID 已死 / PID 被复用,2026-09-10
|
|
1062
|
+
* cmdline 校验根治)→ self-host 会自动接管。真正的锁由 self-host 子进程
|
|
1063
|
+
* 获取/释放(它才是数据目录的实际持有者),cli 这里只做 spawn 前预检,
|
|
1064
|
+
* 给用户友好提示(supervisor 等批量拉起同用)。
|
|
1065
|
+
* @returns 可用 true;占用时记录错误并置 exitCode 1 返回 false
|
|
1066
|
+
*/
|
|
1067
|
+
function ensureInstanceLockFree(cfgDir) {
|
|
1068
|
+
const { occupied, pid: holderPid } = checkInstanceLock(cfgDir);
|
|
1069
|
+
if (!occupied) return true;
|
|
1070
|
+
logger.error({
|
|
1071
|
+
pid: holderPid,
|
|
1072
|
+
dataDir: cfgDir
|
|
1073
|
+
}, "数据目录已被其他实例占用,拒绝启动(同一账号数据目录仅允许一个实例;占用校验已含 pid 复用检测,如仍误报请删除该目录下的 instance.lock)");
|
|
1074
|
+
process.exitCode = 1;
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
/** 启动信息走结构化日志(时间戳 + 级别 + pid + 元数据,与 kernel 子进程格式一致)。
|
|
1078
|
+
* 2026-10-25 级别重分配:引导细节降 debug(banner 之外的定位/路径细节对运行
|
|
1079
|
+
* 状态无信息量),致命错误与登录/退出生命周期保持 info 及以上。 */
|
|
1080
|
+
function logBootInfo(qq, cfgDir) {
|
|
1081
|
+
logger.debug({
|
|
1082
|
+
qqVersion: qq.version,
|
|
1083
|
+
qqPath: qq.qqPath,
|
|
1084
|
+
source: qq.source
|
|
1085
|
+
}, "QQ 文件来源");
|
|
1086
|
+
logger.debug({ dataDir: cfgDir }, "数据目录");
|
|
1087
|
+
logger.debug("自建宿主引导(标准 node + stub QQNT.dll)");
|
|
1088
|
+
}
|
|
1089
|
+
/** launchSelfHost 调用参数构造(唯一启动路径:自建宿主,2026-08-07 用户拍板)。
|
|
1090
|
+
* configPath:全局配置文件(项目根 napuketto.toml),注入 NAPKETTO_CONFIG 供装配链读取;
|
|
1091
|
+
* 内联配置生效(NAPKETTO_CONFIG_JSON)时不解析、不注入——子进程经 env 继承
|
|
1092
|
+
* 内联文本,装配链按同一优先级读取;避免项目根探测出无关路径注入孙进程。
|
|
1093
|
+
* extraEnv:传播有效控制台渲染决策(子进程 stdout 恒 pipe,TTY 判定失真)+
|
|
1094
|
+
* 数据根(NAPUTO_DATA_ROOT,self-host 停止哨兵目录)。
|
|
1095
|
+
* cwd:子进程 cwd 指向数据根——QQ 原生层 fallback 落盘(guild1.db 等)落在专门
|
|
1096
|
+
* 的数据目录,不污染项目根(实测 08-07)。 */
|
|
1097
|
+
function buildLaunchOptions(ctx) {
|
|
1098
|
+
const { opts, qq, cfgDir, dataRoot, entries, stubDir } = ctx;
|
|
1099
|
+
return {
|
|
1100
|
+
qq,
|
|
1101
|
+
kernelEntry: entries.kernelEntry,
|
|
1102
|
+
adapterEntry: entries.adapterEntry,
|
|
1103
|
+
networkEntry: entries.networkEntry,
|
|
1104
|
+
cfgDir,
|
|
1105
|
+
cwd: dataRoot,
|
|
1106
|
+
...isInlineConfigActive() ? {} : { configPath: resolveConfigPath({ dataRoot }) },
|
|
1107
|
+
selfHost: true,
|
|
1108
|
+
stdio: [
|
|
1109
|
+
"inherit",
|
|
1110
|
+
"pipe",
|
|
1111
|
+
"pipe"
|
|
1112
|
+
],
|
|
1113
|
+
extraEnv: {
|
|
1114
|
+
...effectiveConsoleEnv(resolveCliLogConfig(opts.dataDir)),
|
|
1115
|
+
NAPUTO_DATA_ROOT: dataRoot
|
|
1116
|
+
},
|
|
1117
|
+
...opts.qq !== void 0 ? { quickUin: opts.qq } : {},
|
|
1118
|
+
stubDir
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/** 启动自建宿主子进程(包入口解析 → stub 目录 → 启动信息 → launchSelfHost)。
|
|
1122
|
+
* ⚠️ P2(2026-08-12):launchSelfHost 变 async(linux 场景需下载 win-node)。 */
|
|
1123
|
+
async function startSelfHost(opts, qq, cfgDir, dataRoot) {
|
|
1124
|
+
const entries = await resolvePackageEntries();
|
|
1125
|
+
const stubDir = resolveStubDir(opts);
|
|
1126
|
+
logBootInfo(qq, cfgDir);
|
|
1127
|
+
return await launchSelfHost(buildLaunchOptions({
|
|
1128
|
+
opts,
|
|
1129
|
+
qq,
|
|
1130
|
+
cfgDir,
|
|
1131
|
+
dataRoot,
|
|
1132
|
+
entries,
|
|
1133
|
+
stubDir
|
|
1134
|
+
}));
|
|
1135
|
+
}
|
|
1136
|
+
/** 接管子进程 stdout/stderr:过滤 MMKV / 符号查找失败等原生噪音,其余转发。 */
|
|
1137
|
+
function forwardChildOutput(child) {
|
|
1138
|
+
if (child.stdout !== null) forwardFiltered(child.stdout, process.stdout);
|
|
1139
|
+
if (child.stderr !== null) forwardFiltered(child.stderr, process.stderr);
|
|
1140
|
+
}
|
|
1141
|
+
/** 单账号运行时状态落盘(napuketto status/stop 消费,2026-09-08 T8;
|
|
1142
|
+
* pid 记 boot 进程——树杀连带 self-host 孙进程)。 */
|
|
1143
|
+
function recordRuntimeState(dataRoot, account, child, status, exitCode) {
|
|
1144
|
+
writeAccountRuntime(dataRoot, {
|
|
1145
|
+
uin: account,
|
|
1146
|
+
pid: process.pid,
|
|
1147
|
+
...child.pid !== void 0 ? { selfHostPid: child.pid } : {},
|
|
1148
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1149
|
+
kind: "single-boot",
|
|
1150
|
+
status,
|
|
1151
|
+
...exitCode !== void 0 ? { exitCode } : {}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
/** 优雅关停编排(2026-10-25 生命周期契约):触发后等 self-host 经自身哨兵/信号
|
|
1155
|
+
* 处理自行退出(exit 0),10s 未退则 kill 兜底(Windows 下为硬杀)——
|
|
1156
|
+
* boot 进程随后随 child 退出自然返回(退出码 0 = 请求式正常退出)。
|
|
1157
|
+
* @returns 「是否已收到停止请求」查询(退出日志 graceful 标注) */
|
|
1158
|
+
function installGracefulStop(child, dataRoot, account) {
|
|
1159
|
+
let stopArmed = false;
|
|
1160
|
+
const killIfHung = () => {
|
|
1161
|
+
if (child.exitCode !== null || child.killed) return;
|
|
1162
|
+
logger.warn({ account }, "自建宿主未在限时内退出,强制结束");
|
|
1163
|
+
child.kill();
|
|
1164
|
+
};
|
|
1165
|
+
const armGracefulStop = (reason) => {
|
|
1166
|
+
if (stopArmed) return;
|
|
1167
|
+
stopArmed = true;
|
|
1168
|
+
logger.info({
|
|
1169
|
+
account,
|
|
1170
|
+
reason
|
|
1171
|
+
}, "收到停止请求,等待自建宿主退出");
|
|
1172
|
+
setTimeout(killIfHung, GRACEFUL_STOP_TIMEOUT_MS).unref?.();
|
|
1173
|
+
};
|
|
1174
|
+
watchStopSentinel({
|
|
1175
|
+
dataRoot,
|
|
1176
|
+
account,
|
|
1177
|
+
onTrigger: () => armGracefulStop("停止哨兵")
|
|
1178
|
+
});
|
|
1179
|
+
const onSignal = (signal) => {
|
|
1180
|
+
writeStopSentinel(dataRoot, account);
|
|
1181
|
+
armGracefulStop(`信号 ${signal}`);
|
|
1182
|
+
};
|
|
1183
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
1184
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
1185
|
+
process.on("SIGBREAK", () => onSignal("SIGBREAK"));
|
|
1186
|
+
return () => stopArmed;
|
|
1187
|
+
}
|
|
1188
|
+
/** 常驻:等待自建宿主进程退出(exit / error 两路径均 resolve,去留由 self-host 决定)。 */
|
|
1189
|
+
async function waitSelfHostExit(child, dataRoot, account, isStopping) {
|
|
1190
|
+
await new Promise((resolve) => {
|
|
1191
|
+
child.on("exit", (code) => {
|
|
1192
|
+
logger.info({
|
|
1193
|
+
code,
|
|
1194
|
+
graceful: isStopping()
|
|
1195
|
+
}, "自建宿主进程退出");
|
|
1196
|
+
recordRuntimeState(dataRoot, account, child, "exited", code);
|
|
1197
|
+
resolve();
|
|
1198
|
+
});
|
|
1199
|
+
child.on("error", (err) => {
|
|
1200
|
+
logger.error({ err }, "自建宿主进程启动失败");
|
|
1201
|
+
resolve();
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1151
1204
|
}
|
|
1152
|
-
/**
|
|
1153
|
-
async function
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
entry,
|
|
1165
|
-
"--data-dir",
|
|
1166
|
-
dataRoot
|
|
1167
|
-
];
|
|
1168
|
-
spawnDetached(args);
|
|
1169
|
-
logger.info({
|
|
1170
|
-
args: args.slice(1),
|
|
1171
|
-
qq: opts.qq ?? "(supervisor 全量)"
|
|
1172
|
-
}, "已 detached 拉起(日志不再接入当前终端,见数据目录 logs/)");
|
|
1205
|
+
/** 启动单个账号(自建宿主 + 常驻)。 */
|
|
1206
|
+
async function runSingleAccount(opts = {}) {
|
|
1207
|
+
const dataRoot = resolveDataRoot(opts.dataDir);
|
|
1208
|
+
const qq = await locateQqFiles(dataRoot, opts);
|
|
1209
|
+
if (qq === void 0) return;
|
|
1210
|
+
const account = opts.qq ?? "default";
|
|
1211
|
+
const cfgDir = path.join(dataRoot, account);
|
|
1212
|
+
if (!ensureInstanceLockFree(cfgDir)) return;
|
|
1213
|
+
const { child } = await startSelfHost(opts, qq, cfgDir, dataRoot);
|
|
1214
|
+
forwardChildOutput(child);
|
|
1215
|
+
recordRuntimeState(dataRoot, account, child, "running");
|
|
1216
|
+
await waitSelfHostExit(child, dataRoot, account, installGracefulStop(child, dataRoot, account));
|
|
1173
1217
|
}
|
|
1174
1218
|
//#endregion
|
|
1175
|
-
//#region src/supervisor.ts
|
|
1219
|
+
//#region src/runtime/supervisor.ts
|
|
1176
1220
|
/**
|
|
1177
1221
|
* supervisor:多账号子进程编排(P6,2026-08-05;2026-08-07 自建宿主唯一)
|
|
1178
1222
|
*
|
|
@@ -1183,8 +1227,6 @@ async function cmdRestart(dataRoot, opts) {
|
|
|
1183
1227
|
* - 守护:账号异常退出且 autoRestart → 延迟重启
|
|
1184
1228
|
* - 信号转发:SIGINT/SIGTERM → 停止全部子进程 → 父进程退出
|
|
1185
1229
|
*/
|
|
1186
|
-
/** 默认重启延迟(毫秒)。 */
|
|
1187
|
-
const DEFAULT_RESTART_DELAY_MS = 2e3;
|
|
1188
1230
|
/** 退出轮询间隔(毫秒)。 */
|
|
1189
1231
|
const EXIT_POLL_MS = 100;
|
|
1190
1232
|
/** 子进程优雅退出时限(毫秒):哨兵/信号后子进程自行停止,超时强杀兜底。 */
|
|
@@ -1368,35 +1410,13 @@ async function runSupervisor(opts = {}) {
|
|
|
1368
1410
|
await new Promise(() => {});
|
|
1369
1411
|
}
|
|
1370
1412
|
//#endregion
|
|
1371
|
-
//#region src/
|
|
1413
|
+
//#region src/commands/start.ts
|
|
1372
1414
|
/**
|
|
1373
|
-
*
|
|
1374
|
-
* 2026-08-08 配置结构拍板:账号必填,协议配置嵌在账号内)
|
|
1375
|
-
*
|
|
1376
|
-
* commander 参数解析:
|
|
1377
|
-
* - 单账号:-q <uin> → runSingleAccount(自建宿主:标准 node + stub QQNT.dll,不拉起 QQ)
|
|
1378
|
-
* - 多账号:-q A -q B 或 supervisor 子命令 → runSupervisor(子进程编排)
|
|
1379
|
-
* - 无 -q:读全局配置 accounts(至少一个,账号必填)→ supervisor 拉起全部启用账号
|
|
1380
|
-
* - config 子命令:init / list / apply / validate(主配置管理)
|
|
1381
|
-
* - 退出码契约(2026-10-25):0 = 请求式优雅关停;78 = 配置校验失败(ConfigError
|
|
1382
|
-
* 一族,宿主消费,免重启);其余错误 = 1(handleActionError 统一出口)
|
|
1415
|
+
* 默认动作(无子命令)用例:-q 单账号 / 多 -q supervisor / 无 -q 读配置拉起全部账号。
|
|
1383
1416
|
*
|
|
1384
|
-
*
|
|
1417
|
+
* 从 index.ts 拆出(2026-10-25 模块收拢):这里只做编排,不 import commander;
|
|
1418
|
+
* 参数解析、帮助文案与退出码收敛在 cli/program.ts。
|
|
1385
1419
|
*/
|
|
1386
|
-
/** commander collect:累积 -q 多值。 */
|
|
1387
|
-
function collectQq(value, prev) {
|
|
1388
|
-
prev.push(value);
|
|
1389
|
-
return prev;
|
|
1390
|
-
}
|
|
1391
|
-
/** 启动字符画(NapukettoQQ 品牌标识,内嵌;art 含反引号/反斜杠,用数组逐行转义)。 */
|
|
1392
|
-
const BANNER = [
|
|
1393
|
-
" _ __ __ __ __ ____ ____ ",
|
|
1394
|
-
" / | / /___ _____ __ __/ /_____ / /_/ /_____ / __ \\/ __ \\",
|
|
1395
|
-
" / |/ / __ `/ __ \\/ / / / //_/ _ \\/ __/ __/ __ \\/ / / / / / /",
|
|
1396
|
-
" / /| / /_/ / /_/ / /_/ / ,< / __/ /_/ /_/ /_/ / /_/ / /_/ / ",
|
|
1397
|
-
"/_/ |_/\\__,_/ .___/\\__,_/_/|_|\\___/\\__/\\__/\\____/\\___\\_\\___\\_\\ ",
|
|
1398
|
-
" /_/ "
|
|
1399
|
-
].join("\n");
|
|
1400
1420
|
/** 构造可选 dataDir 参数(exactOptionalPropertyTypes 不允许显式 undefined)。 */
|
|
1401
1421
|
function withDataDir(dataDir) {
|
|
1402
1422
|
if (dataDir === void 0) return {};
|
|
@@ -1412,9 +1432,100 @@ function withQqPath(qqPath) {
|
|
|
1412
1432
|
if (qqPath === void 0) return {};
|
|
1413
1433
|
return { qqPath };
|
|
1414
1434
|
}
|
|
1435
|
+
/** 单账号启动分支(-q 单值;构造 bootOptions 后走 runSingleAccount)。 */
|
|
1436
|
+
async function runSingleAccountBranch(opts, qqs) {
|
|
1437
|
+
const [only] = qqs;
|
|
1438
|
+
if (only === void 0) return;
|
|
1439
|
+
await runSingleAccount({
|
|
1440
|
+
qq: only,
|
|
1441
|
+
...withDataDir(opts.dataDir),
|
|
1442
|
+
...withQqPath(opts.qqPath),
|
|
1443
|
+
...withStubDir(opts.stubDir)
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
/** 无 -q 时:读全局配置 accounts(账号必填,2026-08-08 拍板),supervisor 拉起全部启用账号。
|
|
1447
|
+
* 不再有「配置为空 → 交互式单账号登录」老路径:缺账号直接报错提示编辑模板。
|
|
1448
|
+
* 配置加载内联优先(NAPKETTO_CONFIG_JSON > 文件);校验失败 ConfigError → exit 78。 */
|
|
1449
|
+
async function autoStart(opts) {
|
|
1450
|
+
const { config } = await loadCliConfigWithSource(withDataDir(opts.dataDir));
|
|
1451
|
+
if (config.accounts.length === 0) throw new Error("主配置没有账号,请编辑 napuketto.toml 填写 [[accounts]] 段");
|
|
1452
|
+
await runSupervisor({
|
|
1453
|
+
...withDataDir(opts.dataDir),
|
|
1454
|
+
...withStubDir(opts.stubDir)
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
/** 主命令分发:无 -q 读配置 supervisor 拉起 / 单 -q 单账号 / 多 -q supervisor。 */
|
|
1458
|
+
async function dispatchMainAction(opts) {
|
|
1459
|
+
const qqs = opts.qq ?? [];
|
|
1460
|
+
if (qqs.length === 0) {
|
|
1461
|
+
await autoStart(opts);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (qqs.length === 1) {
|
|
1465
|
+
await runSingleAccountBranch(opts, qqs);
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
await runSupervisor({
|
|
1469
|
+
qqs,
|
|
1470
|
+
...withDataDir(opts.dataDir),
|
|
1471
|
+
...withStubDir(opts.stubDir)
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
/** supervisor 子命令用例(读主配置 accounts 批量拉起;-d 复用主命令 option)。 */
|
|
1475
|
+
async function runSupervisorCommand(opts) {
|
|
1476
|
+
await runSupervisor(withDataDir(opts.dataDir));
|
|
1477
|
+
}
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/cli/error.ts
|
|
1480
|
+
/**
|
|
1481
|
+
* CLI 动作错误出口(2026-10-25 退出码契约):
|
|
1482
|
+
* 0 = 请求式优雅关停(0.2.0 已有,不经此路径);78 = 配置校验失败
|
|
1483
|
+
* (ConfigError 一族,宿主据此区分「配置未就绪」与运行时崩溃,免重启);
|
|
1484
|
+
* 其余 = 1(运行时崩溃,现状不变)。
|
|
1485
|
+
*
|
|
1486
|
+
* 退出码只认错误类型(instanceof ConfigError),与日志文案完全解耦——
|
|
1487
|
+
* 文案是人读的,退出码是宿主消费的机器契约。
|
|
1488
|
+
*/
|
|
1489
|
+
/** 统一错误输出(结构化日志,错误对象经 pino serializer 带堆栈)。 */
|
|
1490
|
+
function reportError(err) {
|
|
1491
|
+
let message = String(err);
|
|
1492
|
+
if (err instanceof Error) {
|
|
1493
|
+
const { message: errMessage } = err;
|
|
1494
|
+
message = errMessage;
|
|
1495
|
+
}
|
|
1496
|
+
logger.error({ err }, message);
|
|
1497
|
+
}
|
|
1498
|
+
/** 统一 catch 出口:打印错误并按类型设置进程退出码(78 = 配置校验失败)。 */
|
|
1499
|
+
function handleActionError(err) {
|
|
1500
|
+
reportError(err);
|
|
1501
|
+
process.exitCode = err instanceof ConfigError ? EXIT_CONFIG_INVALID : 1;
|
|
1502
|
+
}
|
|
1503
|
+
//#endregion
|
|
1504
|
+
//#region src/cli/program.ts
|
|
1505
|
+
/**
|
|
1506
|
+
* commander 装配层:全局选项、banner、子命令注册、默认动作分发。
|
|
1507
|
+
*
|
|
1508
|
+
* 从 index.ts 拆出(2026-10-25 模块收拢):只做 CLI 契约适配(参数/帮助/输出/
|
|
1509
|
+
* 退出码),用例实现见 commands/。禁止在此写业务逻辑。
|
|
1510
|
+
*/
|
|
1511
|
+
/** commander collect:累积 -q 多值。 */
|
|
1512
|
+
function collectQq(value, prev) {
|
|
1513
|
+
prev.push(value);
|
|
1514
|
+
return prev;
|
|
1515
|
+
}
|
|
1516
|
+
/** 启动字符画(NapukettoQQ 品牌标识,内嵌;art 含反引号/反斜杠,用数组逐行转义)。 */
|
|
1517
|
+
const BANNER = [
|
|
1518
|
+
" _ __ __ __ __ ____ ____ ",
|
|
1519
|
+
" / | / /___ _____ __ __/ /_____ / /_/ /_____ / __ \\/ __ \\",
|
|
1520
|
+
" / |/ / __ `/ __ \\/ / / / //_/ _ \\/ __/ __/ __ \\/ / / / / / /",
|
|
1521
|
+
" / /| / /_/ / /_/ / /_/ / ,< / __/ /_/ /_/ /_/ / /_/ / /_/ / ",
|
|
1522
|
+
"/_/ |_/\\__,_/ .___/\\__,_/_/|_|\\___/\\__/\\__/\\____/\\___\\_\\___\\_\\ ",
|
|
1523
|
+
" /_/ "
|
|
1524
|
+
].join("\n");
|
|
1415
1525
|
/** 读主命令已解析的 data-dir(-d 定义在主命令,子命令经此共享)。 */
|
|
1416
1526
|
function rootDataDir(program) {
|
|
1417
|
-
|
|
1527
|
+
const { dataDir } = program.opts();
|
|
1528
|
+
return dataDir === void 0 ? {} : { dataDir };
|
|
1418
1529
|
}
|
|
1419
1530
|
/** 注册 config 子命令(init/list/apply,P6;-d 复用主命令 option)。 */
|
|
1420
1531
|
function registerConfigCommands(program) {
|
|
@@ -1457,7 +1568,7 @@ function registerConfigCommands(program) {
|
|
|
1457
1568
|
function registerSupervisorCommand(program) {
|
|
1458
1569
|
program.command("supervisor").description("多账号编排(读全局配置 accounts 拉起子进程)").action(async () => {
|
|
1459
1570
|
try {
|
|
1460
|
-
await
|
|
1571
|
+
await runSupervisorCommand(rootDataDir(program));
|
|
1461
1572
|
} catch (err) {
|
|
1462
1573
|
handleActionError(err);
|
|
1463
1574
|
}
|
|
@@ -1479,45 +1590,6 @@ function registerOpsCommands(program) {
|
|
|
1479
1590
|
program.command("stop").description("停止账号进程树(无 -q 停 supervisor 全部;-q 停单账号,supervisor 守护时会自动拉起)").option("-q, --qq <uin>", "目标账号(缺省停 supervisor + 全部账号)").action(opsAction(program, (dataRoot, arg) => cmdStop(dataRoot, arg)));
|
|
1480
1591
|
program.command("restart").description("重启账号(无 -q 重启 supervisor 全量;-q 重启单账号;detached 拉起)").option("-q, --qq <uin>", "目标账号(缺省重启 supervisor 全量)").action(opsAction(program, (dataRoot, arg) => cmdRestart(dataRoot, arg)));
|
|
1481
1592
|
}
|
|
1482
|
-
/** 单账号启动分支(-q 单值;构造 bootOptions 后走 runSingleAccount)。 */
|
|
1483
|
-
async function runSingleAccountBranch(opts, qqs) {
|
|
1484
|
-
const [only] = qqs;
|
|
1485
|
-
if (only === void 0) return;
|
|
1486
|
-
await runSingleAccount({
|
|
1487
|
-
qq: only,
|
|
1488
|
-
...withDataDir(opts.dataDir),
|
|
1489
|
-
...withQqPath(opts.qqPath),
|
|
1490
|
-
...withStubDir(opts.stubDir)
|
|
1491
|
-
});
|
|
1492
|
-
}
|
|
1493
|
-
/** 无 -q 时:读全局配置 accounts(账号必填,2026-08-08 拍板),supervisor 拉起全部启用账号。
|
|
1494
|
-
* 不再有「配置为空 → 交互式单账号登录」老路径:缺账号直接报错提示编辑模板。
|
|
1495
|
-
* 配置加载内联优先(NAPKETTO_CONFIG_JSON > 文件);校验失败 ConfigError → exit 78。 */
|
|
1496
|
-
async function autoStart(opts) {
|
|
1497
|
-
const { config } = await loadCliConfigWithSource(withDataDir(opts.dataDir));
|
|
1498
|
-
if (config.accounts.length === 0) throw new Error("主配置没有账号,请编辑 napuketto.toml 填写 [[accounts]] 段");
|
|
1499
|
-
await runSupervisor({
|
|
1500
|
-
...withDataDir(opts.dataDir),
|
|
1501
|
-
...withStubDir(opts.stubDir)
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
/** 主命令分发:无 -q 读配置 supervisor 拉起 / 单 -q 单账号 / 多 -q supervisor。 */
|
|
1505
|
-
async function dispatchMainAction(opts) {
|
|
1506
|
-
const qqs = opts.qq ?? [];
|
|
1507
|
-
if (qqs.length === 0) {
|
|
1508
|
-
await autoStart(opts);
|
|
1509
|
-
return;
|
|
1510
|
-
}
|
|
1511
|
-
if (qqs.length === 1) {
|
|
1512
|
-
await runSingleAccountBranch(opts, qqs);
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
|
-
await runSupervisor({
|
|
1516
|
-
qqs,
|
|
1517
|
-
...withDataDir(opts.dataDir),
|
|
1518
|
-
...withStubDir(opts.stubDir)
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
1593
|
/** 注册主命令 action(-q 单账号 / 多 -q supervisor / 无 -q 读配置 supervisor 拉起)。 */
|
|
1522
1594
|
function registerMainAction(program) {
|
|
1523
1595
|
program.action(async (opts) => {
|
|
@@ -1542,6 +1614,25 @@ function main() {
|
|
|
1542
1614
|
registerMainAction(program);
|
|
1543
1615
|
program.parse(process.argv);
|
|
1544
1616
|
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/index.ts
|
|
1619
|
+
/**
|
|
1620
|
+
* @napuketto/cli 入口(P2-6 + P6,2026-08-05;2026-08-07 用户拍板:只保留自建宿主;
|
|
1621
|
+
* 2026-08-08 配置结构拍板:账号必填,协议配置嵌在账号内)
|
|
1622
|
+
*
|
|
1623
|
+
* commander 参数解析:
|
|
1624
|
+
* - 单账号:-q <uin> → runSingleAccount(自建宿主:标准 node + stub QQNT.dll,不拉起 QQ)
|
|
1625
|
+
* - 多账号:-q A -q B 或 supervisor 子命令 → runSupervisor(子进程编排)
|
|
1626
|
+
* - 无 -q:读全局配置 accounts(至少一个,账号必填)→ supervisor 拉起全部启用账号
|
|
1627
|
+
* - config 子命令:init / list / apply / validate(主配置管理)
|
|
1628
|
+
* - 退出码契约(2026-10-25):0 = 请求式优雅关停;78 = 配置校验失败(ConfigError
|
|
1629
|
+
* 一族,宿主消费,免重启);其余错误 = 1(handleActionError 统一出口)
|
|
1630
|
+
*
|
|
1631
|
+
* 路线 B(拉起 QQ + 注入)已淘汰(2026-08-07 用户拍板),相关入口已移除。
|
|
1632
|
+
*
|
|
1633
|
+
* 2026-10-25 模块收拢:本文件只保留进程入口;commander 装配在 cli/program.ts,
|
|
1634
|
+
* 用例在 commands/,配置领域在 config/,运行期在 runtime/。
|
|
1635
|
+
*/
|
|
1545
1636
|
main();
|
|
1546
1637
|
//#endregion
|
|
1547
1638
|
export {};
|