diff --git a/bds-tools/package.json b/bds-tools/package.json index a2b0fe56..d5716db8 100644 --- a/bds-tools/package.json +++ b/bds-tools/package.json @@ -32,6 +32,8 @@ "./bds-manager": "./dist/bds-manager.js", "./pack-manager": "./dist/cli-pack-manager.js", "./world-packs": "./dist/world-packs.js", + "./taskbar": "./dist/taskbar.js", + "./update-result": "./dist/update-result.js", "./package.json": "./package.json", "./types": { "types": "./dist/types.d.ts" diff --git a/bds-tools/src/check-update.ts b/bds-tools/src/check-update.ts index 9f90563a..c8067aa5 100644 --- a/bds-tools/src/check-update.ts +++ b/bds-tools/src/check-update.ts @@ -21,6 +21,8 @@ import { httpDownload } from "./http.js"; import { isMainModule } from "./is-main.js"; import { loadConfig, LOG_PATH, resolvePaths } from "./paths.js"; import { sendText, sendWithImage } from "./qqutil.js"; +import { emitUpdateResult } from "./update-result.js"; +import { extractZipFileToDir } from "./zipx.js"; import { clearRollbackMarker, getDirSize, @@ -133,7 +135,7 @@ async function restorePreserves(bdsPath: string, backupDir: string, preserve: st } } -/** 把 srcDir 下的内容移动到 destDir (覆盖) — 经 zipx 安全解压 */ +/** 把 zip 解压到 destDir (覆盖) — 经 zipx 安全解压 */ async function extractZipToBds(zipPath: string, destDir: string): Promise { // 抽出到临时目录,避免旧内容干扰 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bds-extract-")); @@ -183,7 +185,7 @@ export async function runUpdate(): Promise { } console.log(`CURRENT=${currentVer}`); console.log(`LATEST=${latestVer}`); - console.log("SFMC_UPDATE_RESULT=uptodate"); + emitUpdateResult("uptodate"); return 0; } } catch (e) { @@ -198,7 +200,7 @@ export async function runUpdate(): Promise { if (qqNotify) { await sendText(`⚠️ BDS ${latestVer} 不在兼容性白名单,已跳过升级。请人工确认。`); } - console.log("SFMC_UPDATE_RESULT=skipped"); + emitUpdateResult("skipped"); return 2; } @@ -220,7 +222,7 @@ export async function runUpdate(): Promise { console.log(`LATEST=${latestVer}`); console.log(`CHANNEL=${channel}`); console.log(`URLS=${downloadUrls.length}`); - console.log("SFMC_UPDATE_RESULT=check-only"); + emitUpdateResult("check-only"); return 0; } @@ -474,7 +476,7 @@ export async function runUpdate(): Promise { } log.info(`===== 更新完成 (${(durationMs / 1000).toFixed(1)}s) =====`); /* 机器可读结果标记:供 sfmc 等监督器判断「真正完成部署」,勿依赖本地化日志文案。 */ - console.log("SFMC_UPDATE_RESULT=deployed"); + emitUpdateResult("deployed"); return 0; } diff --git a/bds-tools/src/taskbar.ts b/bds-tools/src/taskbar.ts index 292cd649..89dcdced 100644 --- a/bds-tools/src/taskbar.ts +++ b/bds-tools/src/taskbar.ts @@ -62,6 +62,11 @@ function canWriteOsc(): boolean { return supported && !!process.stdout.isTTY; } +/** 从日志文本剥离 WT 任务栏 OSC 9;4(与写入侧同一权威,供父进程 DRY)。 */ +export function stripTaskbarOsc(s: string): string { + return s.replace(/\x1b\]9;4[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""); +} + /** * 设置任务栏进度。 * - normal: 绿色 (pct 0-100) diff --git a/bds-tools/src/update-result.ts b/bds-tools/src/update-result.ts new file mode 100644 index 00000000..3f829686 --- /dev/null +++ b/bds-tools/src/update-result.ts @@ -0,0 +1,20 @@ +/** + * update-result.ts — BDS updater ↔ sfmc 监督器机器可读结果契约(DRY / DIP) + * + * 勿依赖本地化日志文案判断「是否真正部署」;一律解析本标记。 + */ +export const UPDATE_RESULT_PREFIX = "SFMC_UPDATE_RESULT="; + +export type UpdateResultKind = "uptodate" | "skipped" | "check-only" | "deployed"; + +/** 向 stdout 打印机器标记(供父进程捕获)。 */ +export function emitUpdateResult(kind: UpdateResultKind): void { + console.log(`${UPDATE_RESULT_PREFIX}${kind}`); +} + +/** 从 updater 合并输出中解析是否完成部署。 */ +export function didUpdateDeploy(out: string): boolean { + return new RegExp( + `(?:^|\\n)${UPDATE_RESULT_PREFIX}deployed(?:\\r?\\n|$)` + ).test(out); +} diff --git a/sfmc/src/commands.ts b/sfmc/src/commands.ts index 297b2d55..e032fe77 100644 --- a/sfmc/src/commands.ts +++ b/sfmc/src/commands.ts @@ -3,6 +3,8 @@ import { t } from "./i18n/index.js"; import { spawnService } from "./runtime.js"; import { ROOT, SERVICE_NAMES, services, type ServiceName } from "./services.js"; import { c, DIVIDER, highlightLogLine } from "./theme.js"; +import { stripTaskbarOsc } from "@sfmc-bds/bds-tools/taskbar"; +import { didUpdateDeploy } from "@sfmc-bds/bds-tools/update-result"; function parseService(raw: string): ServiceName | null { const s = raw.toLowerCase() as ServiceName; @@ -161,9 +163,9 @@ export async function cmdStopAll(): Promise { return c.dim(t("svc.allStopped")); } -/** 剥离 Windows Terminal 任务栏 OSC,避免 pipe 日志出现空白行 */ -function stripTaskbarOsc(s: string): string { - return s.replace(/\x1b\]9;4[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""); +/** 剥离 Windows Terminal 任务栏 OSC,避免 pipe 日志出现空白行(委托 bds-tools/taskbar) */ +function stripOsc(s: string): string { + return stripTaskbarOsc(s); } /** @@ -188,7 +190,7 @@ export async function cmdUpdate(args: string[] = []): Promise { }); let out = ""; const pushChunk = (raw: string, level: "info" | "error"): void => { - const s = stripTaskbarOsc(raw); + const s = stripOsc(raw); out += s; for (const line of s .split(/\r?\n/) @@ -215,7 +217,7 @@ export async function cmdUpdate(args: string[] = []): Promise { /* 仅在真正完成部署后拉起 BDS;「已是最新」不打扰当前状态。 * 以 updater 输出的 SFMC_UPDATE_RESULT=deployed 机器标记为准(勿匹配本地化日志)。 */ - const didDeploy = /(?:^|\n)SFMC_UPDATE_RESULT=deployed(?:\r?\n|$)/.test(result.out); + const didDeploy = didUpdateDeploy(result.out); if (result.code === 0 && didDeploy && !userNoStart && !checkOnly) { if (bds.running) { return result.out; diff --git a/sfmc/src/i18n/locales/en.ts b/sfmc/src/i18n/locales/en.ts index 60c8a0aa..803e1505 100644 --- a/sfmc/src/i18n/locales/en.ts +++ b/sfmc/src/i18n/locales/en.ts @@ -294,6 +294,10 @@ export const en = { "mod.dbUnreachable": "Cannot reach db-server at {url}: {message}. Is db-service running? (sfmc> start db)", "mod.toggleFailed": "{action} {id} failed: {err}", + "mod.action.enabled": "enabled", + "mod.action.disabled": "disabled", + "mod.toggleOk": "{label} {id}", + "mod.toggleBadBody": "{action} {id} returned: {text}", "mod.enable.usage": "Usage: sfmc module|mod enable ", "mod.disable.usage": "Usage: sfmc module|mod disable ", "mod.install.usage": "Usage: sfmc module|mod install [id2 ...] [--from ] [--link]", @@ -337,6 +341,10 @@ export const en = { "packs.installedEnabled": "[packs] Installed & enabled {kind}: {name} v{version}", "packs.installFailed": "[packs] Install failed {name}: {reason}", "packs.listEmpty": "(none)", + "packs.list.bpHeader": "Behavior packs:", + "packs.list.rpHeader": "Resource packs:", + "packs.list.on": "on ", + "packs.list.off": "off", "packs.doctor.parseFail": "{file} could not be parsed", "packs.doctor.missingDir": "List has uuid but directory missing ({kind}): {uuid}", "packs.doctor.versionMismatch": diff --git a/sfmc/src/i18n/locales/zh-CN.ts b/sfmc/src/i18n/locales/zh-CN.ts index 98e43be1..728402f1 100644 --- a/sfmc/src/i18n/locales/zh-CN.ts +++ b/sfmc/src/i18n/locales/zh-CN.ts @@ -280,6 +280,10 @@ export const zhCN: Record = { "mod.dbUnreachable": "无法连接 db-server {url}: {message}。db 服务是否在运行?(sfmc> start db)", "mod.toggleFailed": "{action} {id} 失败: {err}", + "mod.action.enabled": "已启用", + "mod.action.disabled": "已禁用", + "mod.toggleOk": "{label} {id}", + "mod.toggleBadBody": "{action} {id} 返回: {text}", "mod.enable.usage": "用法: sfmc module|mod enable ", "mod.disable.usage": "用法: sfmc module|mod disable ", "mod.install.usage": "用法: sfmc module|mod install [id2 ...] [--from ] [--link]", @@ -321,6 +325,10 @@ export const zhCN: Record = { "packs.installedEnabled": "[packs] 已安装并启用 {kind}: {name} v{version}", "packs.installFailed": "[packs] 安装失败 {name}: {reason}", "packs.listEmpty": "(无)", + "packs.list.bpHeader": "行为包:", + "packs.list.rpHeader": "资源包:", + "packs.list.on": "开 ", + "packs.list.off": "关", "packs.doctor.parseFail": "{file} 无法解析", "packs.doctor.missingDir": "清单有 uuid 但目录缺失 ({kind}): {uuid}", "packs.doctor.versionMismatch": @@ -342,8 +350,8 @@ export const zhCN: Record = { "packs.search.usage": "用法: packs search ", "packs.toggle.usage": "用法: packs {verb} ", "packs.install.usage": "用法: packs install [path|--inbox] [--force]", - "packs.installInbox": "install inbox: installed={installed} skipped={skipped} failed={failed}", - "packs.scanResult": "scan: installed={installed} skipped={skipped} failed={failed}", + "packs.installInbox": "收件箱安装: 成功={installed} 跳过={skipped} 失败={failed}", + "packs.scanResult": "扫描: 成功={installed} 跳过={skipped} 失败={failed}", "packs.unknownSub": "未知 packs 子命令: {verb}\n", "packs.usage.title": "{cmd}(别名 {alias})— 世界侧任意 BP/RP", "packs.usage.bump": "仅 RP,patch 版本 +1", diff --git a/sfmc/src/module-commands.ts b/sfmc/src/module-commands.ts index a43cbd4d..43af7f5a 100644 --- a/sfmc/src/module-commands.ts +++ b/sfmc/src/module-commands.ts @@ -371,9 +371,11 @@ async function postModuleToggle(id: string, action: "enable" | "disable"): Promi return failResult(c.red(t("mod.toggleFailed", { action, id, err }))); } const ok = (body as { success?: boolean })?.success !== false; - return ok - ? okResult(c.green(`${action}d ${id}`)) - : failResult(c.red(`${action} ${id} returned: ${text}`)); + if (ok) { + const label = action === "enable" ? t("mod.action.enabled") : t("mod.action.disabled"); + return okResult(c.green(t("mod.toggleOk", { label, id }))); + } + return failResult(c.red(t("mod.toggleBadBody", { action, id, text }))); } export async function cmdModuleEnable(args: string[]): Promise { diff --git a/sfmc/src/world-packs.ts b/sfmc/src/world-packs.ts index 9922e407..069f7e93 100644 --- a/sfmc/src/world-packs.ts +++ b/sfmc/src/world-packs.ts @@ -409,16 +409,16 @@ function formatPackList(packs: InstalledWorldPack[]): string { const bp = packs.filter((p) => p.kind === "behavior"); const rp = packs.filter((p) => p.kind === "resource"); if (bp.length) { - lines.push(c.bold("Behavior packs:")); + lines.push(c.bold(t("packs.list.bpHeader"))); for (const p of bp) { - const en = p.enabled ? c.green("on ") : c.dim("off"); + const en = p.enabled ? c.green(t("packs.list.on")) : c.dim(t("packs.list.off")); lines.push(` [${en}] ${p.folderName} ${p.name} v${fmtVer(p.version)} ${c.dim(p.uuid)}`); } } if (rp.length) { - lines.push(c.bold("Resource packs:")); + lines.push(c.bold(t("packs.list.rpHeader"))); for (const p of rp) { - const en = p.enabled ? c.green("on ") : c.dim("off"); + const en = p.enabled ? c.green(t("packs.list.on")) : c.dim(t("packs.list.off")); lines.push(` [${en}] ${p.folderName} ${p.name} v${fmtVer(p.version)} ${c.dim(p.uuid)}`); } }