From 94d625c9a3a628e78059fed3b155505686ac8e58 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 06:11:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(ci):=20=E7=A7=BB=E9=99=A4=E5=B7=B2?= =?UTF-8?q?=E6=8A=BD=E5=8F=96=E6=A8=A1=E5=9D=97=E5=9C=A8=20package-lock=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E6=82=AC=E7=A9=BA=20workspace=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块抽到 Tanya7z/sfmc-modules 后 lockfile 仍保留 node_modules/@sfmc/module-* link 指向已删除的 modules/packages/*,导致 npm arborist 读取 extraneous 崩溃, ootb「Install root deps」直接失败。 Co-authored-by: Shiroha --- package-lock.json | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) 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 From 7ca72170fa396700898cc09e3d1daaa0796cc5cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 06:11:28 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(modules):=20=E7=A9=BA=20catalog=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=A3=B3=E9=80=82=E9=85=8D=20+=20rebuild-cat?= =?UTF-8?q?alog=20+=20zip=20=E8=B7=AF=E5=BE=84=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务模块抽离后 catalog 为空是合法平台壳状态,但 check-catalog/ootb/smoke 仍硬性要求非空,且 fetch-module unzip 未处理 Windows 反斜杠路径。 - 新增 tools/rebuild-catalog.js:从 packages/*/sapi/manifest 生成 catalog(DRY) - fetch-module 安装后自动 rebuild;unzip 归一化 \\ → / - check-catalog / ootb / smoke / sim 允许空 catalog,并校验 services - ootb CI 在 smoke 前安装 afk fixture 覆盖启停翻转 - gitignore 忽略已安装 packages/* 与 registry 缓存 - 清理过期 _manifests(与空 packages 对齐) Co-authored-by: Shiroha --- .github/workflows/ootb.yml | 8 ++ .gitignore | 7 ++ modules/_manifests/module-manifests.json | 86 +-------------- modules/catalog.json | 2 +- sfmc/src/module-commands.ts | 10 +- tools/check-catalog.js | 42 +++++--- tools/check-ootb.js | 8 +- tools/fetch-module.mjs | 39 ++++++- tools/rebuild-catalog.js | 132 +++++++++++++++++++++++ tools/sim-new-user.js | 2 +- tools/smoke-modules.js | 103 ++++++++++++------ 11 files changed, 301 insertions(+), 138 deletions(-) create mode 100644 tools/rebuild-catalog.js diff --git a/.github/workflows/ootb.yml b/.github/workflows/ootb.yml index 349ac82..e158f62 100644 --- a/.github/workflows/ootb.yml +++ b/.github/workflows/ootb.yml @@ -44,6 +44,14 @@ jobs: shell: pwsh run: node tools/check-ootb.js + # 业务模块已外置:安装一个 can_disable fixture,覆盖启停翻转冒烟路径 + - name: Seed fixture module (afk) for smoke toggle + shell: pwsh + run: | + node tools/fetch-module.mjs install afk + node tools/rebuild-catalog.js + node tools/check-catalog.js + - name: Run smoke-modules (against temporary db-server) shell: pwsh run: | diff --git a/.gitignore b/.gitignore index 5266d5a..089d27a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,10 @@ BDS dist/sea/ .vscode/ remote-controller/dist/index.js + +# 业务模块由 tools/fetch-module.mjs 安装到 packages//,不入库 +modules/packages/*/ +!modules/packages/.gitkeep + +# fetch-module 一级 registry 缓存 +tools/.sfmc-registry-cache.json diff --git a/modules/_manifests/module-manifests.json b/modules/_manifests/module-manifests.json index fe911a0..f3448d2 100644 --- a/modules/_manifests/module-manifests.json +++ b/modules/_manifests/module-manifests.json @@ -1,87 +1,5 @@ { "schemaVersion": 1, - "generatedAt": "2026-07-22T05:42:54.451Z", - "modules": { - "feature-afk": { - "name": "AFK", - "type": "feature", - "configKey": "afk", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-clean": { - "name": "清理", - "type": "feature", - "configKey": "clean", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-spawn-protect": { - "name": "重生保护", - "type": "feature", - "configKey": "spawn_protect", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-chat-sounds": { - "name": "聊天音效", - "type": "feature", - "configKey": "chat_sounds", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-area-fly": { - "name": "区域飞行", - "type": "feature", - "configKey": "fly", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-area-creative": { - "name": "创造区域", - "type": "feature", - "configKey": "creative", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-area-survival": { - "name": "生存区域", - "type": "feature", - "configKey": "survival", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-area-peace": { - "name": "和平区域", - "type": "feature", - "configKey": "peace", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - }, - "feature-qa": { - "name": "问答系统", - "type": "feature", - "configKey": "qa", - "requires": [], - "handlers": [], - "routes": [], - "migrations": [] - } - } + "generatedAt": "2026-07-22T06:10:52.887Z", + "modules": {} } diff --git a/modules/catalog.json b/modules/catalog.json index a0a0821..d8db666 100644 --- a/modules/catalog.json +++ b/modules/catalog.json @@ -1,5 +1,5 @@ { - "_comment": "modules/catalog.json 是本地 mirror;source of truth 为 github:Tanya7z/sfmc-modules/main/index.json。业务模块已全部提取至 Tanya7z/sfmc-modules(最新 modules-v0.4.0)。本仓库 modules/packages/ 不再内置业务包;请用 tools/fetch-module.mjs / sfmc 从 registry 安装到 modules/packages//。", + "_comment": "modules/catalog.json 是本地 mirror;由 tools/rebuild-catalog.js 根据 modules/packages/*/sapi/manifest.json 生成。 source of truth 为 github:Tanya7z/sfmc-modules。空 modules 表示尚未安装业务包,可用 tools/fetch-module.mjs install 。", "version": 1, "modules": [] } diff --git a/sfmc/src/module-commands.ts b/sfmc/src/module-commands.ts index c76ce78..2b44970 100644 --- a/sfmc/src/module-commands.ts +++ b/sfmc/src/module-commands.ts @@ -27,7 +27,7 @@ import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { configPath, readJson, type DBConfig } from "@sfmc/sdk/node/config"; import { c } from "./theme.js"; import { ROOT } from "./runtime.js"; @@ -324,6 +324,14 @@ export async function cmdModuleUninstall(args: string[]): Promise { const target = path.join(modulesDir(), id); if (!existsSync(target)) return c.yellow(`Module ${id} not installed (no folder at ${target})`); await fs.rm(target, { recursive: true, force: true }); + // 同步本地 catalog mirror,避免 API 仍列出已删包(DRY:与 fetch-module 共用 rebuild-catalog) + const rebuild = path.join(ROOT, "tools", "rebuild-catalog.js"); + if (existsSync(rebuild)) { + const r = spawnSync(process.execPath, [rebuild], { cwd: ROOT, encoding: "utf-8" }); + if (r.status !== 0) { + return c.green(`Removed ${id} from ${target}\n`) + c.yellow(r.stderr || r.stdout || `rebuild-catalog exit ${r.status}`); + } + } return c.green(`Removed ${id} from ${target}\n`); } diff --git a/tools/check-catalog.js b/tools/check-catalog.js index 9453e96..996df74 100644 --- a/tools/check-catalog.js +++ b/tools/check-catalog.js @@ -40,12 +40,38 @@ function exists(p) { } } +/** 校验 services/catalog.json(与 modules catalog 共用同一套入口约定)。 */ +function validateServicesCatalog() { + if (!exists(SERVICES_CATALOG)) return 0; + const svc = JSON.parse(readFileSync(SERVICES_CATALOG, "utf-8")); + if (svc.version !== 1) fail(`services/catalog.json 版本不支持: ${svc.version}`); + const svcMods = Array.isArray(svc.modules) ? svc.modules : []; + for (const m of svcMods) { + 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}`); + } + } + return svcMods.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 : []; - if (modules.length === 0) fail("catalog 为空"); + // 业务模块已外置到 Tanya7z/sfmc-modules;空 catalog 表示尚未 fetch 安装,平台壳合法。 + if (modules.length === 0) { + const svcCount = validateServicesCatalog(); + console.log( + svcCount > 0 + ? `[check-catalog] OK (0 feature modules; ${svcCount} services)` + : "[check-catalog] OK (0 modules; platform shell)" + ); + return; + } const byId = new Map(); const byKey = new Map(); @@ -65,19 +91,7 @@ function main() { } } - // 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}`); - } - } - } + validateServicesCatalog(); for (const m of modules) { for (const dep of [...(m.requires || []), ...(m.optional || [])]) { diff --git a/tools/check-ootb.js b/tools/check-ootb.js index 240bb13..7ec50cd 100644 --- a/tools/check-ootb.js +++ b/tools/check-ootb.js @@ -115,7 +115,13 @@ 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 为空"); + if (!Array.isArray(mods.body.modules)) throw new Error("modules 非数组"); + // 业务包已外置:空 catalog 时 API 返回 [] 合法;非空则与 catalog 对齐。 + const cat = await fetchJson("/api/sfmc/modules/catalog"); + if (cat.status !== 200 || !Array.isArray(cat.body.modules)) throw new Error("catalog 接口异常"); + if (mods.body.modules.length !== cat.body.modules.length) { + throw new Error(`modules(${mods.body.modules.length}) != catalog(${cat.body.modules.length})`); + } pass("db-server 启动 + 模块接口"); } catch (e) { fail("db-server 启动 + 模块接口", e.message); diff --git a/tools/fetch-module.mjs b/tools/fetch-module.mjs index 11020dd..4c48190 100644 --- a/tools/fetch-module.mjs +++ b/tools/fetch-module.mjs @@ -41,6 +41,7 @@ import { pipeline } from "node:stream/promises"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -309,8 +310,11 @@ async function unzip(zipPath, dstDir) { const zip = await JSZip.loadAsync(data); const entries = Object.values(zip.files); for (const e of entries) { - const out = path.join(dstDir, e.name); - if (e.dir) { + // Windows 打的 zip 常带反斜杠;统一成 POSIX 再 join,避免 Linux 写出字面量 "sapi\manifest.json" + const rel = String(e.name || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!rel || rel.includes("..")) continue; + const out = path.join(dstDir, ...rel.split("/")); + if (e.dir || rel.endsWith("/")) { await fsp.mkdir(out, { recursive: true }); continue; } @@ -330,6 +334,19 @@ async function copyDir(src, dst) { } } +/** 安装/变更 packages 后重建本地 catalog mirror(单一真相:磁盘上的 manifest)。 */ +function rebuildCatalogMirror() { + const script = path.join(ROOT, "tools", "rebuild-catalog.js"); + if (!fs.existsSync(script)) { + console.warn("[fetch-module] tools/rebuild-catalog.js missing; skip catalog rebuild"); + return; + } + const r = spawnSync(process.execPath, [script], { cwd: ROOT, encoding: "utf-8" }); + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + if (r.status !== 0) die(`rebuild-catalog failed (exit ${r.status ?? 1})`); +} + /* ── entry ──────────────────────────────────────────────── */ async function main() { if (!verb) { @@ -382,9 +399,21 @@ Sources: flags.from = source; } - if (flags.from.startsWith("local:")) return fromLocal(id, flags.from, flags); - if (flags.from.startsWith("dir:")) return fromDir(id, flags.from); - if (flags.from.startsWith("github:")) return fromGithub(id, flags.from, flags); + if (flags.from.startsWith("local:")) { + await fromLocal(id, flags.from, flags); + rebuildCatalogMirror(); + return; + } + if (flags.from.startsWith("dir:")) { + await fromDir(id, flags.from); + rebuildCatalogMirror(); + return; + } + if (flags.from.startsWith("github:")) { + await fromGithub(id, flags.from, flags); + rebuildCatalogMirror(); + return; + } die(`unknown source: ${flags.from}`); } diff --git a/tools/rebuild-catalog.js b/tools/rebuild-catalog.js new file mode 100644 index 0000000..7fe16a2 --- /dev/null +++ b/tools/rebuild-catalog.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * rebuild-catalog.js — 从已安装的 modules/packages//sapi/manifest.json + * 重建 modules/catalog.json(本地 mirror)。 + * + * 权威来源:磁盘上的模块包 manifest(DIP:catalog 不再手写维护业务清单)。 + * 空 packages/ → catalog.modules = [] 合法(平台壳,业务模块远程拉取)。 + * + * 用法: node tools/rebuild-catalog.js [--check] + * --check 只校验当前 catalog 是否与 packages 一致,不一致则 exit 1 + */ +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const PACKAGES_DIR = path.join(ROOT, "modules", "packages"); +const CATALOG_PATH = path.join(ROOT, "modules", "catalog.json"); + +const CATALOG_COMMENT = + "modules/catalog.json 是本地 mirror;由 tools/rebuild-catalog.js 根据 modules/packages/*/sapi/manifest.json 生成。" + + " source of truth 为 github:Tanya7z/sfmc-modules。空 modules 表示尚未安装业务包,可用 tools/fetch-module.mjs install 。"; + +function die(msg) { + console.error(`[rebuild-catalog] ${msg}`); + process.exit(1); +} + +function readJson(p) { + return JSON.parse(fs.readFileSync(p, "utf-8")); +} + +function listPackageFolders() { + if (!fs.existsSync(PACKAGES_DIR)) return []; + return fs + .readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); +} + +/** + * 将单包 manifest 投影为 catalog 条目。 + * entry.path 约定:modules/packages//sapi/src/index.ts + */ +function catalogEntryFromPackage(folder) { + const pkgRoot = path.join(PACKAGES_DIR, folder); + const manifestPath = path.join(pkgRoot, "sapi", "manifest.json"); + const indexPath = path.join(pkgRoot, "sapi", "src", "index.ts"); + if (!fs.existsSync(manifestPath)) { + console.warn(`[rebuild-catalog] skip ${folder}: 缺少 sapi/manifest.json`); + return null; + } + if (!fs.existsSync(indexPath)) { + console.warn(`[rebuild-catalog] skip ${folder}: 缺少 sapi/src/index.ts`); + return null; + } + const m = readJson(manifestPath); + const id = String(m.id || "").trim(); + const configKey = String(m.configKey || m.config_key || "").trim(); + if (!id || !configKey) { + console.warn(`[rebuild-catalog] skip ${folder}: manifest 缺少 id/configKey`); + return null; + } + let description = ""; + if (typeof m.notes === "string" && m.notes.trim()) description = m.notes.trim(); + else { + const pkgJsonPath = path.join(pkgRoot, "package.json"); + if (fs.existsSync(pkgJsonPath)) { + try { + description = String(readJson(pkgJsonPath).description || ""); + } catch { + /* ignore */ + } + } + } + return { + id, + configKey, + name: String(m.name || configKey), + type: String(m.type || "feature"), + description, + enabledByDefault: m.enabledByDefault !== false, + canDisable: m.canDisable !== false, + requires: Array.isArray(m.requires) ? m.requires.map(String) : [], + entry: { + kind: "sapi", + path: `modules/packages/${folder}/sapi/src/index.ts`, + }, + }; +} + +function buildCatalog() { + const modules = []; + for (const folder of listPackageFolders()) { + const entry = catalogEntryFromPackage(folder); + if (entry) modules.push(entry); + } + modules.sort((a, b) => a.id.localeCompare(b.id)); + return { + _comment: CATALOG_COMMENT, + version: 1, + modules, + }; +} + +function stableModules(modules) { + return JSON.stringify(modules, null, 2); +} + +function main() { + const checkOnly = process.argv.includes("--check"); + const next = buildCatalog(); + + if (checkOnly) { + if (!fs.existsSync(CATALOG_PATH)) die(`找不到 ${CATALOG_PATH}`); + const cur = readJson(CATALOG_PATH); + const curMods = Array.isArray(cur.modules) ? cur.modules : []; + if (stableModules(curMods) !== stableModules(next.modules)) { + die( + `catalog 与 packages 不一致(磁盘 ${next.modules.length} 个,catalog ${curMods.length} 个)。运行: node tools/rebuild-catalog.js` + ); + } + console.log(`[rebuild-catalog] OK (check, ${next.modules.length} modules)`); + return; + } + + fs.mkdirSync(path.dirname(CATALOG_PATH), { recursive: true }); + fs.writeFileSync(CATALOG_PATH, JSON.stringify(next, null, 2) + "\n", "utf-8"); + console.log(`[rebuild-catalog] wrote ${CATALOG_PATH} (${next.modules.length} modules)`); +} + +main(); diff --git a/tools/sim-new-user.js b/tools/sim-new-user.js index 8206073..797451b 100644 --- a/tools/sim-new-user.js +++ b/tools/sim-new-user.js @@ -136,7 +136,7 @@ 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 返回模块"); + 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")); diff --git a/tools/smoke-modules.js b/tools/smoke-modules.js index ff5b025..a95bb04 100644 --- a/tools/smoke-modules.js +++ b/tools/smoke-modules.js @@ -4,14 +4,12 @@ * * 验证: * - catalog 通过构建期自检 - * - db-server 模块 API 返回 29 条且字段完整 - * - 安装/卸载工具可执行且 lock 文件更新 - * - 模块依赖启用前置校验 + * - 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,14 +18,17 @@ 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(urlPath) { 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: urlPath, method: "GET", timeout: 3000 }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => { - try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode, body: data }); } + try { + resolve({ status: res.statusCode, body: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, body: data }); + } }); }); req.on("error", reject); @@ -39,18 +40,27 @@ function fetchJson(path) { function postJson(urlPath, payload) { return new Promise((resolve, reject) => { const data = JSON.stringify(payload || {}); - const req = http.request({ - hostname: HOST, port: PORT, path: urlPath, method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, - timeout: 3000, - }, (res) => { - let d = ""; - res.on("data", (c) => (d += c)); - res.on("end", () => { - try { resolve({ status: res.statusCode, body: JSON.parse(d) }); } - catch { resolve({ status: res.statusCode, body: d }); } - }); - }); + const req = http.request( + { + hostname: HOST, + port: PORT, + path: urlPath, + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, + timeout: 3000, + }, + (res) => { + let d = ""; + res.on("data", (c) => (d += c)); + res.on("end", () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(d) }); + } catch { + resolve({ status: res.statusCode, body: d }); + } + }); + } + ); req.on("error", reject); req.on("timeout", () => req.destroy(new Error("timeout"))); req.write(data); @@ -59,27 +69,53 @@ function postJson(urlPath, payload) { } function expect(cond, msg) { - if (!cond) { console.error(`[smoke] FAIL: ${msg}`); process.exit(1); } + if (!cond) { + console.error(`[smoke] FAIL: ${msg}`); + process.exit(1); + } console.log(`[smoke] PASS: ${msg}`); } async function main() { // 1) catalog 自检 - const check = spawnSync(process.execPath, [path.join(ROOT, "tools", "check-catalog.js")], { cwd: ROOT, encoding: "utf-8" }); + 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 接口(空 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.modules 为数组 (len=${cat.body.modules.length})`); // 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 一致`); + expect( + Array.isArray(list.body.modules) && list.body.modules.length === cat.body.modules.length, + `合并列表与 catalog 一致` + ); + + if (list.body.modules.length === 0) { + console.log("[smoke] catalog 为空 — 跳过启停翻转(请先 fetch-module + rebuild-catalog)"); + console.log("[smoke] 全部通过"); + return; + } // 4) 字段完整性 - const required = ["id", "name", "config_key", "type", "description", "default_enabled", "can_disable", "requires", "entry", "enabled"]; + const required = [ + "id", + "name", + "config_key", + "type", + "description", + "default_enabled", + "can_disable", + "requires", + "entry", + "enabled", + ]; for (const m of list.body.modules) { for (const k of required) { expect(m[k] !== undefined, `${m.id} 字段 ${k} 存在`); @@ -88,13 +124,15 @@ 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"); - expect(list2.body.modules.length === cat.body.modules.length, `合并列表与 catalog 一致 (${list2.body.modules.length} === ${cat.body.modules.length})`); + 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 翻转 const target = list.body.modules.find((m) => m.can_disable); @@ -108,4 +146,7 @@ async function main() { console.log("[smoke] 全部通过"); } -main().catch((e) => { console.error("[smoke] ERROR:", e.message); process.exit(1); }); +main().catch((e) => { + console.error("[smoke] ERROR:", e.message); + process.exit(1); +}); From 20f98862df090849c770dfe31ad84a71556a06bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 06:12:46 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(sfmc):=20registry=20index=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=AF=B9=E9=BD=90=20fetch-module=EF=BC=88modules=20?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.json 权威形态为 { version, modules: { id → {repo,tag} } }。 sfmc/src/registry.ts 原先把整棵 JSON 当扁平 map,导致 CLI 把 version/ generatedAt 当成模块 id(DRY/LSP 与 fetch-module 分叉)。与 #15 同修。 Co-authored-by: Shiroha --- sfmc/src/registry.ts | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) 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 {