From 6bdb2bb1eab625a8db828a8bbfab42639fd3af20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 15:37:53 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20zip-slip/=E5=86=B2=E7=AA=81=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=A6=86=E7=9B=96=20+=20i18n=20=E7=BC=BA=E5=8F=A3=20+?= =?UTF-8?q?=20updater=20=E7=BB=93=E6=9E=9C=E5=A5=91=E7=BA=A6=20DRY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查 main@8d206ff(i18n/updater/docs)与遗留 #51: - BLOCKER(DRY): zipx 安全解压,world-packs/check-update 共用 - BLOCKER: installPackDirectory 残缺 manifest 同名不再静默覆盖 - MAJOR(LSP/Demeter): enabledMap 统一;doctor 经 listWorldEnableEntries - MAJOR(DRY): SFMC_UPDATE_RESULT / stripTaskbarOsc 抽到 bds-tools 权威模块 - MAJOR(i18n/LSP): postModuleToggle 与 packs list 头/开关走 t();补全 zh-CN 收件箱文案 Co-authored-by: Shiroha --- bds-tools/package.json | 4 +- bds-tools/src/check-update.ts | 27 ++++-------- bds-tools/src/cli-pack-manager.ts | 73 ++++++++++++++++--------------- bds-tools/src/taskbar.ts | 5 +++ bds-tools/src/update-result.ts | 20 +++++++++ bds-tools/src/world-packs.ts | 60 +++++++++++++------------ bds-tools/src/zipx.ts | 56 ++++++++++++++++++++++++ bds-tools/world-packs.test.mjs | 40 +++++++++++++++++ bds-tools/zipx.test.mjs | 41 +++++++++++++++++ sfmc/src/commands.ts | 12 ++--- sfmc/src/i18n/locales/en.ts | 8 ++++ sfmc/src/i18n/locales/zh-CN.ts | 12 ++++- sfmc/src/module-commands.ts | 8 ++-- sfmc/src/runtime.ts | 10 ++++- sfmc/src/world-packs.ts | 26 +++-------- 15 files changed, 288 insertions(+), 114 deletions(-) create mode 100644 bds-tools/src/update-result.ts create mode 100644 bds-tools/src/zipx.ts create mode 100644 bds-tools/zipx.test.mjs diff --git a/bds-tools/package.json b/bds-tools/package.json index 6456b025..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" @@ -42,7 +44,7 @@ "dev": "tsx src/bds-manager.ts", "typecheck": "tsc --noEmit", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "test": "node --test is-main.test.mjs pack-manager-lifecycle.test.mjs server-properties.test.mjs world-packs.test.mjs", + "test": "node --test is-main.test.mjs pack-manager-lifecycle.test.mjs server-properties.test.mjs world-packs.test.mjs zipx.test.mjs", "update": "node dist/check-update.js", "update:check": "node dist/check-update.js --check-only", "update:force": "node dist/check-update.js --force", diff --git a/bds-tools/src/check-update.ts b/bds-tools/src/check-update.ts index 980aceb9..3609508f 100644 --- a/bds-tools/src/check-update.ts +++ b/bds-tools/src/check-update.ts @@ -11,7 +11,6 @@ import { createFileSink, createLogger, createStdoutSink } from "@sfmc-bds/sdk/logs"; import cliProgress from "cli-progress"; -import JSZip from "jszip"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -22,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,24 +134,12 @@ async function restorePreserves(bdsPath: string, backupDir: string, preserve: st } } -/** 把 srcDir 下的内容移动到 destDir (覆盖) */ +/** 把 zip 解压到 destDir (覆盖) — 经 zipx 安全解压 */ async function extractZipToBds(zipPath: string, destDir: string): Promise { - const data = await fs.promises.readFile(zipPath); - const zip = await JSZip.loadAsync(data); // 抽出到临时目录,避免旧内容干扰 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bds-extract-")); try { - const entries = Object.values(zip.files); - for (const e of entries) { - const out = path.join(tmpDir, e.name); - if (e.dir) { - fs.mkdirSync(out, { recursive: true }); - continue; - } - fs.mkdirSync(path.dirname(out), { recursive: true }); - const buf = await e.async("nodebuffer"); - fs.writeFileSync(out, buf); - } + await extractZipFileToDir(zipPath, tmpDir); // 把临时目录里的内容复制到 BDS 路径 for (const entry of fs.readdirSync(tmpDir)) { const srcPath = path.join(tmpDir, entry); @@ -195,7 +184,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) { @@ -210,7 +199,7 @@ export async function runUpdate(): Promise { if (qqNotify) { await sendText(`⚠️ BDS ${latestVer} 不在兼容性白名单,已跳过升级。请人工确认。`); } - console.log("SFMC_UPDATE_RESULT=skipped"); + emitUpdateResult("skipped"); return 2; } @@ -232,7 +221,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; } @@ -486,7 +475,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/cli-pack-manager.ts b/bds-tools/src/cli-pack-manager.ts index 7cedc7e2..2252e81f 100644 --- a/bds-tools/src/cli-pack-manager.ts +++ b/bds-tools/src/cli-pack-manager.ts @@ -210,43 +210,46 @@ async function main(): Promise { process.stdout.write(`${JSON.stringify(list)}\n`); return; } - case "list-installed": { - const wp = await import("./world-packs.js"); - const bdsRoot = need(args, "bds-root"); - const level = need(args, "level"); - const list = wp.listInstalledWorldPacks(path.resolve(bdsRoot), level); - process.stdout.write(`${JSON.stringify(list)}\n`); - return; - } - case "bump-version": { - const wp = await import("./world-packs.js"); - const packDir = need(args, "pack-dir"); - const next = wp.bumpPackPatchVersion(path.resolve(packDir)); - process.stdout.write(`${JSON.stringify({ version: next })}\n`); - return; - } - case "install-dir": { - const wp = await import("./world-packs.js"); - const src = need(args, "src"); - const destParent = need(args, "dest-parent"); - const force = !!args["force"]; - const r = await wp.installPackDirectory({ - srcDir: path.resolve(src), - destParent: path.resolve(destParent), - force, - ...(args["folder-name"] ? { folderName: args["folder-name"] } : {}), - }); - process.stdout.write(`${JSON.stringify(r)}\n`); - if (!r.ok && !r.conflict) process.exit(1); - return; - } + case "list-installed": + case "bump-version": + case "install-dir": case "discover-packs": { + /* OCP: world-packs 动词表扩展,避免每 case 重复 dynamic import(DRY) */ const wp = await import("./world-packs.js"); - const root = need(args, "root"); - const depth = args["max-depth"] ? Number(args["max-depth"]) : 2; - const roots = wp.discoverPackRoots(path.resolve(root), { maxDepth: depth }); - process.stdout.write(`${JSON.stringify(roots)}\n`); - return; + if (verb === "list-installed") { + const bdsRoot = need(args, "bds-root"); + const level = need(args, "level"); + const list = wp.listInstalledWorldPacks(path.resolve(bdsRoot), level); + process.stdout.write(`${JSON.stringify(list)}\n`); + return; + } + if (verb === "bump-version") { + const packDir = need(args, "pack-dir"); + const next = wp.bumpPackPatchVersion(path.resolve(packDir)); + process.stdout.write(`${JSON.stringify({ version: next })}\n`); + return; + } + if (verb === "install-dir") { + const src = need(args, "src"); + const destParent = need(args, "dest-parent"); + const force = !!args["force"]; + const r = await wp.installPackDirectory({ + srcDir: path.resolve(src), + destParent: path.resolve(destParent), + force, + ...(args["folder-name"] ? { folderName: args["folder-name"] } : {}), + }); + process.stdout.write(`${JSON.stringify(r)}\n`); + if (!r.ok && !r.conflict) process.exit(1); + return; + } + { + const root = need(args, "root"); + const depth = args["max-depth"] ? Number(args["max-depth"]) : 2; + const roots = wp.discoverPackRoots(path.resolve(root), { maxDepth: depth }); + process.stdout.write(`${JSON.stringify(roots)}\n`); + return; + } } default: die(`unknown verb: ${verb}`); 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/bds-tools/src/world-packs.ts b/bds-tools/src/world-packs.ts index d69456e8..4da7ae90 100644 --- a/bds-tools/src/world-packs.ts +++ b/bds-tools/src/world-packs.ts @@ -4,15 +4,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import JSZip from "jszip"; import { copyDirAsync } from "./fsx.js"; import { disablePackInWorld, enablePackInWorld, readPackManifestHeader, readWorldPackList, - worldPackListHas, } from "./pack-manager.js"; +import { extractZipFileToDir } from "./zipx.js"; export type WorldPackKind = "behavior" | "resource"; @@ -123,25 +122,10 @@ export function isPackArchive(filePath: string): boolean { return ARCHIVE_EXTS.some((ext) => lower.endsWith(ext)); } -/** 解压 zip/mcpack/mcaddon 到临时目录,返回该目录 */ +/** 解压 zip/mcpack/mcaddon 到临时目录,返回该目录(经 zipx 防 zip-slip) */ export async function extractArchiveToTemp(filePath: string): Promise { - const abs = path.resolve(filePath); - if (!fs.existsSync(abs)) throw new Error(`archive not found: ${abs}`); - const buf = fs.readFileSync(abs); - const zip = await JSZip.loadAsync(buf); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pack-")); - const entries = Object.keys(zip.files); - for (const name of entries) { - const entry = zip.files[name]; - if (!entry || entry.dir) { - fs.mkdirSync(path.join(tmp, name), { recursive: true }); - continue; - } - const out = path.join(tmp, name); - fs.mkdirSync(path.dirname(out), { recursive: true }); - const data = await entry.async("nodebuffer"); - fs.writeFileSync(out, data); - } + await extractZipFileToDir(filePath, tmp); return tmp; } @@ -173,7 +157,7 @@ export function listInstalledWorldPacks(bdsRoot: string, levelName: string): Ins for (const dir of listPackDirsIn(parent)) { const info = readPackManifestInfo(dir); if (!info || info.kind !== kind) { - // 仍尝试用 header 展示 + // 仍尝试用 header 展示(enabled 与正常路径同契约:enable-list 含 uuid → LSP) const header = readPackManifestHeader(dir); if (!header) continue; result.push({ @@ -183,7 +167,7 @@ export function listInstalledWorldPacks(bdsRoot: string, levelName: string): Ins name: path.basename(dir), uuid: header.uuid, version: header.version, - enabled: worldPackListHas(worldsDir, levelName, kind, header.uuid), + enabled: enabledMap.has(header.uuid), }); continue; } @@ -255,25 +239,36 @@ export async function installPackDirectory(opts: { ); await fs.promises.mkdir(opts.destParent, { recursive: true }); - // 冲突:同 uuid 任意文件夹,或同 folderName + // 冲突:同 uuid,或同 folderName(即使旧包 manifest 无法完整识别也要拦 — 禁止静默覆盖) let conflictDir: string | null = null; + let conflictExisting: PackManifestInfo | null = null; for (const dir of listPackDirsIn(opts.destParent)) { const existing = readPackManifestInfo(dir); - if (!existing) continue; - if (existing.uuid === info.uuid || path.basename(dir) === folderName) { - conflictDir = dir; - break; + const sameFolder = path.basename(dir) === folderName; + const sameUuid = !!existing && existing.uuid === info.uuid; + if (!sameFolder && !sameUuid) continue; + conflictDir = dir; + if (existing) { + conflictExisting = existing; + } else { + const header = readPackManifestHeader(dir); + conflictExisting = { + name: path.basename(dir), + uuid: header?.uuid ?? "(unknown)", + version: header?.version ?? [0, 0, 0], + kind: info.kind, + }; } + break; } if (conflictDir && !opts.force) { - const existing = readPackManifestInfo(conflictDir)!; return { ok: false, skipped: true, reason: "conflict", conflict: { - existing: { ...existing, dir: conflictDir }, + existing: { ...(conflictExisting ?? info), dir: conflictDir }, incoming: info, }, }; @@ -335,6 +330,15 @@ export function worldPackParentDir( ); } +/** 世界 enable-list 权威读取 — 委托 pack-manager(供 doctor 等,避免硬编码文件名) */ +export function listWorldEnableEntries( + bdsRoot: string, + levelName: string, + kind: WorldPackKind +): Array<{ pack_id: string; version: [number, number, number] }> { + return readWorldPackList(path.join(bdsRoot, "worlds"), levelName, kind); +} + export function findInstalledPackById( packs: InstalledWorldPack[], id: string diff --git a/bds-tools/src/zipx.ts b/bds-tools/src/zipx.ts new file mode 100644 index 00000000..ab2aae42 --- /dev/null +++ b/bds-tools/src/zipx.ts @@ -0,0 +1,56 @@ +/** + * zipx.ts — JSZip 安全解压(防 zip-slip / 绝对路径 / Windows `\`) + * 单一权威,供 world-packs / check-update 复用(DRY)。 + */ +import fs from "node:fs"; +import path from "node:path"; +import JSZip from "jszip"; + +/** + * 将 zip 条目名解析为 destRoot 下的绝对路径。 + * 拒绝 `..`、绝对路径(含前导 `/`、盘符)、以及 resolve 后逃逸 destRoot 的条目。 + */ +export function resolveSafeZipEntryPath(destRoot: string, entryName: string): string { + const raw = String(entryName ?? "").replace(/\\/g, "/"); + // 先于去前导斜杠检查:避免 `/etc/passwd` 被剥成相对路径静默写入 dest + if (!raw || raw.includes("..") || raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) { + throw new Error(`unsafe zip entry path: ${entryName}`); + } + const parts = raw.replace(/\/$/, "").split("/").filter(Boolean); + if (parts.length === 0) { + throw new Error(`unsafe zip entry path: ${entryName}`); + } + const rootResolved = path.resolve(destRoot); + const out = path.resolve(rootResolved, ...parts); + const rootPrefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep; + if (out !== rootResolved && !out.startsWith(rootPrefix)) { + throw new Error(`zip entry escapes dest: ${entryName}`); + } + return out; +} + +/** 将 zip Buffer 解压到 destDir(已存在则写入其下)。 */ +export async function extractZipBufferToDir(data: Buffer, destDir: string): Promise { + const zip = await JSZip.loadAsync(data); + await fs.promises.mkdir(destDir, { recursive: true }); + for (const name of Object.keys(zip.files)) { + const entry = zip.files[name]; + if (!entry) continue; + const out = resolveSafeZipEntryPath(destDir, name); + if (entry.dir || name.endsWith("/") || name.endsWith("\\")) { + await fs.promises.mkdir(out, { recursive: true }); + continue; + } + await fs.promises.mkdir(path.dirname(out), { recursive: true }); + const buf = await entry.async("nodebuffer"); + await fs.promises.writeFile(out, buf); + } +} + +/** 从磁盘 zip 文件解压到 destDir。 */ +export async function extractZipFileToDir(zipPath: string, destDir: string): Promise { + const abs = path.resolve(zipPath); + if (!fs.existsSync(abs)) throw new Error(`archive not found: ${abs}`); + const data = await fs.promises.readFile(abs); + await extractZipBufferToDir(data, destDir); +} diff --git a/bds-tools/world-packs.test.mjs b/bds-tools/world-packs.test.mjs index 410019fa..f34709ae 100644 --- a/bds-tools/world-packs.test.mjs +++ b/bds-tools/world-packs.test.mjs @@ -130,4 +130,44 @@ describe("world-packs primitives", () => { const { version } = JSON.parse(cli.out.trim()); assert.deepEqual(version, [2, 0, 1]); }); + + it("installPackDirectory 同 folderName 即使旧包 kind 不可识别也要 conflict", async () => { + const { installPackDirectory, formatWorldPackFolderName } = await import("./dist/world-packs.js"); + const dest = path.join(tmp, "conflict-parent"); + const folderName = formatWorldPackFolderName("Broken", "resource"); + const existingDir = path.join(dest, folderName); + fs.mkdirSync(existingDir, { recursive: true }); + // 无法 detectPackKind 的残缺 manifest(仅 header,无 modules) + fs.writeFileSync( + path.join(existingDir, "manifest.json"), + JSON.stringify({ + format_version: 2, + header: { + name: "Broken", + uuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + version: [1, 0, 0], + }, + modules: [], + }) + ); + const src = path.join(tmp, "incoming-rp"); + writeManifest(src, { + name: "Incoming", + uuid: "cccccccc-cccc-cccc-cccc-cccccccccccc", + version: [1, 0, 0], + type: "resources", + }); + const r = await installPackDirectory({ + srcDir: src, + destParent: dest, + folderName: "Broken", + force: false, + }); + assert.equal(r.ok, false); + assert.equal(r.reason, "conflict"); + assert.ok(r.conflict); + // 未 force 时不得覆盖 + const still = JSON.parse(fs.readFileSync(path.join(existingDir, "manifest.json"), "utf8")); + assert.equal(still.header.uuid, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + }); }); diff --git a/bds-tools/zipx.test.mjs b/bds-tools/zipx.test.mjs new file mode 100644 index 00000000..3721b8c6 --- /dev/null +++ b/bds-tools/zipx.test.mjs @@ -0,0 +1,41 @@ +/** + * zipx 安全路径 + 解压单测(zip-slip) + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import JSZip from "jszip"; +import { extractZipBufferToDir, resolveSafeZipEntryPath } from "./dist/zipx.js"; + +test("resolveSafeZipEntryPath rejects .. and absolute", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + assert.throws(() => resolveSafeZipEntryPath(root, "../evil.txt")); + assert.throws(() => resolveSafeZipEntryPath(root, "foo/../../evil.txt")); + assert.throws(() => resolveSafeZipEntryPath(root, "/etc/passwd")); + const ok = resolveSafeZipEntryPath(root, "pack/manifest.json"); + assert.equal(ok, path.resolve(root, "pack", "manifest.json")); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("extractZipBufferToDir rejects zip-slip entries", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + const zip = new JSZip(); + zip.file("../../escape.txt", "pwned"); + const buf = await zip.generateAsync({ type: "nodebuffer" }); + await assert.rejects(() => extractZipBufferToDir(buf, root), /unsafe zip entry|escapes/); + assert.equal(fs.existsSync(path.join(root, "escape.txt")), false); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("extractZipBufferToDir writes nested safe entries", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + const zip = new JSZip(); + zip.file("inner/manifest.json", '{"ok":true}'); + const buf = await zip.generateAsync({ type: "nodebuffer" }); + await extractZipBufferToDir(buf, root); + const text = fs.readFileSync(path.join(root, "inner", "manifest.json"), "utf8"); + assert.match(text, /"ok"\s*:\s*true/); + fs.rmSync(root, { recursive: true, force: true }); +}); 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/runtime.ts b/sfmc/src/runtime.ts index 67efcf6c..644609cb 100644 --- a/sfmc/src/runtime.ts +++ b/sfmc/src/runtime.ts @@ -165,8 +165,7 @@ export function resolveNewModule(): string | null { /** * 解析 `@sfmc-bds/sdk` 包根目录(含 package.json)。 - * 优先级: SFMC_SDK_ROOT > createRequire(已知 export) > monorepo modules/sdk/@sfmc-sdk。 - * 注意: SDK package.json 的 exports 未暴露 ./package.json,不能 req.resolve('…/package.json')。 + * 优先级: SFMC_SDK_ROOT > createRequire(@sfmc-bds/sdk/package.json) > 公开 export 向上找 > monorepo。 */ export function resolveSdkPackageRoot(): string { const fromEnv = process.env.SFMC_SDK_ROOT; @@ -176,6 +175,13 @@ export function resolveSdkPackageRoot(): string { function findSdkRootFromRequire(req: NodeRequire): string | null { try { + // SDK 已暴露 ./package.json export;优先直解 + try { + const pkgJson = req.resolve("@sfmc-bds/sdk/package.json"); + return path.dirname(pkgJson); + } catch { + /* 旧包未暴露时回退 */ + } // 任一公开 export 即可;再向上找含 name=@sfmc-bds/sdk 的 package.json const hit = req.resolve("@sfmc-bds/sdk/sapi/runtime"); let dir = path.dirname(hit); diff --git a/sfmc/src/world-packs.ts b/sfmc/src/world-packs.ts index a3bef759..069f7e93 100644 --- a/sfmc/src/world-packs.ts +++ b/sfmc/src/world-packs.ts @@ -16,6 +16,7 @@ import { installPackDirectory, isPackArchive, listInstalledWorldPacks, + listWorldEnableEntries, readPackManifestInfo, worldPackParentDir, type InstalledWorldPack, @@ -408,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)}`); } } @@ -450,24 +451,11 @@ function hasFlag(args: string[], name: string): boolean { async function cmdDoctor(): Promise { const { bdsRoot, levelName } = resolveBdsContext(); const packs = listInstalledWorldPacks(bdsRoot, levelName); - const worldsDir = path.join(bdsRoot, "worlds"); const issues: string[] = []; for (const kind of ["behavior", "resource"] as const) { - const listFile = path.join( - worldsDir, - levelName, - kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" - ); - let entries: Array<{ pack_id: string; version: number[] }> = []; - if (fs.existsSync(listFile)) { - try { - entries = JSON.parse(fs.readFileSync(listFile, "utf8")) as typeof entries; - } catch { - issues.push(t("packs.doctor.parseFail", { file: listFile })); - continue; - } - } + // 经 listWorldEnableEntries → pack-manager.readWorldPackList(DRY/Demeter,不硬编码 JSON 文件名) + const entries = listWorldEnableEntries(bdsRoot, levelName, kind); const byUuid = new Map(packs.filter((p) => p.kind === kind).map((p) => [p.uuid, p])); for (const e of entries) { const p = byUuid.get(e.pack_id);