@napuketto/loader 0.0.38 → 0.1.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.
@@ -227,6 +227,111 @@ function registerLockCleanup(dataDir) {
227
227
  });
228
228
  }
229
229
  //#endregion
230
+ //#region src/shutdown.ts
231
+ /**
232
+ * 优雅关停(2026-10-25 生命周期契约):停止哨兵 + 关停钩子 + 信号接入。
233
+ *
234
+ * 背景:Windows 下 SIGTERM 无法投递给无控制台进程(嵌入式宿主 pipe 场景),
235
+ * 既有 `napuketto stop` / 宿主只能 taskkill /T /F 树杀——被杀进程退出码 1,
236
+ * 与真实崩溃无法区分。本模块提供两层机制,三层常驻进程
237
+ * (supervisor / cli boot / self-host)共用:
238
+ *
239
+ * 1. 停止哨兵(规范化通道,宿主零 win32 依赖):向 `<数据根>/napuketto.stop`
240
+ * 写文件即请求该数据根下的进程树优雅退出。文件内容为目标账号
241
+ * (空 / "all" = 全部;"<uin>" = 单账号——`napuketto stop -q` 用)。
242
+ * 守卫:mtime 晚于进程启动才触发(陈旧哨兵不触发,守护重启不误杀),
243
+ * 每进程至多触发一次。轮询 500ms(fs.watch 跨平台创建事件不可靠)。
244
+ * 2. 信号(终端场景):SIGINT/SIGTERM/SIGBREAK(Windows CTRL_BREAK_EVENT
245
+ * 可达)→ requestShutdown,走同一关停编排。
246
+ *
247
+ * 退出码契约:0 = 请求式正常退出;非 0 = 引导失败/运行时致命。taskkill /F
248
+ * 兜底仍产出 code 1,宿主应优先用哨兵通道。
249
+ */
250
+ /** 哨兵文件名(置于数据根)。 */
251
+ const STOP_SENTINEL_FILENAME = "napuketto.stop";
252
+ /** 哨兵轮询间隔(毫秒)。 */
253
+ const SENTINEL_POLL_MS = 500;
254
+ /** 哨兵文件路径。 */
255
+ function stopSentinelPath(dataRoot) {
256
+ return (0, node_path.join)(dataRoot, STOP_SENTINEL_FILENAME);
257
+ }
258
+ /** 哨兵内容是否命中本观察者(all/空 = 全部;否则精确匹配账号)。 */
259
+ function sentinelMatches(content, account) {
260
+ const trimmed = content.trim();
261
+ if (trimmed === "" || trimmed === "all") return true;
262
+ return account !== void 0 && trimmed === account;
263
+ }
264
+ /**
265
+ * 观察停止哨兵:存在、mtime 晚于进程启动且内容命中 → onTrigger(一次性)。
266
+ * 返回取消函数。mtime 守卫:进程启动前遗留的哨兵不触发(守护重启安全)。
267
+ */
268
+ function watchStopSentinel(opts) {
269
+ const path = stopSentinelPath(opts.dataRoot);
270
+ const processStartMs = performance.timeOrigin;
271
+ let triggered = false;
272
+ const timer = setInterval(() => {
273
+ if (triggered) return;
274
+ let mtimeMs = 0;
275
+ let content = "";
276
+ try {
277
+ if (!(0, node_fs.existsSync)(path)) return;
278
+ mtimeMs = (0, node_fs.statSync)(path).mtimeMs;
279
+ content = (0, node_fs.readFileSync)(path, "utf8");
280
+ } catch {
281
+ return;
282
+ }
283
+ if (mtimeMs <= processStartMs) return;
284
+ if (sentinelMatches(content, opts.account)) {
285
+ triggered = true;
286
+ opts.onTrigger();
287
+ }
288
+ }, opts.pollMs ?? SENTINEL_POLL_MS);
289
+ timer.unref?.();
290
+ return () => {
291
+ triggered = true;
292
+ clearInterval(timer);
293
+ };
294
+ }
295
+ const shutdownHooks = [];
296
+ let shutdownStarted = false;
297
+ /** 注册关停钩子(按注册逆序执行)。 */
298
+ function registerShutdownHook(hook) {
299
+ if (!shutdownStarted) shutdownHooks.push(hook);
300
+ }
301
+ /**
302
+ * 执行关停编排:逆序跑全部钩子(单钩子异常不阻断后续)后 exit 0。
303
+ * 幂等(重复调用只跑一次)。退出码 0 = 请求式正常退出(生命周期契约)。
304
+ */
305
+ async function requestShutdown(reason) {
306
+ if (shutdownStarted) return;
307
+ shutdownStarted = true;
308
+ node_process.default.stdout.write(`[self-host] 优雅关停:${reason}\n`);
309
+ while (shutdownHooks.length > 0) {
310
+ const hook = shutdownHooks.pop();
311
+ if (hook === void 0) break;
312
+ try {
313
+ await hook();
314
+ } catch (err) {
315
+ const message = err?.message ?? String(err);
316
+ node_process.default.stdout.write(`[self-host] 关停钩子异常(继续): ${message}\n`);
317
+ }
318
+ }
319
+ node_process.default.exit(0);
320
+ }
321
+ /**
322
+ * 信号接入(SIGINT/SIGTERM/SIGBREAK → requestShutdown)。
323
+ * Windows CTRL_BREAK_EVENT 以 SIGBREAK 到达(宿主 CREATE_NEW_PROCESS_GROUP +
324
+ * GenerateConsoleCtrlEvent 可优雅停整树);终端 Ctrl+C 以 SIGINT 到达。
325
+ */
326
+ function installSignalShutdown() {
327
+ const handler = (signal) => {
328
+ requestShutdown(`信号 ${signal}`);
329
+ };
330
+ node_process.default.on("SIGINT", () => handler("SIGINT"));
331
+ node_process.default.on("SIGTERM", () => handler("SIGTERM"));
332
+ node_process.default.on("SIGBREAK", () => handler("SIGBREAK"));
333
+ }
334
+ //#endregion
230
335
  //#region src/host/env.ts
231
336
  /**
232
337
  * env.ts:引导进程环境变量访问层(2026-08-07 阶段 2)。
@@ -269,7 +374,9 @@ const env = {
269
374
  /** 全局 TOML 配置显式路径(优先级最高)。 */
270
375
  NAPKETTO_CONFIG: process.env["NAPKETTO_CONFIG"],
271
376
  /** 数据根(kernel 数据目录,cleanCache 用)。 */
272
- NAPKETTO_DATA: process.env["NAPKETTO_DATA"]
377
+ NAPKETTO_DATA: process.env["NAPKETTO_DATA"],
378
+ /** 数据根(launcher 注入,停止哨兵目录解析用)。 */
379
+ NAPUTO_DATA_ROOT: process.env["NAPUTO_DATA_ROOT"]
273
380
  };
274
381
  //#endregion
275
382
  //#region src/host/util.ts
@@ -1206,6 +1313,48 @@ function handleControl(payload, onExit, onLogin) {
1206
1313
  });
1207
1314
  }
1208
1315
  //#endregion
1316
+ //#region src/host/log-cfg.ts
1317
+ /**
1318
+ * loader 引导链日志配置解析(2026-10-25 日志可配置化)。
1319
+ *
1320
+ * 自建宿主进程(self-host → bootstrap → NapukettoCore / kernel-services)不经过
1321
+ * cli 的 loadCliConfig,这里按同一契约解析:读全局 TOML(路径优先级与
1322
+ * loadProtocolSections 一致:NAPKETTO_CONFIG > NAPUTO_CFG_DIR 兜底)的 [log] 段,
1323
+ * 经 kernel.parseLogConfig(env 覆盖在内)解析。kernel 无该导出(老版本)或
1324
+ * 配置读取失败时回退缺省——日志配置损坏不阻塞引导。
1325
+ */
1326
+ /** 缺省(kernel parseLogConfig 缺省值的最小面镜像;未配置/解析失败兜底)。 */
1327
+ function defaultLogConfig() {
1328
+ return {
1329
+ level: "info",
1330
+ banner: true,
1331
+ timestamp: "local",
1332
+ unboundReceive: true,
1333
+ levels: {}
1334
+ };
1335
+ }
1336
+ /** 解析引导链日志配置(toml [log] 段 + env 覆盖;失败回退缺省)。 */
1337
+ function resolveLoaderLogConfig(kernel) {
1338
+ if (typeof kernel.parseLogConfig !== "function") return defaultLogConfig();
1339
+ const cfgFile = env.NAPKETTO_CONFIG || (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "napuketto.toml");
1340
+ try {
1341
+ const parsed = kernel.parseToml((0, node_fs.readFileSync)(cfgFile, "utf8"));
1342
+ return kernel.parseLogConfig(parsed["log"], process.env);
1343
+ } catch {
1344
+ return defaultLogConfig();
1345
+ }
1346
+ }
1347
+ /** [log.file] 相对路径展开基准(数据根:NAPUTO_DATA_ROOT > NAPKETTO_DATA > CFG_DIR 父目录)。 */
1348
+ function logFileBaseDir() {
1349
+ if (env.NAPUTO_DATA_ROOT !== void 0 && env.NAPUTO_DATA_ROOT !== "") return env.NAPUTO_DATA_ROOT;
1350
+ if (env.NAPKETTO_DATA !== void 0 && env.NAPKETTO_DATA !== "") return env.NAPKETTO_DATA;
1351
+ return env.NAPUTO_CFG_DIR !== void 0 && env.NAPUTO_CFG_DIR !== "" ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "..") : ".";
1352
+ }
1353
+ /** [log.file] path → 绝对路径。 */
1354
+ function resolveLogFilePath(path) {
1355
+ return (0, node_path.isAbsolute)(path) ? path : (0, node_path.join)(logFileBaseDir(), path);
1356
+ }
1357
+ //#endregion
1209
1358
  //#region src/host/core/login.ts
1210
1359
  /** 打印可用快速登录账号 + 返回目标账号(启动横幅)。forcedUin(cli `-q` / NAPUTO_QUICK_UIN)优先。 */
1211
1360
  async function pickLoginAccount(kernel, ctx, loginResultRef, forcedUin) {
@@ -1351,7 +1500,7 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1351
1500
  groupChannel: services.groupChannel,
1352
1501
  friendChannel: services.friendChannel,
1353
1502
  buddyCacheEvents: services.buddyCache?.events,
1354
- logger: services.logger,
1503
+ logger: services.calibLogger,
1355
1504
  msgApi: services.msgApi,
1356
1505
  groupApi: services.groupApi,
1357
1506
  groupNotifyApi: services.groupNotifyApi,
@@ -1420,6 +1569,7 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1420
1569
  msgChannel: channel,
1421
1570
  msgApi: services.msgApi,
1422
1571
  self: services.self,
1572
+ receiveFilter: services.receiveFilter,
1423
1573
  logger: services.logger
1424
1574
  });
1425
1575
  await kurobot.start();
@@ -1471,21 +1621,29 @@ function formatMessageLog(kernel, msg) {
1471
1621
  const content = rendered.replace(/\n/g, "\n ");
1472
1622
  return {
1473
1623
  plain: `接收 <- ${kind} ${plainTags}: ${content}`,
1474
- colored: `${ANSI.gray}loader | 接收 <- ${kind}${ANSI.reset} ${coloredTags}: ${content}`
1624
+ colored: `${ANSI.gray}loader | 接收 <- ${kind}${ANSI.reset} ${coloredTags}: ${content}`,
1625
+ isGroup,
1626
+ peerUin
1475
1627
  };
1476
1628
  }
1477
1629
  /**
1478
1630
  * 消息日志订阅(onRecvMsg 回调参数为消息数组——2026-08-07 运行时实证,遍历逐条打印)。
1479
1631
  * 返回退订函数(软重登重装配时清理旧订阅,2026-09-08);通道返回面宽松
1480
1632
  * (EventChannelLike.on 返回 unknown),非函数时退化为 no-op。
1633
+ *
1634
+ * 2026-10-25 级别重分配:接收行 info → debug(运行状态无信息量的高频行;
1635
+ * 终端要看配 [log.levels] loader = "debug")。
1481
1636
  */
1482
- function setupMsgLogging(kernel, channel, logger, colorize) {
1637
+ function setupMsgLogging(kernel, channel, logger, colorize, receiveFilter) {
1483
1638
  const off = channel.on("Msg/onRecvMsg", (msgs) => {
1484
1639
  forEachRawMessage(msgs, (m) => {
1485
1640
  try {
1486
- const { plain, colored } = formatMessageLog(kernel, m);
1641
+ const { plain, colored, isGroup, peerUin } = formatMessageLog(kernel, m);
1642
+ if (receiveFilter?.current !== null && receiveFilter !== void 0) {
1643
+ if (!receiveFilter.current(peerUin, isGroup)) return;
1644
+ }
1487
1645
  log(plain);
1488
- logger?.info(colorize ? colored : plain);
1646
+ logger?.debug(colorize ? colored : plain);
1489
1647
  } catch (e) {
1490
1648
  const line2 = `接收消息(解析失败: ${errMsg(e)})`;
1491
1649
  log(line2);
@@ -1504,15 +1662,37 @@ function setupMsgLogging(kernel, channel, logger, colorize) {
1504
1662
  * IPC 模式(koishi 插件)经此拿服务装配 ipc-server;非 IPC(cli)经此拿服务
1505
1663
  * 装配 OB11/Satori 适配器。
1506
1664
  */
1665
+ /** 静默 logger(kernel.createLogger 不可用时的兜底)。 */
1666
+ const NOOP_LOGGER = {
1667
+ info: () => {},
1668
+ warn: () => {},
1669
+ error: () => {},
1670
+ debug: () => {}
1671
+ };
1672
+ /** loader logger 构造参数([log] 段 + IPC/文件路径折叠;直传 undefined,免条件展开)。 */
1673
+ function buildLoaderLoggerOptions(logCfg, consoleEnabled, logFile, account) {
1674
+ const cfg = logCfg ?? defaultLogConfig();
1675
+ return {
1676
+ console: consoleEnabled,
1677
+ file: logFile,
1678
+ fileLevel: cfg.file?.level,
1679
+ level: cfg.levels.loader ?? cfg.level,
1680
+ format: cfg.format,
1681
+ timestamp: cfg.timestamp,
1682
+ colorize: cfg.colorize,
1683
+ base: {
1684
+ name: "loader",
1685
+ account
1686
+ }
1687
+ };
1688
+ }
1507
1689
  /** 登录成功后创建 kernel 业务服务(channel/bridge/cache/apis)。失败返回 null。 */
1508
- async function createKernelServices(kernel, ctx, loginResult) {
1690
+ async function createKernelServices(kernel, ctx, loginResult, logCfg) {
1509
1691
  const ipcMode = env.NAPUTO_IPC === "1";
1510
- const logFile = env.NAPUTO_CFG_DIR !== void 0 ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "logs", "loader.log") : (0, node_path.join)((0, node_os.tmpdir)(), "napuketto-loader.log");
1511
- const logger = kernel.createLogger?.({
1512
- console: !ipcMode,
1513
- file: logFile,
1514
- base: { name: "loader" }
1515
- });
1692
+ const logFile = logCfg?.file !== void 0 ? resolveLogFilePath(logCfg.file.path) : env.NAPUTO_CFG_DIR !== void 0 ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "logs", "loader.log") : (0, node_path.join)((0, node_os.tmpdir)(), "napuketto-loader.log");
1693
+ const logger = kernel.createLogger?.(buildLoaderLoggerOptions(logCfg, !ipcMode, logFile, loginResult.uin));
1694
+ const ob11Level = logCfg?.levels.ob11;
1695
+ const calibLogger = logger !== void 0 && typeof logger.child === "function" ? ob11Level !== void 0 ? logger.child({ module: "ob11" }, { level: ob11Level }) : logger.child({ module: "ob11" }) : logger;
1516
1696
  const session = ctx.session;
1517
1697
  if (!session) {
1518
1698
  log("bootstrap: session 为空,无法创建 kernel 服务");
@@ -1521,7 +1701,8 @@ async function createKernelServices(kernel, ctx, loginResult) {
1521
1701
  const channel = new kernel.NTEventChannel("Msg");
1522
1702
  const bridge = new kernel.MsgBridge(session, channel);
1523
1703
  bridge.register();
1524
- const offMsgLogging = setupMsgLogging(kernel, channel, logger, !ipcMode);
1704
+ const receiveFilter = { current: null };
1705
+ const offMsgLogging = setupMsgLogging(kernel, channel, logger, !ipcMode && (logCfg?.colorize ?? true), logCfg?.unboundReceive === false ? receiveFilter : void 0);
1525
1706
  const groupApi = new kernel.GroupApi(session);
1526
1707
  const util = ctx.exports?.NodeQQNTWrapperUtil;
1527
1708
  const msgApi = new kernel.MsgApi(session, channel, util);
@@ -1581,6 +1762,8 @@ async function createKernelServices(kernel, ctx, loginResult) {
1581
1762
  uin: loginResult.uin,
1582
1763
  nickname: loginResult.nick ?? ""
1583
1764
  },
1765
+ calibLogger: calibLogger ?? NOOP_LOGGER,
1766
+ receiveFilter,
1584
1767
  session,
1585
1768
  engine: ctx.engine,
1586
1769
  util: ctx.exports?.NodeQQNTWrapperUtil,
@@ -1611,10 +1794,11 @@ async function createKernelServices(kernel, ctx, loginResult) {
1611
1794
  *
1612
1795
  * 返回 null = 服务未装配(session 缺失 / OB11/Satori 装配失败)——调用方据此判
1613
1796
  * 引导失败。env 未注入 adapter/network 入口时「跳过装配」仍返回服务(合法运行形态,
1614
- * 如纯 kernel 探测),不算失败。
1797
+ * 如纯 kernel 探测),不算失败。logCfg 为日志配置(loader logger 构造与接收行
1798
+ * 过滤用,2026-10-25)。
1615
1799
  */
1616
- async function startProtocols(kernel, ctx, loginResult) {
1617
- const services = await createKernelServices(kernel, ctx, loginResult);
1800
+ async function startProtocols(kernel, ctx, loginResult, logCfg) {
1801
+ const services = await createKernelServices(kernel, ctx, loginResult, logCfg);
1618
1802
  if (services === null) return null;
1619
1803
  if (env.NAPUTO_IPC === "1") {
1620
1804
  log("bootstrap: IPC 模式,跳过 OB11/Satori 装配(koishi 插件直接消费 kernel 服务)");
@@ -2155,17 +2339,30 @@ function runProbePhase(kernel, ctx) {
2155
2339
  log(`bootstrap: probe error: ${errMsg(e)}`);
2156
2340
  }
2157
2341
  }
2342
+ /** NapukettoCore.create 选项([log] 段 + env 折叠;IPC 模式关 console)。 */
2343
+ function buildCoreCreateOptions(logCfg, bootEnv) {
2344
+ return {
2345
+ paths: { dataRoot: bootEnv.dataDir },
2346
+ logLevel: logCfg.levels.kernel ?? logCfg.level,
2347
+ ...logCfg.format !== void 0 ? { logFormat: logCfg.format } : {},
2348
+ ...logCfg.timestamp !== void 0 ? { logTimestamp: logCfg.timestamp } : {},
2349
+ ...logCfg.colorize !== void 0 ? { logColorize: logCfg.colorize } : {},
2350
+ ...logCfg.file !== void 0 ? {
2351
+ logFile: resolveLogFilePath(logCfg.file.path),
2352
+ logFileLevel: logCfg.file.level
2353
+ } : {},
2354
+ ...env.NAPUTO_QUICK_UIN !== void 0 ? { account: env.NAPUTO_QUICK_UIN } : {},
2355
+ ...env.NAPUTO_IPC === "1" ? { consoleLog: false } : {}
2356
+ };
2357
+ }
2158
2358
  /** NapukettoCore 装配路径:create → attachWrapper → 登录 → 激活 → 协议 → 探测。
2159
2359
  * @returns 引导是否完整完成(协议服务装配成功 = true;登录失败/服务装配失败等
2160
2360
  * 静默收敛为 false,具体原因见 napuketto-boot.log)。 */
2161
2361
  async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
2362
+ const logCfg = resolveLoaderLogConfig(kernel);
2162
2363
  const coreCtor = kernel.NapukettoCore;
2163
2364
  if (coreCtor === void 0) return false;
2164
- const core = coreCtor.create({
2165
- paths: { dataRoot: bootEnv.dataDir },
2166
- logLevel: "info",
2167
- ...env.NAPUTO_IPC === "1" ? { consoleLog: false } : {}
2168
- });
2365
+ const core = coreCtor.create(buildCoreCreateOptions(logCfg, bootEnv));
2169
2366
  let ipcActions = null;
2170
2367
  const preempt = { resolve: null };
2171
2368
  const preemptLogin = new Promise((resolve) => {
@@ -2192,6 +2389,7 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
2192
2389
  let services = null;
2193
2390
  let stopIpcServices = null;
2194
2391
  let stopOb11Bridge = null;
2392
+ let shutdownHooksRegistered = false;
2195
2393
  /**
2196
2394
  * 装配链尾部(登录成功后;初次引导与软重登共用)。
2197
2395
  * @returns 装配是否成功(协议服务未装配 = false,调用方判引导失败)。
@@ -2202,7 +2400,7 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
2202
2400
  await waitSessionReady(kernel, ctx);
2203
2401
  if (env.NAPUTO_IPC === "1") sendStatus("sessioning");
2204
2402
  if (initial) await runSmokeIfEnabled(kernel, ctx, loginResult);
2205
- const newServices = await startProtocols(kernel, ctx, loginResult);
2403
+ const newServices = await startProtocols(kernel, ctx, loginResult, logCfg);
2206
2404
  if (newServices === null) {
2207
2405
  log("bootstrap: 协议服务装配失败,引导中止");
2208
2406
  return false;
@@ -2212,6 +2410,19 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
2212
2410
  stopOb11Bridge = await attachOb11IpcBridge(ipcActions, newServices);
2213
2411
  }
2214
2412
  services = newServices;
2413
+ if (!shutdownHooksRegistered) {
2414
+ shutdownHooksRegistered = true;
2415
+ registerShutdownHook(() => {
2416
+ services?.dispose();
2417
+ });
2418
+ registerShutdownHook(() => {
2419
+ stopIpcServices?.();
2420
+ stopOb11Bridge?.();
2421
+ });
2422
+ registerShutdownHook(() => {
2423
+ return services?.stopAdapters?.();
2424
+ });
2425
+ }
2215
2426
  return true;
2216
2427
  };
2217
2428
  /**
@@ -2422,6 +2633,17 @@ async function bootstrap(state) {
2422
2633
  * → kernel 装配 → 登录(快速/QR)→ session(先 init 后 start)→ 冒烟 → 协议装配
2423
2634
  */
2424
2635
  const state = createState();
2636
+ installSignalShutdown();
2637
+ {
2638
+ const dataRoot = env.NAPUTO_DATA_ROOT;
2639
+ if (dataRoot !== void 0 && dataRoot !== "") watchStopSentinel({
2640
+ dataRoot,
2641
+ account: env.NAPUTO_CFG_DIR !== void 0 && env.NAPUTO_CFG_DIR !== "" ? env.NAPUTO_CFG_DIR.split(/[\\/]/).filter(Boolean).pop() ?? "default" : "default",
2642
+ onTrigger: () => {
2643
+ requestShutdown("停止哨兵");
2644
+ }
2645
+ });
2646
+ }
2425
2647
  const ipcMode = env.NAPUTO_IPC === "1";
2426
2648
  if (ipcMode) {
2427
2649
  enableIpc();
package/dist/index.cjs CHANGED
@@ -209,11 +209,13 @@ const IpcMessageSchema = zod.z.discriminatedUnion("type", [
209
209
  * 失败的加载警告(无害,纯噪音)
210
210
  * - `loaded [mmkv.*] with N key-values`:MMKV 初始化完成行(野生日志,风格三,
211
211
  * 无统一前缀/时间戳,过滤)
212
+ * - `:N::loadFromFile> loading [mmkv.*] with crc`:MMKV 载入变体行
213
+ * (2026-10-25 补:`loading` 拼写与 `loaded` 并存,此前漏过滤外泄到宿主控制台)
212
214
  *
213
215
  * 边界:只处理「整条」噪音行;原生输出插进 JSON 协议行字节中间的撕裂不归这里管
214
216
  * (IPC 路径由 status 重播与脏行诊断兜底,见 ipc-sender.ts / koishi 插件 client)。
215
217
  */
216
- const NATIVE_NOISE = /<MMKV|<MemoryFile_Win32|<MMKV_IO|loadSymbolFromShell|getNodeGetJsListApi|get symbol failed|loaded \[mmkv/i;
218
+ const NATIVE_NOISE = /<MMKV|<MemoryFile_Win32|<MMKV_IO|loadSymbolFromShell|getNodeGetJsListApi|get symbol failed|loaded \[mmkv|loadFromFile> loading \[mmkv/i;
217
219
  /** IPC 协议行前缀(encodeIpcMessage 走 JSON.stringify 无空白,前缀恒定)。 */
218
220
  const IPC_LINE_PREFIX = `{"v":1`;
219
221
  /**
@@ -1121,7 +1123,8 @@ function buildLaunchEnv(options, useWine) {
1121
1123
  ...options.configPath !== void 0 ? { [ENV.CONFIG_PATH]: p((0, node_path.resolve)(options.configPath)) } : {},
1122
1124
  ...options.quickUin !== void 0 ? { [ENV.QUICK_UIN]: options.quickUin } : {},
1123
1125
  ...options.qrOnly === true ? { [ENV.QR_ONLY]: "1" } : {},
1124
- ...options.ipc === true ? { [ENV.IPC]: "1" } : {}
1126
+ ...options.ipc === true ? { [ENV.IPC]: "1" } : {},
1127
+ ...options.extraEnv !== void 0 ? options.extraEnv : {}
1125
1128
  };
1126
1129
  return {
1127
1130
  ...env,
@@ -1150,7 +1153,9 @@ const ENV = {
1150
1153
  /** 强制扫码登录(跳过快速登录直接 QR;koishi 面板「扫码登录」重启路径用)。 */
1151
1154
  QR_ONLY: "NAPUTO_QR_ONLY",
1152
1155
  /** IPC 子进程模式(koishi 插件驱动:stdout JSON 行 + stdin action/control)。 */
1153
- IPC: "NAPUTO_IPC"
1156
+ IPC: "NAPUTO_IPC",
1157
+ /** 数据根(cli 注入:停止哨兵目录解析用,self-host 不再从 CFG_DIR 反推)。 */
1158
+ DATA_ROOT: "NAPUTO_DATA_ROOT"
1154
1159
  };
1155
1160
  //#endregion
1156
1161
  //#region src/locate-qq.ts
@@ -1394,6 +1399,125 @@ async function ensureQqFiles(options = {}) {
1394
1399
  }
1395
1400
  }
1396
1401
  //#endregion
1402
+ //#region src/shutdown.ts
1403
+ /**
1404
+ * 优雅关停(2026-10-25 生命周期契约):停止哨兵 + 关停钩子 + 信号接入。
1405
+ *
1406
+ * 背景:Windows 下 SIGTERM 无法投递给无控制台进程(嵌入式宿主 pipe 场景),
1407
+ * 既有 `napuketto stop` / 宿主只能 taskkill /T /F 树杀——被杀进程退出码 1,
1408
+ * 与真实崩溃无法区分。本模块提供两层机制,三层常驻进程
1409
+ * (supervisor / cli boot / self-host)共用:
1410
+ *
1411
+ * 1. 停止哨兵(规范化通道,宿主零 win32 依赖):向 `<数据根>/napuketto.stop`
1412
+ * 写文件即请求该数据根下的进程树优雅退出。文件内容为目标账号
1413
+ * (空 / "all" = 全部;"<uin>" = 单账号——`napuketto stop -q` 用)。
1414
+ * 守卫:mtime 晚于进程启动才触发(陈旧哨兵不触发,守护重启不误杀),
1415
+ * 每进程至多触发一次。轮询 500ms(fs.watch 跨平台创建事件不可靠)。
1416
+ * 2. 信号(终端场景):SIGINT/SIGTERM/SIGBREAK(Windows CTRL_BREAK_EVENT
1417
+ * 可达)→ requestShutdown,走同一关停编排。
1418
+ *
1419
+ * 退出码契约:0 = 请求式正常退出;非 0 = 引导失败/运行时致命。taskkill /F
1420
+ * 兜底仍产出 code 1,宿主应优先用哨兵通道。
1421
+ */
1422
+ /** 哨兵文件名(置于数据根)。 */
1423
+ const STOP_SENTINEL_FILENAME = "napuketto.stop";
1424
+ /** 哨兵轮询间隔(毫秒)。 */
1425
+ const SENTINEL_POLL_MS = 500;
1426
+ /** 哨兵文件路径。 */
1427
+ function stopSentinelPath(dataRoot) {
1428
+ return (0, node_path.join)(dataRoot, STOP_SENTINEL_FILENAME);
1429
+ }
1430
+ /**
1431
+ * 写停止哨兵(请求优雅关停)。target = "all"/空 = 全部账号;"<uin>" = 单账号。
1432
+ * 任何内容(含空)都视为请求;mtime 由写入时刻决定。
1433
+ */
1434
+ function writeStopSentinel(dataRoot, target) {
1435
+ (0, node_fs.writeFileSync)(stopSentinelPath(dataRoot), target === "all" ? "all\n" : `${target}\n`, "utf8");
1436
+ }
1437
+ /** 删除停止哨兵(napuketto stop 等待结束后清理,防陈旧文件累积)。 */
1438
+ function removeStopSentinel(dataRoot) {
1439
+ const path = stopSentinelPath(dataRoot);
1440
+ if ((0, node_fs.existsSync)(path)) try {
1441
+ (0, node_fs.rmSync)(path);
1442
+ } catch {}
1443
+ }
1444
+ /** 哨兵内容是否命中本观察者(all/空 = 全部;否则精确匹配账号)。 */
1445
+ function sentinelMatches(content, account) {
1446
+ const trimmed = content.trim();
1447
+ if (trimmed === "" || trimmed === "all") return true;
1448
+ return account !== void 0 && trimmed === account;
1449
+ }
1450
+ /**
1451
+ * 观察停止哨兵:存在、mtime 晚于进程启动且内容命中 → onTrigger(一次性)。
1452
+ * 返回取消函数。mtime 守卫:进程启动前遗留的哨兵不触发(守护重启安全)。
1453
+ */
1454
+ function watchStopSentinel(opts) {
1455
+ const path = stopSentinelPath(opts.dataRoot);
1456
+ const processStartMs = performance.timeOrigin;
1457
+ let triggered = false;
1458
+ const timer = setInterval(() => {
1459
+ if (triggered) return;
1460
+ let mtimeMs = 0;
1461
+ let content = "";
1462
+ try {
1463
+ if (!(0, node_fs.existsSync)(path)) return;
1464
+ mtimeMs = (0, node_fs.statSync)(path).mtimeMs;
1465
+ content = (0, node_fs.readFileSync)(path, "utf8");
1466
+ } catch {
1467
+ return;
1468
+ }
1469
+ if (mtimeMs <= processStartMs) return;
1470
+ if (sentinelMatches(content, opts.account)) {
1471
+ triggered = true;
1472
+ opts.onTrigger();
1473
+ }
1474
+ }, opts.pollMs ?? SENTINEL_POLL_MS);
1475
+ timer.unref?.();
1476
+ return () => {
1477
+ triggered = true;
1478
+ clearInterval(timer);
1479
+ };
1480
+ }
1481
+ const shutdownHooks = [];
1482
+ let shutdownStarted = false;
1483
+ /** 注册关停钩子(按注册逆序执行)。 */
1484
+ function registerShutdownHook(hook) {
1485
+ if (!shutdownStarted) shutdownHooks.push(hook);
1486
+ }
1487
+ /**
1488
+ * 执行关停编排:逆序跑全部钩子(单钩子异常不阻断后续)后 exit 0。
1489
+ * 幂等(重复调用只跑一次)。退出码 0 = 请求式正常退出(生命周期契约)。
1490
+ */
1491
+ async function requestShutdown(reason) {
1492
+ if (shutdownStarted) return;
1493
+ shutdownStarted = true;
1494
+ node_process.default.stdout.write(`[self-host] 优雅关停:${reason}\n`);
1495
+ while (shutdownHooks.length > 0) {
1496
+ const hook = shutdownHooks.pop();
1497
+ if (hook === void 0) break;
1498
+ try {
1499
+ await hook();
1500
+ } catch (err) {
1501
+ const message = err?.message ?? String(err);
1502
+ node_process.default.stdout.write(`[self-host] 关停钩子异常(继续): ${message}\n`);
1503
+ }
1504
+ }
1505
+ node_process.default.exit(0);
1506
+ }
1507
+ /**
1508
+ * 信号接入(SIGINT/SIGTERM/SIGBREAK → requestShutdown)。
1509
+ * Windows CTRL_BREAK_EVENT 以 SIGBREAK 到达(宿主 CREATE_NEW_PROCESS_GROUP +
1510
+ * GenerateConsoleCtrlEvent 可优雅停整树);终端 Ctrl+C 以 SIGINT 到达。
1511
+ */
1512
+ function installSignalShutdown() {
1513
+ const handler = (signal) => {
1514
+ requestShutdown(`信号 ${signal}`);
1515
+ };
1516
+ node_process.default.on("SIGINT", () => handler("SIGINT"));
1517
+ node_process.default.on("SIGTERM", () => handler("SIGTERM"));
1518
+ node_process.default.on("SIGBREAK", () => handler("SIGBREAK"));
1519
+ }
1520
+ //#endregion
1397
1521
  exports.DownloadError = DownloadError;
1398
1522
  exports.ENV = ENV;
1399
1523
  exports.INSTANCE_LOCK_FILE = INSTANCE_LOCK_FILE;
@@ -1410,6 +1534,7 @@ exports.IpcResultPayloadSchema = IpcResultPayloadSchema;
1410
1534
  exports.IpcStatusPayloadSchema = IpcStatusPayloadSchema;
1411
1535
  exports.QQ_FILES_DIR_NAME = QQ_FILES_DIR_NAME;
1412
1536
  exports.QqReleasesError = QqReleasesError;
1537
+ exports.STOP_SENTINEL_FILENAME = STOP_SENTINEL_FILENAME;
1413
1538
  exports.WIN_NODE_DIR_NAME = WIN_NODE_DIR_NAME;
1414
1539
  exports.acquireInstanceLock = acquireInstanceLock;
1415
1540
  exports.buildSpawnCommand = buildSpawnCommand;
@@ -1423,6 +1548,7 @@ exports.ensureWinNode = ensureWinNode;
1423
1548
  exports.extractInstaller = extractInstaller;
1424
1549
  exports.extractWrapperFiles = extractWrapperFiles;
1425
1550
  exports.findSevenZip = findSevenZip;
1551
+ exports.installSignalShutdown = installSignalShutdown;
1426
1552
  exports.isLinux = isLinux;
1427
1553
  exports.isNativeNoiseLine = isNativeNoiseLine;
1428
1554
  exports.latestRelease = latestRelease;
@@ -1433,9 +1559,15 @@ exports.locateQqPath = locateQqPath;
1433
1559
  exports.nodeZipUrl = nodeZipUrl;
1434
1560
  exports.readCmdlineForPid = readCmdlineForPid;
1435
1561
  exports.registerLockCleanup = registerLockCleanup;
1562
+ exports.registerShutdownHook = registerShutdownHook;
1436
1563
  exports.releaseInstanceLock = releaseInstanceLock;
1564
+ exports.removeStopSentinel = removeStopSentinel;
1565
+ exports.requestShutdown = requestShutdown;
1437
1566
  exports.resolveDownloadUrl = resolveDownloadUrl;
1438
1567
  exports.resolveQqFiles = resolveQqFiles;
1439
1568
  exports.resolveQqInstall = resolveQqInstall;
1569
+ exports.stopSentinelPath = stopSentinelPath;
1440
1570
  exports.toWinePath = toWinePath;
1571
+ exports.watchStopSentinel = watchStopSentinel;
1441
1572
  exports.wineBinary = wineBinary;
1573
+ exports.writeStopSentinel = writeStopSentinel;
package/dist/index.d.cts CHANGED
@@ -572,6 +572,12 @@ interface LaunchOptions {
572
572
  * 查找失败警告),其余转发——原生 printf 直写 fd 的字节无法从 JS 层拦截。
573
573
  */
574
574
  stdio?: StdioOptions;
575
+ /**
576
+ * 追加注入子进程的环境变量(覆盖同名继承值;值按原样传递,不转 wine 路径)。
577
+ * cli 用:传播有效控制台渲染决策(NAPUKETTO_LOG_FORMAT/COLORIZE——子进程
578
+ * stdout 恒为 pipe,TTY 判定失真)与数据根(NAPUTO_DATA_ROOT,停止哨兵用)。
579
+ */
580
+ extraEnv?: Record<string, string>;
575
581
  /**
576
582
  * 阶段回调(stub 校验/win-node 下载/spawn 提示,2026-08-23 起——下载
577
583
  * 313MB 安装包与 win-node 全程静默的坑)。调用方接 logger.info。
@@ -624,6 +630,8 @@ declare const ENV: {
624
630
  readonly QR_ONLY: "NAPUTO_QR_ONLY";
625
631
  /** IPC 子进程模式(koishi 插件驱动:stdout JSON 行 + stdin action/control)。 */
626
632
  readonly IPC: "NAPUTO_IPC";
633
+ /** 数据根(cli 注入:停止哨兵目录解析用,self-host 不再从 CFG_DIR 反推)。 */
634
+ readonly DATA_ROOT: "NAPUTO_DATA_ROOT";
627
635
  };
628
636
  //#endregion
629
637
  //#region src/pid-cmdline.d.ts
@@ -735,6 +743,50 @@ declare function latestRelease(releases: QqReleasesFile): QqReleaseEntry;
735
743
  */
736
744
  declare function resolveDownloadUrl(release: QqReleaseEntry): string;
737
745
  //#endregion
746
+ //#region src/shutdown.d.ts
747
+ /** 哨兵文件名(置于数据根)。 */
748
+ declare const STOP_SENTINEL_FILENAME = "napuketto.stop";
749
+ /** 哨兵文件路径。 */
750
+ declare function stopSentinelPath(dataRoot: string): string;
751
+ /**
752
+ * 写停止哨兵(请求优雅关停)。target = "all"/空 = 全部账号;"<uin>" = 单账号。
753
+ * 任何内容(含空)都视为请求;mtime 由写入时刻决定。
754
+ */
755
+ declare function writeStopSentinel(dataRoot: string, target: "all" | string): void;
756
+ /** 删除停止哨兵(napuketto stop 等待结束后清理,防陈旧文件累积)。 */
757
+ declare function removeStopSentinel(dataRoot: string): void;
758
+ /** 哨兵观察选项。 */
759
+ interface StopSentinelWatchOptions {
760
+ /** 数据根(哨兵所在目录)。 */
761
+ dataRoot: string;
762
+ /** 本进程对应账号(uin 或 "default";undefined = 仅响应 all/空)。 */
763
+ account?: string;
764
+ /** 触发回调(每进程至多一次)。 */
765
+ onTrigger: () => void;
766
+ /** 轮询间隔(测试注入用)。 */
767
+ pollMs?: number;
768
+ }
769
+ /**
770
+ * 观察停止哨兵:存在、mtime 晚于进程启动且内容命中 → onTrigger(一次性)。
771
+ * 返回取消函数。mtime 守卫:进程启动前遗留的哨兵不触发(守护重启安全)。
772
+ */
773
+ declare function watchStopSentinel(opts: StopSentinelWatchOptions): () => void;
774
+ /** 关停钩子(自建宿主引导链注册:协议适配器停止 / 桥退订等)。 */
775
+ type ShutdownHook = () => void | Promise<void>;
776
+ /** 注册关停钩子(按注册逆序执行)。 */
777
+ declare function registerShutdownHook(hook: ShutdownHook): void;
778
+ /**
779
+ * 执行关停编排:逆序跑全部钩子(单钩子异常不阻断后续)后 exit 0。
780
+ * 幂等(重复调用只跑一次)。退出码 0 = 请求式正常退出(生命周期契约)。
781
+ */
782
+ declare function requestShutdown(reason: string): Promise<void>;
783
+ /**
784
+ * 信号接入(SIGINT/SIGTERM/SIGBREAK → requestShutdown)。
785
+ * Windows CTRL_BREAK_EVENT 以 SIGBREAK 到达(宿主 CREATE_NEW_PROCESS_GROUP +
786
+ * GenerateConsoleCtrlEvent 可优雅停整树);终端 Ctrl+C 以 SIGINT 到达。
787
+ */
788
+ declare function installSignalShutdown(): void;
789
+ //#endregion
738
790
  //#region src/wine.d.ts
739
791
  /**
740
792
  * wine.ts:Linux 上经 wine 运行 Windows 版 node.exe 的路径与 spawn 组装(P2)。
@@ -772,5 +824,5 @@ declare function buildSpawnCommand(options: {
772
824
  selfHostPath: string;
773
825
  }): SpawnCommand;
774
826
  //#endregion
775
- export { DownloadError, type DownloadOptions, type DownloadResult, ENV, type ExtractOptions, INSTANCE_LOCK_FILE, IPC_VERSION, type InstanceLockCheck, type InstanceLockInfo, type IpcActionMessage, type IpcActionPayload, IpcActionPayloadSchema, type IpcBootPhase, IpcBootPhaseSchema, type IpcControlMessage, type IpcControlPayload, IpcControlPayloadSchema, type IpcEventMessage, type IpcEventPayload, IpcEventPayloadSchema, type IpcLogLevel, IpcLogLevelSchema, type IpcLogMessage, type IpcLogPayload, IpcLogPayloadSchema, type IpcLoginMessage, type IpcLoginPayload, IpcLoginPayloadSchema, type IpcMessage, IpcMessageSchema, type IpcPingMessage, type IpcPongMessage, type IpcQrMessage, type IpcResultMessage, type IpcResultPayload, IpcResultPayloadSchema, type IpcStatusMessage, type IpcStatusPayload, IpcStatusPayloadSchema, type LaunchOptions, type LaunchResult, QQ_FILES_DIR_NAME, type QqFileSource, type QqInstallInfo, type QqReleaseEntry, QqReleasesError, type QqReleasesFile, type ResolveQqFilesOptions, type SevenZipResult, type SpawnCommand, WIN_NODE_DIR_NAME, type WinNodeInfo, acquireInstanceLock, buildSpawnCommand, checkInstanceLock, clearCacheVersion, defaultStubDir, downloadFile, ensureLinuxSevenZip, ensureQqFiles, ensureWinNode, extractInstaller, extractWrapperFiles, findSevenZip, isLinux, isNativeNoiseLine, latestRelease, launchSelfHost, linuxSevenZipUrl, loadQqReleases, locateQqPath, nodeZipUrl, readCmdlineForPid, registerLockCleanup, releaseInstanceLock, resolveDownloadUrl, resolveQqFiles, resolveQqInstall, toWinePath, wineBinary };
827
+ export { DownloadError, type DownloadOptions, type DownloadResult, ENV, type ExtractOptions, INSTANCE_LOCK_FILE, IPC_VERSION, type InstanceLockCheck, type InstanceLockInfo, type IpcActionMessage, type IpcActionPayload, IpcActionPayloadSchema, type IpcBootPhase, IpcBootPhaseSchema, type IpcControlMessage, type IpcControlPayload, IpcControlPayloadSchema, type IpcEventMessage, type IpcEventPayload, IpcEventPayloadSchema, type IpcLogLevel, IpcLogLevelSchema, type IpcLogMessage, type IpcLogPayload, IpcLogPayloadSchema, type IpcLoginMessage, type IpcLoginPayload, IpcLoginPayloadSchema, type IpcMessage, IpcMessageSchema, type IpcPingMessage, type IpcPongMessage, type IpcQrMessage, type IpcResultMessage, type IpcResultPayload, IpcResultPayloadSchema, type IpcStatusMessage, type IpcStatusPayload, IpcStatusPayloadSchema, type LaunchOptions, type LaunchResult, QQ_FILES_DIR_NAME, type QqFileSource, type QqInstallInfo, type QqReleaseEntry, QqReleasesError, type QqReleasesFile, type ResolveQqFilesOptions, STOP_SENTINEL_FILENAME, type SevenZipResult, type SpawnCommand, type StopSentinelWatchOptions, WIN_NODE_DIR_NAME, type WinNodeInfo, acquireInstanceLock, buildSpawnCommand, checkInstanceLock, clearCacheVersion, defaultStubDir, downloadFile, ensureLinuxSevenZip, ensureQqFiles, ensureWinNode, extractInstaller, extractWrapperFiles, findSevenZip, installSignalShutdown, isLinux, isNativeNoiseLine, latestRelease, launchSelfHost, linuxSevenZipUrl, loadQqReleases, locateQqPath, nodeZipUrl, readCmdlineForPid, registerLockCleanup, registerShutdownHook, releaseInstanceLock, removeStopSentinel, requestShutdown, resolveDownloadUrl, resolveQqFiles, resolveQqInstall, stopSentinelPath, toWinePath, watchStopSentinel, wineBinary, writeStopSentinel };
776
828
  //# sourceMappingURL=index.d.cts.map