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
8 changes: 8 additions & 0 deletions .github/workflows/ootb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ BDS
dist/sea/
.vscode/
remote-controller/dist/index.js

# 业务模块由 tools/fetch-module.mjs 安装到 packages/<id>/,不入库
modules/packages/*/
!modules/packages/.gitkeep

# fetch-module 一级 registry 缓存
tools/.sfmc-registry-cache.json
86 changes: 2 additions & 84 deletions modules/_manifests/module-manifests.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
2 changes: 1 addition & 1 deletion modules/catalog.json
Original file line number Diff line number Diff line change
@@ -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/<id>/。",
"_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 <id>。",
"version": 1,
"modules": []
}
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.

10 changes: 9 additions & 1 deletion sfmc/src/module-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -324,6 +324,14 @@ export async function cmdModuleUninstall(args: string[]): Promise<string> {
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`);
}

Expand Down
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
42 changes: 28 additions & 14 deletions tools/check-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 || [])]) {
Expand Down
8 changes: 7 additions & 1 deletion tools/check-ootb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 34 additions & 5 deletions tools/fetch-module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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}`);
}

Expand Down
Loading
Loading