From 5a2530095eae974d04acb9168c51018036ed7f99 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Mon, 28 Sep 2026 18:24:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20=E6=96=B0=E5=A2=9E=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E8=BF=9B=E7=A8=8B=E7=9B=91=E7=AE=A1=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AF=E5=88=86=E7=A6=BB=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退出 CLI 不再停服,运维命令经命名管道 RPC 转发到 daemon;移除 bds_updater 崩溃自启配置,由 daemon 固定拉起 BDS。 --- .changeset/cli-daemon-supervisor.md | 8 + docs/zh/guide/config.md | 25 +- .../@sfmc-sdk/schemas/bds_updater.schema.json | 14 +- .../sdk/@sfmc-sdk/src/node/config/index.ts | 5 - packages/bds-tools/src/bds-manager.ts | 10 +- packages/bds-tools/src/check-update.ts | 2 +- packages/bds-tools/src/types.ts | 3 - packages/cli/src/command-surface.ts | 2 + packages/cli/src/commands.ts | 80 +++- packages/cli/src/daemon/client.ts | 354 ++++++++++++++++++ packages/cli/src/daemon/paths.ts | 103 +++++ packages/cli/src/daemon/protocol.ts | 89 +++++ packages/cli/src/daemon/role.ts | 21 ++ packages/cli/src/daemon/server.ts | 265 +++++++++++++ packages/cli/src/help-text.ts | 2 + packages/cli/src/i18n/locales/en.ts | 11 +- packages/cli/src/i18n/locales/zh-CN.ts | 11 +- packages/cli/src/logs.ts | 19 +- packages/cli/src/main.ts | 33 +- packages/cli/src/repl.ts | 92 +++-- packages/cli/src/runtime.ts | 5 +- packages/cli/src/services.ts | 36 +- 22 files changed, 1080 insertions(+), 110 deletions(-) create mode 100644 .changeset/cli-daemon-supervisor.md create mode 100644 packages/cli/src/daemon/client.ts create mode 100644 packages/cli/src/daemon/paths.ts create mode 100644 packages/cli/src/daemon/protocol.ts create mode 100644 packages/cli/src/daemon/role.ts create mode 100644 packages/cli/src/daemon/server.ts diff --git a/.changeset/cli-daemon-supervisor.md b/.changeset/cli-daemon-supervisor.md new file mode 100644 index 00000000..307f817b --- /dev/null +++ b/.changeset/cli-daemon-supervisor.md @@ -0,0 +1,8 @@ +--- +"@sfmc-bds/cli": minor +"@sfmc-bds/sdk": patch +"@sfmc-bds/bds-tools": patch +"@sfmc-bds/sfmc": patch +--- + +新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。 diff --git a/docs/zh/guide/config.md b/docs/zh/guide/config.md index f1d5b525..6551b929 100644 --- a/docs/zh/guide/config.md +++ b/docs/zh/guide/config.md @@ -98,9 +98,10 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 --- -### `bds_updater.json`:BDS 核心与进程守护 +### `bds_updater.json`:BDS 核心更新 -管理 Bedrock Dedicated Server 的自动版本检测、热补丁下载、升级前安全备份与进程崩溃自启。 +管理 Bedrock Dedicated Server 的自动版本检测、热补丁下载与升级前安全备份。 +进程崩溃拉起由 `sfmc` 后台守护进程负责,不再在此配置开关。 ```json title="configs/bds_updater.json" { @@ -109,23 +110,19 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 "backup_dir": "./backups", "channel": "release", "auto_check": true, - "crash_restart": true, - "crash_restart_delay": 5, "preserve": ["server.properties", "whitelist.json", "permissions.json", "worlds"], "qq_notify": true } ``` -| 字段 | 类型 | 说明 | -| :-------------------- | :--------- | :---------------------------------------------------------------------------------- | -| `bds_path` | `string` | BDS 核心可执行程序所在目录。 | -| `backup_dir` | `string` | 升级或回滚时的全量备份落盘路径(必须位于 `bds_path` 目录之外)。 | -| `channel` | `string` | 更新通道:`"release"`(正式稳定版)或 `"preview"`(预览测试版)。 | -| `auto_check` | `boolean` | 服务启动时是否静默请求 Mojang 官方源检测最新版本。 | -| `crash_restart` | `boolean` | 当 `bedrock_server` 异常崩溃退出时,是否自动尝试拉起恢复。 | -| `crash_restart_delay` | `number` | 崩溃重启前的缓冲等待时间(秒),避免因端口未释放引发频繁颠簸。 | -| `preserve` | `string[]` | 覆盖更新时**绝对予以保留并自动复原**的文件/目录白名单(避免存档或基础配置被冲洗)。 | -| `qq_notify` | `boolean` | 更新启动、完成或崩溃告警时,是否通过 QQ 群同步广播进度。 | +| 字段 | 类型 | 说明 | +| :----------- | :--------- | :---------------------------------------------------------------------------------- | +| `bds_path` | `string` | BDS 核心可执行程序所在目录。 | +| `backup_dir` | `string` | 升级或回滚时的全量备份落盘路径(必须位于 `bds_path` 目录之外)。 | +| `channel` | `string` | 更新通道:`"release"`(正式稳定版)或 `"preview"`(预览测试版)。 | +| `auto_check` | `boolean` | 服务启动时是否静默请求 Mojang 官方源检测最新版本。 | +| `preserve` | `string[]` | 覆盖更新时**绝对予以保留并自动复原**的文件/目录白名单(避免存档或基础配置被冲洗)。 | +| `qq_notify` | `boolean` | 更新启动、完成或崩溃告警时,是否通过 QQ 群同步广播进度。 | --- diff --git a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json index 6fbb8267..9bb40d82 100644 --- a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/bds_updater.schema.json", "title": "SFMC bds_updater", - "description": "BDS 自动更新与进程管理配置。由 bds-tools(check-update / bds-manager)读取;路径请用双反斜杠或正斜杠。", + "description": "BDS 自动更新配置。由 bds-tools(check-update)读取;崩溃拉起由 sfmc daemon 负责。路径请用双反斜杠或正斜杠。", "type": "object", "additionalProperties": false, "properties": { @@ -39,18 +39,6 @@ "type": "boolean", "description": "启动时是否自动检查 BDS 更新" }, - "crash_restart": { - "type": "boolean", - "description": "BDS 异常退出时是否自动拉起" - }, - "crash_restart_delay": { - "type": "number", - "description": "崩溃重启前等待秒数(可选)" - }, - "auto_restart": { - "type": "boolean", - "description": "旧字段:兼容保留,用于推断是否启用 QQ 等相关自动行为" - }, "version_mode": { "type": "string", "enum": ["bedrock-oss", "endstone"], diff --git a/modules/sdk/@sfmc-sdk/src/node/config/index.ts b/modules/sdk/@sfmc-sdk/src/node/config/index.ts index aef4fd76..67d851e6 100644 --- a/modules/sdk/@sfmc-sdk/src/node/config/index.ts +++ b/modules/sdk/@sfmc-sdk/src/node/config/index.ts @@ -6,10 +6,7 @@ export interface BdsUpdaterConfig { bds_path?: string; backup_dir?: string; channel?: "release" | "preview" | string; - auto_restart?: boolean; auto_check?: boolean; - crash_restart?: boolean; - crash_restart_delay?: number; qq_notify?: boolean; qq_config?: string; preserve?: string[]; @@ -239,8 +236,6 @@ export const DEFAULT_BDS_UPDATER_CONFIG: BdsUpdaterConfig = { qq_notify: false, qq_config: "../configs/qq_config.json", auto_check: true, - crash_restart: true, - auto_restart: true, }; /** permissions.json 根为数组;默认空表 */ diff --git a/packages/bds-tools/src/bds-manager.ts b/packages/bds-tools/src/bds-manager.ts index 127b8fbc..0aaf394b 100644 --- a/packages/bds-tools/src/bds-manager.ts +++ b/packages/bds-tools/src/bds-manager.ts @@ -57,7 +57,7 @@ export interface BdsManagerOptions { interface CachedProc { process: ReturnType | null; isManualStop: boolean; - crashRestart: boolean; + /** 独立运行 bds-manager 时的崩溃拉起延迟(秒);守护由 sfmc daemon 负责时不读配置 */ crashDelayMs: number; exePath: string; bdsPath: string; @@ -65,6 +65,9 @@ interface CachedProc { let cached: CachedProc | null = null; +/** 独立入口崩溃拉起默认等待(毫秒);配置项已移除,固定值避免与 daemon 监管重复暴露开关 */ +const STANDALONE_CRASH_DELAY_MS = 5_000; + function ensureProc(): CachedProc { if (cached) return cached; const cfg = loadConfig(); @@ -74,8 +77,7 @@ function ensureProc(): CachedProc { cached = { process: null, isManualStop: false, - crashRestart: cfg.crash_restart !== false, - crashDelayMs: (cfg.crash_restart_delay ?? 5) * 1000, + crashDelayMs: STANDALONE_CRASH_DELAY_MS, exePath, bdsPath: bds_path, }; @@ -224,7 +226,7 @@ export function createBdsManager(options: BdsManagerOptions = {}): BdsManager { } else { void postBdsLifecycleEvent("stop"); } - if (!wasManual && p.crashRestart && isMain()) { + if (!wasManual && isMain()) { log.info(`BDS 意外退出,${p.crashDelayMs / 1000}s 后自动重启...`); setTimeout(() => { start().catch((e) => log.error(`自动重启失败: ${e.message}`)); diff --git a/packages/bds-tools/src/check-update.ts b/packages/bds-tools/src/check-update.ts index d66a9a66..fdff5355 100644 --- a/packages/bds-tools/src/check-update.ts +++ b/packages/bds-tools/src/check-update.ts @@ -80,7 +80,7 @@ function buildContext(): UpdateContext { bdsPath: bds_path, backupDir: backup_dir, preserve, - autoRestart: !args["no-start"] && cfg.auto_restart !== false, + autoRestart: !args["no-start"], qqNotify: !!cfg.qq_notify, }; } diff --git a/packages/bds-tools/src/types.ts b/packages/bds-tools/src/types.ts index 12c60478..a9cdc7fc 100644 --- a/packages/bds-tools/src/types.ts +++ b/packages/bds-tools/src/types.ts @@ -6,10 +6,7 @@ export interface BdsUpdaterConfig { bds_path?: string; backup_dir?: string; channel?: "release" | "preview" | string; - auto_restart?: boolean; auto_check?: boolean; - crash_restart?: boolean; - crash_restart_delay?: number; qq_notify?: boolean; qq_config?: string; preserve?: string[]; diff --git a/packages/cli/src/command-surface.ts b/packages/cli/src/command-surface.ts index f2a89cd6..d2f5928f 100644 --- a/packages/cli/src/command-surface.ts +++ b/packages/cli/src/command-surface.ts @@ -185,6 +185,7 @@ const TOP_DESC: Record = { locale: "help.locale", debug: "help.debug.status", ui: "help.ui", + daemon: "help.daemon", install: "help.module.install", uninstall: "help.module.uninstall", search: "help.module.search", @@ -346,6 +347,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ { id: "init", name: "init", channel: "both", needsTty: true }, { id: "update", name: "update", channel: "both" }, { id: "locale", name: "locale", channel: "both" }, + { id: "daemon", name: "daemon", channel: "both" }, { id: "debug", name: "debug", channel: "both", accent: "dev" }, { id: "ui", name: "ui", channel: "both", accent: "dev" }, diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ec80b7ac..80c7ad89 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -19,6 +19,7 @@ import { type QQBridgeConfig, } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; +import { isDaemonServer } from "./daemon/role.js"; import { t } from "./i18n/index.js"; import { pushLog as pushUnifiedLog } from "./logs.js"; import { findNodeServicePids, killNodeServiceByScript, type NodeServiceName } from "./node-service-probe.js"; @@ -26,6 +27,21 @@ import { spawnService } from "./runtime.js"; import { queryServicesRuntime, ROOT, SERVICE_NAMES, services, type ServiceName } from "./services.js"; import { c, DIVIDER, highlightLogLine, padRight } from "./theme.js"; +/** + * CLI 侧经守护进程执行并取回 text;守护进程内直接跑 localFn。 + * 使用场景:status/start/stop/send/update 等运维命令的统一分流,避免 CLI 进程持有子进程。 + */ +async function viaDaemonText( + method: "status" | "start" | "stop" | "restart" | "startAll" | "stopAll" | "send" | "update", + localFn: () => Promise, + params?: { name?: string; message?: string; args?: string[] } +): Promise { + if (isDaemonServer()) return localFn(); + const { textViaDaemon, statusTextViaDaemon } = await import("./daemon/client.js"); + if (method === "status") return statusTextViaDaemon(); + return textViaDaemon(method, params); +} + function parseService(raw: string): ServiceName | null { const s = raw.toLowerCase() as ServiceName; if (SERVICE_NAMES.includes(s)) return s; @@ -76,17 +92,19 @@ function qqBridgeStatusFooter(): string { * @returns 格式化后的状态摘要文本表格。 */ export async function cmdStatus(): Promise { - const rows = await queryServicesRuntime(); - const lines = rows.map((row) => statusLine(row.title, row.running, row.pid, row.uptime, row.ownership)); - const nameH = t("svc.col.name"); - const statusH = t("svc.col.status"); - const ownerH = t("svc.col.owner"); - const pidH = t("svc.col.pid"); - const upH = t("svc.col.uptime"); - const header = ` ${padRight(nameH, 16)}${padRight(statusH, 8)}${padRight(ownerH, 6)}${padRight(pidH, 8)}${upH}`; - return ( - `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n") + qqBridgeStatusFooter() - ); + return viaDaemonText("status", async () => { + const rows = await queryServicesRuntime(); + const lines = rows.map((row) => statusLine(row.title, row.running, row.pid, row.uptime, row.ownership)); + const nameH = t("svc.col.name"); + const statusH = t("svc.col.status"); + const ownerH = t("svc.col.owner"); + const pidH = t("svc.col.pid"); + const upH = t("svc.col.uptime"); + const header = ` ${padRight(nameH, 16)}${padRight(statusH, 8)}${padRight(ownerH, 6)}${padRight(pidH, 8)}${upH}`; + return ( + `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n") + qqBridgeStatusFooter() + ); + }); } /** @@ -168,6 +186,11 @@ function clearQqRuntimeFile(): void { * @returns 启动结果描述文本。 */ export async function cmdStart(raw: string): Promise { + return viaDaemonText("start", () => cmdStartLocal(raw), { name: raw }); +} + +/** 守护进程内启动单服务(含外部探活防双开) */ +async function cmdStartLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); const svcObj = services[svc]; @@ -215,6 +238,11 @@ export async function cmdStart(raw: string): Promise { * @returns 停止操作结果文本。 */ export async function cmdStop(raw: string): Promise { + return viaDaemonText("stop", () => cmdStopLocal(raw), { name: raw }); +} + +/** 守护进程内停止单服务(含外部实例清理) */ +async function cmdStopLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); const svcObj = services[svc]; @@ -281,6 +309,11 @@ export async function cmdStop(raw: string): Promise { } export async function cmdSend(raw: string, message: string): Promise { + return viaDaemonText("send", () => cmdSendLocal(raw, message), { name: raw, message }); +} + +/** 守护进程内向服务 stdin 写入一行 */ +async function cmdSendLocal(raw: string, message: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); if (svc === "tunnel") return c.yellow(t("svc.stdinUnavailable", { title: services[svc].title })); @@ -314,12 +347,17 @@ export async function cmdSend(raw: string, message: string): Promise { * @returns 重启操作结果文本。 */ export async function cmdRestart(raw: string): Promise { + return viaDaemonText("restart", () => cmdRestartLocal(raw), { name: raw }); +} + +/** 守护进程内重启单服务 */ +async function cmdRestartLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); // bds / db / qq:统一走 stop→start,确保外部实例也被清掉 if (svc === "bds" || isNodeSvc(svc)) { - const stopMsg = await cmdStop(svc); - const startMsg = await cmdStart(svc); + const stopMsg = await cmdStopLocal(svc); + const startMsg = await cmdStartLocal(svc); // 若 stop 已报错文案含「失败」,原样带回 if (/失败|failed/i.test(stopMsg) && !/已停止|stopped|already/i.test(stopMsg)) { return stopMsg; @@ -345,6 +383,11 @@ export async function cmdRestart(raw: string): Promise { * @returns 批量启动结果统计报告文本。 */ export async function cmdStartAll(): Promise { + return viaDaemonText("startAll", cmdStartAllLocal); +} + +/** 守护进程内批量启动 */ +async function cmdStartAllLocal(): Promise { const { startAll } = await import("./services.js"); const result = await startAll(); const parts: string[] = []; @@ -382,6 +425,11 @@ export async function cmdStartAll(): Promise { * @returns 批量停止确认文本。 */ export async function cmdStopAll(): Promise { + return viaDaemonText("stopAll", cmdStopAllLocal); +} + +/** 守护进程内批量停止 */ +async function cmdStopAllLocal(): Promise { const { stopAll } = await import("./services.js"); await stopAll(); return c.dim(t("svc.allStopped")); @@ -398,6 +446,11 @@ export async function cmdStopAll(): Promise { * @returns 更新执行结果摘要文本。 */ export async function cmdUpdate(args: string[] = []): Promise { + return viaDaemonText("update", () => cmdUpdateLocal(args), { args }); +} + +/** 守护进程内执行 BDS 更新(需持有 bds Service 句柄以 beginUpdate/endUpdate) */ +async function cmdUpdateLocal(args: string[] = []): Promise { const userNoStart = args.includes("--no-start"); const checkOnly = args.includes("--check-only"); const spawnArgs = userNoStart ? [...args] : [...args, "--no-start"]; @@ -417,6 +470,7 @@ export async function cmdUpdate(args: string[] = []): Promise { const proc = spawnService("update", spawnArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); let out = ""; const pushChunk = (raw: string, level: "info" | "error"): void => { diff --git a/packages/cli/src/daemon/client.ts b/packages/cli/src/daemon/client.ts new file mode 100644 index 00000000..b877859e --- /dev/null +++ b/packages/cli/src/daemon/client.ts @@ -0,0 +1,354 @@ +/** + * client.ts — CLI 侧守护进程客户端:ensureDaemon + RPC + 日志订阅 + * + * 若 `.sfmc/daemon.json` 对应进程不在或管道不可连,则 spawn `sfmc --daemon` + *(detached + windowsHide),再轮询直到 ping 成功。 + */ + +import { isProcessAlive } from "@sfmc-bds/bds-tools/process-probe"; +import { logsDir } from "@sfmc-bds/sdk/node/config"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import process from "node:process"; +import { EventEmitter } from "node:events"; +import { ingestRemoteLog } from "../logs.js"; +import { ROOT } from "../runtime.js"; +import type { ServiceStateEvent, ServiceStatus } from "../services.js"; +import { + clearDaemonMeta, + daemonLogPath, + daemonMetaPath, + readDaemonMeta, + type DaemonMeta, +} from "./paths.js"; +import type { + DaemonEvent, + DaemonFrame, + DaemonMethod, + DaemonParams, + DaemonRequest, + DaemonResult, +} from "./protocol.js"; +import { isLogPayload } from "./protocol.js"; +import { isDaemonServer } from "./role.js"; + +/** 单次 RPC 默认超时(毫秒) */ +const CALL_TIMEOUT_MS = 120_000; +/** ensureDaemon 等待守护进程就绪的总时长 */ +const READY_TIMEOUT_MS = 15_000; +/** 轮询间隔 */ +const READY_POLL_MS = 150; + +/** 共享连接(同一 CLI 进程复用) */ +let shared: DaemonConnection | null = null; + +/** 本地状态事件总线:把 daemon 推送的 state 转给 CLI 侧 onServiceStateChange 订阅者 */ +const remoteStateBus = new EventEmitter(); + +/** + * 订阅守护进程推送的服务启停事件(CLI 进程用)。 + * 使用场景:REPL 根据 running 集合刷新发送目标 / 服务窗,无需本地持有 ChildProcess。 + */ +export function onRemoteServiceStateChange(fn: (ev: ServiceStateEvent) => void): () => void { + remoteStateBus.on("state", fn); + return () => { + remoteStateBus.off("state", fn); + }; +} + +/** 单条连接:请求/响应匹配 + 可选事件订阅 */ +class DaemonConnection { + private sock: net.Socket; + private meta: DaemonMeta; + private buf = ""; + private pending = new Map< + string, + { resolve: (r: DaemonResult) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + private subscribed = false; + closed = false; + + constructor(sock: net.Socket, meta: DaemonMeta) { + this.sock = sock; + this.meta = meta; + sock.setEncoding("utf8"); + sock.on("data", (chunk: string) => this.onData(chunk)); + sock.on("close", () => this.onClose(new Error("daemon connection closed"))); + sock.on("error", (e) => this.onClose(e instanceof Error ? e : new Error(String(e)))); + } + + private onData(chunk: string): void { + this.buf += chunk; + let idx: number; + while ((idx = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, idx).trim(); + this.buf = this.buf.slice(idx + 1); + if (!line) continue; + let frame: DaemonFrame; + try { + frame = JSON.parse(line) as DaemonFrame; + } catch { + continue; + } + if (frame.type === "event") { + this.handleEvent(frame); + continue; + } + if (frame.type === "res") { + const wait = this.pending.get(frame.id); + if (!wait) continue; + this.pending.delete(frame.id); + clearTimeout(wait.timer); + if (frame.ok && frame.result) wait.resolve(frame.result); + else wait.reject(new Error(frame.error ?? "daemon error")); + } + } + } + + private handleEvent(ev: DaemonEvent): void { + if (ev.event === "log" && isLogPayload(ev.payload)) { + ingestRemoteLog(ev.payload.text, ev.payload.source, ev.payload.level); + return; + } + if (ev.event === "state") { + remoteStateBus.emit("state", ev.payload as ServiceStateEvent); + } + } + + private onClose(err: Error): void { + if (this.closed) return; + this.closed = true; + for (const [, wait] of this.pending) { + clearTimeout(wait.timer); + wait.reject(err); + } + this.pending.clear(); + if (shared === this) shared = null; + } + + /** 发送 RPC 并等待匹配 id 的响应 */ + call(method: DaemonMethod, params?: DaemonParams, timeoutMs = CALL_TIMEOUT_MS): Promise { + if (this.closed || this.sock.destroyed) { + return Promise.reject(new Error("daemon not connected")); + } + const id = randomBytes(8).toString("hex"); + const req: DaemonRequest = params + ? { id, token: this.meta.token, method, params } + : { id, token: this.meta.token, method }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`daemon call timeout: ${method}`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { + this.sock.write(JSON.stringify(req) + "\n"); + } catch (e) { + this.pending.delete(id); + clearTimeout(timer); + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + } + + /** 订阅日志与状态事件(同一连接上继续发 RPC) */ + async subscribe(): Promise { + if (this.subscribed) return; + await this.call("subscribe"); + this.subscribed = true; + } + + /** 断开连接(不停守护进程) */ + disconnect(): void { + this.closed = true; + try { + this.sock.end(); + } catch { + /* ignore */ + } + if (shared === this) shared = null; + } +} + +function connectSocket(pipe: string, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection(pipe); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("connect timeout")); + }, timeoutMs); + sock.once("connect", () => { + clearTimeout(timer); + resolve(sock); + }); + sock.once("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + +/** + * 尝试连接已有 daemon;成功则返回连接,失败返回 null。 + */ +async function tryConnectExisting(): Promise { + const meta = readDaemonMeta(); + if (!meta) return null; + if (!(await isProcessAlive(meta.pid))) { + clearDaemonMeta(); + return null; + } + try { + const sock = await connectSocket(meta.pipe); + const conn = new DaemonConnection(sock, meta); + await conn.call("ping", undefined, 3000); + return conn; + } catch { + return null; + } +} + +/** + * 拉起守护进程子进程(detached,日志写入 daemon.log)。 + * 入口脚本复用当前 CLI 的 argv[1](即 sfmc 主入口)。 + */ +function spawnDaemonProcess(): void { + const entry = process.argv[1]; + if (!entry) throw new Error("cannot resolve sfmc entry for daemon spawn"); + fs.mkdirSync(logsDir(ROOT), { recursive: true }); + const logFd = fs.openSync(daemonLogPath(), "a"); + const child = spawn(process.execPath, [entry, "--daemon"], { + detached: true, + windowsHide: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, SFMC_ROOT: ROOT }, + cwd: ROOT, + }); + child.unref(); + try { + fs.closeSync(logFd); + } catch { + /* ignore */ + } +} + +/** + * 确保守护进程可用并返回共享连接。 + * 守护进程进程内调用会抛错(应直接走本地 cmd*)。 + */ +export async function ensureDaemon(): Promise { + if (isDaemonServer()) { + throw new Error("ensureDaemon must not run inside daemon server"); + } + if (shared && !shared.closed) return shared; + + let conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + + const existing = readDaemonMeta(); + if (existing && (await isProcessAlive(existing.pid))) { + /* PID 仍在但暂时连不上:短暂重试,禁止再 spawn 第二套守护进程 */ + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + } + throw new Error(`daemon pid ${existing.pid} is alive but pipe is unreachable`); + } + + clearDaemonMeta(); + spawnDaemonProcess(); + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + if (!fs.existsSync(daemonMetaPath())) continue; + conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + } + throw new Error("failed to start or connect sfmc daemon"); +} + +/** + * 调用守护进程方法;自动 ensureDaemon。 + * 使用场景:CLI 侧 status/start/stop/send/update 等运维命令的统一出口。 + */ +export async function callDaemon(method: DaemonMethod, params?: DaemonParams): Promise { + const conn = await ensureDaemon(); + return conn.call(method, params); +} + +/** + * 确保已连接并订阅远程日志/状态;返回断开函数(不停守护进程)。 + * 使用场景:REPL 启动时挂上实时日志流,退出时只 disconnect。 + */ +export async function ensureDaemonSubscription(): Promise<() => void> { + const conn = await ensureDaemon(); + await conn.subscribe(); + return () => { + conn.disconnect(); + }; +} + +/** + * 请求守护进程 stopAll 后退出(`sfmc daemon stop`)。 + */ +export async function shutdownDaemon(): Promise { + try { + const conn = await ensureDaemon(); + await conn.call("shutdown"); + conn.disconnect(); + } catch (e) { + /* 守护进程可能已不在 */ + clearDaemonMeta(); + return e instanceof Error ? e.message : String(e); + } + /* 短暂等待 meta 清理 */ + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!readDaemonMeta()) break; + await new Promise((r) => setTimeout(r, 100)); + } + clearDaemonMeta(); + return ""; +} + +/** + * 从守护进程拉取运行态行;失败时返回空数组由调用方降级。 + */ +export async function queryRuntimeViaDaemon(): Promise { + const result = await callDaemon("status"); + if (result.kind === "status") return result.rows; + return []; +} + +/** + * 从守护进程拉取格式化 status 文本。 + */ +export async function statusTextViaDaemon(): Promise { + const result = await callDaemon("status"); + if (result.kind === "status") return result.text; + if (result.kind === "text") return result.text; + return ""; +} + +/** + * 通用「返回 text」类 RPC 辅助。 + */ +export async function textViaDaemon(method: DaemonMethod, params?: DaemonParams): Promise { + const result = await callDaemon(method, params); + if (result.kind === "text") return result.text; + if (result.kind === "status") return result.text; + return ""; +} diff --git a/packages/cli/src/daemon/paths.ts b/packages/cli/src/daemon/paths.ts new file mode 100644 index 00000000..51abdb37 --- /dev/null +++ b/packages/cli/src/daemon/paths.ts @@ -0,0 +1,103 @@ +/** + * paths.ts — 守护进程连接信息与管道路径 + * + * 权威落盘:`/.sfmc/daemon.json`(与 stateDir 一致)。 + * 同一 ROOT 只允许一个守护进程;CLI 通过本文件解析管道并校验 token。 + */ + +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { logsDir, stateDir } from "@sfmc-bds/sdk/node/config"; +import { ROOT } from "../runtime.js"; + +/** 守护进程元数据:PID、管道路径、鉴权 token */ +export interface DaemonMeta { + /** 守护进程 PID */ + pid: number; + /** 命名管道(Windows)或 Unix socket 绝对路径 */ + pipe: string; + /** 连接鉴权 token(随机) */ + token: string; + /** ISO 启动时间 */ + startedAt: string; +} + +/** 守护进程元数据文件路径 */ +export function daemonMetaPath(root: string = ROOT): string { + return path.join(stateDir(root), "daemon.json"); +} + +/** 守护进程自身日志(stdout/stderr 追加) */ +export function daemonLogPath(root: string = ROOT): string { + return path.join(logsDir(root), "daemon.log"); +} + +/** + * 为本机 ROOT 生成唯一管道名。 + * Windows:`\\.\pipe\sfmc-`;POSIX:`.sfmc/daemon.sock` + */ +export function daemonPipePath(root: string = ROOT): string { + const hash = createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 16); + if (process.platform === "win32") { + return `\\\\.\\pipe\\sfmc-${hash}`; + } + return path.join(stateDir(root), "daemon.sock"); +} + +/** 生成连接鉴权 token */ +export function createDaemonToken(): string { + return randomBytes(24).toString("hex"); +} + +/** 读取守护进程元数据;文件缺失或损坏时返回 null */ +export function readDaemonMeta(root: string = ROOT): DaemonMeta | null { + const file = daemonMetaPath(root); + try { + if (!fs.existsSync(file)) return null; + const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial; + if ( + typeof raw.pid !== "number" || + typeof raw.pipe !== "string" || + typeof raw.token !== "string" || + !raw.pipe || + !raw.token + ) { + return null; + } + return { + pid: raw.pid, + pipe: raw.pipe, + token: raw.token, + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "", + }; + } catch { + return null; + } +} + +/** 写入守护进程元数据(启动 listen 成功后调用) */ +export function writeDaemonMeta(meta: DaemonMeta, root: string = ROOT): void { + const dir = stateDir(root); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(logsDir(root), { recursive: true }); + fs.writeFileSync(daemonMetaPath(root), JSON.stringify(meta, null, 2) + "\n", "utf-8"); +} + +/** 清除守护进程元数据与 POSIX socket 文件 */ +export function clearDaemonMeta(root: string = ROOT): void { + const meta = readDaemonMeta(root); + try { + fs.unlinkSync(daemonMetaPath(root)); + } catch { + /* ignore */ + } + if (process.platform !== "win32") { + const sock = meta?.pipe ?? daemonPipePath(root); + try { + if (fs.existsSync(sock)) fs.unlinkSync(sock); + } catch { + /* ignore */ + } + } +} diff --git a/packages/cli/src/daemon/protocol.ts b/packages/cli/src/daemon/protocol.ts new file mode 100644 index 00000000..36fb8d0e --- /dev/null +++ b/packages/cli/src/daemon/protocol.ts @@ -0,0 +1,89 @@ +/** + * protocol.ts — CLI ↔ 守护进程 换行分隔 JSON 协议类型 + * + * 请求/响应成对匹配 id;subscribe 后服务端可推送 event 帧。 + * 所有请求须携带与 daemon.json 一致的 token。 + */ + +import type { UnifiedLog, LogLevel, LogSource } from "../logs.js"; +import type { ServiceName, ServiceStateEvent, ServiceStatus } from "../services.js"; + +/** 协议方法名(固定集合,OCP:新增能力加此处与 server 分发) */ +export type DaemonMethod = + | "ping" + | "status" + | "start" + | "stop" + | "restart" + | "startAll" + | "stopAll" + | "send" + | "update" + | "subscribe" + | "shutdown"; + +/** 客户端 → 服务端请求帧 */ +export interface DaemonRequest { + id: string; + token: string; + method: DaemonMethod; + params?: DaemonParams; +} + +/** 各方法参数(按 method 选用) */ +export type DaemonParams = { + name?: string; + message?: string; + args?: string[]; +}; + +/** 服务端 → 客户端响应帧 */ +export interface DaemonResponse { + type: "res"; + id: string; + ok: boolean; + result?: DaemonResult; + error?: string; +} + +/** 服务端推送事件(subscribe 之后) */ +export interface DaemonEvent { + type: "event"; + event: "log" | "state"; + payload: DaemonLogPayload | ServiceStateEvent; +} + +/** 日志事件载荷(与 UnifiedLog 对齐,便于 CLI 侧 pushLog) */ +export interface DaemonLogPayload { + text: string; + source: LogSource; + level: LogLevel; + time?: string; +} + +/** status 等方法的结构化结果 */ +export type DaemonResult = + | { kind: "pong" } + | { kind: "text"; text: string } + | { kind: "status"; text: string; rows: ServiceStatus[] } + | { kind: "subscribed" } + | { kind: "shutdown" }; + +export type DaemonFrame = DaemonResponse | DaemonEvent; + +/** 类型守卫:日志事件载荷 */ +export function isLogPayload(p: DaemonEvent["payload"]): p is DaemonLogPayload { + return typeof (p as DaemonLogPayload).text === "string" && typeof (p as DaemonLogPayload).source === "string"; +} + +/** 从 UnifiedLog 构造可序列化日志载荷 */ +export function toLogPayload(log: UnifiedLog): DaemonLogPayload { + return { + text: log.text, + source: log.source, + level: log.level, + time: log.time instanceof Date ? log.time.toISOString() : String(log.time ?? ""), + }; +} + +export type { ServiceName, ServiceStateEvent, ServiceStatus }; diff --git a/packages/cli/src/daemon/role.ts b/packages/cli/src/daemon/role.ts new file mode 100644 index 00000000..3f37c8b5 --- /dev/null +++ b/packages/cli/src/daemon/role.ts @@ -0,0 +1,21 @@ +/** + * role.ts — 标识当前进程是守护进程服务端还是 CLI 客户端 + * + * 使用场景:commands / services 在导出入口处分流——守护进程内直接操作 + * Service 子进程;CLI 进程则经本机管道 RPC 转发到守护进程。 + */ + +/** 进程角色:cli 为默认客户端;daemon 为持有监管权的服务端 */ +export type DaemonRole = "cli" | "daemon"; + +let role: DaemonRole = "cli"; + +/** 将当前进程标记为守护进程服务端(仅 `--daemon` 入口调用一次) */ +export function markDaemonServer(): void { + role = "daemon"; +} + +/** 当前是否为守护进程服务端(持有 ChildProcess 与自动重启定时器) */ +export function isDaemonServer(): boolean { + return role === "daemon"; +} diff --git a/packages/cli/src/daemon/server.ts b/packages/cli/src/daemon/server.ts new file mode 100644 index 00000000..131b4a9c --- /dev/null +++ b/packages/cli/src/daemon/server.ts @@ -0,0 +1,265 @@ +/** + * server.ts — 守护进程服务端:本机管道 listen + 将 RPC 分发给现有监管逻辑 + * + * 仅在 `sfmc --daemon` 进程内运行;持有 Service 子进程、自动重启与 BDS stdin。 + * CLI 退出不断开本进程;`shutdown` 方法会先 stopAll 再退出。 + */ + +import net from "node:net"; +import process from "node:process"; +import { + cmdRestart, + cmdSend, + cmdStart, + cmdStartAll, + cmdStatus, + cmdStop, + cmdStopAll, + cmdUpdate, +} from "../commands.js"; +import { onLog } from "../logs.js"; +import { onServiceStateChange, queryServicesRuntimeLocal, stopAll } from "../services.js"; +import { + clearDaemonMeta, + createDaemonToken, + daemonPipePath, + writeDaemonMeta, + type DaemonMeta, +} from "./paths.js"; +import type { DaemonEvent, DaemonRequest, DaemonResponse, DaemonResult } from "./protocol.js"; +import { toLogPayload } from "./protocol.js"; +import { markDaemonServer } from "./role.js"; + +/** 当前已订阅事件推送的 socket 集合 */ +const subscribers = new Set(); + +/** 是否已开始优雅退出(避免重复 shutdown) */ +let shuttingDown = false; + +/** + * 向所有订阅者广播事件帧。 + * 使用场景:服务日志与启停状态需实时出现在已连接的 REPL。 + */ +function broadcast(event: DaemonEvent): void { + const line = JSON.stringify(event) + "\n"; + for (const sock of subscribers) { + if (sock.destroyed) { + subscribers.delete(sock); + continue; + } + try { + sock.write(line); + } catch { + subscribers.delete(sock); + } + } +} + +function writeRes(sock: net.Socket, res: DaemonResponse): void { + if (sock.destroyed) return; + try { + sock.write(JSON.stringify(res) + "\n"); + } catch { + /* ignore */ + } +} + +/** + * 分发单条 RPC 到现有 cmd* / services API(本进程已是 daemon,cmd* 走本地实现)。 + */ +async function dispatch(req: DaemonRequest): Promise { + switch (req.method) { + case "ping": + return { kind: "pong" }; + case "status": { + const text = await cmdStatus(); + const rows = await queryServicesRuntimeLocal(); + return { kind: "status", text, rows }; + } + case "start": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + return { kind: "text", text: await cmdStartAll() }; + } + return { kind: "text", text: await cmdStart(name) }; + } + case "stop": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + return { kind: "text", text: await cmdStopAll() }; + } + return { kind: "text", text: await cmdStop(name) }; + } + case "restart": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + await cmdStopAll(); + return { kind: "text", text: await cmdStartAll() }; + } + return { kind: "text", text: await cmdRestart(name) }; + } + case "startAll": + return { kind: "text", text: await cmdStartAll() }; + case "stopAll": + return { kind: "text", text: await cmdStopAll() }; + case "send": { + const name = req.params?.name; + const message = req.params?.message; + if (!name || message === undefined) throw new Error("missing params.name/message"); + return { kind: "text", text: await cmdSend(name, message) }; + } + case "update": + return { kind: "text", text: await cmdUpdate(req.params?.args ?? []) }; + case "subscribe": + return { kind: "subscribed" }; + case "shutdown": { + if (!shuttingDown) { + shuttingDown = true; + await stopAll(); + setTimeout(() => { + clearDaemonMeta(); + process.exit(0); + }, 50).unref(); + } + return { kind: "shutdown" }; + } + default: + throw new Error(`unknown method: ${(req as DaemonRequest).method}`); + } +} + +/** + * 处理单条连接上的请求行;鉴权失败则关闭。 + */ +async function handleLine(sock: net.Socket, line: string, expectedToken: string): Promise { + let req: DaemonRequest; + try { + req = JSON.parse(line) as DaemonRequest; + } catch { + writeRes(sock, { type: "res", id: "?", ok: false, error: "invalid json" }); + return; + } + if (!req?.id || !req.method) { + writeRes(sock, { type: "res", id: req?.id ?? "?", ok: false, error: "invalid request" }); + return; + } + if (req.token !== expectedToken) { + writeRes(sock, { type: "res", id: req.id, ok: false, error: "unauthorized" }); + sock.end(); + return; + } + + if (req.method === "subscribe") { + subscribers.add(sock); + sock.once("close", () => subscribers.delete(sock)); + writeRes(sock, { type: "res", id: req.id, ok: true, result: { kind: "subscribed" } }); + return; + } + + try { + const result = await dispatch(req); + writeRes(sock, { type: "res", id: req.id, ok: true, result }); + } catch (e) { + writeRes(sock, { + type: "res", + id: req.id, + ok: false, + error: e instanceof Error ? e.message : String(e), + }); + } +} + +/** + * 启动守护进程:标记角色、listen 管道、落盘 daemon.json,并转发日志/状态事件。 + * 本函数在成功 listen 后永不返回(进程常驻)。 + */ +export async function runDaemonServer(): Promise { + markDaemonServer(); + clearDaemonMeta(); + + const token = createDaemonToken(); + const pipe = daemonPipePath(); + const meta: DaemonMeta = { + pid: process.pid, + pipe, + token, + startedAt: new Date().toISOString(), + }; + + const unsubLog = onLog((log) => { + broadcast({ type: "event", event: "log", payload: toLogPayload(log) }); + }); + const unsubState = onServiceStateChange((ev) => { + broadcast({ type: "event", event: "state", payload: ev }); + }); + + const server = net.createServer((sock) => { + let buf = ""; + sock.setEncoding("utf8"); + sock.on("data", (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + void handleLine(sock, line, token); + } + }); + sock.on("error", () => { + subscribers.delete(sock); + }); + sock.on("close", () => { + subscribers.delete(sock); + }); + }); + + await new Promise((resolve, reject) => { + const onErr = (err: NodeJS.ErrnoException): void => { + /* 另一实例已占用管道:安静退出,避免双守护进程 */ + if (err.code === "EADDRINUSE") { + process.exit(0); + } + reject(err); + }; + server.once("error", onErr); + server.listen(pipe, () => { + server.off("error", onErr); + resolve(); + }); + }); + + writeDaemonMeta(meta); + + const cleanup = (): void => { + unsubLog(); + unsubState(); + try { + server.close(); + } catch { + /* ignore */ + } + clearDaemonMeta(); + }; + + process.on("exit", cleanup); + process.on("SIGTERM", () => { + if (shuttingDown) return; + shuttingDown = true; + void stopAll().finally(() => { + cleanup(); + process.exit(0); + }); + }); + /* 守护进程忽略 SIGINT:脱离终端后不应因信号停服;显式 shutdown 退出 */ + process.on("SIGINT", () => { + /* no-op */ + }); + + await new Promise(() => { + /* park forever */ + }); +} diff --git a/packages/cli/src/help-text.ts b/packages/cli/src/help-text.ts index 22a9e69a..f2f6c782 100644 --- a/packages/cli/src/help-text.ts +++ b/packages/cli/src/help-text.ts @@ -121,6 +121,7 @@ ${helpLine(`${c.blue("sfmc debug")} status|enable|disable|sentry …`, "")} const general = [ show("init") ? helpLine(p(mode, "init"), t("help.init")) : "", show("locale") ? helpLine(pArgs(mode, "locale", " [zh|en]"), t("help.locale")) : "", + show("daemon") ? helpLine(pArgs(mode, "daemon", " [status|stop]"), t("help.daemon")) : "", ] .filter(Boolean) .join("\n"); @@ -165,6 +166,7 @@ ${packsLines} : ""; const general = [ + show("daemon") ? helpLine(pArgs(mode, "daemon", " [status|stop]"), t("help.daemon")) : "", show("version") ? helpLine(p(mode, "version"), t("help.version")) : "", show("help") ? helpLine(p(mode, "help"), t("help.help")) : "", show("quit") ? helpLine(p(mode, "quit"), t("help.quit")) : "", diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 907591d7..71da846d 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -106,7 +106,7 @@ export const en = { "help.locale": "Show / set CLI language", "help.version": "Show version", "help.help": "Show this help", - "help.quit": "Exit", + "help.quit": "Exit CLI (services keep running)", "help.shortcuts": "Shortcuts:", "help.shortcut.tab": "Send target / list select", "help.shortcut.right": "Accept suggestion", @@ -115,6 +115,7 @@ export const en = { /* ─── repl ─── */ "repl.stopping": "stopping services...", + "repl.detach": "CLI detached (services keep running; use stop / daemon stop to halt)", "repl.bye": "bye", "repl.forceStop": "force stopping services...", "repl.nonInteractive": " Non-interactive mode (pipe detected)\n", @@ -133,6 +134,14 @@ export const en = { "repl.history.last1hour": "last 1 hour", "repl.history.all": "all buffered", + /* ─── daemon ─── */ + "daemon.stopped": "daemon stopped", + "daemon.notRunning": "daemon is not running", + "daemon.running": "daemon running (PID {pid})", + "daemon.usage": "usage: daemon [status|stop]", + "daemon.connectFailed": "failed to connect daemon: {message}", + "help.daemon": "Show/stop background daemon", + /* ─── services / commands ─── */ "svc.header": "Services", "svc.col.name": "Name", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index 88443918..5bedcbc4 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -103,7 +103,7 @@ export const zhCN: Record = { "help.locale": "设置 CLI 语言", "help.version": "显示版本", "help.help": "显示帮助", - "help.quit": "退出", + "help.quit": "退出 CLI(后台服务继续运行)", "help.shortcuts": "快捷键:", "help.shortcut.tab": "切换发送目标 / 命令列表选择", "help.shortcut.right": "接受建议", @@ -112,9 +112,18 @@ export const zhCN: Record = { /* ─── REPL ─── */ "repl.stopping": "正在停止服务...", + "repl.detach": "已断开 CLI(后台服务继续运行;停服用 stop / daemon stop)", "repl.bye": "bye...", "repl.forceStop": "正在强制停止服务...", "repl.nonInteractive": " 非交互模式(检测到管道)\n", + + /* ─── 守护进程 ─── */ + "daemon.stopped": "守护进程已停止", + "daemon.notRunning": "守护进程未运行", + "daemon.running": "守护进程运行中 (PID {pid})", + "daemon.usage": "用法: daemon [status|stop]", + "daemon.connectFailed": "无法连接守护进程: {message}", + "help.daemon": "查看/停止后台守护进程", "repl.historyHeader": "── 历史 {count} 行 ──", "repl.filter": "过滤: {level} / {source} / {history}", "repl.followRequiresTty": "跟踪模式需要 TTY", diff --git a/packages/cli/src/logs.ts b/packages/cli/src/logs.ts index d62a29fa..85d5359a 100644 --- a/packages/cli/src/logs.ts +++ b/packages/cli/src/logs.ts @@ -10,6 +10,8 @@ * - bds / llbot → `.sfmc/logs/{bds,llbot}.log`(外部进程无自带 file sink) * - 其余(system / pack / …) → `.sfmc/logs/sfmc.log` * + * CLI 重连后历史回放以落盘为准(readDiskLogs);内存缓冲只服务本进程实时订阅。 + * * formatLog 保留 theme.ts (chalk) 配色,比共享包的纯 ANSI 版本视觉更丰富。 */ @@ -128,6 +130,15 @@ export function pushLog(text: string, source: LogSource, level: LogLevel): void } } +/** + * 摄入守护进程转发的远程日志:只写入本进程内存缓冲并通知订阅者,不再落盘。 + * 使用场景:CLI 客户端 subscribe 后把 daemon 推送的 log 事件灌入本地,供 REPL 主面板实时展示; + * 落盘已由守护进程完成,此处跳过避免重复写 `.sfmc/logs/*`。 + */ +export function ingestRemoteLog(text: string, source: LogSource, level: LogLevel): void { + buffer.pushDirect(text, source, level); +} + /** 订阅新日志事件,返回取消订阅函数 */ export function onLog(fn: (log: UnifiedLog) => void): () => void { return buffer.subscribe(fn); @@ -197,11 +208,13 @@ export type ReadDiskLogsOpts = { sources?: LogSource[]; /** 覆盖日志目录(测试用);默认 `/.sfmc/logs` */ dir?: string; + /** 仅保留时间序最后 N 条;省略则返回全部解析结果 */ + limit?: number; }; /** * 从 `.sfmc/logs/*.log` 读取并解析为 UnifiedLog(按时间升序)。 - * 供 /logs 筛选窗回放;与内存缓冲无关。 + * CLI 重连后主面板 /logs 的历史权威来源;内存缓冲仅承载本进程订阅到的实时增量。 */ export function readDiskLogs(opts: ReadDiskLogsOpts = {}): UnifiedLog[] { const levels = opts.levels ?? []; @@ -233,6 +246,10 @@ export function readDiskLogs(opts: ReadDiskLogsOpts = {}): UnifiedLog[] { } } out.sort((a, b) => a.time.getTime() - b.time.getTime()); + const limit = opts.limit; + if (limit !== undefined && limit > 0 && out.length > limit) { + return out.slice(out.length - limit); + } return out; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 2aa126e6..6dba9bed 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -10,7 +10,6 @@ import { cmdLocale } from "./locale-command.js"; import { dispatchModuleCommand, isModuleCommand, scanAndWarnUnknown } from "./module-commands.js"; import { getHelp, playWelcomeAnimation, startRepl } from "./repl.js"; import { ROOT } from "./runtime.js"; -import { setArgvDaemonize } from "./services.js"; import { c } from "./theme.js"; import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; @@ -32,12 +31,18 @@ function deny(msg: string): never { async function main(): Promise { const stripped = stripLangArgs(process.argv.slice(2)); initLocale({ root: ROOT, flag: stripped.lang }); - const { packsMode, args } = parseGlobalArgv(stripped.args); + let args = stripped.args; - if (args.length > 0 || packsMode) { - setArgvDaemonize(true); + /* 内部入口:后台守护进程(不解析用户命令) */ + if (args[0] === "--daemon") { + const { runDaemonServer } = await import("./daemon/server.js"); + await runDaemonServer(); + return; } + const { packsMode, args: parsedArgs } = parseGlobalArgv(args); + args = parsedArgs; + if (args.length === 0 && !packsMode) { const { isRuntimeInitialized } = await import("./runtime.js"); if (!isRuntimeInitialized()) { @@ -98,6 +103,26 @@ async function main(): Promise { console.log(await cmdUi(rest, { block: true })); break; } + case "daemon": { + const sub = rest[0]; + if (sub === "stop") { + const { shutdownDaemon } = await import("./daemon/client.js"); + const err = await shutdownDaemon(); + console.log(err ? c.red(err) : c.dim(t("daemon.stopped"))); + } else if (sub === "status" || !sub) { + const { readDaemonMeta } = await import("./daemon/paths.js"); + const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); + const meta = readDaemonMeta(); + if (!meta || !(await isProcessAlive(meta.pid))) { + console.log(c.dim(t("daemon.notRunning"))); + } else { + console.log(c.green(t("daemon.running", { pid: String(meta.pid) }))); + } + } else { + console.log(c.yellow(t("daemon.usage"))); + } + break; + } case "status": console.log(await cmdStatus()); break; diff --git a/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index ad2a38e9..f4a74d3f 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -25,7 +25,6 @@ import { t } from "./i18n/index.js"; import { cmdLocale } from "./locale-command.js"; import { formatLog, - getAllLogs, logPrefixWidth, onLog, readDiskLogs, @@ -54,7 +53,7 @@ import { type WindowKeyResult, } from "./repl-windows/index.js"; import { SEND_TARGET_ORDER, paintSendPrompt, paintSfmcPrompt, plainPrompt } from "./send-target.js"; -import { forceStopAll, onServiceStateChange, queryServicesRuntime, SERVICE_NAMES, stopAll, type ServiceName } from "./services.js"; +import { onServiceStateChange, queryServicesRuntime, SERVICE_NAMES, type ServiceName } from "./services.js"; import { c, T } from "./theme.js"; import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; @@ -815,9 +814,13 @@ function writeLogLineToTty(wrapped: string): void { export async function startRepl(): Promise { let stopping = false; + /** 断开与守护进程的订阅(不停服) */ + let disconnectDaemon: (() => void) | null = null; + const onSigint = (): void => { - stdout.write(c.yellow("\n" + t("repl.forceStop") + "\n")); - forceStopAll(); + stdout.write(c.yellow("\n" + t("repl.detach") + "\n")); + disconnectDaemon?.(); + disconnectDaemon = null; process.exit(130); }; const shutdown = async (): Promise => { @@ -825,8 +828,9 @@ export async function startRepl(): Promise { stopping = true; process.off("SIGINT", onSigint); openLogsWindowHook = null; - stdout.write(c.dim(t("repl.stopping") + "\n")); - await stopAll(); + stdout.write(c.dim(t("repl.detach") + "\n")); + disconnectDaemon?.(); + disconnectDaemon = null; try { setRaw(false); stdin.pause(); @@ -838,6 +842,15 @@ export async function startRepl(): Promise { }; process.on("SIGINT", onSigint); + /* 连接(或拉起)守护进程并订阅远程日志/状态 */ + try { + const { ensureDaemonSubscription } = await import("./daemon/client.js"); + disconnectDaemon = await ensureDaemonSubscription(); + } catch (e) { + stdout.write(c.red(t("daemon.connectFailed", { message: (e as Error).message })) + "\n"); + process.exit(1); + } + if (!stdin.isTTY) { console.log(c.dim(t("repl.nonInteractive"))); for await (const line of (await import("node:readline/promises")).createInterface({ @@ -878,31 +891,25 @@ export async function startRepl(): Promise { stdout.write("\x1B[3J"); } - /** 首次进入窗口:从内存/落盘拉一次作为种子缓存 */ + /** 首次进入窗口:从落盘拉历史作为种子(CLI 重连后内存为空,权威在 `.sfmc/logs/*.log`) */ function seedWindowBuffer(w: { id: string; acceptLog: (l: UnifiedLog) => boolean; formatLogLine: (l: UnifiedLog) => { text: string; indent: number }; getReplayFilter?: () => { levels: string[]; sources: string[] }; }): string[] { + const f = w.getReplayFilter?.() ?? { levels: [], sources: [] }; + /* 与旧内存环大小对齐,避免超大 log 文件拖垮清屏回放 */ + const disk = readDiskLogs({ + levels: f.levels as LogLevel[], + sources: f.sources, + limit: 5000, + }); const lines: string[] = []; - if (w.id === "logs") { - const f = w.getReplayFilter?.() ?? { levels: [], sources: [] }; - const disk = readDiskLogs({ - levels: f.levels as LogLevel[], - sources: f.sources, - }); - for (const log of disk) { - if (!w.acceptLog(log)) continue; - const { text, indent } = w.formatLogLine(log); - lines.push(wrapLogLine(text, indent)); - } - } else { - for (const log of getAllLogs()) { - if (!w.acceptLog(log)) continue; - const { text, indent } = w.formatLogLine(log); - lines.push(wrapLogLine(text, indent)); - } + for (const log of disk) { + if (!w.acceptLog(log)) continue; + const { text, indent } = w.formatLogLine(log); + lines.push(wrapLogLine(text, indent)); } host.setBuffer(w.id, lines); return lines; @@ -910,8 +917,8 @@ export async function startRepl(): Promise { /** * 展示活动窗:先清屏,再恢复/seed。 - * - /logs:用落盘种子缓存(改筛选才 reseed),避免重复读文件 - * - 服务主面板:每次从内存重 seed,保证离开 /logs 或 Tab 回来时内容实时 + * 历史一律落盘 seed;实时行由 onLog → routeLog 追加进各窗缓存。 + * 改筛选时传 reseed 强制重读盘。 */ function showActiveWindow(opts?: { reseed?: boolean }): void { clearTerminal(); @@ -920,15 +927,8 @@ export async function startRepl(): Promise { currentRedraw?.(); return; } - let lines: readonly string[]; - if (w.id === "logs") { - if (opts?.reseed) host.invalidateBuffer(w.id); - lines = host.hasBuffer(w.id) ? host.getBuffer(w.id) : seedWindowBuffer(w); - } else { - /* 主面板实时:不作废也无妨,直接重拉内存缓冲 */ - host.invalidateBuffer(w.id); - lines = seedWindowBuffer(w); - } + if (opts?.reseed) host.invalidateBuffer(w.id); + const lines = host.hasBuffer(w.id) ? host.getBuffer(w.id) : seedWindowBuffer(w); for (const l of lines) stdout.write(l + "\n"); currentRedraw?.(); } @@ -963,7 +963,7 @@ export async function startRepl(): Promise { } function openLogsWindow(): void { - /* 离开主面板:其缓存可留着,但展示上先清屏再进 /logs;Esc 回来会按实时重 seed 主面板 */ + /* 离开主面板:缓存保留;Esc 回来若主面板已有缓存则直接恢复(含期间 routeLog 追加) */ host.open("logs"); showActiveWindow(); } @@ -1179,6 +1179,26 @@ async function execCmd(parts: string[]): Promise { stdout.write((await cmdUi(args, { block: false })) + "\n"); break; } + case "daemon": { + const sub = args[0]; + if (sub === "stop") { + const { shutdownDaemon } = await import("./daemon/client.js"); + const err = await shutdownDaemon(); + stdout.write((err ? c.red(err) : c.dim(t("daemon.stopped"))) + "\n"); + } else if (sub === "status" || !sub) { + const { readDaemonMeta } = await import("./daemon/paths.js"); + const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); + const meta = readDaemonMeta(); + if (!meta || !(await isProcessAlive(meta.pid))) { + stdout.write(c.dim(t("daemon.notRunning")) + "\n"); + } else { + stdout.write(c.green(t("daemon.running", { pid: String(meta.pid) })) + "\n"); + } + } else { + stdout.write(c.yellow(t("daemon.usage") + "\n")); + } + break; + } case "init": { const wasRaw = stdin.isRaw ?? false; setRaw(false); diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 0211ad79..d00b4c3f 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -258,16 +258,17 @@ function serviceChildEnv(service: ServiceId, optsEnv?: NodeJS.ProcessEnv | null) * * 透传给子进程的 env: * - SFMC_SERVICE / SFMC_ROOT / SFMC_PACKAGES_DIR + * 默认 windowsHide:Windows 上隐藏子进程控制台,避免守护进程拉起 db/qq/bds 时弹出空白窗口。 */ export function spawnService(service: ServiceId, args: string[] = [], opts: SpawnOptions = {}) { const env = serviceChildEnv(service, opts.env as NodeJS.ProcessEnv | undefined); const script = resolveServiceScript(service); - return spawn(nodeBinary(), [script, ...args], { ...opts, env }); + return spawn(nodeBinary(), [script, ...args], { windowsHide: true, ...opts, env }); } /** spawnService 的同步版本 */ export function spawnServiceSync(service: ServiceId, args: string[] = [], opts: SpawnSyncOptions = {}) { const env = serviceChildEnv(service, opts.env as NodeJS.ProcessEnv | undefined); const script = resolveServiceScript(service); - return spawnSync(nodeBinary(), [script, ...args], { ...opts, env }); + return spawnSync(nodeBinary(), [script, ...args], { windowsHide: true, ...opts, env }); } diff --git a/packages/cli/src/services.ts b/packages/cli/src/services.ts index e02c30c9..f80fca18 100644 --- a/packages/cli/src/services.ts +++ b/packages/cli/src/services.ts @@ -35,10 +35,12 @@ import { writeBdsPidFile, } from "@sfmc-bds/bds-tools/process-probe"; import { postBdsLifecycleEvent } from "@sfmc-bds/bds-tools/qq-events-notify"; -import { spawn, type ChildProcess, type IOType } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; +import { isDaemonServer } from "./daemon/role.js"; +import { onRemoteServiceStateChange } from "./daemon/client.js"; import { t } from "./i18n/index.js"; import { resolveLlbotLaunch } from "./llbot-launch.js"; import { inferLevel, pushLog as pushUnifiedLog } from "./logs.js"; @@ -64,12 +66,6 @@ export function nonBlankOutputLines(text: string): string[] { export type ServiceName = "bds" | "db" | "tunnel" | "qq" | "llbot"; export const SERVICE_NAMES: ServiceName[] = ["bds", "db", "tunnel", "qq", "llbot"]; -/** argv 一次性 start 在 POSIX 上须 daemonize,否则父进程退出会带走子进程(Windows 不需要) */ -let argvDaemonize = false; -export function setArgvDaemonize(on: boolean): void { - argvDaemonize = on; -} - /** 单服务 start 结果:optional 服务 validate 失败记为 skipped,不抛错 */ export type StartOutcome = { status: "started" } | { status: "already" } | { status: "skipped"; reason: string }; @@ -242,17 +238,18 @@ class Service { // beforeStart 含异步检查;更新可能在等待期间开始。 if (this.updateInProgress) throw new Error("BDS 正在更新,暂不可启动"); this.manualStop = false; - const daemonize = argvDaemonize && process.platform !== "win32"; + /* 子进程始终由守护进程用管道持有(stdin 发 stop、stdout 日志、autoRestart); + * 不再对子服务做 detached/unref——脱离终端由 daemon 进程自身负责。 + * windowsHide:Windows 上避免为每个服务弹出空白控制台(stdout 已 pipe,窗内无输出)。 */ const spawnOpts = { cwd: this.def.cwd, - stdio: (daemonize ? "ignore" : ["pipe", "pipe", "pipe"]) as "ignore" | Array, + stdio: ["pipe", "pipe", "pipe"] as Array<"pipe">, env: this.def.env ? { ...process.env, ...this.def.env } : process.env, - detached: daemonize, + windowsHide: true, }; const child = this.def.service ? spawnService(this.def.service, this.def.args ?? [], spawnOpts) : spawn(this.def.cmd as string, this.def.args ?? [], spawnOpts); - if (daemonize) child.unref(); this.proc = child; this.pid = child.pid ?? 0; this.running = true; @@ -474,7 +471,8 @@ function createServices(): Record { env: bdsSpawnEnvExtra(bdsPath), stopCommand: "stop", stopTimeout: 30000, - autoRestart: bdsCfg.crash_restart !== false, + /* 崩溃拉起由 sfmc daemon 固定开启;不再读 bds_updater.crash_restart */ + autoRestart: true, restartDelay: 5000, validate: () => { if (!fs.existsSync(bdsExe)) return `not found: ${bdsExe}`; @@ -625,6 +623,10 @@ export type ServiceStateEvent = { name: ServiceName; running: boolean; pid: numb /** 订阅任意服务启停(含探测回写);返回取消函数 */ export function onServiceStateChange(fn: (ev: ServiceStateEvent) => void): () => void { + /* CLI 进程不持有 Service 子进程:改订守护进程推送的远程状态事件 */ + if (!isDaemonServer()) { + return onRemoteServiceStateChange(fn); + } const handler = (ev: ServiceStateEvent): void => { fn(ev); }; @@ -651,8 +653,18 @@ async function reconcileManagedAlive(service: Service): Promise { /** * 统一运行态查询(权威入口):OS/健康探测 + 回写 Service 内存标志。 * status / Tab 发送目标 / reload 等均应走此接口,勿直接读 `service.running`。 + * CLI 进程经守护进程 RPC 获取;守护进程内走本地探活。 */ export async function queryServicesRuntime(): Promise { + if (!isDaemonServer()) { + const { queryRuntimeViaDaemon } = await import("./daemon/client.js"); + return queryRuntimeViaDaemon(); + } + return queryServicesRuntimeLocal(); +} + +/** 守护进程内本地探活实现(亦供 server status 组装 rows) */ +export async function queryServicesRuntimeLocal(): Promise { return Promise.all( SERVICE_NAMES.map(async (name) => { const service = services[name]; From 253026445115a3ef43a763009f8eadeb8f92c81d Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Mon, 28 Sep 2026 18:30:33 +0800 Subject: [PATCH 2/3] Co-authored-by: --- .changeset/cli-daemon-supervisor.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/cli-daemon-supervisor.md b/.changeset/cli-daemon-supervisor.md index 307f817b..2231f761 100644 --- a/.changeset/cli-daemon-supervisor.md +++ b/.changeset/cli-daemon-supervisor.md @@ -1,8 +1,8 @@ --- -"@sfmc-bds/cli": minor +"@sfmc-bds/cli": patch "@sfmc-bds/sdk": patch "@sfmc-bds/bds-tools": patch "@sfmc-bds/sfmc": patch --- -新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。 +新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 From 02203f2a93f4bb6c61f0d78c847678b9e39fd9d9 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Mon, 28 Sep 2026 18:32:15 +0800 Subject: [PATCH 3/3] Co-authored-by: --- .changeset/cli-daemon-supervisor.md | 8 -------- modules/sdk/@sfmc-sdk/CHANGELOG.md | 6 ++++++ modules/sdk/@sfmc-sdk/package.json | 2 +- packages/bds-tools/CHANGELOG.md | 8 ++++++++ packages/bds-tools/package.json | 2 +- packages/cli/CHANGELOG.md | 9 +++++++++ packages/cli/package.json | 2 +- packages/db-server/CHANGELOG.md | 8 ++++++++ packages/db-server/package.json | 2 +- packages/devkit/CHANGELOG.md | 9 +++++++++ packages/devkit/package.json | 2 +- packages/meta/CHANGELOG.md | 12 ++++++++++++ packages/meta/package.json | 2 +- packages/qq-bridge/CHANGELOG.md | 7 +++++++ packages/qq-bridge/package.json | 2 +- packages/tools/CHANGELOG.md | 8 ++++++++ packages/tools/package.json | 2 +- 17 files changed, 75 insertions(+), 16 deletions(-) delete mode 100644 .changeset/cli-daemon-supervisor.md diff --git a/.changeset/cli-daemon-supervisor.md b/.changeset/cli-daemon-supervisor.md deleted file mode 100644 index 2231f761..00000000 --- a/.changeset/cli-daemon-supervisor.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@sfmc-bds/cli": patch -"@sfmc-bds/sdk": patch -"@sfmc-bds/bds-tools": patch -"@sfmc-bds/sfmc": patch ---- - -新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 diff --git a/modules/sdk/@sfmc-sdk/CHANGELOG.md b/modules/sdk/@sfmc-sdk/CHANGELOG.md index ad66ed5a..6246d374 100644 --- a/modules/sdk/@sfmc-sdk/CHANGELOG.md +++ b/modules/sdk/@sfmc-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @sfmc-bds/sdk +## 0.2.1 + +### Patch Changes + +- 5a25300: 新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 + ## 0.2.0 ### Minor Changes diff --git a/modules/sdk/@sfmc-sdk/package.json b/modules/sdk/@sfmc-sdk/package.json index a865c064..a7baeafb 100644 --- a/modules/sdk/@sfmc-sdk/package.json +++ b/modules/sdk/@sfmc-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/sdk", - "version": "0.2.0", + "version": "0.2.1", "description": "ScriptsForMinecraftServer unified SDK — SAPI/Node umbrella covering contracts, runtime, module-loader, and db/config/service capabilities.", "author": "Shiroha7z", "license": "ISC", diff --git a/packages/bds-tools/CHANGELOG.md b/packages/bds-tools/CHANGELOG.md index a25a821e..02b965ea 100644 --- a/packages/bds-tools/CHANGELOG.md +++ b/packages/bds-tools/CHANGELOG.md @@ -1,5 +1,13 @@ # @sfmc-bds/bds-tools +## 0.2.1 + +### Patch Changes + +- 5a25300: 新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 +- Updated dependencies [5a25300] + - @sfmc-bds/sdk@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/packages/bds-tools/package.json b/packages/bds-tools/package.json index 496775d8..790000ae 100644 --- a/packages/bds-tools/package.json +++ b/packages/bds-tools/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/bds-tools", - "version": "0.2.0", + "version": "0.2.1", "description": "SFMC BDS auto-updater, lifecycle manager, and behavior-pack assembler", "author": "Shiroha7z", "license": "AGPL-3.0-only", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 7b455b0e..e9de0020 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,14 @@ # @sfmc-bds/cli +## 0.2.3 + +### Patch Changes + +- 5a25300: 新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 +- Updated dependencies [5a25300] + - @sfmc-bds/sdk@0.2.1 + - @sfmc-bds/bds-tools@0.2.1 + ## 0.2.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 93b51e5e..8db5a7a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/cli", - "version": "0.2.2", + "version": "0.2.3", "description": "SFMC management CLI — REPL, service supervisor, module install, behavior-pack build/deploy", "author": "Shiroha7z", "license": "AGPL-3.0-only", diff --git a/packages/db-server/CHANGELOG.md b/packages/db-server/CHANGELOG.md index 2988887d..01641f5a 100644 --- a/packages/db-server/CHANGELOG.md +++ b/packages/db-server/CHANGELOG.md @@ -1,5 +1,13 @@ # @sfmc-bds/db-server +## 0.2.1 + +### Patch Changes + +- Updated dependencies [5a25300] + - @sfmc-bds/sdk@0.2.1 + - @sfmc-bds/bds-tools@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/packages/db-server/package.json b/packages/db-server/package.json index 2a0ad6dd..34a72881 100644 --- a/packages/db-server/package.json +++ b/packages/db-server/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/db-server", - "version": "0.2.0", + "version": "0.2.1", "description": "SFMC SQLite HTTP REST backend — configs, modules, gameplay data", "author": "Shiroha7z", "license": "AGPL-3.0-only", diff --git a/packages/devkit/CHANGELOG.md b/packages/devkit/CHANGELOG.md index 2c8436e0..7a3d3538 100644 --- a/packages/devkit/CHANGELOG.md +++ b/packages/devkit/CHANGELOG.md @@ -1,5 +1,14 @@ # @sfmc-bds/devkit +## 1.0.3 + +### Patch Changes + +- Updated dependencies [5a25300] + - @sfmc-bds/cli@0.2.3 + - @sfmc-bds/sdk@0.2.1 + - @sfmc-bds/bds-tools@0.2.1 + ## 1.0.2 ### Patch Changes diff --git a/packages/devkit/package.json b/packages/devkit/package.json index b78b246d..7162f637 100644 --- a/packages/devkit/package.json +++ b/packages/devkit/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/devkit", - "version": "1.0.2", + "version": "1.0.3", "description": "SFMC module authoring toolkit — watch, rebuild, enable/disable (for VS Code extension & tooling)", "repository": { "type": "git", diff --git a/packages/meta/CHANGELOG.md b/packages/meta/CHANGELOG.md index 648f5e1e..86a1178d 100644 --- a/packages/meta/CHANGELOG.md +++ b/packages/meta/CHANGELOG.md @@ -1,5 +1,17 @@ # @sfmc-bds/sfmc +## 0.2.3 + +### Patch Changes + +- 5a25300: 新增 CLI 守护进程监管:退出 CLI 不停服,经命名管道 RPC 连接;移除 bds_updater 崩溃自启配置项,改由 daemon 固定拉起。正式版补丁 0.2.3。 +- Updated dependencies [5a25300] + - @sfmc-bds/cli@0.2.3 + - @sfmc-bds/sdk@0.2.1 + - @sfmc-bds/bds-tools@0.2.1 + - @sfmc-bds/db-server@0.2.1 + - @sfmc-bds/qq-bridge@0.2.1 + ## 0.2.2 ### Patch Changes diff --git a/packages/meta/package.json b/packages/meta/package.json index f945177c..3a1f0d24 100644 --- a/packages/meta/package.json +++ b/packages/meta/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/sfmc", - "version": "0.2.2", + "version": "0.2.3", "description": "SFMC all-in-one meta package — CLI + db-server + qq-bridge + bds-tools. One install, then `sfmc` to init and run.", "author": "Shiroha7z", "license": "AGPL-3.0-only", diff --git a/packages/qq-bridge/CHANGELOG.md b/packages/qq-bridge/CHANGELOG.md index f4ad426e..06dfad8e 100644 --- a/packages/qq-bridge/CHANGELOG.md +++ b/packages/qq-bridge/CHANGELOG.md @@ -1,5 +1,12 @@ # @sfmc-bds/qq-bridge +## 0.2.1 + +### Patch Changes + +- Updated dependencies [5a25300] + - @sfmc-bds/sdk@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/packages/qq-bridge/package.json b/packages/qq-bridge/package.json index c09f31de..da8da79c 100644 --- a/packages/qq-bridge/package.json +++ b/packages/qq-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/qq-bridge", - "version": "0.2.0", + "version": "0.2.1", "description": "SFMC QQ ↔ MC bridge — official QQ Open Platform Bot and/or LLBot OneBot 11", "author": "Shiroha7z", "license": "AGPL-3.0-only", diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md index 1cc64bce..c7142f99 100644 --- a/packages/tools/CHANGELOG.md +++ b/packages/tools/CHANGELOG.md @@ -1,5 +1,13 @@ # @sfmc-bds/tools +## 0.2.1 + +### Patch Changes + +- Updated dependencies [5a25300] + - @sfmc-bds/sdk@0.2.1 + - @sfmc-bds/bds-tools@0.2.1 + ## 0.2.0 ### Patch Changes diff --git a/packages/tools/package.json b/packages/tools/package.json index a12bf61b..f773d303 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/tools", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "SFMC monorepo-only scripts — verify, docs, release, build bins (not published to npm)", "author": "Shiroha7z",