@napuketto/cli 0.1.27 → 0.3.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 CHANGED
@@ -1,307 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
- import { ConfigBase, createLogger, kernelError, parseToml, resolveConfigPath, resolveDataRoot, stringifyToml } from "@napuketto/kernel";
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
- import path, { dirname, extname, join } from "node:path";
5
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import path, { dirname, extname, isAbsolute, join } from "node:path";
6
7
  import { createInterface } from "node:readline";
7
8
  import { fileURLToPath } from "node:url";
8
- import { checkInstanceLock, defaultStubDir, isNativeNoiseLine, launchSelfHost, resolveQqFiles } from "@napuketto/loader";
9
+ import { checkInstanceLock, defaultStubDir, isNativeNoiseLine, launchSelfHost, removeStopSentinel, resolveQqFiles, watchStopSentinel, writeStopSentinel } from "@napuketto/loader";
9
10
  import QRCode from "qrcode";
10
11
  import { spawn } from "node:child_process";
11
- import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import { access, readFile, writeFile } from "node:fs/promises";
13
- //#region src/logger.ts
14
- /**
15
- * cli 进程级 logger(ADR-007 日志规范,2026-08-07 统一改造)。
16
- *
17
- * 复用 kernel `createLogger`(console pretty + redact,格式与 kernel 子进程完全一致:
18
- * `[时间] LEVEL (name/pid): 消息` + 缩进元数据)。只打 console 不写文件——文件日志由
19
- * kernel 装配(NapukettoCore.create → `<数据根>/logs/napuketto.log`)负责。
20
- *
21
- * 无全局单例(ADR-015 推论):cli 每进程一份,模块级单例即可;`base.name` 用 pino
22
- * 保留字段 name(logger name)标注来源——pino-pretty 自动渲染为 `(cli/pid)` 元数据头,
23
- * 不占额外属性行,文件/JSON 侧仍保留 name 字段可过滤。
24
- *
25
- * 级别:`NAPKETTO_LOG_LEVEL` 环境变量可覆盖(缺省 info,非法值忽略)。
26
- */
27
- /** 合法级别集合(pino 标准级别)。 */
28
- const LOG_LEVELS = [
29
- "trace",
30
- "debug",
31
- "info",
32
- "warn",
33
- "error",
34
- "fatal",
35
- "silent"
36
- ];
37
- /** 解析日志级别(env 覆盖,非法/缺失回退 info)。 */
38
- function resolveLevel() {
39
- const raw = process.env["NAPKETTO_LOG_LEVEL"];
40
- if (raw !== void 0 && LOG_LEVELS.includes(raw)) return raw;
41
- return "info";
42
- }
43
- /** cli 进程日志(name 渲染为 (cli/pid) 元数据头,文件 JSON 保留 name 字段可过滤)。 */
44
- const logger = createLogger({
45
- level: resolveLevel(),
46
- console: true,
47
- base: { service: "cli" }
48
- });
49
- //#endregion
50
- //#region src/runtime-state.ts
51
- /**
52
- * runtime-state.ts:账号运行时状态文件 + 进程控制原语(2026-09-08 T8)。
53
- *
54
- * 跨进程通信选型(决策记录):现有 IPC 为 stdio JSON 行协议(NAPUTO_IPC=1,
55
- * koishi 插件专用),supervisor 子进程不在此模式;引入独立通道(socket/pipe)
56
- * 复杂度高。采用 **PID 状态文件**:supervisor/单账号 boot 启动时把进程信息写
57
- * `<数据根>/<uin>/runtime.json`,supervisor 自身写 `<数据根>/supervisor.json`;
58
- * `napuketto status/stop/restart` 读文件 + liveness 检测 + 树杀(win32
59
- * taskkill /T /F,杀 boot 进程树连带 self-host 孙进程)+ detached 重启。
60
- */
61
- /** runtime.json 文件名。 */
62
- const RUNTIME_FILE = "runtime.json";
63
- /** supervisor.json 文件名。 */
64
- const SUPERVISOR_FILE = "supervisor.json";
65
- /** 账号状态文件路径。 */
66
- function runtimeStatePath(dataRoot, uin) {
67
- return join(dataRoot, uin, RUNTIME_FILE);
68
- }
69
- /** supervisor 状态文件路径。 */
70
- function supervisorStatePath(dataRoot) {
71
- return join(dataRoot, SUPERVISOR_FILE);
72
- }
73
- /** 写账号运行时状态(原子覆盖)。 */
74
- function writeAccountRuntime(dataRoot, state) {
75
- const path = runtimeStatePath(dataRoot, state.uin);
76
- mkdirSync(join(path, ".."), { recursive: true });
77
- writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
78
- }
79
- /** 读账号运行时状态(缺失/损坏返回 null)。 */
80
- function readAccountRuntime(dataRoot, uin) {
81
- return readJson(runtimeStatePath(dataRoot, uin));
82
- }
83
- /** 写 supervisor 运行时状态。 */
84
- function writeSupervisorRuntime(dataRoot, state) {
85
- writeFileSync(supervisorStatePath(dataRoot), `${JSON.stringify(state, null, 2)}\n`, "utf8");
86
- }
87
- /** 读 supervisor 运行时状态(缺失返回 null)。 */
88
- function readSupervisorRuntime(dataRoot) {
89
- return readJson(supervisorStatePath(dataRoot));
90
- }
91
- /** 读 JSON 文件(缺失/解析失败返回 null)。 */
92
- function readJson(path) {
93
- try {
94
- return JSON.parse(readFileSync(path, "utf8"));
95
- } catch {
96
- return null;
97
- }
98
- }
99
- /** 列出数据根下全部账号运行时状态(扫一级目录 runtime.json)。 */
100
- function listAccountRuntimes(dataRoot) {
101
- let entries = [];
102
- try {
103
- entries = readdirSync(dataRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
104
- } catch {
105
- return [];
106
- }
107
- const out = [];
108
- for (const uin of entries) {
109
- const state = readAccountRuntime(dataRoot, uin);
110
- if (state !== null) out.push(state);
111
- }
112
- return out;
113
- }
114
- /** pid 存活检测(signal 0;EPERM 视为存活——进程存在但无权限)。 */
115
- function isPidAlive(pid) {
116
- if (!Number.isInteger(pid) || pid <= 0) return false;
117
- try {
118
- process.kill(pid, 0);
119
- return true;
120
- } catch (err) {
121
- return err.code === "EPERM";
122
- }
123
- }
124
- /** 树杀命令构造(win32:taskkill /T /F 连孙进程;posix:kill 信号)。 */
125
- function killProcessTree(pid) {
126
- if (process.platform === "win32") {
127
- spawn("taskkill", [
128
- "/PID",
129
- String(pid),
130
- "/T",
131
- "/F"
132
- ], { stdio: "ignore" });
133
- return;
134
- }
135
- try {
136
- process.kill(pid, "SIGTERM");
137
- } catch {}
138
- }
139
- /** detached 拉起 node 进程(restart 用;立即返回不等待)。 */
140
- function spawnDetached(args, cwd) {
141
- const child = spawn(process.execPath, args, {
142
- detached: true,
143
- stdio: "ignore",
144
- ...cwd !== void 0 ? { cwd } : {}
145
- });
146
- child.unref();
147
- return child;
148
- }
149
- /** 聚合各账号状态(liveness 修正:文件 running 但 pid 已死 → not running)。 */
150
- function aggregateStatus(runtimes, aliveFn = isPidAlive) {
151
- return runtimes.map((r) => ({
152
- uin: r.uin,
153
- pid: r.pid,
154
- alive: r.status === "running" ? aliveFn(r.pid) : false,
155
- kind: r.kind,
156
- startedAt: r.startedAt,
157
- restartCount: r.restartCount ?? 0,
158
- exitCode: r.exitCode
159
- }));
160
- }
161
- //#endregion
162
- //#region src/boot.ts
163
- /**
164
- * cli boot:单账号启动序列(2026-08-07 用户拍板:只保留自建宿主)
165
- *
166
- * resolveQqFiles(多级来源定位 QQ 原生文件)→ 解析各包 dist 入口 → launchSelfHost
167
- * (标准 node + stub QQNT.dll 直接 dlopen,不拉起 QQ / 不注入)→ 常驻。
168
- *
169
- * QQ 原生文件来源(本机未装 QQ 也能跑,2026-08-16 接入):
170
- * L0 NAPUTO_QQ_FILES 显式文件根 → L1 本机 QQ 安装(--qq-path / NAPUTO_QQ_PATH /
171
- * 注册表 / 常见路径)→ L2 数据根缓存 <数据根>/qq-files/<版本> → 全部缺失时
172
- * 自动下载官方安装包(sha256 校验 + 7z 解包 + 提取缓存,幂等)。
173
- * cached 来源下 qq.qqPath 为语义占位(缓存目录无 QQ.exe)——launchSelfHost 只消费
174
- * qq.wrapperPath / qq.version(NAPUTO_QQ_PATH 仅注入、self-host 未读取),占位安全。
175
- *
176
- * 不写业务逻辑:kernel 装配 + 登录 + 协议装配全部在 self-host.cjs → boot-bootstrap.js 完成。
177
- * 路线 B(拉起 QQ + 注入)已淘汰(launchQqWithLoader 已移除,cli 只走自建宿主)。
178
- */
179
- /** 解析 workspace 包的 dist 入口(ESM 解析:包是 ESM-only,exports 无 require 条件)。 */
180
- async function packageEntry(pkg) {
181
- const url = await import.meta.resolve(pkg);
182
- return fileURLToPath(url);
183
- }
184
- /** QR 透出标记行前缀(loader bootstrap-core 非 IPC 模式输出,cli 解析终端渲染)。 */
185
- const QR_LINE_PREFIX = "NAPUTO_QR ";
186
- /**
187
- * 逐行转发子进程输出到父进程,过滤原生噪音。
188
- * readline 按 UTF-8 解码 pipe 字节流,再经 process.stdout(TTY 路径,
189
- * WriteConsoleW UTF-16)输出——顺带修复 pino 中文在 cmd.exe/管道 936 转码
190
- * 链路下的乱码(原生 printf 字节流无法从 JS 侧改编码,转 pipe 后统一解码)。
191
- *
192
- * NAPUTO_QR 标记行(loader bootstrap-core 输出,QR 登录二维码数据)不转发,
193
- * 解析后用 qrcode 包渲染终端二维码;png 落盘与 URL 提示由 kernel 日志完成。
194
- * 噪音判定走 loader 共享单一来源(isNativeNoiseLine,与 koishi IPC 路径同源)。
195
- */
196
- function forwardFiltered(input, out) {
197
- createInterface({ input }).on("line", (line) => {
198
- if (line.startsWith(QR_LINE_PREFIX)) {
199
- renderTerminalQr(line.slice(10));
200
- return;
201
- }
202
- if (isNativeNoiseLine(line)) return;
203
- out.write(`${line}\n`);
204
- });
205
- }
206
- /**
207
- * 渲染终端二维码(qrcode 包 terminal 模式,half-block 字符)。
208
- * 解析失败/无 url 时静默丢弃——标记行是内部协议,坏行不影响登录。
209
- */
210
- function renderTerminalQr(raw) {
211
- try {
212
- const { qrcodeUrl } = JSON.parse(raw);
213
- if (qrcodeUrl === void 0 || qrcodeUrl === "") return;
214
- QRCode.toString(qrcodeUrl, {
215
- type: "terminal",
216
- small: true
217
- }).then((str) => {
218
- process.stdout.write(`${str}\n`);
219
- }).catch(() => {});
220
- } catch {}
221
- }
222
- /** 启动单个账号(自建宿主 + 常驻)。 */
223
- async function runSingleAccount(opts = {}) {
224
- const dataRoot = resolveDataRoot(opts.dataDir);
225
- let qq;
226
- try {
227
- qq = await resolveQqFiles({
228
- dataRoot,
229
- ...opts.qqPath !== void 0 ? { qqPath: opts.qqPath } : {}
230
- });
231
- } catch (err) {
232
- logger.error({ err }, `定位/下载 QQ 原生文件失败:${err instanceof Error ? err.message : String(err)}\n(本机未装 QQ 时自动下载官方安装包;若下载失败请检查网络,或手动安装 QQ、设置 NAPUTO_QQ_PATH 指定已有安装、NAPUTO_QQ_FILES 指定文件根、NAPUTO_QQ_URL 覆盖下载地址)`);
233
- process.exitCode = 1;
234
- return;
235
- }
236
- const cfgDir = path.join(dataRoot, opts.qq ?? "default");
237
- const { occupied, pid: holderPid } = checkInstanceLock(cfgDir);
238
- if (occupied) {
239
- logger.error({
240
- pid: holderPid,
241
- dataDir: cfgDir
242
- }, "数据目录已被其他实例占用,拒绝启动(同一账号数据目录仅允许一个实例;占用校验已含 pid 复用检测,如仍误报请删除该目录下的 instance.lock)");
243
- process.exitCode = 1;
244
- return;
245
- }
246
- const kernelEntry = await packageEntry("@napuketto/kernel");
247
- const adapterEntry = await packageEntry("@napuketto/adapter");
248
- const networkEntry = await packageEntry("@napuketto/network");
249
- const stubDir = opts.stubDir ?? process.env["NAPUTO_STUB_DIR"] ?? defaultStubDir();
250
- logger.info({
251
- qqVersion: qq.version,
252
- qqPath: qq.qqPath,
253
- source: qq.source
254
- }, "QQ 文件来源");
255
- logger.info({ dataDir: cfgDir }, "数据目录");
256
- logger.info("自建宿主引导(标准 node + stub QQNT.dll)");
257
- const { child } = await launchSelfHost({
258
- qq,
259
- kernelEntry,
260
- adapterEntry,
261
- networkEntry,
262
- cfgDir,
263
- cwd: dataRoot,
264
- configPath: resolveConfigPath({ dataRoot }),
265
- selfHost: true,
266
- stdio: [
267
- "inherit",
268
- "pipe",
269
- "pipe"
270
- ],
271
- ...opts.qq !== void 0 ? { quickUin: opts.qq } : {},
272
- ...stubDir !== void 0 ? { stubDir } : {}
273
- });
274
- if (child.stdout !== null) forwardFiltered(child.stdout, process.stdout);
275
- if (child.stderr !== null) forwardFiltered(child.stderr, process.stderr);
276
- writeAccountRuntime(dataRoot, {
277
- uin: opts.qq ?? "default",
278
- pid: process.pid,
279
- ...child.pid !== void 0 ? { selfHostPid: child.pid } : {},
280
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
281
- kind: "single-boot",
282
- status: "running"
283
- });
284
- await new Promise((resolve) => {
285
- child.on("exit", (code) => {
286
- logger.info({ code }, "自建宿主进程退出");
287
- writeAccountRuntime(dataRoot, {
288
- uin: opts.qq ?? "default",
289
- pid: process.pid,
290
- ...child.pid !== void 0 ? { selfHostPid: child.pid } : {},
291
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
292
- kind: "single-boot",
293
- status: "exited",
294
- exitCode: code
295
- });
296
- resolve();
297
- });
298
- child.on("error", (err) => {
299
- logger.error({ err }, "自建宿主进程启动失败");
300
- resolve();
301
- });
302
- });
303
- }
304
- //#endregion
305
13
  //#region src/config-template.ts
306
14
  /**
307
15
  * 配置模板(TOML 注释版,从 config-cmds.ts 拆分,2026-08-08 FTA 优化)。
@@ -339,6 +47,31 @@ autoRestart = true
339
47
  # 崩溃后自动重启的延迟(毫秒)
340
48
  restartDelayMs = 2000
341
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
+
342
75
  # ------------------------------------------------------------
343
76
  # 账号(至少一个,qq 必填)
344
77
  # ------------------------------------------------------------
@@ -433,89 +166,664 @@ port = 3001
433
166
  `;
434
167
  }
435
168
  //#endregion
436
- //#region src/config-parse.ts
169
+ //#region src/config-parse.ts
170
+ /**
171
+ * 主配置类型与手写校验器(从 config-cmds.ts 拆分,2026-08-08 FTA 优化)
172
+ *
173
+ * 主配置 parse(不引入 zod):数据根 / 自动重启 / 重启延迟 / 账号列表。
174
+ * 2026-08-08 结构拍板:**一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在
175
+ * 账号内**([accounts.onebot11] / [accounts.satori]);账号必填(至少一个)。
176
+ * 协议段为宽松对象,装配时由对应协议包的 zod schema 严格校验。
177
+ * 全部校验错误为 ConfigError(2026-10-25 嵌入式退出码契约:CLI exit 78 一族,
178
+ * 内联与文件两形态共用本校验器)。
179
+ */
180
+ /** 默认自动重启。 */
181
+ const DEFAULT_AUTO_RESTART = true;
182
+ /** 默认重启延迟(毫秒)。 */
183
+ const DEFAULT_RESTART_DELAY_MS$1 = 2e3;
184
+ /** QQ 号格式:纯数字 5-11 位(QQ 号合法范围,勿写死 11 位)。 */
185
+ const QQ_IN_FORMAT_RE = /^\d{5,11}$/;
186
+ /** 解析账号内协议段(宽松对象,装配时由 zod schema 严格校验)。 */
187
+ function parseProtocolSection(raw, key) {
188
+ const section = raw[key];
189
+ if (section === void 0) return;
190
+ if (typeof section !== "object" || section === null) throw configError(`主配置账号 ${key} 段必须是对象`);
191
+ return section;
192
+ }
193
+ /**
194
+ * 解析 dataDir(缺省用当前解析的数据根 = 项目根/.napuketto)。
195
+ * 支持绝对路径、"~/" 前缀(用户主目录)、相对路径(相对启动目录),
196
+ * 消费端 resolveDataRoot 统一展开为绝对路径。
197
+ */
198
+ function parseDataDir(raw) {
199
+ if (raw["dataDir"] === void 0) return resolveDataRoot();
200
+ if (typeof raw["dataDir"] !== "string" || raw["dataDir"] === "") throw configError("主配置 dataDir 必须是非空字符串");
201
+ return raw["dataDir"];
202
+ }
203
+ /** 解析 autoRestart。 */
204
+ function parseAutoRestart(raw) {
205
+ if (raw["autoRestart"] === void 0) return DEFAULT_AUTO_RESTART;
206
+ if (typeof raw["autoRestart"] !== "boolean") throw configError("主配置 autoRestart 必须是布尔值");
207
+ return raw["autoRestart"];
208
+ }
209
+ /** 解析 restartDelayMs(毫秒)。 */
210
+ function parseRestartDelayMs(raw) {
211
+ if (raw["restartDelayMs"] === void 0) return DEFAULT_RESTART_DELAY_MS$1;
212
+ if (typeof raw["restartDelayMs"] !== "number" || !Number.isInteger(raw["restartDelayMs"]) || raw["restartDelayMs"] <= 0) throw configError("主配置 restartDelayMs 必须是正整数");
213
+ return raw["restartDelayMs"];
214
+ }
215
+ /** 解析 accounts(必须至少一个账号,qq 必填)。 */
216
+ function parseAccounts(raw) {
217
+ if (raw["accounts"] === void 0) throw configError("主配置缺少 accounts(必须至少一个账号);首次启动已生成模板,请编辑后重试");
218
+ if (!Array.isArray(raw["accounts"]) || raw["accounts"].length === 0) throw configError("主配置 accounts 必须是非空数组(至少一个 QQ 账号);首次启动已生成模板,请编辑后重试");
219
+ const accounts = [];
220
+ for (const item of raw["accounts"]) accounts.push(parseAccount(item));
221
+ return accounts;
222
+ }
223
+ /** 账号 qq 必填校验(非空字符串)。 */
224
+ function parseAccountQq(raw) {
225
+ const qq = raw["qq"];
226
+ if (typeof qq !== "string" || qq === "") throw configError("主配置账号缺少非空 qq");
227
+ assertValidQqFormat(qq);
228
+ return qq;
229
+ }
230
+ /** qq 格式与占位值校验(5-11 位纯数字;模板占位号显式指路不静默)。 */
231
+ function assertValidQqFormat(qq) {
232
+ if (!QQ_IN_FORMAT_RE.test(qq)) throw configError("主配置账号 qq 必须是 5-11 位纯数字 QQ 号");
233
+ if (RESERVED_PLACEHOLDER_QINS.some((placeholder) => placeholder === qq)) throw configError(`主配置账号 qq "${qq}" 是模板占位值,请编辑配置文件填入真实 QQ 号后再启动`);
234
+ }
235
+ /** 账号 enabled 可选校验(缺省 = 启用;显式给值必须是布尔)。 */
236
+ function applyAccountEnabled(out, enabled) {
237
+ if (enabled === void 0) return;
238
+ if (typeof enabled !== "boolean") throw configError("主配置账号 enabled 必须是布尔值");
239
+ out.enabled = enabled;
240
+ }
241
+ /** 账号内协议段装配(onebot11/satori/kurobot 缺省 = 不启用该协议)。 */
242
+ function applyProtocolSections(raw, out) {
243
+ const onebot11 = parseProtocolSection(raw, "onebot11");
244
+ if (onebot11 !== void 0) out.onebot11 = onebot11;
245
+ const satori = parseProtocolSection(raw, "satori");
246
+ if (satori !== void 0) out.satori = satori;
247
+ const kurobot = parseProtocolSection(raw, "kurobot");
248
+ if (kurobot !== void 0) out.kurobot = kurobot;
249
+ }
250
+ /** 解析单个账号项(qq 必填;协议段缺省 = 不启用)。 */
251
+ function parseAccount(item) {
252
+ if (typeof item !== "object" || item === null) throw configError("主配置 accounts 元素必须是对象");
253
+ const raw = item;
254
+ const out = { qq: parseAccountQq(raw) };
255
+ applyAccountEnabled(out, raw["enabled"]);
256
+ applyProtocolSections(raw, out);
257
+ return out;
258
+ }
259
+ /**
260
+ * [log] 段落库净化:format 为 undefined(= 按 TTY 自动)时剔除该键,
261
+ * 避免 toml 回写(config list/apply 的 stringifyToml)遇到 undefined 值。
262
+ */
263
+ function stripUndefinedFormat(log) {
264
+ const { format, ...rest } = log;
265
+ return format === void 0 ? rest : {
266
+ ...rest,
267
+ format
268
+ };
269
+ }
270
+ /**
271
+ * [log] 段解析(kernel parseLogConfig 错误归一为 ConfigError):日志段非法同属
272
+ * 「配置未就绪」一族(exit 78);错误文案保持 kernel 原文(主配置 log.xxx ...)。
273
+ */
274
+ function parseLogSection(input) {
275
+ try {
276
+ return parseLogConfig(input);
277
+ } catch (err) {
278
+ const detail = err instanceof Error ? err.message : String(err);
279
+ throw configError(`主配置 [log] 段非法: ${detail}`, { cause: err });
280
+ }
281
+ }
282
+ /** 手写校验器:主配置 parse(不引入 zod)。全部校验错误为 ConfigError(exit 78 一族)。 */
283
+ function parseCliConfig(input) {
284
+ if (typeof input !== "object" || input === null) throw configError("主配置必须是对象");
285
+ const raw = input;
286
+ const dataDir = parseDataDir(raw);
287
+ const autoRestart = parseAutoRestart(raw);
288
+ const restartDelayMs = parseRestartDelayMs(raw);
289
+ const accounts = parseAccounts(raw);
290
+ const log = raw["log"] !== void 0 ? stripUndefinedFormat(parseLogSection(raw["log"])) : void 0;
291
+ return {
292
+ dataDir,
293
+ autoRestart,
294
+ restartDelayMs,
295
+ accounts,
296
+ ...log !== void 0 ? { log } : {}
297
+ };
298
+ }
299
+ //#endregion
300
+ //#region src/config-inline.ts
301
+ /**
302
+ * 内联配置(NAPKETTO_CONFIG_JSON,2026-10-25 嵌入式宿主契约)。
303
+ *
304
+ * KuroBridge 等嵌入式宿主每次拉起前整体生成配置,经环境变量喂 JSON 文本
305
+ * (与 napuketto.toml 同构:parseCliConfig 直接吃 JSON.parse 产物),消除托管
306
+ * toml 文件本体。优先级 NAPKETTO_CONFIG_JSON > NAPKETTO_CONFIG > 缺省模板生成;
307
+ * 内联生效时不读 / 不写 / 不校验任何 toml 文件。Windows 单环境变量上限 32767
308
+ * 字符(约 32KB),嵌入式配置形状远小于该限(超限宿主退回文件形态)。
309
+ *
310
+ * 错误一概 ConfigError(exit 78 一族),文案指明来源为「内联配置」且不含任何
311
+ * toml 文件路径。本模块不 import logger(logger.ts 反向依赖本模块判内联来源)。
312
+ */
313
+ /** 内联配置 env 键(读取为精确键访问;Windows 上 process.env 本身大小写不敏感)。 */
314
+ const INLINE_CONFIG_ENV = "NAPKETTO_CONFIG_JSON";
315
+ /**
316
+ * 读取内联配置文本(未设置 / 空串 / 纯空白 → undefined,回退文件形态)。
317
+ */
318
+ function readInlineConfigText() {
319
+ const raw = process.env[INLINE_CONFIG_ENV];
320
+ if (raw === void 0 || raw.trim() === "") return;
321
+ return raw;
322
+ }
323
+ /** 内联是否生效(boot/装配链决定「不碰文件」的判定口径,与 readInlineConfigText 同源)。 */
324
+ function isInlineConfigActive() {
325
+ return readInlineConfigText() !== void 0;
326
+ }
327
+ /**
328
+ * 内联 JSON 文本 → 校验后的主配置。JSON 解析失败 / 非对象 / 字段校验失败
329
+ * 均抛 ConfigError(宿主消费 exit 78,不重试)。
330
+ */
331
+ function parseInlineConfig(text) {
332
+ let doc;
333
+ try {
334
+ doc = JSON.parse(text);
335
+ } catch (err) {
336
+ const detail = err instanceof Error ? err.message : String(err);
337
+ throw configError(`内联配置 JSON 解析失败: ${detail}`, { cause: err });
338
+ }
339
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) throw configError("内联配置必须是 JSON 对象(与 napuketto.toml 同构)");
340
+ return parseCliConfig(doc);
341
+ }
342
+ //#endregion
343
+ //#region src/logger.ts
344
+ /**
345
+ * cli 进程级 logger(ADR-007 日志规范,2026-08-07 统一改造;2026-10-25 日志可配置化)。
346
+ *
347
+ * 复用 kernel `createLogger`;输出格式/级别/时间戳/颜色由 napuketto.toml [log] 段 +
348
+ * 环境变量决定(kernel parseLogConfig 单一解析源)。懒构造:commander preAction
349
+ * 钩子先 resolveCliLogConfig(dataDir)(banner 决策 + 缓存),首个日志调用时按
350
+ * 缓存配置构造;未初始化时兜底用缺省数据根解析(-d 生效前的极早期日志)。
351
+ *
352
+ * 环境变量(优先级高于 toml,见 kernel parseLogConfig):NAPUKETTO_LOG_LEVEL /
353
+ * NAPUKETTO_LOG_FORMAT / NAPUKETTO_NO_BANNER / NAPUKETTO_LOG_COLORIZE,
354
+ * 兼容旧拼写 NAPKETTO_LOG_LEVEL。
355
+ */
356
+ /** 已解析日志配置缓存(preAction 首次解析后固定;-d 后续变化不影响已构造 logger)。 */
357
+ let cachedConfig = null;
358
+ /** 解析配置时用的数据根([log.file] 相对路径展开基准)。 */
359
+ let cachedDataRoot = resolveDataRoot();
360
+ /** 同步解析日志配置(内联 > toml 文件的 [log] 段 + env 覆盖)。损坏/缺失配置回退缺省
361
+ * (不阻塞启动:配置的严格校验由 loadCliConfig 主路径负责报错)。内联生效时不读
362
+ * 任何 toml 文件(嵌入式契约,2026-10-25)。 */
363
+ function resolveCliLogConfig(dataDir) {
364
+ if (cachedConfig !== null) return cachedConfig;
365
+ cachedDataRoot = resolveDataRoot(dataDir);
366
+ let section;
367
+ const inlineText = readInlineConfigText();
368
+ if (inlineText !== void 0) try {
369
+ section = JSON.parse(inlineText)["log"];
370
+ } catch {
371
+ section = void 0;
372
+ }
373
+ else try {
374
+ const configPath = resolveConfigPath({ dataRoot: cachedDataRoot });
375
+ section = parseToml(readFileSync(configPath, "utf8"))["log"];
376
+ } catch {
377
+ section = void 0;
378
+ }
379
+ try {
380
+ cachedConfig = parseLogConfig(section, process.env);
381
+ } catch {
382
+ cachedConfig = parseLogConfig(void 0, process.env);
383
+ }
384
+ return cachedConfig;
385
+ }
386
+ /**
387
+ * 顶层进程的最终控制台格式([log] format 显式优先,否则按 TTY 自动:
388
+ * 终端 pretty / 管道 single-line)。supervisor 的前缀转发决策同源。
389
+ */
390
+ function effectiveConsoleFormat(cfg) {
391
+ const isTty = process.stdout.isTTY === true;
392
+ return cfg.format ?? (isTty ? "pretty" : "single-line");
393
+ }
394
+ /**
395
+ * 顶层进程的有效控制台渲染决策(传播给子进程):
396
+ * 子进程 stdout 恒为 pipe(TTY 判定失真),由本进程按最终控制台形态决定
397
+ * format/colorize 并经 env 注入(NAPUKETTO_LOG_FORMAT/NAPUKETTO_LOG_COLORIZE)。
398
+ */
399
+ function effectiveConsoleEnv(cfg) {
400
+ const isTty = process.stdout.isTTY === true;
401
+ const format = effectiveConsoleFormat(cfg);
402
+ return {
403
+ NAPUKETTO_LOG_FORMAT: format,
404
+ NAPUKETTO_LOG_COLORIZE: cfg.colorize ?? (format === "pretty" && isTty) ? "1" : "0"
405
+ };
406
+ }
407
+ /** [log.file] 相对路径按数据根展开。 */
408
+ function resolveLogFilePath(path) {
409
+ return isAbsolute(path) ? path : join(cachedDataRoot, path);
410
+ }
411
+ /** cli logger 构造参数([log] 段折叠;file/fileLevel 成对生效,其余直传 undefined)。 */
412
+ function cliLoggerOptions(cfg) {
413
+ return {
414
+ level: cfg.levels.cli ?? cfg.level,
415
+ format: cfg.format,
416
+ timestamp: cfg.timestamp,
417
+ colorize: cfg.colorize,
418
+ file: cfg.file !== void 0 ? resolveLogFilePath(cfg.file.path) : void 0,
419
+ fileLevel: cfg.file?.level,
420
+ base: {
421
+ name: "cli",
422
+ service: "cli"
423
+ }
424
+ };
425
+ }
426
+ /** 懒构造 cli 进程日志(name 渲染为 (cli/pid) 元数据头,文件 JSON 保留 name 字段可过滤)。 */
427
+ var LazyCliLogger = class {
428
+ #inner = null;
429
+ #ensure() {
430
+ if (this.#inner === null) this.#inner = createLogger(cliLoggerOptions(resolveCliLogConfig()));
431
+ return this.#inner;
432
+ }
433
+ info(obj, msg) {
434
+ this.#ensure().info(obj, msg);
435
+ }
436
+ warn(obj, msg) {
437
+ this.#ensure().warn(obj, msg);
438
+ }
439
+ error(obj, msg) {
440
+ this.#ensure().error(obj, msg);
441
+ }
442
+ debug(obj, msg) {
443
+ this.#ensure().debug(obj, msg);
444
+ }
445
+ trace(obj, msg) {
446
+ this.#ensure().trace(obj, msg);
447
+ }
448
+ fatal(obj, msg) {
449
+ this.#ensure().fatal(obj, msg);
450
+ }
451
+ };
452
+ /** cli 进程日志(懒构造,配置见 resolveCliLogConfig)。 */
453
+ const logger = new LazyCliLogger();
454
+ //#endregion
455
+ //#region src/action-error.ts
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
481
+ /**
482
+ * runtime-state.ts:账号运行时状态文件 + 进程控制原语(2026-09-08 T8)。
483
+ *
484
+ * 跨进程通信选型(决策记录):现有 IPC 为 stdio JSON 行协议(NAPUTO_IPC=1,
485
+ * koishi 插件专用),supervisor 子进程不在此模式;引入独立通道(socket/pipe)
486
+ * 复杂度高。采用 **PID 状态文件**:supervisor/单账号 boot 启动时把进程信息写
487
+ * `<数据根>/<uin>/runtime.json`,supervisor 自身写 `<数据根>/supervisor.json`;
488
+ * `napuketto status/stop/restart` 读文件 + liveness 检测 + 树杀(win32
489
+ * taskkill /T /F,杀 boot 进程树连带 self-host 孙进程)+ detached 重启。
490
+ */
491
+ /** runtime.json 文件名。 */
492
+ const RUNTIME_FILE = "runtime.json";
493
+ /** supervisor.json 文件名。 */
494
+ const SUPERVISOR_FILE = "supervisor.json";
495
+ /** 账号状态文件路径。 */
496
+ function runtimeStatePath(dataRoot, uin) {
497
+ return join(dataRoot, uin, RUNTIME_FILE);
498
+ }
499
+ /** supervisor 状态文件路径。 */
500
+ function supervisorStatePath(dataRoot) {
501
+ return join(dataRoot, SUPERVISOR_FILE);
502
+ }
503
+ /** 写账号运行时状态(原子覆盖)。 */
504
+ function writeAccountRuntime(dataRoot, state) {
505
+ const path = runtimeStatePath(dataRoot, state.uin);
506
+ mkdirSync(join(path, ".."), { recursive: true });
507
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
508
+ }
509
+ /** 读账号运行时状态(缺失/损坏返回 null)。 */
510
+ function readAccountRuntime(dataRoot, uin) {
511
+ return readJson(runtimeStatePath(dataRoot, uin));
512
+ }
513
+ /** 写 supervisor 运行时状态。 */
514
+ function writeSupervisorRuntime(dataRoot, state) {
515
+ writeFileSync(supervisorStatePath(dataRoot), `${JSON.stringify(state, null, 2)}\n`, "utf8");
516
+ }
517
+ /** 读 supervisor 运行时状态(缺失返回 null)。 */
518
+ function readSupervisorRuntime(dataRoot) {
519
+ return readJson(supervisorStatePath(dataRoot));
520
+ }
521
+ /** 读 JSON 文件(缺失/解析失败返回 null)。 */
522
+ function readJson(path) {
523
+ try {
524
+ return JSON.parse(readFileSync(path, "utf8"));
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+ /** 列出数据根下全部账号运行时状态(扫一级目录 runtime.json)。 */
530
+ function listAccountRuntimes(dataRoot) {
531
+ let entries = [];
532
+ try {
533
+ entries = readdirSync(dataRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
534
+ } catch {
535
+ return [];
536
+ }
537
+ const out = [];
538
+ for (const uin of entries) {
539
+ const state = readAccountRuntime(dataRoot, uin);
540
+ if (state !== null) out.push(state);
541
+ }
542
+ return out;
543
+ }
544
+ /** pid 存活检测(signal 0;EPERM 视为存活——进程存在但无权限)。 */
545
+ function isPidAlive(pid) {
546
+ if (!Number.isInteger(pid) || pid <= 0) return false;
547
+ try {
548
+ process.kill(pid, 0);
549
+ return true;
550
+ } catch (err) {
551
+ return err.code === "EPERM";
552
+ }
553
+ }
554
+ /** 树杀命令构造(win32:taskkill /T /F 连孙进程;posix:kill 信号)。 */
555
+ function killProcessTree(pid) {
556
+ if (process.platform === "win32") {
557
+ spawn("taskkill", [
558
+ "/PID",
559
+ String(pid),
560
+ "/T",
561
+ "/F"
562
+ ], { stdio: "ignore" });
563
+ return;
564
+ }
565
+ try {
566
+ process.kill(pid, "SIGTERM");
567
+ } catch {}
568
+ }
569
+ /** detached 拉起 node 进程(restart 用;立即返回不等待)。 */
570
+ function spawnDetached(args, cwd) {
571
+ const child = spawn(process.execPath, args, {
572
+ detached: true,
573
+ stdio: "ignore",
574
+ ...cwd !== void 0 ? { cwd } : {}
575
+ });
576
+ child.unref();
577
+ return child;
578
+ }
579
+ /** 聚合各账号状态(liveness 修正:文件 running 但 pid 已死 → not running)。 */
580
+ function aggregateStatus(runtimes, aliveFn = isPidAlive) {
581
+ return runtimes.map((r) => ({
582
+ uin: r.uin,
583
+ pid: r.pid,
584
+ alive: r.status === "running" ? aliveFn(r.pid) : false,
585
+ kind: r.kind,
586
+ startedAt: r.startedAt,
587
+ restartCount: r.restartCount ?? 0,
588
+ exitCode: r.exitCode
589
+ }));
590
+ }
591
+ //#endregion
592
+ //#region src/boot.ts
593
+ /** 解析 workspace 包的 dist 入口(ESM 解析:包是 ESM-only,exports 无 require 条件)。 */
594
+ async function packageEntry(pkg) {
595
+ const url = await import.meta.resolve(pkg);
596
+ return fileURLToPath(url);
597
+ }
598
+ /** QR 透出标记行前缀(loader bootstrap-core 非 IPC 模式输出,cli 解析终端渲染)。 */
599
+ const QR_LINE_PREFIX = "NAPUTO_QR ";
600
+ /** 优雅停等待上限(毫秒):超时 kill 兜底(Windows 下为硬杀)。 */
601
+ const GRACEFUL_STOP_TIMEOUT_MS = 1e4;
437
602
  /**
438
- * 主配置类型与手写校验器(从 config-cmds.ts 拆分,2026-08-08 FTA 优化)
603
+ * 逐行转发子进程输出到父进程,过滤原生噪音。
604
+ * readline 按 UTF-8 解码 pipe 字节流,再经 process.stdout(TTY 路径,
605
+ * WriteConsoleW UTF-16)输出——顺带修复 pino 中文在 cmd.exe/管道 936 转码
606
+ * 链路下的乱码(原生 printf 字节流无法从 JS 侧改编码,转 pipe 后统一解码)。
439
607
  *
440
- * 主配置 parse(不引入 zod):数据根 / 自动重启 / 重启延迟 / 账号列表。
441
- * 2026-08-08 结构拍板:**一个 QQ 账号一个 [[accounts]] 段,协议与通信配置嵌在
442
- * 账号内**([accounts.onebot11] / [accounts.satori]);账号必填(至少一个)。
443
- * 协议段为宽松对象,装配时由对应协议包的 zod schema 严格校验。
608
+ * NAPUTO_QR 标记行(loader bootstrap-core 输出,QR 登录二维码数据)不转发,
609
+ * 解析后用 qrcode 包渲染终端二维码;png 落盘与 URL 提示由 kernel 日志完成。
610
+ * 噪音判定走 loader 共享单一来源(isNativeNoiseLine,与 koishi IPC 路径同源)。
444
611
  */
445
- /** 默认自动重启。 */
446
- const DEFAULT_AUTO_RESTART = true;
447
- /** 默认重启延迟(毫秒)。 */
448
- const DEFAULT_RESTART_DELAY_MS$1 = 2e3;
449
- /** QQ 号格式:纯数字 5-11 位(QQ 号合法范围,勿写死 11 位)。 */
450
- const QQ_IN_FORMAT_RE = /^\d{5,11}$/;
451
- /** 解析账号内协议段(宽松对象,装配时由 zod schema 严格校验)。 */
452
- function parseProtocolSection(raw, key) {
453
- const section = raw[key];
454
- if (section === void 0) return;
455
- if (typeof section !== "object" || section === null) throw kernelError(`主配置账号 ${key} 段必须是对象`, "INVALID_PARAM");
456
- return section;
612
+ function forwardFiltered(input, out) {
613
+ createInterface({ input }).on("line", (line) => {
614
+ if (line.startsWith(QR_LINE_PREFIX)) {
615
+ renderTerminalQr(line.slice(10));
616
+ return;
617
+ }
618
+ if (isNativeNoiseLine(line)) return;
619
+ out.write(`${line}\n`);
620
+ });
457
621
  }
458
622
  /**
459
- * 解析 dataDir(缺省用当前解析的数据根 = 项目根/.napuketto)。
460
- * 支持绝对路径、"~/" 前缀(用户主目录)、相对路径(相对启动目录),
461
- * 消费端 resolveDataRoot 统一展开为绝对路径。
623
+ * 渲染终端二维码(qrcode 包 terminal 模式,half-block 字符)。
624
+ * 解析失败/无 url 时静默丢弃——标记行是内部协议,坏行不影响登录。
462
625
  */
463
- function parseDataDir(raw) {
464
- if (raw["dataDir"] === void 0) return resolveDataRoot();
465
- if (typeof raw["dataDir"] !== "string" || raw["dataDir"] === "") throw kernelError("主配置 dataDir 必须是非空字符串", "INVALID_PARAM");
466
- return raw["dataDir"];
626
+ function renderTerminalQr(raw) {
627
+ try {
628
+ const { qrcodeUrl } = JSON.parse(raw);
629
+ if (qrcodeUrl === void 0 || qrcodeUrl === "") return;
630
+ QRCode.toString(qrcodeUrl, {
631
+ type: "terminal",
632
+ small: true
633
+ }).then((str) => {
634
+ process.stdout.write(`${str}\n`);
635
+ }).catch(() => {});
636
+ } catch {}
467
637
  }
468
- /** 解析 autoRestart。 */
469
- function parseAutoRestart(raw) {
470
- if (raw["autoRestart"] === void 0) return DEFAULT_AUTO_RESTART;
471
- if (typeof raw["autoRestart"] !== "boolean") throw kernelError("主配置 autoRestart 必须是布尔值", "INVALID_PARAM");
472
- return raw["autoRestart"];
638
+ /** 解析自建宿主装配面的三个包入口(ESM 解析,顺序同旧实现)。 */
639
+ async function resolvePackageEntries() {
640
+ return {
641
+ kernelEntry: await packageEntry("@napuketto/kernel"),
642
+ adapterEntry: await packageEntry("@napuketto/adapter"),
643
+ networkEntry: await packageEntry("@napuketto/network")
644
+ };
473
645
  }
474
- /** 解析 restartDelayMs(毫秒)。 */
475
- function parseRestartDelayMs(raw) {
476
- if (raw["restartDelayMs"] === void 0) return DEFAULT_RESTART_DELAY_MS$1;
477
- if (typeof raw["restartDelayMs"] !== "number" || !Number.isInteger(raw["restartDelayMs"]) || raw["restartDelayMs"] <= 0) throw kernelError("主配置 restartDelayMs 必须是正整数", "INVALID_PARAM");
478
- return raw["restartDelayMs"];
646
+ /** stub QQNT.dll 目录(--stub-dir > NAPUTO_STUB_DIR > loader 包内闭源 submodule 默认)。 */
647
+ function resolveStubDir(opts) {
648
+ return opts.stubDir ?? process.env["NAPUTO_STUB_DIR"] ?? defaultStubDir();
479
649
  }
480
- /** 解析 accounts(必须至少一个账号,qq 必填)。 */
481
- function parseAccounts(raw) {
482
- if (raw["accounts"] === void 0) throw kernelError("主配置缺少 accounts(必须至少一个账号);首次启动已生成模板,请编辑后重试", "INVALID_PARAM");
483
- if (!Array.isArray(raw["accounts"]) || raw["accounts"].length === 0) throw kernelError("主配置 accounts 必须是非空数组(至少一个 QQ 账号);首次启动已生成模板,请编辑后重试", "INVALID_PARAM");
484
- const accounts = [];
485
- for (const item of raw["accounts"]) accounts.push(parseAccount(item));
486
- return accounts;
650
+ /** 错误文案提取(Error 用 message,其余字符串化——保留旧日志文案语义)。 */
651
+ function errorMessage(err) {
652
+ return err instanceof Error ? err.message : String(err);
487
653
  }
488
- /** 解析单个账号项(qq 必填;协议段缺省 = 不启用)。 */
489
- function parseAccount(item) {
490
- if (typeof item !== "object" || item === null) throw kernelError("主配置 accounts 元素必须是对象", "INVALID_PARAM");
491
- const { qq, enabled } = item;
492
- if (typeof qq !== "string" || qq === "") throw kernelError("主配置账号缺少非空 qq", "INVALID_PARAM");
493
- if (!QQ_IN_FORMAT_RE.test(qq)) throw kernelError("主配置账号 qq 必须是 5-11 位纯数字 QQ 号", "INVALID_PARAM");
494
- if (RESERVED_PLACEHOLDER_QINS.some((placeholder) => placeholder === qq)) throw kernelError(`主配置账号 qq "${qq}" 是模板占位值,请编辑配置文件填入真实 QQ 号后再启动`, "INVALID_PARAM");
495
- const out = { qq };
496
- if (enabled !== void 0) {
497
- if (typeof enabled !== "boolean") throw kernelError("主配置账号 enabled 必须是布尔值", "INVALID_PARAM");
498
- out.enabled = enabled;
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;
499
666
  }
500
- const raw = item;
501
- const onebot11 = parseProtocolSection(raw, "onebot11");
502
- if (onebot11 !== void 0) out.onebot11 = onebot11;
503
- const satori = parseProtocolSection(raw, "satori");
504
- if (satori !== void 0) out.satori = satori;
505
- const kurobot = parseProtocolSection(raw, "kurobot");
506
- if (kurobot !== void 0) out.kurobot = kurobot;
507
- return out;
508
667
  }
509
- /** 手写校验器:主配置 parse(不引入 zod)。 */
510
- function parseCliConfig(input) {
511
- if (typeof input !== "object" || input === null) throw kernelError("主配置必须是对象", "INVALID_PARAM");
512
- const raw = input;
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;
513
709
  return {
514
- dataDir: parseDataDir(raw),
515
- autoRestart: parseAutoRestart(raw),
516
- restartDelayMs: parseRestartDelayMs(raw),
517
- accounts: parseAccounts(raw)
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}`);
518
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));
519
827
  }
520
828
  //#endregion
521
829
  //#region src/config-cmds.ts
@@ -549,12 +857,41 @@ var CliConfigStore = class extends ConfigBase {
549
857
  });
550
858
  }
551
859
  };
552
- /** 读取主配置(缺失自动落模板)。 */
860
+ /** 读取主配置(缺失自动落模板;loadCliConfigWithSource 文件分支内部用)。 */
553
861
  async function loadCliConfig(dataRoot) {
554
862
  const store = new CliConfigStore(dataRoot);
555
863
  await ensureConfigTemplate(store.path);
556
864
  return await store.load();
557
865
  }
866
+ /** 「配置来源」日志每进程至多一行(autoStart 与 runSupervisor 各加载一次)。 */
867
+ let sourceLogged = false;
868
+ /**
869
+ * 加载主配置(内联优先,2026-10-25 嵌入式契约):
870
+ * NAPKETTO_CONFIG_JSON 非空 → 内存文档校验(不读 / 不写 / 不校验任何 toml
871
+ * 文件),打一行来源日志;否则现行文件链路(缺失自动落模板)。校验失败两形态
872
+ * 同为 ConfigError(exit 78),文案区别只在来源标注。
873
+ */
874
+ async function loadCliConfigWithSource(opts = {}) {
875
+ const inlineText = readInlineConfigText();
876
+ if (inlineText !== void 0) {
877
+ const config = parseInlineConfig(inlineText);
878
+ if (!sourceLogged) {
879
+ sourceLogged = true;
880
+ logger.info("配置来源: inline(NAPKETTO_CONFIG_JSON)");
881
+ }
882
+ return {
883
+ config,
884
+ source: "inline"
885
+ };
886
+ }
887
+ const dataRoot = resolveDataRoot(opts.dataDir);
888
+ const configPath = resolveConfigPath({ dataRoot });
889
+ return {
890
+ config: await loadCliConfig(dataRoot),
891
+ source: "file",
892
+ path: configPath
893
+ };
894
+ }
558
895
  /** CliConfig → 普通对象(smol-toml stringify 入参)。 */
559
896
  function toRecord(config) {
560
897
  return config;
@@ -647,20 +984,86 @@ async function cmdConfigApply(file, opts) {
647
984
  logger.info({ path: store.path }, "全局配置已更新");
648
985
  process.stdout.write(`${stringifyToml(toRecord(next))}\n`);
649
986
  }
987
+ /** 读取配置文件文本(validate 纯只读:ENOENT 不落模板,按「配置缺失」报 ConfigError)。 */
988
+ async function readConfigTextForValidate(path) {
989
+ try {
990
+ return await readFile(path, "utf8");
991
+ } catch (err) {
992
+ if (err.code === "ENOENT") throw configError(`配置文件不存在: ${path}`);
993
+ throw configError(`读取配置文件失败: ${path}`, { cause: err });
994
+ }
995
+ }
996
+ /**
997
+ * 校验配置文件(与运行时同构:ConfigBase.parseText 反序列化 + 校验,报错文案
998
+ * 含路径且与 loadCliConfig 主路径逐字一致;格式按扩展名推断,同 ConfigBase)。
999
+ */
1000
+ async function validateConfigFile(path, dataRoot) {
1001
+ const raw = await readConfigTextForValidate(path);
1002
+ new ConfigBase({
1003
+ path,
1004
+ schema: { parse: parseCliConfig },
1005
+ defaults: defaultCliConfig(dataRoot)
1006
+ }).parseText(raw);
1007
+ }
1008
+ /**
1009
+ * config validate:校验配置(exit 0 = 合法;exit 78 = 校验失败,ConfigError 一族;
1010
+ * --config 与 --config-json 同给 = 用法错误,exit 1)。纯只读——文件形态不落模板
1011
+ * (无参形态文件缺失按配置缺失报 78),不触碰数据目录。
1012
+ */
1013
+ async function cmdConfigValidate(opts) {
1014
+ const { configPath, configJson } = opts;
1015
+ if (configPath !== void 0 && configJson !== void 0) throw kernelError("config validate:--config 与 --config-json 只能二选一", "INVALID_PARAM");
1016
+ const dataRoot = resolveDataRoot(opts.dataDir);
1017
+ if (configJson !== void 0) {
1018
+ parseInlineConfig(configJson);
1019
+ return { source: "inline" };
1020
+ }
1021
+ if (configPath !== void 0) {
1022
+ await validateConfigFile(configPath, dataRoot);
1023
+ return {
1024
+ source: "file",
1025
+ path: configPath
1026
+ };
1027
+ }
1028
+ const inlineText = readInlineConfigText();
1029
+ if (inlineText !== void 0) {
1030
+ parseInlineConfig(inlineText);
1031
+ return { source: "inline" };
1032
+ }
1033
+ const path = resolveConfigPath({ dataRoot });
1034
+ await validateConfigFile(path, dataRoot);
1035
+ return {
1036
+ source: "file",
1037
+ path
1038
+ };
1039
+ }
650
1040
  //#endregion
651
1041
  //#region src/ops-commands.ts
652
1042
  /**
653
1043
  * ops-commands.ts:status / stop / restart 运维命令实现(2026-09-08 T8)。
654
1044
  *
655
1045
  * 数据源:runtime-state 状态文件(supervisor/单账号 boot 写入)+ liveness 检测。
656
- * stop/restart 语义:
657
- * - stop -q <uin>:树杀该账号 boot 进程(连带 self-host)。⚠️ supervisor 在跑
658
- * 且 autoRestart 时账号会被自动拉起——提示用户先 `napuketto stop`(停 supervisor)。
659
- * - stop(无 -q):树杀 supervisor(子进程随 supervisor 的信号转发退出;若
660
- * 残留按各账号 runtime pid 逐个树杀兜底)。
1046
+ * stop/restart 语义(2026-10-25 优雅化):
1047
+ * - stop -q <uin>:写单账号停止哨兵 → 等该账号进程链优雅退出 → 超时树杀兜底。
1048
+ * ⚠️ supervisor 在跑且 autoRestart 时账号会被自动拉起——提示用户先 `napuketto stop`。
1049
+ * - stop(无 -q):写 all 哨兵(supervisor + 全部账号优雅退出)→ 超时树杀兜底。
661
1050
  * - restart:stop 同目标 + detached 重新拉起(-q → 单账号 boot;无 -q →
662
1051
  * supervisor autoStart)。
663
1052
  */
1053
+ /** 优雅停等待上限(毫秒):哨兵后子进程链自行退出,超时树杀兜底。 */
1054
+ const GRACEFUL_STOP_WAIT_MS = 8e3;
1055
+ /** 存活轮询间隔(毫秒)。 */
1056
+ const LIVENESS_POLL_MS = 200;
1057
+ /** 等待 pid 列表全部退出(返回超时仍未退的 pid)。 */
1058
+ async function waitForPidsExit(pids) {
1059
+ const deadline = Date.now() + GRACEFUL_STOP_WAIT_MS;
1060
+ let alive = pids.filter((pid) => pid > 0 && isPidAlive(pid));
1061
+ while (alive.length > 0 && Date.now() < deadline) {
1062
+ await new Promise((resolve) => setTimeout(resolve, LIVENESS_POLL_MS));
1063
+ alive = alive.filter((pid) => isPidAlive(pid));
1064
+ }
1065
+ return alive;
1066
+ }
664
1067
  /** status:聚合输出各账号运行状态(含 supervisor 自身)。 */
665
1068
  function cmdStatus(dataRoot, opts) {
666
1069
  const supervisor = readSupervisorRuntime(dataRoot);
@@ -694,7 +1097,31 @@ function stopAccount(runtime) {
694
1097
  pid: runtime.pid
695
1098
  }, "已发送停止(树杀)");
696
1099
  }
697
- /** stop:-q 停单账号;无 -q 停 supervisor + 全部账号兜底。 */
1100
+ /** stop:-q 停单账号;无 -q 停 supervisor + 全部账号兜底。
1101
+ *
1102
+ * 2026-10-25 优雅化:先写停止哨兵(目标账号或 all)→ 等进程链自行有序退出
1103
+ * (退出码 0)→ 超时残留树杀兜底 → 清理哨兵。单账号哨兵只命中对应 boot/self-host,
1104
+ * 不干扰同数据根的其他账号。
1105
+ */
1106
+ /** stop -q:写单账号哨兵 → 等优雅退出 → 超时树杀兜底 → 清哨兵。 */
1107
+ async function stopSingleAccount(dataRoot, target) {
1108
+ logger.info({
1109
+ uin: target.uin,
1110
+ pid: target.pid
1111
+ }, "已发送停止请求(哨兵),等待退出");
1112
+ writeStopSentinel(dataRoot, target.uin);
1113
+ const stragglers = await waitForPidsExit([target.pid]);
1114
+ for (const pid of stragglers) {
1115
+ logger.warn({
1116
+ uin: target.uin,
1117
+ pid
1118
+ }, "限时内未退出,树杀兜底");
1119
+ killProcessTree(pid);
1120
+ }
1121
+ removeStopSentinel(dataRoot);
1122
+ const supervisor = readSupervisorRuntime(dataRoot);
1123
+ if (supervisor !== null) logger.warn({ supervisorPid: supervisor.pid }, "supervisor 仍在运行且 autoRestart 开启时会自动拉起该账号;如需彻底停止请执行 napuketto stop(停 supervisor)");
1124
+ }
698
1125
  async function cmdStop(dataRoot, opts) {
699
1126
  const runtimes = listAccountRuntimes(dataRoot);
700
1127
  if (opts.qq !== void 0) {
@@ -704,17 +1131,23 @@ async function cmdStop(dataRoot, opts) {
704
1131
  process.exitCode = 1;
705
1132
  return;
706
1133
  }
707
- stopAccount(target);
708
- const supervisor = readSupervisorRuntime(dataRoot);
709
- if (supervisor !== null) logger.warn({ supervisorPid: supervisor.pid }, "supervisor 仍在运行且 autoRestart 开启时会自动拉起该账号;如需彻底停止请执行 napuketto stop(停 supervisor)");
1134
+ await stopSingleAccount(dataRoot, target);
710
1135
  return;
711
1136
  }
712
1137
  const supervisor = readSupervisorRuntime(dataRoot);
1138
+ const supervisorPid = supervisor !== null && isPidAlive(supervisor.pid) ? supervisor.pid : 0;
713
1139
  if (supervisor !== null) {
714
- killProcessTree(supervisor.pid);
715
- logger.info({ pid: supervisor.pid }, "已停止 supervisor(树杀,子进程随信号转发退出)");
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
+ }
716
1147
  } else logger.warn("supervisor 未运行(无状态文件)");
717
- for (const runtime of runtimes) stopAccount(runtime);
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);
718
1151
  }
719
1152
  /** restart:stop 同目标 + detached 拉起。 */
720
1153
  async function cmdRestart(dataRoot, opts) {
@@ -754,8 +1187,8 @@ async function cmdRestart(dataRoot, opts) {
754
1187
  const DEFAULT_RESTART_DELAY_MS = 2e3;
755
1188
  /** 退出轮询间隔(毫秒)。 */
756
1189
  const EXIT_POLL_MS = 100;
757
- /** 子进程强制退出时限(毫秒,超时未退则父进程强制退出)。 */
758
- const FORCE_EXIT_MS = 5e3;
1190
+ /** 子进程优雅退出时限(毫秒):哨兵/信号后子进程自行停止,超时强杀兜底。 */
1191
+ const FORCE_EXIT_MS = 8e3;
759
1192
  /** 解析账号列表(显式 -q 优先,否则主配置 accounts 过滤启用项)。 */
760
1193
  function resolveAccounts(opts, config) {
761
1194
  if (opts.qqs !== void 0 && opts.qqs.length > 0) return opts.qqs.map((qq) => ({ qq }));
@@ -766,18 +1199,22 @@ function resolveRestartDelay(opts, config) {
766
1199
  if (opts.qqs === void 0) return config.restartDelayMs;
767
1200
  return DEFAULT_RESTART_DELAY_MS;
768
1201
  }
769
- /** 子进程输出前缀转发(stdout/stderr 逐行 `[uin] ` 前缀;pino 行结构化保留在行内)。 */
770
- function forwardPrefixed(stream, uin) {
1202
+ /**
1203
+ * 子进程输出前缀转发(stdout/stderr 逐行):pretty 模式加 `[uin] ` 前缀
1204
+ * (pino 行结构化保留在行内);single-line/json 模式记录自身已带结构化
1205
+ * account 字段(json 加前缀会破坏逐行 JSON.parse),原样透传。
1206
+ */
1207
+ function forwardPrefixed(stream, uin, prefixEnabled) {
771
1208
  if (stream === null) return;
772
1209
  createInterface({ input: stream }).on("line", (line) => {
773
- process.stdout.write(`[${uin}] ${line}\n`);
1210
+ process.stdout.write(prefixEnabled ? `[${uin}] ${line}\n` : `${line}\n`);
774
1211
  });
775
1212
  }
776
1213
  /** 启动一个账号子进程(exit 后按 autoRestart 守护重启)。 */
777
1214
  function startAccount(ctx, acct) {
778
1215
  if (ctx.isStopping()) return;
779
1216
  const restartCount = ctx.restartCounts.get(acct.qq) ?? 0;
780
- logger.info({
1217
+ logger.debug({
781
1218
  qq: acct.qq,
782
1219
  pid: process.pid,
783
1220
  restartCount
@@ -790,13 +1227,20 @@ function startAccount(ctx, acct) {
790
1227
  ctx.dataRoot
791
1228
  ];
792
1229
  if (ctx.stubDir !== void 0) args.push("--stub-dir", ctx.stubDir);
793
- const child = spawn(process.execPath, args, { stdio: [
794
- "inherit",
795
- "pipe",
796
- "pipe"
797
- ] });
798
- forwardPrefixed(child.stdout, acct.qq);
799
- forwardPrefixed(child.stderr, acct.qq);
1230
+ const child = spawn(process.execPath, args, {
1231
+ stdio: [
1232
+ "inherit",
1233
+ "pipe",
1234
+ "pipe"
1235
+ ],
1236
+ env: {
1237
+ ...process.env,
1238
+ NAPUKETTO_NO_BANNER: "1",
1239
+ ...ctx.childEnv
1240
+ }
1241
+ });
1242
+ forwardPrefixed(child.stdout, acct.qq, ctx.prefixEnabled);
1243
+ forwardPrefixed(child.stderr, acct.qq, ctx.prefixEnabled);
800
1244
  ctx.children.set(acct.qq, child);
801
1245
  writeAccountRuntime(ctx.dataRoot, {
802
1246
  uin: acct.qq,
@@ -813,6 +1257,22 @@ function startAccount(ctx, acct) {
813
1257
  code,
814
1258
  signal: signal ?? "none"
815
1259
  }, "账号退出");
1260
+ if (code === EXIT_CONFIG_INVALID) {
1261
+ logger.error({
1262
+ qq: acct.qq,
1263
+ code
1264
+ }, "账号因配置校验失败退出(78),不自动重启");
1265
+ writeAccountRuntime(ctx.dataRoot, {
1266
+ uin: acct.qq,
1267
+ pid: child.pid ?? 0,
1268
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1269
+ kind: "supervisor-child",
1270
+ status: "exited",
1271
+ exitCode: code,
1272
+ restartCount
1273
+ });
1274
+ return;
1275
+ }
816
1276
  if (!ctx.isStopping() && ctx.autoRestart) {
817
1277
  ctx.restartCounts.set(acct.qq, restartCount + 1);
818
1278
  setTimeout(() => {
@@ -837,14 +1297,21 @@ function startAccount(ctx, acct) {
837
1297
  ctx.children.delete(acct.qq);
838
1298
  });
839
1299
  }
840
- /** 停止全部子进程,全部退出后父进程退出(2026-08-07:超时强杀兜底)。 */
1300
+ /** 停止全部子进程,全部退出后父进程退出(2026-10-25 优雅化)。
1301
+ *
1302
+ * 优雅路径:写停止哨兵(all)→ 子进程链(boot / self-host)自行有序退出 →
1303
+ * 全部退出后 exit 0(退出码契约:0 = 请求式正常退出)。超时(FORCE_EXIT_MS)
1304
+ * 未退的子进程 kill 兜底(Windows 下为硬杀)后仍 exit 0——关停请求本身是正常路径,
1305
+ * 与崩溃(非 0)区分是契约的核心目标。
1306
+ */
841
1307
  function stopAll(ctx) {
842
1308
  logger.info("收到退出信号,停止全部账号");
843
- for (const child of ctx.children.values()) child.kill();
1309
+ writeStopSentinel(ctx.dataRoot, "all");
844
1310
  const forceTimer = setTimeout(() => {
845
- logger.warn("子进程未在限时内退出,强制退出");
846
- process.exit(1);
1311
+ for (const child of ctx.children.values()) child.kill();
1312
+ process.exit(0);
847
1313
  }, FORCE_EXIT_MS);
1314
+ forceTimer.unref?.();
848
1315
  const wait = setInterval(() => {
849
1316
  if (ctx.children.size === 0) {
850
1317
  clearInterval(wait);
@@ -856,7 +1323,7 @@ function stopAll(ctx) {
856
1323
  /** 运行 supervisor(多账号编排)。 */
857
1324
  async function runSupervisor(opts = {}) {
858
1325
  const dataRoot = resolveDataRoot(opts.dataDir);
859
- const config = await loadCliConfig(dataRoot);
1326
+ const { config } = await loadCliConfigWithSource(opts.dataDir !== void 0 ? { dataDir: opts.dataDir } : {});
860
1327
  const accounts = resolveAccounts(opts, config);
861
1328
  if (accounts.length === 0) {
862
1329
  logger.warn("supervisor:没有启用的账号(主配置 accounts 为空或全部禁用)");
@@ -867,6 +1334,7 @@ async function runSupervisor(opts = {}) {
867
1334
  const children = /* @__PURE__ */ new Map();
868
1335
  const restartCounts = /* @__PURE__ */ new Map();
869
1336
  let stopping = false;
1337
+ const logCfg = resolveCliLogConfig(opts.dataDir);
870
1338
  const ctx = {
871
1339
  dataRoot,
872
1340
  entry,
@@ -875,6 +1343,8 @@ async function runSupervisor(opts = {}) {
875
1343
  ...opts.stubDir !== void 0 ? { stubDir: opts.stubDir } : {},
876
1344
  children,
877
1345
  restartCounts,
1346
+ childEnv: effectiveConsoleEnv(logCfg),
1347
+ prefixEnabled: effectiveConsoleFormat(logCfg) === "pretty",
878
1348
  isStopping: () => stopping
879
1349
  };
880
1350
  writeSupervisorRuntime(dataRoot, {
@@ -890,6 +1360,11 @@ async function runSupervisor(opts = {}) {
890
1360
  };
891
1361
  process.on("SIGINT", shutdown);
892
1362
  process.on("SIGTERM", shutdown);
1363
+ process.on("SIGBREAK", shutdown);
1364
+ watchStopSentinel({
1365
+ dataRoot,
1366
+ onTrigger: shutdown
1367
+ });
893
1368
  await new Promise(() => {});
894
1369
  }
895
1370
  //#endregion
@@ -902,7 +1377,9 @@ async function runSupervisor(opts = {}) {
902
1377
  * - 单账号:-q <uin> → runSingleAccount(自建宿主:标准 node + stub QQNT.dll,不拉起 QQ)
903
1378
  * - 多账号:-q A -q B 或 supervisor 子命令 → runSupervisor(子进程编排)
904
1379
  * - 无 -q:读全局配置 accounts(至少一个,账号必填)→ supervisor 拉起全部启用账号
905
- * - config 子命令:init / list / apply(主配置管理)
1380
+ * - config 子命令:init / list / apply / validate(主配置管理)
1381
+ * - 退出码契约(2026-10-25):0 = 请求式优雅关停;78 = 配置校验失败(ConfigError
1382
+ * 一族,宿主消费,免重启);其余错误 = 1(handleActionError 统一出口)
906
1383
  *
907
1384
  * 路线 B(拉起 QQ + 注入)已淘汰(2026-08-07 用户拍板),相关入口已移除。
908
1385
  */
@@ -920,20 +1397,21 @@ const BANNER = [
920
1397
  "/_/ |_/\\__,_/ .___/\\__,_/_/|_|\\___/\\__/\\__/\\____/\\___\\_\\___\\_\\ ",
921
1398
  " /_/ "
922
1399
  ].join("\n");
923
- /** 统一错误输出(结构化日志,错误对象经 pino serializer 带堆栈)。 */
924
- function reportError(err) {
925
- let message = String(err);
926
- if (err instanceof Error) {
927
- const { message: errMessage } = err;
928
- message = errMessage;
929
- }
930
- logger.error({ err }, message);
931
- }
932
1400
  /** 构造可选 dataDir 参数(exactOptionalPropertyTypes 不允许显式 undefined)。 */
933
1401
  function withDataDir(dataDir) {
934
1402
  if (dataDir === void 0) return {};
935
1403
  return { dataDir };
936
1404
  }
1405
+ /** 构造可选 stubDir 参数(同 withDataDir)。 */
1406
+ function withStubDir(stubDir) {
1407
+ if (stubDir === void 0) return {};
1408
+ return { stubDir };
1409
+ }
1410
+ /** 构造可选 qqPath 参数(同 withDataDir)。 */
1411
+ function withQqPath(qqPath) {
1412
+ if (qqPath === void 0) return {};
1413
+ return { qqPath };
1414
+ }
937
1415
  /** 读主命令已解析的 data-dir(-d 定义在主命令,子命令经此共享)。 */
938
1416
  function rootDataDir(program) {
939
1417
  return withDataDir(program.opts().dataDir);
@@ -945,24 +1423,33 @@ function registerConfigCommands(program) {
945
1423
  try {
946
1424
  await cmdConfigInit(rootDataDir(program));
947
1425
  } catch (err) {
948
- reportError(err);
949
- process.exitCode = 1;
1426
+ handleActionError(err);
950
1427
  }
951
1428
  });
952
1429
  configCmd.command("list").description("列出全局配置与账号配置").action(async () => {
953
1430
  try {
954
1431
  await cmdConfigList(rootDataDir(program));
955
1432
  } catch (err) {
956
- reportError(err);
957
- process.exitCode = 1;
1433
+ handleActionError(err);
958
1434
  }
959
1435
  });
960
1436
  configCmd.command("apply <file>").description("应用外部配置(TOML/JSON,校验后写回全局配置)").action(async (file) => {
961
1437
  try {
962
1438
  await cmdConfigApply(file, rootDataDir(program));
963
1439
  } catch (err) {
964
- reportError(err);
965
- process.exitCode = 1;
1440
+ handleActionError(err);
1441
+ }
1442
+ });
1443
+ configCmd.command("validate").description("校验配置(--config <path> / --config-json <json> 二选一;无参 = 校验生效配置)").option("--config <path>", "待校验配置文件路径(.toml/.json 按扩展名解析)").option("--config-json <json>", "待校验内联配置 JSON 文本").action(async (opts) => {
1444
+ try {
1445
+ const result = await cmdConfigValidate({
1446
+ configPath: opts.config,
1447
+ configJson: opts.configJson,
1448
+ ...rootDataDir(program)
1449
+ });
1450
+ process.stdout.write(result.source === "inline" ? "配置校验通过(来源: inline)\n" : `配置校验通过(来源: file ${result.path})\n`);
1451
+ } catch (err) {
1452
+ handleActionError(err);
966
1453
  }
967
1454
  });
968
1455
  }
@@ -972,85 +1459,83 @@ function registerSupervisorCommand(program) {
972
1459
  try {
973
1460
  await runSupervisor(rootDataDir(program));
974
1461
  } catch (err) {
975
- reportError(err);
976
- process.exitCode = 1;
1462
+ handleActionError(err);
977
1463
  }
978
1464
  });
979
1465
  }
980
- /** 注册运维子命令(status/stop/restart,2026-09-08 T8;-d 复用主命令 option)。 */
981
- function registerOpsCommands(program) {
982
- program.command("status").description("查看各账号运行状态(读 runtime.json + 进程存活检测)").option("-q, --qq <uin>", "只看指定账号").action(async (opts) => {
983
- try {
984
- cmdStatus(resolveDataRoot(rootDataDir(program).dataDir), { ...opts.qq !== void 0 ? { qq: opts.qq } : {} });
985
- } catch (err) {
986
- reportError(err);
987
- process.exitCode = 1;
988
- }
989
- });
990
- program.command("stop").description("停止账号进程树(无 -q 停 supervisor 全部;-q 停单账号,supervisor 守护时会自动拉起)").option("-q, --qq <uin>", "目标账号(缺省停 supervisor + 全部账号)").action(async (opts) => {
991
- try {
992
- await cmdStop(resolveDataRoot(rootDataDir(program).dataDir), { ...opts.qq !== void 0 ? { qq: opts.qq } : {} });
993
- } catch (err) {
994
- reportError(err);
995
- process.exitCode = 1;
996
- }
997
- });
998
- program.command("restart").description("重启账号(无 -q 重启 supervisor 全量;-q 重启单账号;detached 拉起)").option("-q, --qq <uin>", "目标账号(缺省重启 supervisor 全量)").action(async (opts) => {
1466
+ /** 运维子命令公共壳:解析数据根 + 可选 -q 参数,统一 handleActionError 出口。 */
1467
+ function opsAction(program, run) {
1468
+ return async (opts) => {
999
1469
  try {
1000
- await cmdRestart(resolveDataRoot(rootDataDir(program).dataDir), { ...opts.qq !== void 0 ? { qq: opts.qq } : {} });
1470
+ await run(resolveDataRoot(rootDataDir(program).dataDir), opts.qq !== void 0 ? { qq: opts.qq } : {});
1001
1471
  } catch (err) {
1002
- reportError(err);
1003
- process.exitCode = 1;
1472
+ handleActionError(err);
1004
1473
  }
1005
- });
1474
+ };
1475
+ }
1476
+ /** 注册运维子命令(status/stop/restart,2026-09-08 T8;-q 复用主命令 option)。 */
1477
+ function registerOpsCommands(program) {
1478
+ program.command("status").description("查看各账号运行状态(读 runtime.json + 进程存活检测)").option("-q, --qq <uin>", "只看指定账号").action(opsAction(program, (dataRoot, arg) => cmdStatus(dataRoot, arg)));
1479
+ program.command("stop").description("停止账号进程树(无 -q 停 supervisor 全部;-q 停单账号,supervisor 守护时会自动拉起)").option("-q, --qq <uin>", "目标账号(缺省停 supervisor + 全部账号)").action(opsAction(program, (dataRoot, arg) => cmdStop(dataRoot, arg)));
1480
+ program.command("restart").description("重启账号(无 -q 重启 supervisor 全量;-q 重启单账号;detached 拉起)").option("-q, --qq <uin>", "目标账号(缺省重启 supervisor 全量)").action(opsAction(program, (dataRoot, arg) => cmdRestart(dataRoot, arg)));
1006
1481
  }
1007
1482
  /** 单账号启动分支(-q 单值;构造 bootOptions 后走 runSingleAccount)。 */
1008
1483
  async function runSingleAccountBranch(opts, qqs) {
1009
1484
  const [only] = qqs;
1010
1485
  if (only === void 0) return;
1011
- const bootOptions = { qq: only };
1012
- if (opts.dataDir !== void 0) bootOptions.dataDir = opts.dataDir;
1013
- if (opts.qqPath !== void 0) bootOptions.qqPath = opts.qqPath;
1014
- if (opts.stubDir !== void 0) bootOptions.stubDir = opts.stubDir;
1015
- await runSingleAccount(bootOptions);
1486
+ await runSingleAccount({
1487
+ qq: only,
1488
+ ...withDataDir(opts.dataDir),
1489
+ ...withQqPath(opts.qqPath),
1490
+ ...withStubDir(opts.stubDir)
1491
+ });
1016
1492
  }
1017
1493
  /** 无 -q 时:读全局配置 accounts(账号必填,2026-08-08 拍板),supervisor 拉起全部启用账号。
1018
- * 不再有「配置为空 → 交互式单账号登录」老路径:缺账号直接报错提示编辑模板。 */
1494
+ * 不再有「配置为空 → 交互式单账号登录」老路径:缺账号直接报错提示编辑模板。
1495
+ * 配置加载内联优先(NAPKETTO_CONFIG_JSON > 文件);校验失败 ConfigError → exit 78。 */
1019
1496
  async function autoStart(opts) {
1020
- if ((await loadCliConfig(resolveDataRoot(opts.dataDir))).accounts.length === 0) throw new Error("主配置没有账号,请编辑 napuketto.toml 填写 [[accounts]] 段");
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
+ }
1021
1515
  await runSupervisor({
1022
- ...opts.dataDir !== void 0 ? { dataDir: opts.dataDir } : {},
1023
- ...opts.stubDir !== void 0 ? { stubDir: opts.stubDir } : {}
1516
+ qqs,
1517
+ ...withDataDir(opts.dataDir),
1518
+ ...withStubDir(opts.stubDir)
1024
1519
  });
1025
1520
  }
1026
1521
  /** 注册主命令 action(-q 单账号 / 多 -q supervisor / 无 -q 读配置 supervisor 拉起)。 */
1027
1522
  function registerMainAction(program) {
1028
1523
  program.action(async (opts) => {
1029
1524
  try {
1030
- const qqs = opts.qq ?? [];
1031
- if (qqs.length === 0) {
1032
- await autoStart(opts);
1033
- return;
1034
- }
1035
- if (qqs.length === 1) await runSingleAccountBranch(opts, qqs);
1036
- else {
1037
- const supOpts = { qqs };
1038
- if (opts.dataDir !== void 0) supOpts.dataDir = opts.dataDir;
1039
- if (opts.stubDir !== void 0) supOpts.stubDir = opts.stubDir;
1040
- await runSupervisor(supOpts);
1041
- }
1525
+ await dispatchMainAction(opts);
1042
1526
  } catch (err) {
1043
- reportError(err);
1044
- process.exitCode = 1;
1527
+ handleActionError(err);
1045
1528
  }
1046
1529
  });
1047
1530
  }
1048
1531
  /** 入口:注册全部子命令与主命令后解析。 */
1049
1532
  function main() {
1050
- process.stdout.write(`${BANNER}\n`);
1051
1533
  const program = new Command();
1052
1534
  program.name("napuketto").version("0.0.1").description("NapukettoQQ 机器人框架(OneBot 11 兼容)");
1053
1535
  program.option("-q, --qq <uin>", "QQ 号(可重复;多个时 supervisor 拉起多账号子进程)", collectQq, []).option("-d, --data-dir <dir>", "数据根目录(缺省 <项目根>/.napuketto)").option("--qq-path <path>", "QQ 安装路径(联调覆盖)").option("--stub-dir <dir>", "stub QQNT.dll 目录(自建宿主 PATH 前置,缺省 loader/native/stub-test-env,Git Submodule)");
1536
+ program.hook("preAction", () => {
1537
+ if (resolveCliLogConfig(rootDataDir(program).dataDir).banner) process.stdout.write(`${BANNER}\n`);
1538
+ });
1054
1539
  registerConfigCommands(program);
1055
1540
  registerSupervisorCommand(program);
1056
1541
  registerOpsCommands(program);