From 87f820a68d36df4d154865e6f7dfe36c06510467 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 17:00:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(packs):=20harden=20uninstall=20?= =?UTF-8?q?=E2=80=94=20protected=20abort,=20DRY=20trash=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Abort packs uninstall for platform packs before paired RP / binding side effects (LSP) - Structure uninstall outcomes separately from CLI formatting (DIP) - Centralize DEFAULT_PACK_UNINSTALL + resolveUninstallTrashDir (DRY/OCP) - Tighten uninstallInstalledPack result type (remove unused ok/reason) - Drop accidental root module-lock.json; ignore it going forward - Cover trash/purge/missing + protected-folder contracts with tests Co-authored-by: Shiroha --- .gitignore | 7 +-- bds-tools/src/world-packs.ts | 15 ++++--- bds-tools/world-packs.test.mjs | 75 ++++++++++++++++++++++++++++++++ db-server/src/routes/config.ts | 2 +- module-lock.json | 4 -- sfmc/pack-update-config.test.mjs | 12 ++++- sfmc/src/pack-update/config.ts | 24 +++++++--- sfmc/src/pack-update/types.ts | 6 +++ sfmc/src/world-packs.ts | 74 +++++++++++++++++++------------ 9 files changed, 170 insertions(+), 49 deletions(-) delete mode 100644 module-lock.json diff --git a/.gitignore b/.gitignore index 539829fa..619a8bce 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,8 @@ data/ # 模块启停状态:本地生成(init / fetch-module / enable|disable),勿提交 /modules/module-lock.json +# 误放仓根的 lock(权威路径是 modules/module-lock.json) +/module-lock.json # 遗留聚合 manifest 目录(已由各包 sapi/manifest.json 取代) /modules/_manifests/ @@ -54,6 +56,5 @@ test !.vscode/settings.json remote-controller/dist/index.js -# 测试环境下产物 - -/packs/ \ No newline at end of file +# 测试环境下产物 / 收件箱与构建产物 +/packs/ diff --git a/bds-tools/src/world-packs.ts b/bds-tools/src/world-packs.ts index 3880e02e..74e459d0 100644 --- a/bds-tools/src/world-packs.ts +++ b/bds-tools/src/world-packs.ts @@ -378,7 +378,12 @@ export async function disableInstalledPack(opts: { }); } -export type UninstallPackAction = "trashed" | "deleted" | "missing"; +export type UninstallPackResult = + | { action: "trashed"; dest: string } + | { action: "deleted" } + | { action: "missing" }; + +export type UninstallPackAction = UninstallPackResult["action"]; /** * 卸载已安装世界包:先 disable enable-list,再移入 trashDir 或直接删除目录。 @@ -390,7 +395,7 @@ export async function uninstallInstalledPack(opts: { pack: InstalledWorldPack; /** 提供则移入该目录;null/省略则直接删除 */ trashDir?: string | null; -}): Promise<{ ok: boolean; action: UninstallPackAction; dest?: string; reason?: string }> { +}): Promise { await disableInstalledPack({ bdsRoot: opts.bdsRoot, levelName: opts.levelName, @@ -400,7 +405,7 @@ export async function uninstallInstalledPack(opts: { }); if (!fs.existsSync(opts.pack.dir)) { - return { ok: true, action: "missing" }; + return { action: "missing" }; } const trashDir = opts.trashDir?.trim() || null; @@ -417,11 +422,11 @@ export async function uninstallInstalledPack(opts: { await copyDirAsync(opts.pack.dir, dest); await fs.promises.rm(opts.pack.dir, { recursive: true, force: true }); } - return { ok: true, action: "trashed", dest }; + return { action: "trashed", dest }; } await fs.promises.rm(opts.pack.dir, { recursive: true, force: true }); - return { ok: true, action: "deleted" }; + return { action: "deleted" }; } export function worldPackParentDir( diff --git a/bds-tools/world-packs.test.mjs b/bds-tools/world-packs.test.mjs index f34709ae..f7028cf3 100644 --- a/bds-tools/world-packs.test.mjs +++ b/bds-tools/world-packs.test.mjs @@ -170,4 +170,79 @@ describe("world-packs primitives", () => { const still = JSON.parse(fs.readFileSync(path.join(existingDir, "manifest.json"), "utf8")); assert.equal(still.header.uuid, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); }); + + it("uninstallInstalledPack:回收站 / purge / 目录缺失", async () => { + const { uninstallInstalledPack, listInstalledWorldPacks } = await import("./dist/world-packs.js"); + const bds = path.join(tmp, "uninstall-bds"); + const level = "Bedrock level"; + const folder = "[BP] Gone"; + const uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + const packDir = path.join(bds, "worlds", level, "behavior_packs", folder); + writeManifest(packDir, { + name: "Gone", + uuid, + version: [1, 0, 0], + type: "data", + }); + fs.mkdirSync(path.join(bds, "worlds", level), { recursive: true }); + fs.writeFileSync( + path.join(bds, "worlds", level, "world_behavior_packs.json"), + JSON.stringify([{ pack_id: uuid, version: [1, 0, 0] }]) + ); + + const packs = listInstalledWorldPacks(bds, level); + assert.equal(packs.length, 1); + const trash = path.join(tmp, "trash-bin"); + const trashed = await uninstallInstalledPack({ + bdsRoot: bds, + levelName: level, + pack: packs[0], + trashDir: trash, + }); + assert.equal(trashed.action, "trashed"); + assert.ok(trashed.dest && fs.existsSync(trashed.dest)); + assert.equal(fs.existsSync(packDir), false); + const enable = JSON.parse( + fs.readFileSync(path.join(bds, "worlds", level, "world_behavior_packs.json"), "utf8") + ); + assert.equal(enable.length, 0); + + /* 目录已不在 list 中;构造 missing 场景 */ + const phantom = { + ...packs[0], + dir: path.join(bds, "worlds", level, "behavior_packs", "no-such"), + enabled: false, + }; + const missing = await uninstallInstalledPack({ + bdsRoot: bds, + levelName: level, + pack: phantom, + trashDir: trash, + }); + assert.equal(missing.action, "missing"); + + /* purge:再装一个直接删 */ + const folder2 = "[BP] PurgeMe"; + const uuid2 = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + const packDir2 = path.join(bds, "worlds", level, "behavior_packs", folder2); + writeManifest(packDir2, { + name: "PurgeMe", + uuid: uuid2, + version: [1, 0, 0], + type: "data", + }); + fs.writeFileSync( + path.join(bds, "worlds", level, "world_behavior_packs.json"), + JSON.stringify([{ pack_id: uuid2, version: [1, 0, 0] }]) + ); + const packs2 = listInstalledWorldPacks(bds, level); + const purged = await uninstallInstalledPack({ + bdsRoot: bds, + levelName: level, + pack: packs2.find((p) => p.uuid === uuid2), + trashDir: null, + }); + assert.equal(purged.action, "deleted"); + assert.equal(fs.existsSync(packDir2), false); + }); }); diff --git a/db-server/src/routes/config.ts b/db-server/src/routes/config.ts index 7d134d32..62da794b 100644 --- a/db-server/src/routes/config.ts +++ b/db-server/src/routes/config.ts @@ -26,7 +26,7 @@ interface Deps { listModules?: () => Array>; /** * 注入模块 HMAC token 表(仅 loopback 可达;供 SAPI host-bootstrap 注入 - * set*ModuleContext,因 SAPI 不能读 data/module-tokens.json)。 + * set*ModuleContext,因 SAPI 不能直接读与 DB 同目录的 module-tokens.json)。 */ getModuleTokens?: () => Record; } diff --git a/module-lock.json b/module-lock.json deleted file mode 100644 index a425b978..00000000 --- a/module-lock.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": 1, - "modules": {} -} diff --git a/sfmc/pack-update-config.test.mjs b/sfmc/pack-update-config.test.mjs index 4af666b0..604ecb40 100644 --- a/sfmc/pack-update-config.test.mjs +++ b/sfmc/pack-update-config.test.mjs @@ -34,7 +34,7 @@ describe("pack-update config + provider resolve", () => { } }); - it("defaultBindingEnabled 默认 false,且出现在类型化配置上", () => { + it("defaultBindingEnabled 默认 false,且出现在类型化配置上", async () => { const cfgPath = path.join(tmpRoot, "configs", "pack-update.json"); if (fs.existsSync(cfgPath)) fs.unlinkSync(cfgPath); const cfg = loadPackUpdateConfig(); @@ -42,6 +42,16 @@ describe("pack-update config + provider resolve", () => { assert.equal(typeof cfg.match.nameMinScore, "number"); assert.equal(cfg.uninstall.recycleBin, true); assert.equal(cfg.uninstall.trashRelativeDir, "packs/_trash"); + + const { DEFAULT_PACK_UNINSTALL } = await import( + pathToFileURL(path.resolve("dist/pack-update/types.js")).href + ); + const { resolveUninstallTrashDir } = await import( + pathToFileURL(path.resolve("dist/pack-update/config.js")).href + ); + assert.equal(cfg.uninstall.trashRelativeDir, DEFAULT_PACK_UNINSTALL.trashRelativeDir); + assert.equal(resolveUninstallTrashDir({ purge: true }), null); + assert.ok(String(resolveUninstallTrashDir({})).includes(DEFAULT_PACK_UNINSTALL.trashRelativeDir)); }); it("旧版 providers.curseforge.match 提升到顶层 match", () => { diff --git a/sfmc/src/pack-update/config.ts b/sfmc/src/pack-update/config.ts index 47f075cf..acdc7837 100644 --- a/sfmc/src/pack-update/config.ts +++ b/sfmc/src/pack-update/config.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import { ROOT } from "../runtime.js"; import type { CurseForgeProviderConfig, PackUpdateConfig, PackUpdateMatchConfig } from "./types.js"; +import { DEFAULT_PACK_UNINSTALL } from "./types.js"; const DEFAULT_MATCH: PackUpdateMatchConfig = { nameMinScore: 0.6, @@ -46,10 +47,7 @@ const DEFAULTS: PackUpdateConfig = { skipDisabledBindings: true, failMode: "continue", }, - uninstall: { - recycleBin: true, - trashRelativeDir: "packs/_trash", - }, + uninstall: { ...DEFAULT_PACK_UNINSTALL }, }; function deepMerge>(base: T, over: Partial): T { @@ -136,10 +134,11 @@ export function getPackMatchConfig(cfg: PackUpdateConfig): PackUpdateMatchConfig return cfg.match; } -/** 卸载回收站绝对路径(相对 SFMC_ROOT) */ +/** 卸载回收站绝对路径(相对 SFMC_ROOT;缺省走 DEFAULT_PACK_UNINSTALL) */ export function resolvePackTrashDir(cfg?: PackUpdateConfig): string { const c = cfg ?? loadPackUpdateConfig(); - const rel = (c.uninstall?.trashRelativeDir || "packs/_trash").trim() || "packs/_trash"; + const fallback = DEFAULT_PACK_UNINSTALL.trashRelativeDir; + const rel = (c.uninstall?.trashRelativeDir || fallback).trim() || fallback; return path.isAbsolute(rel) ? rel : path.join(ROOT, rel); } @@ -149,6 +148,19 @@ export function isPackUninstallRecycleBin(cfg?: PackUpdateConfig): boolean { return c.uninstall?.recycleBin !== false; } +/** + * 解析本次卸载的 trash 目标(DRY:purge + recycleBin 只在一处组合)。 + * 返回 null 表示直接删除。 + */ +export function resolveUninstallTrashDir(opts?: { + purge?: boolean; + cfg?: PackUpdateConfig; +}): string | null { + const cfg = opts?.cfg ?? loadPackUpdateConfig(); + if (opts?.purge || !isPackUninstallRecycleBin(cfg)) return null; + return resolvePackTrashDir(cfg); +} + /** * 确保 configs/pack-update.json 存在;缺失则写入内置 DEFAULTS + $schema。 */ diff --git a/sfmc/src/pack-update/types.ts b/sfmc/src/pack-update/types.ts index 6ded0bba..315a15c9 100644 --- a/sfmc/src/pack-update/types.ts +++ b/sfmc/src/pack-update/types.ts @@ -58,6 +58,12 @@ export interface PackUninstallConfig { trashRelativeDir: string; } +/** 卸载策略内置默认(DEFAULTS / resolve / schema 文案的唯一权威) */ +export const DEFAULT_PACK_UNINSTALL: PackUninstallConfig = { + recycleBin: true, + trashRelativeDir: "packs/_trash", +}; + export interface PackUpdateConfig { enabled: boolean; checkOnBdsStart: boolean; diff --git a/sfmc/src/world-packs.ts b/sfmc/src/world-packs.ts index a53f28d4..24b3faff 100644 --- a/sfmc/src/world-packs.ts +++ b/sfmc/src/world-packs.ts @@ -34,11 +34,10 @@ import { probeSourceAfterInstall, searchRemote, } from "./pack-update/service.js"; -import { getBinding, removeBinding } from "./pack-update/bindings.js"; -import { packSourcesPath } from "./pack-update/bindings.js"; +import { getBinding, removeBinding, packSourcesPath } from "./pack-update/bindings.js"; import { - isPackUninstallRecycleBin, resolvePackTrashDir, + resolveUninstallTrashDir, } from "./pack-update/config.js"; import { ROOT } from "./runtime.js"; import { c } from "./theme.js"; @@ -93,12 +92,8 @@ interface InboxState { installed: Record; } -function trashDir(): string { - return resolvePackTrashDir(); -} - function ensureInboxLayout(): void { - for (const d of [packsInboxDir(), doneDir(), failedDir(), trashDir()]) { + for (const d of [packsInboxDir(), doneDir(), failedDir(), resolvePackTrashDir()]) { fs.mkdirSync(d, { recursive: true }); } } @@ -155,25 +150,43 @@ function pruneInboxStateByUuid(uuid: string): void { if (changed) writeState(state); } -function isProtectedSfmcPack(pack: InstalledWorldPack): boolean { +/** 平台聚合包不可经 packs uninstall 卸出(策略集中,CLI 只消费) */ +export function isProtectedSfmcPack(pack: Pick): boolean { const folder = pack.folderName.toLowerCase(); return folder === BP_NAME.toLowerCase() || folder === RP_NAME.toLowerCase(); } +type UninstallOneOutcome = + | { status: "protected"; folder: string } + | { status: "trashed"; folder: string; dest: string } + | { status: "deleted"; folder: string } + | { status: "missing"; folder: string }; + +function formatUninstallOutcome(r: UninstallOneOutcome): string { + if (r.status === "protected") { + return c.red(t("packs.uninstall.protected", { folder: r.folder })); + } + if (r.status === "trashed") { + return c.green(t("packs.uninstall.trashed", { folder: r.folder, dest: r.dest })); + } + if (r.status === "missing") { + return c.yellow(t("packs.uninstall.dirMissing", { folder: r.folder })); + } + return c.green(t("packs.uninstall.deleted", { folder: r.folder })); +} + /** * 卸载单个已装世界包(disable + 移回收站/删除)。 - * 不负责配对 RP / 绑定 —— 由上层编排。 + * 不负责配对 RP / 绑定 —— 由上层编排。返回结构化结果(DIP:展示与策略分离)。 */ async function uninstallOneInstalledPack( pack: InstalledWorldPack, opts: { bdsRoot: string; levelName: string; purge: boolean } -): Promise { +): Promise { if (isProtectedSfmcPack(pack)) { - return c.red(t("packs.uninstall.protected", { folder: pack.folderName })); + return { status: "protected", folder: pack.folderName }; } - const useTrash = !opts.purge && isPackUninstallRecycleBin(); - const trash = useTrash ? resolvePackTrashDir() : null; - if (trash) fs.mkdirSync(trash, { recursive: true }); + const trash = resolveUninstallTrashDir({ purge: opts.purge }); const r = await uninstallInstalledPack({ bdsRoot: opts.bdsRoot, levelName: opts.levelName, @@ -182,17 +195,12 @@ async function uninstallOneInstalledPack( }); pruneInboxStateByUuid(pack.uuid); if (r.action === "trashed") { - return c.green( - t("packs.uninstall.trashed", { - folder: pack.folderName, - dest: r.dest ?? trash ?? "-", - }) - ); + return { status: "trashed", folder: pack.folderName, dest: r.dest }; } if (r.action === "missing") { - return c.yellow(t("packs.uninstall.dirMissing", { folder: pack.folderName })); + return { status: "missing", folder: pack.folderName }; } - return c.green(t("packs.uninstall.deleted", { folder: pack.folderName })); + return { status: "deleted", folder: pack.folderName }; } function printConflict( @@ -689,6 +697,11 @@ export async function dispatchPacksCommand(sub: string | undefined, args: string const pack = findInstalledPackById(packs, id); if (!pack) return c.red(t("packs.notFound", { id })); + /* LSP:受保护主包直接中止,不碰配对 RP / 绑定 */ + if (isProtectedSfmcPack(pack)) { + return c.red(t("packs.uninstall.protected", { folder: pack.folderName })); + } + /* 移走 BP 前解析配对 RP */ let pairedRp: InstalledWorldPack | null = null; if (pack.kind === "behavior" && !noPaired) { @@ -706,13 +719,16 @@ export async function dispatchPacksCommand(sub: string | undefined, args: string } const lines: string[] = []; - lines.push(await uninstallOneInstalledPack(pack, { bdsRoot, levelName, purge })); + const primary = await uninstallOneInstalledPack(pack, { bdsRoot, levelName, purge }); + lines.push(formatUninstallOutcome(primary)); + if (pairedRp) { - lines.push(await uninstallOneInstalledPack(pairedRp, { bdsRoot, levelName, purge })); + const paired = await uninstallOneInstalledPack(pairedRp, { bdsRoot, levelName, purge }); + lines.push(formatUninstallOutcome(paired)); } - /* 清更新源绑定(BP uuid) */ - if (pack.kind === "behavior") { + /* 清更新源绑定(BP uuid);仅主包已实际卸载后执行 */ + if (pack.kind === "behavior" && primary.status !== "protected") { const removed = removeBinding(pack.uuid); if (removed) { lines.push(c.dim(t("packs.uninstall.bindingRemoved", { uuid: pack.uuid }))); @@ -851,7 +867,7 @@ export async function dispatchPacksCommand(sub: string | undefined, args: string `behavior: ${worldPackParentDir(bdsRoot, levelName, "behavior")}`, `resource: ${worldPackParentDir(bdsRoot, levelName, "resource")}`, `inbox: ${packsInboxDir()}`, - `trash: ${trashDir()}`, + `trash: ${resolvePackTrashDir()}`, `build: ${path.join(packsInboxDir(), "_build")}`, `sources: ${packSourcesPath()}`, ].join("\n"); @@ -883,7 +899,7 @@ export function packsUsage(): string { ${c.green("packs path")} ${t("packs.usage.inbox", { path: packsInboxDir() })} -${t("packs.usage.trash", { path: trashDir() })} +${t("packs.usage.trash", { path: resolvePackTrashDir() })} ${t("packs.usage.sources", { path: packSourcesPath() })} ${t("packs.usage.enableNote", { hint: restartHint() })} ${t("packs.usage.moduleNote", { cmd: c.green("sfmc mod") })}`; From e04a5894cc33160d8b93f7b8adfa113fdf66c9d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 17:01:00 +0000 Subject: [PATCH 2/2] test(packs): add protected-folder uninstall contract Co-authored-by: Shiroha --- sfmc/world-packs-uninstall.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 sfmc/world-packs-uninstall.test.mjs diff --git a/sfmc/world-packs-uninstall.test.mjs b/sfmc/world-packs-uninstall.test.mjs new file mode 100644 index 00000000..c9b959f6 --- /dev/null +++ b/sfmc/world-packs-uninstall.test.mjs @@ -0,0 +1,21 @@ +/** + * packs uninstall 策略契约:受保护平台包识别(DRY 与 CLI 同源) + * 需先 `npm run build -w @sfmc-bds/cli` + */ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; + +const { isProtectedSfmcPack } = await import( + pathToFileURL(path.resolve("dist/world-packs.js")).href +); + +describe("packs uninstall protection", () => { + it("识别 sfmc-modules / sfmc-modules-rp,忽略大小写", () => { + assert.equal(isProtectedSfmcPack({ folderName: "sfmc-modules" }), true); + assert.equal(isProtectedSfmcPack({ folderName: "SFMC-Modules" }), true); + assert.equal(isProtectedSfmcPack({ folderName: "sfmc-modules-rp" }), true); + assert.equal(isProtectedSfmcPack({ folderName: "[BP] MyAddon" }), false); + }); +});