Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
35 changes: 3 additions & 32 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 28 additions & 5 deletions sfmc/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,37 @@ function writeCache(cache: RegistryCache): void {
}
}

/**
* 解析 first-party registry index。
* 权威形态与 tools/fetch-module.mjs 一致:
* { version, modules: { "<id>": { repo, tag }, ... } }
* 兼容旧扁平形态:{ "<id>": { 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<string, unknown>;
const modulesRaw =
typeof root.modules === "object" && root.modules !== null && !Array.isArray(root.modules)
? (root.modules as Record<string, unknown>)
: 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<string, unknown>;
if (typeof entry.repo !== "string" || typeof entry.tag !== "string") continue;
filtered[k] = { repo: entry.repo, tag: entry.tag };
}
return filtered;
}

async function fetchFresh(): Promise<RegistryIndex> {
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 {
Expand Down
83 changes: 44 additions & 39 deletions tools/check-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 类型可选
* - 不允许循环依赖(拓扑排序检测)
*/
Expand Down Expand Up @@ -40,61 +42,39 @@ 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}`);
}
}

const visiting = new Set();
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]);
Expand All @@ -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();
3 changes: 2 additions & 1 deletion tools/check-ootb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions tools/sim-new-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 15 additions & 11 deletions tools/smoke-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 翻转
Expand Down
Loading