diff --git a/.gitignore b/.gitignore index 5266d5a..bc587df 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,4 @@ BDS # Exceeds GitHub's 100MB push limit — re-generate locally after clone. dist/sea/ .vscode/ -remote-controller/dist/index.js +remote-controller/dist/ diff --git a/package-lock.json b/package-lock.json index 5259e9c..aa52399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -755,6 +755,7 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/@minecraft/common/-/common-1.3.0.tgz", "integrity": "sha512-GLT8USFhvEyeTTFHZAgszbrnoT007hmXmK+aO4l+2A1up9/zwZ+4e8R8F0KcKrCWDjLEqkOJtPow0hQCcNJ++Q==", + "dev": true, "license": "MIT", "peer": true }, @@ -762,6 +763,7 @@ "version": "2.10.0-beta.1.26.40-preview.30", "resolved": "https://registry.npmmirror.com/@minecraft/server/-/server-2.10.0-beta.1.26.40-preview.30.tgz", "integrity": "sha512-y2yyRAE1rk2BvhdQAO9biRJ+DyXUwYQ6as5NIBIouPNwY61oCAdlEjMYW6Gh6vxcKjaIGYLWWNGEWroa68BBzw==", + "dev": true, "license": "MIT", "peerDependencies": { "@minecraft/common": "^1.2.0", @@ -800,40 +802,9 @@ "version": "1.26.40-preview.30", "resolved": "https://registry.npmmirror.com/@minecraft/vanilla-data/-/vanilla-data-1.26.40-preview.30.tgz", "integrity": "sha512-YQOEb3+HjjdL527YHmmPq/uqgJd2NO5DrO/SmCMrapDUuxO3LByPhd7IYGQqfy2MByF+AcjZZj4B0Bo27gGaTQ==", + "dev": true, "license": "MIT" }, - "node_modules/@sfmc/module-daily-task": { - "resolved": "modules/packages/daily-task", - "link": true - }, - "node_modules/@sfmc/module-data-backup": { - "resolved": "modules/packages/data-backup", - "link": true - }, - "node_modules/@sfmc/module-economy": { - "resolved": "modules/packages/economy", - "link": true - }, - "node_modules/@sfmc/module-feature-chat": { - "resolved": "modules/packages/feature-chat", - "link": true - }, - "node_modules/@sfmc/module-feature-coop": { - "resolved": "modules/packages/feature-coop", - "link": true - }, - "node_modules/@sfmc/module-gui": { - "resolved": "modules/packages/gui", - "link": true - }, - "node_modules/@sfmc/module-land": { - "resolved": "modules/packages/land", - "link": true - }, - "node_modules/@sfmc/module-land-gui": { - "resolved": "modules/packages/land-gui", - "link": true - }, "node_modules/@sfmc/remote-controller": { "resolved": "remote-controller", "link": true diff --git a/sfmc/src/registry.ts b/sfmc/src/registry.ts index 2b5d5ab..83da140 100644 --- a/sfmc/src/registry.ts +++ b/sfmc/src/registry.ts @@ -54,14 +54,37 @@ function writeCache(cache: RegistryCache): void { } } +/** + * 解析 first-party registry index。 + * 权威形态与 tools/fetch-module.mjs 一致: + * { version, modules: { "": { repo, tag }, ... } } + * 兼容旧扁平形态:{ "": { repo, tag }, ... } + */ +function parseRegistryIndex(json: unknown): RegistryIndex { + if (typeof json !== "object" || json === null || Array.isArray(json)) { + throw new Error("registry index must be a JSON object with a 'modules' field"); + } + const root = json as Record; + const modulesRaw = + typeof root.modules === "object" && root.modules !== null && !Array.isArray(root.modules) + ? (root.modules as Record) + : root; + const filtered: RegistryIndex = {}; + for (const [k, v] of Object.entries(modulesRaw)) { + if (k.startsWith("_")) continue; + if (k === "version" || k === "generatedAt" || k === "modules") continue; + if (typeof v !== "object" || v === null || Array.isArray(v)) continue; + const entry = v as Record; + if (typeof entry.repo !== "string" || typeof entry.tag !== "string") continue; + filtered[k] = { repo: entry.repo, tag: entry.tag }; + } + return filtered; +} + async function fetchFresh(): Promise { const res = await fetch(INDEX_URL, { headers: { "User-Agent": "sfmc-cli" } }); if (!res.ok) throw new Error(`HTTP ${res.status} for ${INDEX_URL}`); - const json = (await res.json()) as unknown; - if (typeof json !== "object" || json === null || Array.isArray(json)) { - throw new Error("registry index must be a JSON object mapping id → { repo, tag }"); - } - return json as RegistryIndex; + return parseRegistryIndex(await res.json()); } export interface RegistryResult { diff --git a/tools/check-catalog.js b/tools/check-catalog.js index 9453e96..a24049b 100644 --- a/tools/check-catalog.js +++ b/tools/check-catalog.js @@ -5,9 +5,11 @@ * 用法: node tools/check-catalog.js * * 规则: + * - modules/catalog.json 允许为空(业务模块外置到 Tanya7z/sfmc-modules) + * - services/catalog.json 必须存在且非空(平台服务仍是本仓真相源) * - 模块 id / configKey 必须唯一 * - requires / optional 引用必须存在 - * - entry.path 路径必须存在(asset/node/sapi 类型) + * - modules/catalog 的 entry.path 路径必须存在(asset/node/sapi 类型) * - entry.init 仅在 sapi 类型可选 * - 不允许循环依赖(拓扑排序检测) */ @@ -40,53 +42,31 @@ function exists(p) { } } -function main() { - if (!exists(CATALOG)) fail(`找不到 ${CATALOG}`); - const raw = JSON.parse(readFileSync(CATALOG, "utf-8")); - if (raw.version !== 1) fail(`catalog 版本不支持: ${raw.version}`); - const modules = Array.isArray(raw.modules) ? raw.modules : []; - if (modules.length === 0) fail("catalog 为空"); +/** + * 校验一份 catalog 模块列表(modules/catalog 或 services/catalog)。 + * 空列表合法:业务模块已外置到 Tanya7z/sfmc-modules,本地 mirror 可为 []. + */ +function validateModuleList(modules, { label, requireEntryOnDisk }) { + if (!Array.isArray(modules)) fail(`${label}: modules 必须是数组`); const byId = new Map(); const byKey = new Map(); for (const m of modules) { - if (!m.id || !m.configKey) fail(`缺少 id 或 configKey: ${JSON.stringify(m)}`); - if (byId.has(m.id)) fail(`重复模块 id: ${m.id}`); - if (byKey.has(m.configKey)) fail(`重复 configKey: ${m.configKey}`); + if (!m.id || !m.configKey) fail(`${label}: 缺少 id 或 configKey: ${JSON.stringify(m)}`); + if (byId.has(m.id)) fail(`${label}: 重复模块 id: ${m.id}`); + if (byKey.has(m.configKey)) fail(`${label}: 重复 configKey: ${m.configKey}`); byId.set(m.id, m); byKey.set(m.configKey, m); } - const sapiCatalog = modules.filter((m) => m.type === "feature" && m.entry?.kind === "sapi"); - for (const m of sapiCatalog) { - const sapiEntry = join(ROOT, m.entry.path); - if (!exists(sapiEntry)) { - fail(`${m.id} entry.path 不存在: ${m.entry.path}`); - } - } - - // Optional: also validate services/catalog.json if it exists. - if (exists(SERVICES_CATALOG)) { - const svc = JSON.parse(readFileSync(SERVICES_CATALOG, "utf-8")); - if (svc.version !== 1) fail(`services/catalog.json 版本不支持: ${svc.version}`); - for (const m of svc.modules || []) { - if (!m.id || !m.configKey) fail(`services 模块缺少 id 或 configKey: ${JSON.stringify(m)}`); - if (!m.entry || !m.entry.path) fail(`${m.id} 缺少 entry.path`); - const entryPath = join(ROOT, m.entry.path); - if (m.entry.kind === "sapi" || m.entry.kind === "node" || m.entry.kind === "asset") { - if (!exists(entryPath)) fail(`${m.id} 服务入口路径不存在: ${m.entry.path}`); - } - } - } - for (const m of modules) { for (const dep of [...(m.requires || []), ...(m.optional || [])]) { - if (!byId.has(dep)) fail(`${m.id} 引用了未知模块 ${dep}`); + if (!byId.has(dep)) fail(`${label}: ${m.id} 引用了未知模块 ${dep}`); } - if (!m.entry || !m.entry.path) fail(`${m.id} 缺少 entry.path`); - const entryPath = join(ROOT, m.entry.path); - if (m.entry.kind === "sapi" || m.entry.kind === "node" || m.entry.kind === "asset") { - if (!exists(entryPath)) fail(`${m.id} 入口路径不存在: ${m.entry.path}`); + if (!m.entry || !m.entry.path) fail(`${label}: ${m.id} 缺少 entry.path`); + if (requireEntryOnDisk && (m.entry.kind === "sapi" || m.entry.kind === "node" || m.entry.kind === "asset")) { + const entryPath = join(ROOT, m.entry.path); + if (!exists(entryPath)) fail(`${label}: ${m.id} 入口路径不存在: ${m.entry.path}`); } } @@ -94,7 +74,7 @@ function main() { const visited = new Set(); function dfs(id, chain) { if (visited.has(id)) return; - if (visiting.has(id)) fail(`检测到循环依赖: ${[...chain, id].join(" -> ")}`); + if (visiting.has(id)) fail(`${label}: 检测到循环依赖: ${[...chain, id].join(" -> ")}`); visiting.add(id); const m = byId.get(id); for (const dep of m.requires || []) dfs(dep, [...chain, id]); @@ -103,7 +83,32 @@ function main() { } for (const m of modules) dfs(m.id, []); - console.log(`[check-catalog] OK (${modules.length} modules)`); + return modules.length; +} + +function main() { + if (!exists(CATALOG)) fail(`找不到 ${CATALOG}`); + const raw = JSON.parse(readFileSync(CATALOG, "utf-8")); + if (raw.version !== 1) fail(`catalog 版本不支持: ${raw.version}`); + const modules = Array.isArray(raw.modules) ? raw.modules : []; + // 业务包外置后 modules/catalog.json 允许为空;仍校验其 schema 与依赖闭包。 + const featureCount = validateModuleList(modules, { + label: "modules/catalog.json", + requireEntryOnDisk: true, + }); + + // 平台服务 catalog 仍是本仓库内置真相源,必须存在且非空。 + if (!exists(SERVICES_CATALOG)) fail(`找不到 ${SERVICES_CATALOG}`); + const svc = JSON.parse(readFileSync(SERVICES_CATALOG, "utf-8")); + if (svc.version !== 1) fail(`services/catalog.json 版本不支持: ${svc.version}`); + const serviceCount = validateModuleList(svc.modules || [], { + label: "services/catalog.json", + // dist 入口在 CI build 后才存在;此处只校验 schema/依赖,不强制 dist 落盘。 + requireEntryOnDisk: false, + }); + if (serviceCount === 0) fail("services/catalog.json 为空"); + + console.log(`[check-catalog] OK (${featureCount} feature modules, ${serviceCount} services)`); } main(); diff --git a/tools/check-ootb.js b/tools/check-ootb.js index 240bb13..c9b52ef 100644 --- a/tools/check-ootb.js +++ b/tools/check-ootb.js @@ -115,7 +115,8 @@ function fileContains(p, needle) { if (!ok) throw new Error("db-server 不可达"); const mods = await fetchJson("/api/sfmc/modules"); if (mods.status !== 200) throw new Error(`modules 接口 ${mods.status}`); - if (!Array.isArray(mods.body.modules) || mods.body.modules.length === 0) throw new Error("modules 为空"); + // 业务模块已外置到 sfmc-modules;空 catalog 是合法的平台基线 + if (!Array.isArray(mods.body.modules)) throw new Error("modules 非数组"); pass("db-server 启动 + 模块接口"); } catch (e) { fail("db-server 启动 + 模块接口", e.message); diff --git a/tools/sim-new-user.js b/tools/sim-new-user.js index 8206073..496d823 100644 --- a/tools/sim-new-user.js +++ b/tools/sim-new-user.js @@ -136,11 +136,12 @@ async function main() { expect(mods.status === 200 && Array.isArray(mods.body.modules), "GET /api/sfmc/modules 返回模块列表"); const catalog = await request("GET", "/api/sfmc/modules/catalog"); - expect(catalog.status === 200 && catalog.body.modules.length > 0, "GET /api/sfmc/modules/catalog 返回模块"); + // 业务模块外置后空 catalog 是新用户基线;仍要求 API 契约(数组 + 与合并列表等长) + expect(catalog.status === 200 && Array.isArray(catalog.body.modules), "GET /api/sfmc/modules/catalog 返回数组"); expect(mods.body.modules.length === catalog.body.modules.length, "模块列表与 catalog 数量一致"); - const moduleLock = JSON.parse(fs.readFileSync(path.join(SIM_DIR, "modules", "module-lock.json"), "utf-8")); - const catData = JSON.parse(fs.readFileSync(path.join(SIM_DIR, "modules", "catalog.json"), "utf-8")); + JSON.parse(fs.readFileSync(path.join(SIM_DIR, "modules", "module-lock.json"), "utf-8")); + JSON.parse(fs.readFileSync(path.join(SIM_DIR, "modules", "catalog.json"), "utf-8")); log("result", "全部模拟通过"); } finally { diff --git a/tools/smoke-modules.js b/tools/smoke-modules.js index ff5b025..265b2aa 100644 --- a/tools/smoke-modules.js +++ b/tools/smoke-modules.js @@ -3,15 +3,13 @@ * smoke-modules.js — 模块系统冒烟回归 * * 验证: - * - catalog 通过构建期自检 - * - db-server 模块 API 返回 29 条且字段完整 - * - 安装/卸载工具可执行且 lock 文件更新 - * - 模块依赖启用前置校验 + * - catalog 通过构建期自检(允许业务 catalog 为空:模块已外置) + * - db-server 模块 API 返回数组且与本地 catalog 长度一致 + * - 若存在 can_disable 模块,校验启停翻转 * * 用法: node tools/smoke-modules.js * 要求: db-server 已启动并暴露 /api/sfmc/modules */ -const fs = require("node:fs"); const path = require("node:path"); const http = require("node:http"); const { spawnSync } = require("node:child_process"); @@ -20,9 +18,9 @@ const ROOT = path.resolve(__dirname, ".."); const HOST = "127.0.0.1"; const PORT = parseInt(process.env.DB_PORT || "3001", 10); -function fetchJson(path) { +function fetchJson(reqPath) { return new Promise((resolve, reject) => { - const req = http.request({ hostname: HOST, port: PORT, path, method: "GET", timeout: 3000 }, (res) => { + const req = http.request({ hostname: HOST, port: PORT, path: reqPath, method: "GET", timeout: 3000 }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => { @@ -68,16 +66,22 @@ async function main() { const check = spawnSync(process.execPath, [path.join(ROOT, "tools", "check-catalog.js")], { cwd: ROOT, encoding: "utf-8" }); expect(check.status === 0, `check-catalog.js 通过 (status=${check.status})`); - // 2) 读 catalog 接口 + // 2) 读取 catalog 接口(空数组合法:业务模块外置后的平台基线) const cat = await fetchJson("/api/sfmc/modules/catalog"); expect(cat.status === 200, `GET /api/sfmc/modules/catalog → 200`); - expect(Array.isArray(cat.body.modules) && cat.body.modules.length > 0, `catalog 返回 ${cat.body.modules.length} 个模块`); + expect(Array.isArray(cat.body.modules), `catalog 返回数组 (len=${Array.isArray(cat.body.modules) ? cat.body.modules.length : "n/a"})`); // 3) 合并列表接口 const list = await fetchJson("/api/sfmc/modules"); expect(list.status === 200, `GET /api/sfmc/modules → 200`); expect(Array.isArray(list.body.modules) && list.body.modules.length === cat.body.modules.length, `合并列表与 catalog 一致`); + if (list.body.modules.length === 0) { + expect(true, "业务 catalog 为空(modules 外置基线)— 跳过启停翻转"); + console.log("[smoke] 全部通过"); + return; + } + // 4) 字段完整性 const required = ["id", "name", "config_key", "type", "description", "default_enabled", "can_disable", "requires", "entry", "enabled"]; for (const m of list.body.modules) { @@ -88,12 +92,12 @@ async function main() { // 4.5) 重置所有 core/feature 模块到 enabled=true,避免之前测试遗留状态污染 for (const m of list.body.modules) { - if ((m.type === 'core' || m.type === 'feature') && !m.enabled) { + if ((m.type === "core" || m.type === "feature") && !m.enabled) { await postJson(`/api/sfmc/modules/${encodeURIComponent(m.id)}/enable`); } } // 重新拉取(可能因 legacy 记录导致 modules > catalog) - let list2 = await fetchJson("/api/sfmc/modules"); + const list2 = await fetchJson("/api/sfmc/modules"); expect(list2.body.modules.length === cat.body.modules.length, `合并列表与 catalog 一致 (${list2.body.modules.length} === ${cat.body.modules.length})`); // 5) 切换启用状态 → 检查 enabled 翻转