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}`);