From fbfe95fab289cae7b85928c026928e88b1b2dc72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 07:29:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(pack):=20catalog=20=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E6=97=B6=E5=87=AD=E7=A3=81=E7=9B=98=20manifest=20=E5=8D=B8?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=20BP/RP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查 #44 合入后的残留: - BLOCKER: deploy-catalog 缺失时 clear-rp 只删目录不卸世界清单 → collectDeployedPackUuids 同时读 catalog + 磁盘 manifest - MAJOR DRY: spawnWorldPack / readWorldPackList 统一清单读写 - MAJOR Demeter: cmdPackList 改走 list-packs CLI - 回归: list-packs + 无 catalog 卸装路径 (bds-tools 7/7) Co-authored-by: Shiroha --- bds-tools/pack-manager-lifecycle.test.mjs | 86 +++++++++++- bds-tools/src/cli-pack-manager.ts | 10 ++ bds-tools/src/pack-manager.ts | 54 +++++--- sfmc/src/pack-lifecycle.ts | 159 +++++++++++----------- 4 files changed, 211 insertions(+), 98 deletions(-) diff --git a/bds-tools/pack-manager-lifecycle.test.mjs b/bds-tools/pack-manager-lifecycle.test.mjs index 89d9a7c7..8a442a02 100644 --- a/bds-tools/pack-manager-lifecycle.test.mjs +++ b/bds-tools/pack-manager-lifecycle.test.mjs @@ -90,8 +90,9 @@ describe("pack-manager CLI extensions", () => { assert.ok(fs.existsSync(path.join(worlds, "behavior_packs", "sfmc-modules", "dummy.txt"))); }); - it("read-manifest / has-pack CLI 契约", async () => { - const { assembleBehaviorPack, enablePackInWorld, worldPackListHas } = await import("./dist/pack-manager.js"); + it("read-manifest / has-pack / list-packs CLI 契约", async () => { + const { assembleBehaviorPack, enablePackInWorld, worldPackListHas, readWorldPackList } = + await import("./dist/pack-manager.js"); const bpOut = path.join(tmp, "bp-assembled"); const src = path.join(tmp, "bp-empty-src"); fs.mkdirSync(path.join(src, "scripts"), { recursive: true }); @@ -135,5 +136,86 @@ describe("pack-manager CLI extensions", () => { ]); assert.equal(hp.status, 0); assert.equal(hp.out.trim(), "1"); + + const lp = run(["list-packs", "--worlds-dir", worldsDir, "--level", "L1", "--kind", "behavior"]); + assert.equal(lp.status, 0, lp.err); + const listed = JSON.parse(lp.out.trim()); + assert.equal(listed.length, 1); + assert.equal(listed[0].pack_id, uuid); + assert.deepEqual(readWorldPackList(worldsDir, "L1", "behavior"), listed); + }); + + it("无 deploy-catalog 时仍可凭磁盘 RP manifest 卸世界清单(BLOCKER 回归)", async () => { + const { + assembleResourcePack, + deployToBDS, + enablePackInWorld, + disablePackInWorld, + readPackManifestHeader, + worldPackListHas, + } = await import("./dist/pack-manager.js"); + + const bds = path.join(tmp, "bds-stale"); + const level = "Bedrock level"; + const worlds = path.join(bds, "worlds", level); + const worldsDir = path.join(bds, "worlds"); + const bpSrc = path.join(tmp, "bp-stale-src"); + const rpMod = path.join(tmp, "mod-stale", "resource_pack"); + fs.mkdirSync(path.join(bpSrc, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(bpSrc, "scripts", "main.js"), "//\n"); + fs.mkdirSync(rpMod, { recursive: true }); + fs.writeFileSync(path.join(rpMod, "x.txt"), "x"); + + const staleUuid = "44444444-4444-4444-4444-444444444444"; + const rpAssembled = path.join(tmp, "rp-stale-assembled"); + await assembleResourcePack({ + moduleResourceDirs: { stale: rpMod }, + outDir: rpAssembled, + projectName: "sfmc-modules-rp", + uuid: staleUuid, + version: [1, 0, 0], + }); + + await deployToBDS({ + bdsRoot: bds, + levelName: level, + behaviorPackSrc: bpSrc, + resourcePackSrc: rpAssembled, + bpName: "sfmc-modules", + rpName: "sfmc-modules-rp", + }); + await enablePackInWorld({ + worldsDir, + levelName: level, + kind: "resource", + packUuid: staleUuid, + version: [1, 0, 0], + }); + assert.equal(worldPackListHas(worldsDir, level, "resource", staleUuid), true); + + /* 模拟 catalog 缺失:只留磁盘 RP,lifecycle 应在 clear 前读 manifest */ + const rpDst = path.join(worlds, "resource_packs", "sfmc-modules-rp"); + const header = readPackManifestHeader(rpDst); + assert.equal(header?.uuid, staleUuid); + assert.ok(header); + + await deployToBDS({ + bdsRoot: bds, + levelName: level, + behaviorPackSrc: bpSrc, + bpName: "sfmc-modules", + rpName: "sfmc-modules-rp", + clearResourcePack: true, + }); + assert.ok(!fs.existsSync(rpDst)); + + await disablePackInWorld({ + worldsDir, + levelName: level, + kind: "resource", + packUuid: header.uuid, + version: [1, 0, 0], + }); + assert.equal(worldPackListHas(worldsDir, level, "resource", staleUuid), false); }); }); diff --git a/bds-tools/src/cli-pack-manager.ts b/bds-tools/src/cli-pack-manager.ts index 8d0f23ed..e990cc26 100644 --- a/bds-tools/src/cli-pack-manager.ts +++ b/bds-tools/src/cli-pack-manager.ts @@ -12,6 +12,7 @@ * node bds-tools/dist/pack-manager.js read-level --bds-root * node bds-tools/dist/pack-manager.js read-manifest --pack-dir * node bds-tools/dist/pack-manager.js has-pack --worlds-dir --level --kind behavior|resource --pack-id + * node bds-tools/dist/pack-manager.js list-packs --worlds-dir --level --kind behavior|resource * * The pure-function API lives in pack-manager.ts. This CLI exists so the * SEA-launched child process doesn't have to deal with module resolution — @@ -200,6 +201,15 @@ async function main(): Promise { process.stdout.write(ok ? "1\n" : "0\n"); return; } + case "list-packs": { + /* 权威世界清单读取 — 调用方勿再直接解析 world_*_packs.json(Demeter/DRY) */ + const worldsDir = need(args, "worlds-dir"); + const level = need(args, "level"); + const kind = need(args, "kind") as "behavior" | "resource"; + const list = mod.readWorldPackList(path.resolve(worldsDir), level, kind); + process.stdout.write(`${JSON.stringify(list)}\n`); + return; + } default: die(`unknown verb: ${verb}`); } diff --git a/bds-tools/src/pack-manager.ts b/bds-tools/src/pack-manager.ts index f4ee0d10..1b0b0056 100644 --- a/bds-tools/src/pack-manager.ts +++ b/bds-tools/src/pack-manager.ts @@ -302,15 +302,8 @@ async function editWorldPackList( opts.levelName, opts.kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" ); - let entries: WorldPackEntry[] = []; - try { - const raw = await fs.promises.readFile(file, "utf8"); - const parsed: unknown = JSON.parse(raw); - if (Array.isArray(parsed)) entries = parsed as WorldPackEntry[]; - } catch { - /* missing / corrupt → start with [] */ - entries = []; - } + /* 与 readWorldPackList 同源解析,再写回(DRY);异步写路径保留原语义 */ + let entries: WorldPackEntry[] = readWorldPackList(opts.worldsDir, opts.levelName, opts.kind); const idx = entries.findIndex((e) => e.pack_id === opts.packUuid); if (mode === "enable") { const next: WorldPackEntry = { pack_id: opts.packUuid, version: opts.version }; @@ -442,23 +435,48 @@ export function loadModuleResourcePackMap(jsonPath: string): Record { const file = path.join( worldsDir, levelName, kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" ); - if (!fs.existsSync(file)) return false; + if (!fs.existsSync(file)) return []; try { - const arr = JSON.parse(fs.readFileSync(file, "utf8")) as Array<{ pack_id?: string }>; - return Array.isArray(arr) && arr.some((e) => e.pack_id === packUuid); + const arr = JSON.parse(fs.readFileSync(file, "utf8")) as unknown; + if (!Array.isArray(arr)) return []; + const out: Array<{ pack_id: string; version: [number, number, number] }> = []; + for (const e of arr) { + if (!e || typeof e !== "object") continue; + const packId = (e as { pack_id?: unknown }).pack_id; + const ver = (e as { version?: unknown }).version; + if (typeof packId !== "string" || !packId) continue; + const version: [number, number, number] = + Array.isArray(ver) && ver.length >= 3 + ? [Number(ver[0]), Number(ver[1]), Number(ver[2])] + : [1, 0, 0]; + out.push({ pack_id: packId, version }); + } + return out; } catch { - return false; + return []; } +} + +/** 世界 enable-list 是否已含指定 pack_id(只读,供 preflight 复用)。 */ +export function worldPackListHas( + worldsDir: string, + levelName: string, + kind: "behavior" | "resource", + packUuid: string +): boolean { + return readWorldPackList(worldsDir, levelName, kind).some((e) => e.pack_id === packUuid); } \ No newline at end of file diff --git a/sfmc/src/pack-lifecycle.ts b/sfmc/src/pack-lifecycle.ts index 161e1308..61401c27 100644 --- a/sfmc/src/pack-lifecycle.ts +++ b/sfmc/src/pack-lifecycle.ts @@ -145,13 +145,57 @@ function readManifestHeader( } } -/** 解析启用状态:lock 优先,否则 catalog.enabledByDefault,再否则 false */ +/** 解析启用状态:lock 优先,否则 catalog.enabledByDefault(缺省 true↔!==false),未收录模块 false */ function isModuleEnabled(logicalId: string, lock: ModuleLock, catalogDefaults: Map): boolean { const st = lock.modules?.[logicalId]; if (st && typeof st.enabled === "boolean") return st.enabled; return catalogDefaults.get(logicalId) ?? false; } +/** 世界 enable/disable 清单 — 统一 argv 形状(DRY) */ +function spawnWorldPack( + verb: "enable-pack" | "disable-pack", + worldsDir: string, + levelName: string, + kind: "behavior" | "resource", + packId: string, + version?: [number, number, number] +): { ok: boolean; out: string; err: string } { + const args = [ + verb, + "--worlds-dir", + worldsDir, + "--level", + levelName, + "--kind", + kind, + "--pack-id", + packId, + ]; + if (version) args.push("--version", version.join(",")); + return spawnPackManager(args); +} + +/** + * 部署前收集世界内已出现的 BP/RP uuid。 + * 同时读 deploy-catalog 与磁盘 manifest — catalog 缺失时仍能卸过期清单项(Demeter/完整契约)。 + */ +function collectDeployedPackUuids( + bdsRoot: string, + levelName: string +): { bp: Set; rp: Set } { + const bp = new Set(); + const rp = new Set(); + const cat = readDeployedCatalog(bdsRoot, levelName); + if (cat?.bpUuid) bp.add(cat.bpUuid); + if (cat?.rpUuid) rp.add(cat.rpUuid); + const liveBp = readManifestHeader(deployedBpDir(bdsRoot, levelName)); + const liveRp = readManifestHeader(deployedRpDir(bdsRoot, levelName)); + if (liveBp?.uuid) bp.add(liveBp.uuid); + if (liveRp?.uuid) rp.add(liveRp.uuid); + return { bp, rp }; +} + /** * 枚举本机 packages,返回 folderId → 元数据。 * 仅含有 sapi/src/index.ts 的模块会进入 BP bundle 候选。 @@ -456,7 +500,8 @@ export async function deployPacks(catalog: DeployCatalog): Promise { throw new Error(`BP not built at ${bpOut()}. Run build first.`); } - const previous = readDeployedCatalog(bdsRoot, levelName); + /* 必须在 deploy/clear-rp 之前采集 — 清目录后无法再读 manifest */ + const previous = collectDeployedPackUuids(bdsRoot, levelName); const deployArgs = [ "deploy", "--bds-root", @@ -484,77 +529,42 @@ export async function deployPacks(catalog: DeployCatalog): Promise { writeJson(path.join(deployedBpDir(bdsRoot, levelName), DEPLOY_CATALOG_NAME), catalog); const worldsDir = path.join(bdsRoot, "worlds"); - const enBp = spawnPackManager([ - "enable-pack", - "--worlds-dir", - worldsDir, - "--level", - levelName, - "--kind", - "behavior", - "--pack-id", - catalog.bpUuid, - "--version", - catalog.bpVersion.join(","), - ]); + const enBp = spawnWorldPack("enable-pack", worldsDir, levelName, "behavior", catalog.bpUuid, catalog.bpVersion); if (!enBp.ok) throw new Error(`enable BP failed: ${enBp.err || enBp.out}`); packLog(`enabled behavior pack ${catalog.bpUuid} in world list`, "success"); if (catalog.rpUuid && catalog.rpVersion) { - const enRp = spawnPackManager([ + const enRp = spawnWorldPack( "enable-pack", - "--worlds-dir", worldsDir, - "--level", levelName, - "--kind", "resource", - "--pack-id", catalog.rpUuid, - "--version", - catalog.rpVersion.join(","), - ]); + catalog.rpVersion + ); if (!enRp.ok) throw new Error(`enable RP failed: ${enRp.err || enRp.out}`); packLog(`enabled resource pack ${catalog.rpUuid} in world list`, "success"); } - /* 卸掉过期 RP:UUID 轮换或模块不再提供 RP(BLOCKER 修复) */ - if (previous?.rpUuid && previous.rpUuid !== (catalog.rpUuid ?? null)) { - const dis = spawnPackManager([ - "disable-pack", - "--worlds-dir", - worldsDir, - "--level", - levelName, - "--kind", - "resource", - "--pack-id", - previous.rpUuid, - ]); + /* 卸掉过期 RP:UUID 轮换 / 不再提供 RP / catalog 缺失但磁盘仍有旧包 */ + for (const staleRp of previous.rp) { + if (staleRp === (catalog.rpUuid ?? null)) continue; + const dis = spawnWorldPack("disable-pack", worldsDir, levelName, "resource", staleRp); if (!dis.ok) { - packLog(`disable stale RP ${previous.rpUuid} failed: ${dis.err || dis.out}`, "warn"); + packLog(`disable stale RP ${staleRp} failed: ${dis.err || dis.out}`, "warn"); } else { - packLog(`disabled stale resource pack ${previous.rpUuid}`, "success"); + packLog(`disabled stale resource pack ${staleRp}`, "success"); } } - /* 若 BP uuid 轮换,卸掉旧 BP 清单项 */ - if (previous?.bpUuid && previous.bpUuid !== catalog.bpUuid) { - const disBp = spawnPackManager([ - "disable-pack", - "--worlds-dir", - worldsDir, - "--level", - levelName, - "--kind", - "behavior", - "--pack-id", - previous.bpUuid, - ]); + /* 卸掉过期 BP(uuid 轮换) */ + for (const staleBp of previous.bp) { + if (staleBp === catalog.bpUuid) continue; + const disBp = spawnWorldPack("disable-pack", worldsDir, levelName, "behavior", staleBp); if (!disBp.ok) { - packLog(`disable stale BP ${previous.bpUuid} failed: ${disBp.err || disBp.out}`, "warn"); + packLog(`disable stale BP ${staleBp} failed: ${disBp.err || disBp.out}`, "warn"); } else { - packLog(`disabled stale behavior pack ${previous.bpUuid}`, "success"); + packLog(`disabled stale behavior pack ${staleBp}`, "success"); } } @@ -755,25 +765,30 @@ export async function cmdPackList(_args: string[]): Promise { ` ${mark} ${m.folderId.padEnd(24)} ${m.logicalId.padEnd(24)} ${m.enabled ? "on " : "off"} ${m.hasResourcePack ? "+rp" : " "} ${m.version}` ); } - const bpList = path.join(bdsRoot, "worlds", levelName, "world_behavior_packs.json"); - const rpList = path.join(bdsRoot, "worlds", levelName, "world_resource_packs.json"); + const worldsDir = path.join(bdsRoot, "worlds"); lines.push(c.bold("\nWorld enable lists")); - for (const [label, file] of [ - ["behavior", bpList], - ["resource", rpList], - ] as const) { - if (!existsSync(file)) { - lines.push(c.dim(` ${label}: (missing)`)); + for (const kind of ["behavior", "resource"] as const) { + const r = spawnPackManager([ + "list-packs", + "--worlds-dir", + worldsDir, + "--level", + levelName, + "--kind", + kind, + ]); + if (!r.ok) { + lines.push(c.yellow(` ${kind}: (list-packs failed)`)); continue; } try { - const arr = JSON.parse(await fs.readFile(file, "utf8")) as Array<{ pack_id: string; version: number[] }>; - if (!arr.length) lines.push(c.dim(` ${label}: (empty)`)); + const arr = JSON.parse(r.out.trim() || "[]") as Array<{ pack_id: string; version: number[] }>; + if (!arr.length) lines.push(c.dim(` ${kind}: (empty)`)); for (const e of arr) { - lines.push(` ${label}: ${e.pack_id} v${(e.version ?? []).join(".")}`); + lines.push(` ${kind}: ${e.pack_id} v${(e.version ?? []).join(".")}`); } } catch { - lines.push(c.yellow(` ${label}: (corrupt)`)); + lines.push(c.yellow(` ${kind}: (corrupt)`)); } } return lines.join("\n") + "\n"; @@ -793,19 +808,7 @@ export async function cmdPackEnableDisable(action: "enable" | "disable", args: s if (!uuid || !version) return c.red(`no ${kind} pack uuid in catalog`); const worldsDir = path.join(bdsRoot, "worlds"); const verb = action === "enable" ? "enable-pack" : "disable-pack"; - const r = spawnPackManager([ - verb, - "--worlds-dir", - worldsDir, - "--level", - levelName, - "--kind", - kind, - "--pack-id", - uuid, - "--version", - version.join(","), - ]); + const r = spawnWorldPack(verb, worldsDir, levelName, kind, uuid, version); if (!r.ok) return c.red(`${action} failed: ${r.err || r.out}`); packLog(`${action}d ${kind} pack ${uuid}`, "success"); return c.green(`${action}d ${kind} pack ${uuid}`);