diff --git a/bds-tools/src/log.ts b/bds-tools/src/log.ts index e08c644..d06366c 100644 --- a/bds-tools/src/log.ts +++ b/bds-tools/src/log.ts @@ -2,8 +2,7 @@ * log.ts — bds-tools 统一日志实例 * * stdout bare + 落盘 LOG_PATH (/.sfmc/logs/bds-update.log)。 - * check-update.ts 是独立入口,用 source = "updater" 单独创建 logger, - * 见该文件内的 createUpdaterLogger()。 + * check-update.ts 是独立入口,用 source = "updater" 单独创建 logger。 */ import { createNodeServiceLogger } from "@sfmc-bds/sdk/logs"; @@ -14,9 +13,7 @@ export const log = createNodeServiceLogger({ logPath: LOG_PATH, }); -/** 关闭文件流 (进程退出前调用,确保缓冲落盘) */ +/** 兼容钩子:FileSink 为 sync append,close 为空操作 */ export function closeLog(): void { log.close(); } - -process.on("exit", () => closeLog()); diff --git a/db-server/src/env.ts b/db-server/src/env.ts index f98622f..4962542 100644 --- a/db-server/src/env.ts +++ b/db-server/src/env.ts @@ -6,19 +6,14 @@ import { ensureCoreConfigs, loadEnsuredConfig, modulePath, - resolveRuntimeRoot, DEFAULT_DB_CONFIG, DEFAULT_QQ_CONFIG, } from "@sfmc-bds/sdk/node/config"; import { isAbsolute, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { log } from "./lib/log.js"; +import { PROJECT_ROOT as RESOLVED_ROOT } from "./project-root.js"; -import { dirname } from "node:path"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); export interface EnvConfig { PROJECT_ROOT: string; @@ -41,7 +36,7 @@ export interface EnvConfig { } export function loadEnv(): EnvConfig { - const PROJECT_ROOT = resolveRuntimeRoot(resolve(__dirname, "..", "..")); + const PROJECT_ROOT = RESOLVED_ROOT; /* 启动时确认 db/qq/permissions 存在;不存在就写带默认值的骨架。 * 不依赖 wizard:wizard 只填字段,骨架由服务自己 ensure。 */ ensureCoreConfigs(PROJECT_ROOT, ["db_config", "qq_config", "permissions"]); diff --git a/db-server/src/lib/log.ts b/db-server/src/lib/log.ts index ad8f2b5..93d54b1 100644 --- a/db-server/src/lib/log.ts +++ b/db-server/src/lib/log.ts @@ -6,16 +6,10 @@ */ import { createNodeServiceLogger } from "@sfmc-bds/sdk/logs"; -import { logFile, resolveRuntimeRoot } from "@sfmc-bds/sdk/node/config"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -/** src/lib → 仓根(与 env.ts 的 src 上溯差一层) */ -const ROOT = resolveRuntimeRoot(resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..")); +import { logFile } from "@sfmc-bds/sdk/node/config"; +import { PROJECT_ROOT } from "../project-root.js"; export const log = createNodeServiceLogger({ source: "db", - logPath: logFile(ROOT, "db"), + logPath: logFile(PROJECT_ROOT, "db"), }); - -process.on("exit", () => log.close()); diff --git a/db-server/src/project-root.ts b/db-server/src/project-root.ts new file mode 100644 index 0000000..5e76f5c --- /dev/null +++ b/db-server/src/project-root.ts @@ -0,0 +1,12 @@ +/** + * project-root.ts — 仓根唯一解析点(DRY/DIP:env / log 勿各自上溯) + */ + +import { resolveRuntimeRoot } from "@sfmc-bds/sdk/node/config"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** SFMC_ROOT > 相对 db-server/src 上溯两级 */ +export const PROJECT_ROOT: string = resolveRuntimeRoot(resolve(HERE, "..", "..")); diff --git a/modules/sdk/@sfmc-sdk/src/logs/logger.ts b/modules/sdk/@sfmc-sdk/src/logs/logger.ts index 31faef4..55ca412 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/logger.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/logger.ts @@ -36,7 +36,9 @@ export interface Logger { /** Node 仓顶服务标准 logger:stdout(可 bare) + 文件落盘 */ export interface NodeServiceLogger extends Logger { - /** 关闭文件 sink */ + /** + * 关闭文件 sink(FileSink 为 sync append 时为空操作;保留统一调用面)。 + */ close(): void; readonly fileSink: FileSink; } diff --git a/modules/sdk/@sfmc-sdk/src/logs/sink.ts b/modules/sdk/@sfmc-sdk/src/logs/sink.ts index 92f5abf..6d46160 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/sink.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/sink.ts @@ -2,7 +2,7 @@ * sink.ts — 日志输出目标实现 * * StdoutSink: 输出到 stdout (可选颜色,可选 stderr 路由 error) - * FileSink: 追加写入文件 (纯文本,无 ANSI 码,单例 FD) + * FileSink: 同步追加写入文件 (纯文本,无 ANSI 码;进程退出无需 flush) */ import fs from "node:fs"; @@ -49,12 +49,13 @@ export function createStdoutSink(opts: StdoutSinkOptions = {}): Sink { export interface FileSinkOptions { /** 是否自动创建父目录 (默认 true) */ mkdir?: boolean; - /** 文件打开模式 (默认 "a" 追加) */ - flags?: string; } export interface FileSink extends Sink { - /** 关闭底层文件流,释放 FD */ + /** + * 兼容钩子:当前实现为 sync appendFile,无持有 FD,调用为空操作。 + * 保留以便 NodeServiceLogger / 进程 exit 统一调用而不破坏 LSP。 + */ close(): void; } diff --git a/qq-bridge/src/config.ts b/qq-bridge/src/config.ts index caf7d77..4c12005 100644 --- a/qq-bridge/src/config.ts +++ b/qq-bridge/src/config.ts @@ -11,19 +11,14 @@ import { configPath, DEFAULT_QQ_CONFIG, loadEnsuredConfig, - resolveRuntimeRoot, stripConfigMeta, } from "@sfmc-bds/sdk/node/config"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { log } from "./log.js"; +import { PROJECT_ROOT } from "./project-root.js"; import type { QQBridgeConfig } from "./types.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -/** 统一通过 SDK 解析项目根:env SFMC_ROOT > __dirname 上溯。 */ -export const ROOT_DIR: string = resolveRuntimeRoot(resolve(__dirname, "..", "..")); +/** 统一通过 SDK 解析项目根:env SFMC_ROOT > project-root 上溯。 */ +export const ROOT_DIR: string = PROJECT_ROOT; export const CFG_PATH: string = configPath(ROOT_DIR, "qq_config.json"); function applyDefaults(raw: Partial): QQBridgeConfig { diff --git a/qq-bridge/src/log.ts b/qq-bridge/src/log.ts index d502ac7..8f408b1 100644 --- a/qq-bridge/src/log.ts +++ b/qq-bridge/src/log.ts @@ -6,15 +6,10 @@ */ import { createNodeServiceLogger } from "@sfmc-bds/sdk/logs"; -import { logFile, resolveRuntimeRoot } from "@sfmc-bds/sdk/node/config"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = resolveRuntimeRoot(resolve(dirname(fileURLToPath(import.meta.url)), "..", "..")); +import { logFile } from "@sfmc-bds/sdk/node/config"; +import { PROJECT_ROOT } from "./project-root.js"; export const log = createNodeServiceLogger({ source: "qq", - logPath: logFile(ROOT, "qq"), + logPath: logFile(PROJECT_ROOT, "qq"), }); - -process.on("exit", () => log.close()); diff --git a/qq-bridge/src/project-root.ts b/qq-bridge/src/project-root.ts new file mode 100644 index 0000000..65bd231 --- /dev/null +++ b/qq-bridge/src/project-root.ts @@ -0,0 +1,12 @@ +/** + * project-root.ts — 仓根唯一解析点(DRY/DIP:config / log 勿各自上溯) + */ + +import { resolveRuntimeRoot } from "@sfmc-bds/sdk/node/config"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** SFMC_ROOT > 相对 qq-bridge/src 上溯两级 */ +export const PROJECT_ROOT: string = resolveRuntimeRoot(resolve(HERE, "..", "..")); diff --git a/sfmc/package.json b/sfmc/package.json index baf1978..be2d3be 100644 --- a/sfmc/package.json +++ b/sfmc/package.json @@ -45,7 +45,7 @@ "start": "node ./dist/main.js", "build": "node ../scripts/esbuild-transpile.mjs --dts", "typecheck": "tsc7 --noEmit -p tsconfig.json", - "test": "npm run build && node --test pack-update-policy.test.mjs pack-update-config.test.mjs terminal-progress.test.mjs wrap-log-line.test.mjs", + "test": "npm run build && node --test pack-update-policy.test.mjs pack-update-config.test.mjs terminal-progress.test.mjs wrap-log-line.test.mjs world-packs-uninstall.test.mjs", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/sfmc/src/logs.ts b/sfmc/src/logs.ts index 9887d90..d031120 100644 --- a/sfmc/src/logs.ts +++ b/sfmc/src/logs.ts @@ -114,25 +114,39 @@ export const SOURCE_META: SourceMeta[] = [ { value: "bds-tools", name: "BDSTools", paint: (s) => c.red(s) }, ]; +/** 源标签无色文本(formatSourceTag / logPrefixWidth 共用) */ +function sourceTagPlain(source: string): string { + const meta = SOURCE_META.find((m) => m.value === source); + if (meta) return `[${meta.name}]`; + return `[${source.padEnd(7).slice(0, 8)}]`; +} + export function formatSourceTag(source: string): string { const meta = SOURCE_META.find((m) => m.value === source); - if (meta) return meta.paint(`[${meta.name}]`); - return c.bold(`[${source.padEnd(7).slice(0, 8)}]`); + const plain = sourceTagPlain(source); + if (meta) return meta.paint(plain); + return c.bold(plain); } /** 简化BDS日志 */ function stripLogPrefix(line: string): string { // 匹配格式: [2026-07-18 23:56:06:778 INFO] - const prefixRegex = /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}:\d{3} (INFO|WARNING|ERROR|FATAL|DEBUG)\]\s*/; - return line.replace(prefixRegex, ""); + return line.replace(BDS_TS_PREFIX_RE, ""); } +/** BDS 行首时间戳+级别前缀(strip / 解析级别共用,DRY) */ +const BDS_TS_PREFIX_RE = + /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}:\d{3} (INFO|WARNING|ERROR|FATAL|DEBUG)\]\s*/i; + /** * 从一行 BDS 日志中提取日志等级 * @param line 日志行字符串 * @returns 日志等级(大写),若无法识别则返回 'UNKNOWN' */ function getLogLevel(line: string): string { + const fromTs = BDS_TS_PREFIX_RE.exec(line); + if (fromTs?.[1]) return fromTs[1].toUpperCase(); + const levelNames = ["INFO", "WARNING", "ERROR", "FATAL", "DEBUG", "TRACE", "WARN"]; const levelPattern = levelNames.join("|"); @@ -151,44 +165,56 @@ function getLogLevel(line: string): string { return "UNKNOWN"; } -/** 格式化日志用于 REPL 展示 (用 theme.ts chalk 配色) */ -export function formatLog(l: UnifiedLog): string { - const ts = c.dim(l.time.toLocaleTimeString()); - let lvl = levelTag(l.level); - let txt = highlightLogLine(l.text); - const src = formatSourceTag(l.source); - if (l.source === "bds") { - const parsed = getLogLevel(l.text); - const mapped: LogLevel = - parsed === "WARNING" || parsed === "WARN" - ? "warn" - : parsed === "ERROR" || parsed === "FATAL" - ? "error" - : parsed === "DEBUG" || parsed === "TRACE" - ? "debug" - : "info"; - /* 去掉 BDS 自带时间戳前缀后再高亮正文 */ - txt = highlightLogLine(stripLogPrefix(l.text)); - lvl = levelTag(mapped); - } - return `${ts} ${src} ${lvl} ${txt}`; +/** 展示用级别:BDS 行从正文解析,其余用 entry.level(DRY:formatLog / logPrefixWidth 共用) */ +export function resolveDisplayLevel(l: UnifiedLog): LogLevel { + if (l.source !== "bds") return l.level; + const parsed = getLogLevel(l.text); + if (parsed === "WARNING" || parsed === "WARN") return "warn"; + if (parsed === "ERROR" || parsed === "FATAL") return "error"; + if (parsed === "DEBUG" || parsed === "TRACE") return "debug"; + return "info"; +} + +/** 无色级别标签文本(可见宽度权威源) */ +const LEVEL_TAG_TEXT: Record = { + error: "[ERR]", + warn: "[WRN]", + success: "[OK]", + debug: "[DBG]", + info: "[INF]", +}; + +function levelTagPlain(lvl: LogLevel): string { + return LEVEL_TAG_TEXT[lvl] ?? LEVEL_TAG_TEXT.info; } function levelTag(lvl: LogLevel): string { + const text = levelTagPlain(lvl); switch (lvl) { case "error": - return c.red("[ERR]"); + return c.red(text); case "warn": - return c.yellow("[WRN]"); + return c.yellow(text); case "success": - return c.green(c.bold("[OK]")); + return c.green(c.bold(text)); case "debug": - return c.dim("[DBG]"); + return c.dim(text); default: - return c.blue("[INF]"); + return c.blue(text); } } +/** 格式化日志用于 REPL 展示 (用 theme.ts chalk 配色) */ +export function formatLog(l: UnifiedLog): string { + const ts = c.dim(l.time.toLocaleTimeString()); + const level = resolveDisplayLevel(l); + const lvl = levelTag(level); + const src = formatSourceTag(l.source); + /* BDS:去掉自带时间戳前缀后再高亮正文 */ + const txt = highlightLogLine(l.source === "bds" ? stripLogPrefix(l.text) : l.text); + return `${ts} ${src} ${lvl} ${txt}`; +} + /* ================================================================== * 软换行: 超终端宽度时换行,后续行缩进对齐 * ================================================================== */ @@ -219,42 +245,14 @@ export function visibleWidth(s: string): number { return w; } -/** 无色级别标签,与 levelTag 可见宽度一致 */ -function levelTagPlain(lvl: LogLevel): string { - switch (lvl) { - case "error": - return "[ERR]"; - case "warn": - return "[WRN]"; - case "success": - return "[OK]"; - case "debug": - return "[DBG]"; - default: - return "[INF]"; - } -} - /** * 日志前缀可见宽度(时间 + 源 + 级别 + 尾空格),供悬挂缩进对齐正文起点。 - * 与 formatLog 拼接顺序保持一致。 + * 与 formatLog 拼接顺序保持一致(共用 resolveDisplayLevel / sourceTagPlain / levelTagPlain)。 */ export function logPrefixWidth(l: UnifiedLog): number { const ts = l.time.toLocaleTimeString(); - const meta = SOURCE_META.find((m) => m.value === l.source); - const src = meta ? `[${meta.name}]` : `[${l.source.padEnd(7).slice(0, 8)}]`; - let level: LogLevel = l.level; - if (l.source === "bds") { - const parsed = getLogLevel(l.text); - level = - parsed === "WARNING" || parsed === "WARN" - ? "warn" - : parsed === "ERROR" || parsed === "FATAL" - ? "error" - : parsed === "DEBUG" || parsed === "TRACE" - ? "debug" - : "info"; - } + const src = sourceTagPlain(l.source); + const level = resolveDisplayLevel(l); return visibleWidth(`${ts} ${src} ${levelTagPlain(level)} `); } diff --git a/sfmc/src/world-packs.ts b/sfmc/src/world-packs.ts index c0d4458..cfb2105 100644 --- a/sfmc/src/world-packs.ts +++ b/sfmc/src/world-packs.ts @@ -228,6 +228,35 @@ function formatUninstallPickLabel(pack: InstalledWorldPack): string { return `[${kind}] ${pack.name} ${pack.folderName} ${en.trim()} ${pack.uuid.slice(0, 8)}`; } +/** + * 按 CLI id 列表解析待卸载包(DIP:解析与 TTY/确认/执行分离,便于单测)。 + * - 任一 id 找不到 → not_found + * - 任一命中受保护包 → protected(与单 id 中止契约一致,LSP) + */ +export function resolveUninstallTargets( + packs: InstalledWorldPack[], + ids: string[] +): + | { status: "ok"; selected: InstalledWorldPack[] } + | { status: "not_found"; missing: string[] } + | { status: "protected"; folder: string } { + const missing: string[] = []; + const byUuid = new Map(); + for (const id of ids) { + const pack = findInstalledPackById(packs, id); + if (!pack) { + missing.push(id); + continue; + } + byUuid.set(pack.uuid.toLowerCase(), pack); + } + if (missing.length) return { status: "not_found", missing }; + const selected = [...byUuid.values()]; + const blocked = selected.find((p) => isProtectedSfmcPack(p)); + if (blocked) return { status: "protected", folder: blocked.folderName }; + return { status: "ok", selected }; +} + /** TTY 多选待卸载包;取消返回 null;无可卸项返回 [] */ async function pickPacksForUninstall(packs: InstalledWorldPack[]): Promise { const candidates = packs.filter((p) => !isProtectedSfmcPack(p)); @@ -816,29 +845,16 @@ export async function dispatchPacksCommand(sub: string | undefined, args: string if (picked === null) return c.dim(t("packs.uninstall.cancelled")); if (picked.length === 0) return c.yellow(t("packs.uninstall.none")); selected = picked; - } else if (ids.length === 1) { - /* 单 id:受保护主包直接中止(与 #72 LSP 一致,不进批量确认) */ - const pack = findInstalledPackById(packs, ids[0]!); - if (!pack) return c.red(t("packs.notFound", { id: ids[0]! })); - if (isProtectedSfmcPack(pack)) { - return c.red(t("packs.uninstall.protected", { folder: pack.folderName })); - } - selected = [pack]; } else { - const missing: string[] = []; - const byUuid = new Map(); - for (const id of ids) { - const pack = findInstalledPackById(packs, id); - if (!pack) { - missing.push(id); - continue; - } - byUuid.set(pack.uuid.toLowerCase(), pack); + /* 单/多 id 共用解析原语(LSP:任一受保护包整体中止) */ + const resolved = resolveUninstallTargets(packs, ids); + if (resolved.status === "not_found") { + return c.red(t("packs.notFound", { id: resolved.missing.join(", ") })); } - if (missing.length) { - return c.red(t("packs.notFound", { id: missing.join(", ") })); + if (resolved.status === "protected") { + return c.red(t("packs.uninstall.protected", { folder: resolved.folder })); } - selected = [...byUuid.values()]; + selected = resolved.selected; } return await uninstallPacksBatch(selected, packs, { diff --git a/sfmc/world-packs-uninstall.test.mjs b/sfmc/world-packs-uninstall.test.mjs index c9b959f..5ef3b63 100644 --- a/sfmc/world-packs-uninstall.test.mjs +++ b/sfmc/world-packs-uninstall.test.mjs @@ -1,5 +1,5 @@ /** - * packs uninstall 策略契约:受保护平台包识别(DRY 与 CLI 同源) + * packs uninstall 策略契约:受保护平台包识别 + 多 id 解析(DRY/LSP 与 CLI 同源) * 需先 `npm run build -w @sfmc-bds/cli` */ import assert from "node:assert/strict"; @@ -7,10 +7,22 @@ import path from "node:path"; import { describe, it } from "node:test"; import { pathToFileURL } from "node:url"; -const { isProtectedSfmcPack } = await import( +const { isProtectedSfmcPack, resolveUninstallTargets } = await import( pathToFileURL(path.resolve("dist/world-packs.js")).href ); +function fakePack(folderName, uuid = `uuid-${folderName}`) { + return { + folderName, + uuid, + name: folderName, + kind: "behavior", + version: [1, 0, 0], + enabled: true, + dir: `/tmp/${folderName}`, + }; +} + describe("packs uninstall protection", () => { it("识别 sfmc-modules / sfmc-modules-rp,忽略大小写", () => { assert.equal(isProtectedSfmcPack({ folderName: "sfmc-modules" }), true); @@ -19,3 +31,39 @@ describe("packs uninstall protection", () => { assert.equal(isProtectedSfmcPack({ folderName: "[BP] MyAddon" }), false); }); }); + +describe("resolveUninstallTargets LSP", () => { + const packs = [ + fakePack("sfmc-modules", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + fakePack("[BP] Addon", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + fakePack("[BP] Other", "cccccccc-cccc-cccc-cccc-cccccccccccc"), + ]; + + it("单 id 受保护 → protected", () => { + const r = resolveUninstallTargets(packs, ["sfmc-modules"]); + assert.equal(r.status, "protected"); + assert.equal(r.folder, "sfmc-modules"); + }); + + it("多 id 含受保护 → 整体 protected(不返回 ok)", () => { + const r = resolveUninstallTargets(packs, ["[BP] Addon", "sfmc-modules"]); + assert.equal(r.status, "protected"); + assert.equal(r.folder, "sfmc-modules"); + }); + + it("多 id 均可卸 → ok,按 uuid 去重", () => { + const r = resolveUninstallTargets(packs, [ + "[BP] Addon", + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "[BP] Other", + ]); + assert.equal(r.status, "ok"); + assert.equal(r.selected.length, 2); + }); + + it("缺 id → not_found", () => { + const r = resolveUninstallTargets(packs, ["nope", "[BP] Addon"]); + assert.equal(r.status, "not_found"); + assert.deepEqual(r.missing, ["nope"]); + }); +}); diff --git a/sfmc/wrap-log-line.test.mjs b/sfmc/wrap-log-line.test.mjs index a56aa69..5c85587 100644 --- a/sfmc/wrap-log-line.test.mjs +++ b/sfmc/wrap-log-line.test.mjs @@ -77,3 +77,25 @@ test("logPrefixWidth 与常见源标签匹配", () => { /* HH:MM:SS + space + [ PACK ] + space + [OK] + space */ assert.ok(w >= 24 && w <= 28, `prefix width unexpected: ${w}`); }); + +test("resolveDisplayLevel:BDS 行从正文解析,其余用 entry.level", async () => { + const { resolveDisplayLevel } = await import("./dist/logs.js"); + assert.equal( + resolveDisplayLevel({ + time: new Date(), + text: "[2026-07-18 23:56:06:778 ERROR] boom", + source: "bds", + level: "info", + }), + "error" + ); + assert.equal( + resolveDisplayLevel({ + time: new Date(), + text: "ok", + source: "pack", + level: "success", + }), + "success" + ); +});