Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions bds-tools/pack-manager-lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* pack-manager SOLID 回归:modules-json / clear-rp / has-pack / read-manifest
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, before, after } from "node:test";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI = path.join(__dirname, "dist", "cli-pack-manager.js");

function run(args) {
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" });
return { status: r.status, out: r.stdout ?? "", err: r.stderr ?? "" };
}

describe("pack-manager CLI extensions", () => {
/** @type {string} */
let tmp;
before(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pm-"));
});
after(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

it("assemble-rp --modules-json 只用显式 map(不扫整树)", async () => {
const { loadModuleResourcePackMap, assembleResourcePack } = await import("./dist/pack-manager.js");
const modA = path.join(tmp, "mod-a", "resource_pack");
const modB = path.join(tmp, "mod-b", "resource_pack");
fs.mkdirSync(modA, { recursive: true });
fs.mkdirSync(modB, { recursive: true });
fs.writeFileSync(path.join(modA, "a.txt"), "a");
fs.writeFileSync(path.join(modB, "b.txt"), "b");
const mapFile = path.join(tmp, "map.json");
/* 仅启用 mod-a */
fs.writeFileSync(mapFile, JSON.stringify({ "mod-a": modA }));
const map = loadModuleResourcePackMap(mapFile);
assert.deepEqual(Object.keys(map), ["mod-a"]);

const out = path.join(tmp, "rp-out");
await assembleResourcePack({
moduleResourceDirs: map,
outDir: out,
projectName: "test-rp",
uuid: "11111111-1111-1111-1111-111111111111",
version: [1, 0, 0],
});
assert.ok(fs.existsSync(path.join(out, "mod-a", "a.txt")));
assert.ok(!fs.existsSync(path.join(out, "mod-b")));

const cli = run([
"assemble-rp",
"--modules-json",
mapFile,
"--out",
path.join(tmp, "rp-cli"),
"--name",
"test-rp-cli",
"--uuid",
"22222222-2222-2222-2222-222222222222",
]);
assert.equal(cli.status, 0, cli.err || cli.out);
});

it("deploy --clear-rp 删除世界内残留 RP 目录", async () => {
const { deployToBDS } = await import("./dist/pack-manager.js");
const bds = path.join(tmp, "bds");
const level = "Bedrock level";
const worlds = path.join(bds, "worlds", level);
const bpSrc = path.join(tmp, "bp-src");
const rpDst = path.join(worlds, "resource_packs", "sfmc-modules-rp");
fs.mkdirSync(bpSrc, { recursive: true });
fs.writeFileSync(path.join(bpSrc, "dummy.txt"), "bp");
fs.mkdirSync(rpDst, { recursive: true });
fs.writeFileSync(path.join(rpDst, "stale.txt"), "old");

await deployToBDS({
bdsRoot: bds,
levelName: level,
behaviorPackSrc: bpSrc,
bpName: "sfmc-modules",
rpName: "sfmc-modules-rp",
clearResourcePack: true,
});
assert.ok(!fs.existsSync(rpDst));
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");
const bpOut = path.join(tmp, "bp-assembled");
const src = path.join(tmp, "bp-empty-src");
fs.mkdirSync(path.join(src, "scripts"), { recursive: true });
fs.writeFileSync(path.join(src, "scripts", "main.js"), "//\n");
const uuid = "33333333-3333-3333-3333-333333333333";
await assembleBehaviorPack({
srcDir: src,
outDir: bpOut,
projectName: "sfmc-modules",
uuid,
version: [1, 2, 3],
});

const rm = run(["read-manifest", "--pack-dir", bpOut]);
assert.equal(rm.status, 0, rm.err);
const header = JSON.parse(rm.out.trim());
assert.equal(header.uuid, uuid);
assert.deepEqual(header.version, [1, 2, 3]);

const worldsDir = path.join(tmp, "worlds-root");
await enablePackInWorld({
worldsDir,
levelName: "L1",
kind: "behavior",
packUuid: uuid,
version: [1, 2, 3],
});
assert.equal(worldPackListHas(worldsDir, "L1", "behavior", uuid), true);
assert.equal(worldPackListHas(worldsDir, "L1", "behavior", "00000000-0000-0000-0000-000000000000"), false);

const hp = run([
"has-pack",
"--worlds-dir",
worldsDir,
"--level",
"L1",
"--kind",
"behavior",
"--pack-id",
uuid,
]);
assert.equal(hp.status, 0);
assert.equal(hp.out.trim(), "1");
});
});
2 changes: 1 addition & 1 deletion bds-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"dev": "tsx src/dist/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",
"test": "node --test is-main.test.mjs pack-manager-lifecycle.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",
Expand Down
47 changes: 41 additions & 6 deletions bds-tools/src/cli-pack-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
* so spawnService can call it as a sub-process.
*
* node bds-tools/dist/pack-manager.js assemble-bp --src <dir> --out <dir> --name <name> [--uuid <uuid>] [--module-uuid <uuid>] [--version 1,0,0] [--description "..."] [--icon <png>]
* node bds-tools/dist/pack-manager.js assemble-rp --modules-dir <dir> --out <dir> --name <name> [--uuid <uuid>] [--module-uuid <uuid>] [--version 1,0,0] [--description "..."]
* node bds-tools/dist/pack-manager.js deploy --bds-root <dir> --level <name> --bp-src <dir> [--rp-src <dir>] --bp-name <name> [--rp-name <name>]
* node bds-tools/dist/pack-manager.js assemble-rp (--modules-dir <dir>|--modules-json <file>) --out <dir> --name <name> [--uuid <uuid>] [--module-uuid <uuid>] [--version 1,0,0] [--description "..."]
* node bds-tools/dist/pack-manager.js deploy --bds-root <dir> --level <name> --bp-src <dir> [--rp-src <dir>] --bp-name <name> [--rp-name <name>] [--clear-rp]
* node bds-tools/dist/pack-manager.js enable-pack --worlds-dir <dir> --level <name> --kind behavior|resource --pack-id <uuid> --version 1,0,0
* node bds-tools/dist/pack-manager.js disable-pack --worlds-dir <dir> --level <name> --kind behavior|resource --pack-id <uuid>
* node bds-tools/dist/pack-manager.js ensure-permission --bds-root <dir> --pack-id <uuid>
* node bds-tools/dist/pack-manager.js read-level --bds-root <dir>
* node bds-tools/dist/pack-manager.js read-manifest --pack-dir <dir>
* node bds-tools/dist/pack-manager.js has-pack --worlds-dir <dir> --level <name> --kind behavior|resource --pack-id <uuid>
*
* 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 —
Expand All @@ -33,7 +35,13 @@ function parseArgs(argv: string[]): { [k: string]: string | undefined } {
if (a && a.startsWith("--")) {
const key = a.slice(2);
const next = argv[++i];
out[key] = next;
/* 布尔旗标(无值或下一参也是 --xxx) */
if (next === undefined || next.startsWith("--")) {
out[key] = "1";
if (next !== undefined) i--;
} else {
out[key] = next;
}
}
}
return out;
Expand Down Expand Up @@ -84,10 +92,16 @@ async function main(): Promise<void> {
return;
}
case "assemble-rp": {
const modulesDir = need(args, "modules-dir");
const out = need(args, "out");
const name = need(args, "name");
const map = mod.scanModuleResourcePacks(path.resolve(modulesDir));
/* OCP:显式 map 优先;否则回退扫描 --modules-dir */
let map: Record<string, string>;
if (args["modules-json"]) {
map = mod.loadModuleResourcePackMap(path.resolve(args["modules-json"]));
} else {
const modulesDir = need(args, "modules-dir");
map = mod.scanModuleResourcePacks(path.resolve(modulesDir));
}
await mod.assembleResourcePack({
moduleResourceDirs: map,
outDir: path.resolve(out),
Expand All @@ -107,13 +121,15 @@ async function main(): Promise<void> {
const bpName = need(args, "bp-name");
const rpSrc = args["rp-src"];
const rpName = args["rp-name"];
const clearRp = args["clear-rp"] === "1" || args["clear-rp"] === "true";
await mod.deployToBDS({
bdsRoot: path.resolve(bdsRoot),
levelName: level,
behaviorPackSrc: path.resolve(bpSrc),
...(rpSrc ? { resourcePackSrc: path.resolve(rpSrc) } : {}),
bpName,
...(rpName ? { rpName } : {}),
...(clearRp ? { clearResourcePack: true } : {}),
});
process.stdout.write(`[pack-manager] deployed to ${path.join(bdsRoot, "worlds", level)}\n`);
return;
Expand Down Expand Up @@ -165,6 +181,25 @@ async function main(): Promise<void> {
process.stdout.write(`${name}\n`);
return;
}
case "read-manifest": {
const packDir = need(args, "pack-dir");
const header = mod.readPackManifestHeader(path.resolve(packDir));
if (!header) {
process.stdout.write("null\n");
return;
}
process.stdout.write(`${JSON.stringify(header)}\n`);
return;
}
case "has-pack": {
const worldsDir = need(args, "worlds-dir");
const level = need(args, "level");
const kind = need(args, "kind") as "behavior" | "resource";
const packId = need(args, "pack-id");
const ok = mod.worldPackListHas(path.resolve(worldsDir), level, kind, packId);
process.stdout.write(ok ? "1\n" : "0\n");
return;
}
default:
die(`unknown verb: ${verb}`);
}
Expand All @@ -173,4 +208,4 @@ async function main(): Promise<void> {
main().catch((err: unknown) => {
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
die(msg);
});
});
51 changes: 50 additions & 1 deletion bds-tools/src/pack-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export interface DeployOpts {
bpName: string;
/** RP 目标目录名 (worlds/<level>/resource_packs/<rpName>);省略时复用 bpName + '-rp' */
rpName?: string;
/**
* 无 resourcePackSrc 时是否删除世界内已有 RP 目录。
* 用于模块不再提供 RP 时清理聚合包残留(默认 false,避免误删未声明的 RP)。
*/
clearResourcePack?: boolean;
}

export interface DeployResult {
Expand Down Expand Up @@ -211,6 +216,9 @@ export async function deployToBDS(opts: DeployOpts): Promise<DeployResult> {
await fs.promises.rm(rpDst, { recursive: true, force: true });
await copyDirAsync(opts.resourcePackSrc, rpDst);
rpDir = rpDst;
} else if (opts.clearResourcePack) {
/* 调用方显式声明不再部署 RP — 清掉聚合 RP 目录残留 */
await fs.promises.rm(rpDst, { recursive: true, force: true });
}
return { bpDir: bpDst, rpDir };
}
Expand Down Expand Up @@ -264,7 +272,6 @@ export async function writePermissionsJson(bpDir: string): Promise<void> {
const tmp = path.join(bpDir, `.permissions.${process.pid}.tmp`);
await fs.promises.writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
await fs.promises.rename(tmp, path.join(bpDir, "permissions.json"));
void copyDirAsync;
}

/**
Expand Down Expand Up @@ -412,4 +419,46 @@ export function scanModuleResourcePacks(modulesDir: string): Record<string, stri
}
}
return out;
}

/**
* 从 JSON 文件加载显式 moduleId → resource_pack 目录映射。
* 供 assemble-rp --modules-json 使用,避免调用方再做临时目录镜像(OCP/DRY)。
*/
export function loadModuleResourcePackMap(jsonPath: string): Record<string, string> {
const raw = JSON.parse(fs.readFileSync(jsonPath, "utf8")) as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`modules-json must be an object: ${jsonPath}`);
}
const out: Record<string, string> = {};
for (const [id, dir] of Object.entries(raw as Record<string, unknown>)) {
if (typeof id !== "string" || !id || typeof dir !== "string" || !dir) continue;
const abs = path.resolve(dir);
if (!fs.existsSync(abs)) {
throw new Error(`modules-json entry missing: ${id} → ${abs}`);
}
out[id] = abs;
}
return out;
}

/** 世界 enable-list 是否已含指定 pack_id(只读,供 preflight 复用)。 */
export function worldPackListHas(
worldsDir: string,
levelName: string,
kind: "behavior" | "resource",
packUuid: string
): boolean {
const file = path.join(
worldsDir,
levelName,
kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json"
);
if (!fs.existsSync(file)) return false;
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);
} catch {
return false;
}
}
Loading
Loading