diff --git a/.github/workflows/ootb.yml b/.github/workflows/ootb.yml index bcfb169..1cf6356 100644 --- a/.github/workflows/ootb.yml +++ b/.github/workflows/ootb.yml @@ -31,24 +31,14 @@ jobs: shell: pwsh run: npm run build --workspaces --if-present - - name: Ensure configs via db-server defaults + - name: Seed configs via SDK ensure shell: pwsh env: SFMC_ROOT: ${{ github.workspace }} run: | - # 各服务 ensure 写入 configs/;此处预跑 db-server 一次生成骨架后退出 + # 直接走 @sfmc-bds/sdk/node/config.ensureCoreConfigs,不启 db-server HTTP New-Item -ItemType Directory -Force -Path configs | Out-Null - $p = Start-Process -FilePath node -ArgumentList "db-server/dist/index.js" -WorkingDirectory $PWD -WindowStyle Hidden -PassThru -RedirectStandardOutput "db-server/_seed.out" -RedirectStandardError "db-server/_seed.err" - $ok = $false - for ($i = 0; $i -lt 40; $i++) { - Start-Sleep -Milliseconds 250 - try { - $r = Invoke-WebRequest -Uri "http://127.0.0.1:3001/api/health" -UseBasicParsing -TimeoutSec 1 - if ($r.StatusCode -eq 200) { $ok = $true; break } - } catch {} - } - if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } - if (-not $ok) { Write-Host "WARN: db-server seed health not ready; configs may be partial" } + node tools/seed-configs.mjs Get-ChildItem configs -Filter *.json | ForEach-Object { Write-Host "seeded $($_.Name)" } - name: Run check-ootb diff --git a/bds-tools/src/paths.ts b/bds-tools/src/paths.ts index 94ca40f..2e88937 100644 --- a/bds-tools/src/paths.ts +++ b/bds-tools/src/paths.ts @@ -8,11 +8,8 @@ import { configPath, DEFAULT_BDS_UPDATER_CONFIG, - ensureJsonConfig, - isConfigMetaKey, + loadEnsuredConfig, resolveRuntimeRoot, - stripConfigMeta, - withConfigSchema, type BdsUpdaterConfig, } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; @@ -32,15 +29,12 @@ export const ROLLBACK_MARKER: string = path.join(SCRIPT_DIR, ".last_update_rollb /** 加载并解析 bds_updater.json。 * 启动时 ensure 文件存在,避免 wizard 没跑时 bds_updater.json 缺失导致 loadConfig 抛错。 */ export function loadConfig(): BdsUpdaterConfig { - const raw = ensureJsonConfig>( + return loadEnsuredConfig( ROOT_DIR, "bds_updater.json", - withConfigSchema({ ...DEFAULT_BDS_UPDATER_CONFIG } as Record, "bds_updater") - ); - for (const k of Object.keys(raw)) { - if (isConfigMetaKey(k)) delete raw[k]; - } - return stripConfigMeta(raw) as BdsUpdaterConfig; + "bds_updater", + { ...DEFAULT_BDS_UPDATER_CONFIG } as Record + ) as BdsUpdaterConfig; } /** 解析后的 BDS 路径 / 备份目录 */ diff --git a/db-server/src/env.ts b/db-server/src/env.ts index 4dd6fa7..f98622f 100644 --- a/db-server/src/env.ts +++ b/db-server/src/env.ts @@ -3,16 +3,12 @@ */ import { - configPath, - DEFAULT_DB_CONFIG, - DEFAULT_PERMISSIONS, - DEFAULT_QQ_CONFIG, - ensureJson, - ensureJsonConfig, - isConfigMetaKey, + ensureCoreConfigs, + loadEnsuredConfig, modulePath, resolveRuntimeRoot, - withConfigSchema, + DEFAULT_DB_CONFIG, + DEFAULT_QQ_CONFIG, } from "@sfmc-bds/sdk/node/config"; import { isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -46,32 +42,31 @@ export interface EnvConfig { export function loadEnv(): EnvConfig { const PROJECT_ROOT = resolveRuntimeRoot(resolve(__dirname, "..", "..")); - /* 启动时确认 db/qq config 存在;不存在就写带默认值的骨架。 + /* 启动时确认 db/qq/permissions 存在;不存在就写带默认值的骨架。 * 不依赖 wizard:wizard 只填字段,骨架由服务自己 ensure。 */ - const dbconfig = ensureJsonConfig>( + ensureCoreConfigs(PROJECT_ROOT, ["db_config", "qq_config", "permissions"]); + const dbconfig = loadEnsuredConfig( PROJECT_ROOT, "db_config.json", - withConfigSchema({ ...DEFAULT_DB_CONFIG } as Record, "db_config") + "db_config", + { ...DEFAULT_DB_CONFIG } as Record ); - const qqconfig = ensureJsonConfig>( + const qqconfig = loadEnsuredConfig( PROJECT_ROOT, "qq_config.json", - withConfigSchema({ ...DEFAULT_QQ_CONFIG } as Record, "qq_config") + "qq_config", + { ...DEFAULT_QQ_CONFIG } as Record ); - ensureJson(configPath(PROJECT_ROOT, "permissions.json"), DEFAULT_PERMISSIONS); // ── 优先级:JSON > 系统环境变量 > 默认值 ─────────────────────── const envBaseline = { ...process.env }; - // 元数据键:`$schema`、`_` 前缀(如 _comment)不写 env、不打日志 for (const [k, v] of Object.entries(dbconfig)) { - if (isConfigMetaKey(k)) continue; const envKey = k.replace(/([A-Z])/g, "_$1").toUpperCase(); process.env[envKey] = String(v); log.info(`db_config::${k} -> process.env.${envKey} = ${String(v)}`); } for (const [k, v] of Object.entries(qqconfig)) { - if (isConfigMetaKey(k)) continue; const envKey = k.replace(/([A-Z])/g, "_$1").toUpperCase(); if (process.env[envKey] === undefined) { process.env[envKey] = String(v); diff --git a/modules/sdk/@sfmc-sdk/src/node/config/index.ts b/modules/sdk/@sfmc-sdk/src/node/config/index.ts index c5fa15f..4f5ea0a 100644 --- a/modules/sdk/@sfmc-sdk/src/node/config/index.ts +++ b/modules/sdk/@sfmc-sdk/src/node/config/index.ts @@ -53,10 +53,12 @@ export interface DBConfig { [key: string]: unknown; } -export interface PermissionsConfig { - permissions?: Array<{ player_name?: string; level?: number; [key: string]: unknown }>; +/** permissions.json 根为数组(与 permissions.schema.json 一致;勿写成 { permissions: [] }) */ +export type PermissionsConfig = Array<{ + player_name: string; + level: number; [key: string]: unknown; -} +}>; export interface RuntimeConfig { runtime_root?: string; @@ -137,20 +139,15 @@ export type ConfigSchemaId = | "module_catalog"; /** - * 生成文件内 `$schema` 相对路径(相对 configs/ 或 packs/)。 - * IDE 用;运行时加载器应忽略该键。 + * 生成文件内 `$schema` 相对路径。 + * configs/、packs/、modules/ 均在仓顶下一层,相对 node_modules 深度相同; + * `from` 仅作调用点语义标注,便于日后若布局分叉再按位置扩展(OCP)。 */ export function configSchemaRef( schemaId: ConfigSchemaId, - from: "configs" | "packs" | "modules" = "configs" + _from: "configs" | "packs" | "modules" = "configs" ): string { - const rel = - from === "configs" - ? "../node_modules/@sfmc-bds/sdk/schemas" - : from === "packs" - ? "../node_modules/@sfmc-bds/sdk/schemas" - : "../node_modules/@sfmc-bds/sdk/schemas"; - return `${rel}/${schemaId}.schema.json`; + return `../node_modules/@sfmc-bds/sdk/schemas/${schemaId}.schema.json`; } /** 在对象根写入 `$schema`(不覆盖已有);数组根勿调用。 */ @@ -215,7 +212,7 @@ export const DEFAULT_BDS_UPDATER_CONFIG: BdsUpdaterConfig = { }; /** permissions.json 根为数组;默认空表 */ -export const DEFAULT_PERMISSIONS: Array<{ player_name: string; level: number }> = []; +export const DEFAULT_PERMISSIONS: PermissionsConfig = []; /** remote.json 骨架(enroll 前) */ export const DEFAULT_REMOTE_CONFIG: RemoteConfig = { @@ -341,4 +338,78 @@ export function ensureJson(filePath: string, seed: T = {} as T): T { */ export function ensureJsonConfig(root: string, name: ConfigName, seed: T = {} as T): T { return ensureJson(configPath(root, name), seed); +} + +/** + * ensure 带 `$schema` 的配置对象(DRY:禁止各服务手写 ensureJsonConfig+withConfigSchema)。 + * 返回磁盘内容(可能含元数据键);运行时业务请用 {@link loadEnsuredConfig}。 + */ +export function ensureSchemaConfig>( + root: string, + name: ConfigName, + schemaId: ConfigSchemaId, + defaults: T, + from: "configs" | "packs" | "modules" = "configs" +): T & { $schema?: string } { + return ensureJsonConfig( + root, + name, + withConfigSchema({ ...defaults } as Record, schemaId, from) + ) as T & { $schema?: string }; +} + +/** + * ensure 后剥离元数据,返回可交给业务逻辑的纯配置(LSP:与无 `$schema` 的契约一致)。 + */ +export function loadEnsuredConfig>( + root: string, + name: ConfigName, + schemaId: ConfigSchemaId, + defaults: T, + from: "configs" | "packs" | "modules" = "configs" +): T { + return stripConfigMeta( + ensureSchemaConfig(root, name, schemaId, defaults, from) as Record + ) as T; +} + +/** 平台核心配置种子集合(扩展新文件时只改此处 + switch) */ +export type CoreConfigKind = "db_config" | "qq_config" | "bds_updater" | "permissions" | "remote"; + +/** + * 按需 ensure 平台核心配置。新种类加到 CoreConfigKind + switch 即可(OCP)。 + * permissions 根为数组,不能走 withConfigSchema。 + */ +export function ensureCoreConfigs(root: string, kinds: readonly CoreConfigKind[]): void { + for (const kind of kinds) { + switch (kind) { + case "db_config": + ensureSchemaConfig(root, "db_config.json", "db_config", { + ...DEFAULT_DB_CONFIG, + } as Record); + break; + case "qq_config": + ensureSchemaConfig(root, "qq_config.json", "qq_config", { + ...DEFAULT_QQ_CONFIG, + } as Record); + break; + case "bds_updater": + ensureSchemaConfig(root, "bds_updater.json", "bds_updater", { + ...DEFAULT_BDS_UPDATER_CONFIG, + } as Record); + break; + case "permissions": + ensureJson(configPath(root, "permissions.json"), DEFAULT_PERMISSIONS); + break; + case "remote": + ensureSchemaConfig(root, "remote.json", "remote", { + ...DEFAULT_REMOTE_CONFIG, + } as Record); + break; + default: { + const _exhaustive: never = kind; + void _exhaustive; + } + } + } } \ No newline at end of file diff --git a/qq-bridge/src/config.ts b/qq-bridge/src/config.ts index adae619..caf7d77 100644 --- a/qq-bridge/src/config.ts +++ b/qq-bridge/src/config.ts @@ -10,10 +10,9 @@ import { configPath, DEFAULT_QQ_CONFIG, - ensureJsonConfig, - readJson, + loadEnsuredConfig, resolveRuntimeRoot, - withConfigSchema, + stripConfigMeta, } from "@sfmc-bds/sdk/node/config"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,28 +27,29 @@ export const ROOT_DIR: string = resolveRuntimeRoot(resolve(__dirname, "..", ".." export const CFG_PATH: string = configPath(ROOT_DIR, "qq_config.json"); function applyDefaults(raw: Partial): QQBridgeConfig { + /* 以 SDK DEFAULT_QQ_CONFIG 为唯一缺省权威(DRY/LSP),再叠运行时派生字段 */ + const merged = { ...DEFAULT_QQ_CONFIG, ...raw } as Partial & Record; + const stripped = stripConfigMeta(merged); return { - qq_enabled: raw.qq_enabled !== false, - qq_ws_port: parseInt(String(raw.qq_ws_port ?? "3002"), 10), - qq_group_id: String(raw.qq_group_id ?? ""), - bridge_channel_id: String(raw.bridge_channel_id ?? ""), - db_host: String(raw.db_host ?? "127.0.0.1"), - db_port: parseInt(String(raw.db_port ?? "3001"), 10), - mctoqq_prefix: String(raw.mctoqq_prefix ?? "[MC]"), - ...raw, + qq_enabled: stripped.qq_enabled !== false, + qq_ws_port: parseInt(String(stripped.qq_ws_port ?? DEFAULT_QQ_CONFIG.qq_ws_port ?? 3002), 10), + qq_group_id: String(stripped.qq_group_id ?? DEFAULT_QQ_CONFIG.qq_group_id ?? "0"), + bridge_channel_id: String(stripped.bridge_channel_id ?? DEFAULT_QQ_CONFIG.bridge_channel_id ?? ""), + db_host: String(stripped.db_host ?? "127.0.0.1"), + db_port: parseInt(String(stripped.db_port ?? "3001"), 10), + mctoqq_prefix: String(stripped.mctoqq_prefix ?? DEFAULT_QQ_CONFIG.mctoqq_prefix ?? "[MC]"), + ...stripped, }; } function readFromDisk(): QQBridgeConfig { - ensureJsonConfig( + const raw = loadEnsuredConfig( ROOT_DIR, "qq_config.json", - withConfigSchema({ ...DEFAULT_QQ_CONFIG } as Record, "qq_config") + "qq_config", + { ...DEFAULT_QQ_CONFIG } as Record ); - const raw = readJson>(CFG_PATH); - if (!raw) throw new Error(`配置文件不存在或无法解析: ${CFG_PATH}`); - const { $schema: _s, ...rest } = raw as Partial & { $schema?: string }; - return applyDefaults(rest); + return applyDefaults(raw as Partial); } /** 进程启动时加载一次。失败直接退出,与旧实现一致。 */ @@ -74,4 +74,4 @@ export function reloadInto(cfg: QQBridgeConfig): void { // reload 失败不抛,旧实现也是只 log log.error(`重载配置失败: ${(e as Error).message}`); } -} \ No newline at end of file +} diff --git a/sfmc/src/remote-agent.ts b/sfmc/src/remote-agent.ts index a889f3b..075f2ee 100644 --- a/sfmc/src/remote-agent.ts +++ b/sfmc/src/remote-agent.ts @@ -2,7 +2,7 @@ import { WebSocket } from "ws"; import { configPath, DEFAULT_REMOTE_CONFIG, - ensureJson, + loadEnsuredConfig, writeJson, withConfigSchema, type RemoteConfig, @@ -38,12 +38,14 @@ let stopping = false; const HEARTBEAT_INTERVAL_MS = 20_000; -/** 启动时确保 remote.json 存在,返回现有或空配置。 */ +/** 启动时确保 remote.json 存在,返回现有或空配置(已剥离 $schema)。 */ function loadConfig(): RemoteConfig { - return ensureJson( - configPath(ROOT, "remote.json"), - withConfigSchema({ ...DEFAULT_REMOTE_CONFIG } as Record, "remote") as RemoteConfig - ); + return loadEnsuredConfig( + ROOT, + "remote.json", + "remote", + { ...DEFAULT_REMOTE_CONFIG } as Record + ) as RemoteConfig; } function writeConfig(config: RemoteConfig): void { diff --git a/sfmc/src/services.ts b/sfmc/src/services.ts index 7b5e372..4eea449 100644 --- a/sfmc/src/services.ts +++ b/sfmc/src/services.ts @@ -1,12 +1,10 @@ import type { BdsUpdaterConfig, DBConfig, QQBridgeConfig } from "@sfmc-bds/sdk/node/config"; import { - configPath, + ensureCoreConfigs, + loadEnsuredConfig, DEFAULT_BDS_UPDATER_CONFIG, DEFAULT_DB_CONFIG, DEFAULT_QQ_CONFIG, - ensureJsonConfig, - readJson, - withConfigSchema, } from "@sfmc-bds/sdk/node/config"; import { spawn, type ChildProcess, type IOType } from "node:child_process"; import { EventEmitter } from "node:events"; @@ -204,26 +202,27 @@ class Service { } function createServices(): Record { - /* 各服务/CLI 用代码内 DEFAULT ensure 缺失配置(含 $schema),不再从 configs-default 拷贝。 */ - ensureJsonConfig( + /* 各服务/CLI 用 SDK ensureCoreConfigs 播种(含 $schema),不再从 configs-default 拷贝。 */ + ensureCoreConfigs(ROOT, ["bds_updater", "qq_config", "db_config"]); + ensurePackUpdateConfigFile(); + const bdsCfg = loadEnsuredConfig( ROOT, "bds_updater.json", - withConfigSchema({ ...DEFAULT_BDS_UPDATER_CONFIG } as Record, "bds_updater") - ); - ensureJsonConfig( + "bds_updater", + { ...DEFAULT_BDS_UPDATER_CONFIG } as Record + ) as BdsUpdaterConfig; + const qqCfg = loadEnsuredConfig( ROOT, "qq_config.json", - withConfigSchema({ ...DEFAULT_QQ_CONFIG } as Record, "qq_config") - ); - ensureJsonConfig( + "qq_config", + { ...DEFAULT_QQ_CONFIG } as Record + ) as QQBridgeConfig; + const dbCfg = loadEnsuredConfig( ROOT, "db_config.json", - withConfigSchema({ ...DEFAULT_DB_CONFIG } as Record, "db_config") - ); - ensurePackUpdateConfigFile(); - const bdsCfg = readJson(configPath(ROOT, "bds_updater.json")) ?? {}; - const qqCfg = readJson(configPath(ROOT, "qq_config.json")) ?? {}; - const dbCfg = readJson(configPath(ROOT, "db_config.json")) ?? {}; + "db_config", + { ...DEFAULT_DB_CONFIG } as Record + ) as DBConfig; const bdsPath = bdsCfg.bds_path ?? ROOT; const llbotEnabled = qqCfg.llbot_enabled !== false; const llbotPath = qqCfg.llbot_path ?? "D:\\LLBot-CLI-win-x64\\llbot.exe"; diff --git a/sfmc/src/wizard.ts b/sfmc/src/wizard.ts index c541ca3..94770ff 100644 --- a/sfmc/src/wizard.ts +++ b/sfmc/src/wizard.ts @@ -1,12 +1,7 @@ import { confirm, intro, isCancel, multiselect, note, outro, select, tasks, text } from "@clack/prompts"; import { configPath, - DEFAULT_BDS_UPDATER_CONFIG, - DEFAULT_DB_CONFIG, - DEFAULT_PERMISSIONS, - DEFAULT_QQ_CONFIG, - ensureJson, - ensureJsonConfig, + ensureCoreConfigs, modulePath, patchJson as patchConfig, readJson, @@ -90,22 +85,7 @@ async function prepareRuntimeAssets(rootDir: string): Promise { /** 非 monorepo 的 npm 安装布局:ensure 配置骨架 + 空 modules */ function seedNpmRuntimeLayout(rootDir: string): void { - ensureJsonConfig( - rootDir, - "db_config.json", - withConfigSchema({ ...DEFAULT_DB_CONFIG } as Record, "db_config") - ); - ensureJsonConfig( - rootDir, - "qq_config.json", - withConfigSchema({ ...DEFAULT_QQ_CONFIG } as Record, "qq_config") - ); - ensureJsonConfig( - rootDir, - "bds_updater.json", - withConfigSchema({ ...DEFAULT_BDS_UPDATER_CONFIG } as Record, "bds_updater") - ); - ensureJson(configPath(rootDir, "permissions.json"), DEFAULT_PERMISSIONS); + ensureCoreConfigs(rootDir, ["db_config", "qq_config", "bds_updater", "permissions"]); /* pack-update 的 ensure 使用 runtime ROOT;npm 布局下应与 rootDir 一致 */ if (path.resolve(rootDir) === path.resolve(ROOT)) { ensurePackUpdateConfigFile(); diff --git a/tools/check-ootb.mjs b/tools/check-ootb.mjs index b607ef6..65c0466 100755 --- a/tools/check-ootb.mjs +++ b/tools/check-ootb.mjs @@ -74,7 +74,7 @@ async function main() { } } - // 2) 配置 schema + configs(缺失时由各服务 ensure 生成,本检查只验 schema 在仓) + // 2) 配置 schema + ensure 播种后 configs 必须齐全(不再“缺了也 pass”) { const schemas = [ "db_config.schema.json", @@ -91,15 +91,17 @@ async function main() { if (missing.length === 0) pass("配置 JSON Schema 齐全"); else fail("配置 JSON Schema 齐全", "缺失: " + missing.join(", ")); - const need = ["db_config.json", "bds_updater.json", "qq_config.json"]; - const present = need.filter((n) => exists(path.join(CONFIGS_DIR, n))); - if (present.length === need.length) pass("configs/ 已有核心配置"); - else { - pass("configs/ 可由服务 ensure 生成"); - console.log( - `[ootb] INFO: configs/ 缺少 ${need.filter((n) => !exists(path.join(CONFIGS_DIR, n))).join(", ")} — 启动 db-server / sfmc 时会写入默认值` - ); - } + const seed = runSync(process.execPath, [path.join(ROOT, "tools", "seed-configs.mjs")], { + cwd: ROOT, + env: { ...process.env, SFMC_ROOT: ROOT }, + }); + if (seed.status === 0) pass("seed-configs 写入核心配置"); + else fail("seed-configs 写入核心配置", (seed.stderr || seed.stdout || `exit ${seed.status}`).trim()); + + const need = ["db_config.json", "bds_updater.json", "qq_config.json", "permissions.json", "remote.json"]; + const absent = need.filter((n) => !exists(path.join(CONFIGS_DIR, n))); + if (absent.length === 0) pass("configs/ 核心配置已就绪"); + else fail("configs/ 核心配置已就绪", "缺少: " + absent.join(", ")); } // 3) catalog-sync + check-modules(空 catalog 合法;有已装包则投影入 catalog) diff --git a/tools/seed-configs.mjs b/tools/seed-configs.mjs new file mode 100644 index 0000000..b8478b7 --- /dev/null +++ b/tools/seed-configs.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * seed-configs.mjs — 用 SDK ensureCoreConfigs 播种 configs/(不启 HTTP 服务) + * + * 替代 ootb 里「拉起 db-server 等 /api/health」的脆弱路径(DIP:依赖配置抽象而非服务进程)。 + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ensureCoreConfigs, + configDir, +} from "@sfmc-bds/sdk/node/config"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = process.env.SFMC_ROOT ? path.resolve(process.env.SFMC_ROOT) : path.resolve(__dirname, ".."); + +const kinds = ["db_config", "qq_config", "bds_updater", "permissions", "remote"]; + +fs.mkdirSync(configDir(ROOT), { recursive: true }); +ensureCoreConfigs(ROOT, kinds); + +const need = ["db_config.json", "qq_config.json", "bds_updater.json", "permissions.json", "remote.json"]; +const missing = need.filter((n) => !fs.existsSync(path.join(configDir(ROOT), n))); +if (missing.length) { + console.error(`[seed-configs] FAIL: 仍缺少 ${missing.join(", ")}`); + process.exit(1); +} + +for (const n of need) { + console.log(`[seed-configs] ok ${n}`); +}