From 6912799d732fced6803e3839b79ec3fbfcd837ea Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Fri, 21 Aug 2026 19:28:14 +0800 Subject: [PATCH 1/8] chore(tools): drop unused catalog/check scripts; CI-only release entrypoints Remove catalog-sync/check-modules wrappers and unused publish/version helpers from the monorepo tools surface. Keep ci-release-packages for Changesets; drop local prerelease/release npm scripts. Co-authored-by: Cursor --- .changeset/README.md | 27 +- AGENTS.md | 5 +- docs/en/guide/modules.mdx | 7 +- docs/en/guide/troubleshooting.mdx | 4 +- docs/zh/dev/contributing.md | 9 +- docs/zh/dev/module-author.mdx | 8 +- docs/zh/dev/tools.md | 51 +- docs/zh/guide/modules.mdx | 7 +- docs/zh/guide/troubleshooting.mdx | 4 +- .../schemas/module_catalog.schema.json | 2 +- package-lock.json | 42 +- package.json | 4 - packages/tools/README.md | 9 +- packages/tools/catalog-sync.mjs | 30 -- packages/tools/changeset-assert-mode.mjs | 6 +- packages/tools/check-minecraft-versions.mjs | 99 ---- packages/tools/check-modules.mjs | 22 - packages/tools/lib/minecraft-versions.mjs | 163 ------ packages/tools/lib/verify/offline.mjs | 15 +- packages/tools/package.json | 76 ++- packages/tools/run-release.mjs | 12 +- packages/tools/verify-module-publish.mjs | 462 ------------------ packages/tools/verify-module-publish.test.mjs | 168 ------- packages/tools/verify.mjs | 5 +- 24 files changed, 106 insertions(+), 1131 deletions(-) delete mode 100755 packages/tools/catalog-sync.mjs delete mode 100644 packages/tools/check-minecraft-versions.mjs delete mode 100755 packages/tools/check-modules.mjs delete mode 100644 packages/tools/lib/minecraft-versions.mjs delete mode 100644 packages/tools/verify-module-publish.mjs delete mode 100644 packages/tools/verify-module-publish.test.mjs diff --git a/.changeset/README.md b/.changeset/README.md index 2570d76e..20f193de 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -16,25 +16,20 @@ npm run changeset # 选受影响的包 + patch|minor|major,写中文摘要 ``` -## 本地发版(维护者) +然后 **push `main`** 即可:CI 会开/更新 Version Packages PR;合并该 PR 后自动 `ci-release-packages`(publish → tag → push → GitHub Release)。 -当前在 pre mode,请用 **prerelease**: +本地一般 **不需要** 手动跑发版命令。 -```bash -npm run prerelease-packages -``` - -入口为 `node tools/run-release.mjs --pre`,内部依次:assert pre → ensure changeset → version → commit → tag → push → publish → GitHub Pre-release。 +## CI 发版入口 -退出 pre 且达标后,正式发版: - -```bash -npx changeset pre exit -npm run release-packages # run-release.mjs --stable → npm latest + GitHub Release -``` +根 `package.json`: -## CI +| 脚本 | 谁调 | +|------|------| +| `version-packages` | changesets/action(Version PR) | +| `ci-release-packages` | 合并 Version PR 后的 publish 步骤 | +| `build:publishable` | CI build 可发包拓扑 | -`changeset-release.yml` 在 `main` 上开 Version PR;合并后跑 `ci-release-packages`(`run-release.mjs --ci`:publish → tag → push tags → gh release,无交互)。 +应急单包:`.github/workflows/npm-publish.yml`(`workflow_dispatch`)。 -详情见 [docs/dev/publish.md](../docs/dev/publish.md)(平台包附录)。 +详情见 [docs/zh/dev/publish.md](../docs/zh/dev/publish.md)。 diff --git a/AGENTS.md b/AGENTS.md index 68047262..d25d4a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,6 @@ npm run start # sfmc CLI REPL npm run lint # 先 build eslint-plugin,再 eslint . npm run typecheck npm run verify # 平台集成自检(CI 默认) -npm run catalog-sync # 装模块后投影 catalog(维护命令) npm run syncpack:lint ``` @@ -215,9 +214,7 @@ npm start -- update ```bash node packages/tools/verify.mjs -node packages/tools/catalog-sync.mjs -node packages/tools/check-modules.mjs -sfmc mod install +sfmc mod install # 在独立 SFMC 根,非主仓 ``` ## SAPI debug 与 Sentry diff --git a/docs/en/guide/modules.mdx b/docs/en/guide/modules.mdx index 90997b35..bda704fd 100644 --- a/docs/en/guide/modules.mdx +++ b/docs/en/guide/modules.mdx @@ -73,11 +73,8 @@ The list below is generated at build time from the `sfmc-modules` registry (loca -## Related checks (monorepo) +## Related checks (user SFMC root) -```bash -npm run catalog-sync -npm run check-modules -``` +Module install and catalog projection are done by `sfmc mod install` (the monorepo does not host business modules). Official module service index: [Module services](../api/modules/index.md). diff --git a/docs/en/guide/troubleshooting.mdx b/docs/en/guide/troubleshooting.mdx index 69cca14e..fef05e31 100644 --- a/docs/en/guide/troubleshooting.mdx +++ b/docs/en/guide/troubleshooting.mdx @@ -10,8 +10,6 @@ Pick a symptom and work through the steps; the full checklist below remains for ```bash npm run verify -npm run check-modules -npm run catalog-sync ``` ### db-server won't start @@ -31,7 +29,7 @@ curl http://127.0.0.1:3001/api/health 1. Read the load summary in the console and `/.sfmc/logs/` 2. Run `mod build` and check esbuild errors -3. `npm run check-modules` (monorepo) +3. In your SFMC root, confirm the module is `mod install`ed and enabled in lock 4. Inbox conflicts blocking pre-start scan (see [Add-ons](./addons.md)) ### Module enabled but no effect in-game diff --git a/docs/zh/dev/contributing.md b/docs/zh/dev/contributing.md index 8a53246c..564d63ce 100644 --- a/docs/zh/dev/contributing.md +++ b/docs/zh/dev/contributing.md @@ -34,8 +34,6 @@ npm run verify | 命令 | 说明 | | ------ | ------ | | `npm run verify` | 平台集成自检 | -| `npm run catalog-sync` | packages → catalog | -| `npm run check-modules` | catalog + manifest | ### 文档 @@ -57,11 +55,10 @@ npx syncpack format --check | 命令 | 说明 | | ------ | ------ | -| `npm run changeset` | 添加 changeset | -| `npm run prerelease-packages` | 本地 beta 一键 | -| `npm run pack:verify` | `npm pack` 冒烟 | +| `npm run changeset` | 添加 changeset;push `main` 后 CI 开 Version PR | +| `npm run pack:verify` | 可选:`npm pack` 冒烟 | -当前 **beta-only**。模块作者发包见 [发布你的模块](./publish.md) 附录。 +日常发版:**合并 Version PR 即可**,无需本地 `prerelease` / `release`。当前 **beta-only**。模块作者发包见 [发布你的模块](./publish.md)。 ## 仓库规则 diff --git a/docs/zh/dev/module-author.mdx b/docs/zh/dev/module-author.mdx index 0dfcb4d5..b3298ca7 100644 --- a/docs/zh/dev/module-author.mdx +++ b/docs/zh/dev/module-author.mdx @@ -185,7 +185,7 @@ v3 是**完全可选**的能力扩展:不写 v3 字段也能 boot;旧 v2 模 ### 兼容性与迁移 -- **v2 → v3 自动迁移**:沙箱与 `check-modules` 都内置自动 migrate(`migrateV2toV3`)。v2 manifest 加载时直接补 `schemaVersion: 3`、保留所有原有字段,`semantic` 视为空。 +- **v2 → v3 自动迁移**:沙箱与 CLI `check-modules`(`mod install` 后)都内置自动 migrate(`migrateV2toV3`)。v2 manifest 加载时直接补 `schemaVersion: 3`、保留所有原有字段,`semantic` 视为空。 - **缺省 semantic 合法**:v3 manifest 可以不写 `semantic`,等价于「暂时没声明语义」——后续可逐字段补全。 - **运行时校验**:保留 v2 的硬校验(id/name/type/configKey/requires/permissions/services 必填);`semantic` 子字段类型错时给 `errors: string[]`,但不拒绝启动(沙箱会剥掉坏字段、用 v3 顶层继续)。 @@ -193,8 +193,8 @@ v3 是**完全可选**的能力扩展:不写 v3 字段也能 boot;旧 v2 模 | 工具 | 行为 | | --- | --- | -| `tools/check-modules.mjs` | v2 校验路径不变;v3 manifest 走新校验(`semantic` 字段类型错会报错)。 | -| `catalog-sync` / CLI `fetch-module` | 投影时把 `semantic` 块原样写入 catalog(仅读取,不主动注入)。 | +| CLI `scripts/module-install/check-modules.mjs` | `mod install` 后校验;v3 `semantic` 类型错会报错 | +| CLI `fetch-module` | 投影时把 `semantic` 块原样写入 catalog(仅读取,不主动注入) | ### 与缺口 1 / 3 的衔接 @@ -205,4 +205,4 @@ v3 `semantic` 不是终点,是**数据源**: `publicApi` 当前只支持单签名;多载/重载留 TODO,等下一轮按真实模块诉求再加。 -> v2 manifest 作者请保持现状不动;只有新增模块或主动升级时再写 v3。`fetch-module` 与 `catalog-sync` 不会因为写错 v3 而拒绝模块——错就报在 `check-modules`,不必动 v2 字段。 +> v2 manifest 作者请保持现状不动;只有新增模块或主动升级时再写 v3。`fetch-module` 不会因为写错 v3 而拒绝模块——错就报在 install 后的 check-modules,不必动 v2 字段。 diff --git a/docs/zh/dev/tools.md b/docs/zh/dev/tools.md index 4d376813..837f3dc4 100644 --- a/docs/zh/dev/tools.md +++ b/docs/zh/dev/tools.md @@ -1,57 +1,22 @@ # 工具脚本 -`packages/tools/` 为 **monorepo 私有**脚本(不发 npm)。模块安装在 `@sfmc-bds/cli`,脚手架在 `@sfmc-bds/devkit`;作者日常用扩展「SFMC Module」。Watch / 重建见 `packages/devkit/`。 - -**目录约定:** `packages/*` = 平台包;`modules/packages/*` = 业务模块。`catalog-sync` 扫描的是后者。 +`packages/tools/` 为 **monorepo 私有**脚本(不发 npm)。模块安装在 `@sfmc-bds/cli`,脚手架在 `@sfmc-bds/devkit`。 ## 常用 | 命令 | 作用 | |------|------| | `npm run verify` | 平台集成自检(CI 默认) | -| `npm run catalog-sync` | 扫 `modules/packages` → `catalog.json` | -| `npm run check-modules` | 离线校验 catalog + manifest | -| `sfmc mod search` / `sfmc mod install ` | 查注册表 / 安装(`@sfmc-bds/cli`) | -| `npx sfmc-new-module ` | 在空目录生成单包根(`@sfmc-bds/devkit`) | - -## 平台自检 - -主仓默认无业务模块。`npm run verify` 一次完成离线检查 + db-server 集成 + 隔离 `SFMC_ROOT` 模拟。 - -| 脚本 | 何时用 | -|------|--------| -| `packages/tools/verify.mjs` | CI / 本地「平台能不能跑」 | -| `packages/tools/catalog-sync.mjs` | 装包后投影 catalog | -| `packages/tools/check-modules.mjs` | 离线校验(薄封装 → CLI 权威实现) | - -约定检查见 [ESLint 约定](./eslint.md)。 +| `sfmc mod install ` | 安装模块(独立 SFMC 根,非主仓) | +| `npx sfmc-new-module ` | 脚手架(`@sfmc-bds/devkit`) | -## fetch-module(CLI) +主仓默认 **不装业务模块**;catalog / lock 由用户环境里的 `mod install` 维护,主仓不再提供 `catalog-sync` / `check-modules` 入口。 -```bash -sfmc mod install afk -sfmc mod install foo --from dir:/path --link -sfmc mod uninstall afk -# 等价:node packages/cli/scripts/module-install/fetch-module.mjs … -``` +## 发版 -默认注册表:`Tanya7z/sfmc-modules` 的 `index.json`(`npm` 字段优先)。 - -## new-module(devkit) - -```bash -mkdir my-mod && cd my-mod -npx sfmc-new-module my-mod --name "我的模块" -# monorepo:node packages/devkit/scripts/new-module.mjs … -``` - -`--official` → `@sfmc-bds/module-*`。脚手架会写入 `eslint.config.js` 与 `.vscode/`。也可用 [sfmc-module-template](https://github.com/Tanya7z/sfmc-module-template) 或扩展 `SFMC: New Module`。 - -(作者测试)见 [测试沙箱](./testing.md)(`npm test`)。 +日常:`npx changeset` → push `main` → Version Packages PR → 合并后 CI `ci-release-packages`。 ## 共享库 -- 模块 catalog / lock / install:`packages/cli/scripts/module-install/lib/` -- 仓内自检 / 发版:`packages/tools/lib/`(`paths`、`proc`、`npm-publish-packages` 等) - -新脚本优先复用,勿复制 lock/catalog 读写。 +- 模块 catalog / lock / install:`packages/cli/scripts/module-install/` +- 仓内自检 / 发版:`packages/tools/lib/` diff --git a/docs/zh/guide/modules.mdx b/docs/zh/guide/modules.mdx index ef91aa6f..e80d4811 100644 --- a/docs/zh/guide/modules.mdx +++ b/docs/zh/guide/modules.mdx @@ -73,12 +73,9 @@ sfmc mod reload -## 相关校验(monorepo) +## 相关校验(用户 SFMC 根) -```bash -npm run catalog-sync -npm run check-modules -``` +模块安装与 catalog 投影由 `sfmc mod install` 完成(主仓不装业务模块)。 官方模块服务清单见 [模块服务目录](../api/modules/index.md)。 diff --git a/docs/zh/guide/troubleshooting.mdx b/docs/zh/guide/troubleshooting.mdx index f4a6e6fc..1da51744 100644 --- a/docs/zh/guide/troubleshooting.mdx +++ b/docs/zh/guide/troubleshooting.mdx @@ -10,8 +10,6 @@ ```bash npm run verify -npm run check-modules -npm run catalog-sync ``` ### db-server 起不来 @@ -31,7 +29,7 @@ curl http://127.0.0.1:3001/api/health 1. 看控制台装载摘要与 `/.sfmc/logs/` 2. `mod build` 看 esbuild 是否报错 -3. `npm run check-modules`(monorepo) +3. 独立 SFMC 根下确认模块已 `mod install` 且 lock 为 enabled 4. 收件箱冲突是否挡住启动前扫描(见 [附加包](./addons.md)) ### 模块已 enable 但游戏里没效果 diff --git a/modules/sdk/@sfmc-sdk/schemas/module_catalog.schema.json b/modules/sdk/@sfmc-sdk/schemas/module_catalog.schema.json index 946fa9fc..7dd0aeff 100644 --- a/modules/sdk/@sfmc-sdk/schemas/module_catalog.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/module_catalog.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/module_catalog.schema.json", "title": "SFMC module catalog", - "description": "modules/catalog.json 本地 mirror;权威源为 github:Tanya7z/sfmc-modules。安装/卸载后请用 catalog-sync 或 fetch-module 投影。", + "description": "modules/catalog.json 本地 mirror;权威源为 github:Tanya7z/sfmc-modules。安装/卸载后由 fetch-module(sfmc mod install)投影。", "type": "object", "additionalProperties": false, "properties": { diff --git a/package-lock.json b/package-lock.json index 62232563..15cfd916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "modules/sdk/@sfmc-sdk": { "name": "@sfmc-bds/sdk", - "version": "0.2.0-beta.8", + "version": "0.2.0-beta.9", "license": "ISC", "engines": { "node": ">=22.13.0" @@ -4625,10 +4625,10 @@ }, "packages/bds-tools": { "name": "@sfmc-bds/bds-tools", - "version": "0.2.0-beta.8", + "version": "0.2.0-beta.9", "license": "AGPL-3.0-only", "dependencies": { - "@sfmc-bds/sdk": "^0.2.0-beta.8", + "@sfmc-bds/sdk": "^0.2.0-beta.9", "jszip": "^3.10.1", "node-html-parser": "^9.0.0" }, @@ -4638,12 +4638,12 @@ }, "packages/cli": { "name": "@sfmc-bds/cli", - "version": "0.2.0-beta.8", + "version": "0.2.0-beta.9", "license": "AGPL-3.0-only", "dependencies": { "@clack/prompts": "^1.7.0", - "@sfmc-bds/bds-tools": "^0.2.0-beta.8", - "@sfmc-bds/sdk": "^0.2.0-beta.8", + "@sfmc-bds/bds-tools": "^0.2.0-beta.9", + "@sfmc-bds/sdk": "^0.2.0-beta.9", "chalk": "^6.0.0" }, "bin": { @@ -4656,10 +4656,10 @@ }, "packages/db-server": { "name": "@sfmc-bds/db-server", - "version": "0.2.0-beta.4", + "version": "0.2.0-beta.5", "license": "AGPL-3.0-only", "dependencies": { - "@sfmc-bds/sdk": "^0.2.0-beta.8", + "@sfmc-bds/sdk": "^0.2.0-beta.9", "sql-template-strings": "^2.2.2" }, "engines": { @@ -4684,14 +4684,14 @@ }, "packages/meta": { "name": "@sfmc-bds/sfmc", - "version": "0.2.0-beta.4", + "version": "0.2.0-beta.5", "license": "AGPL-3.0-only", "dependencies": { - "@sfmc-bds/bds-tools": "^0.2.0-beta.8", - "@sfmc-bds/cli": "^0.2.0-beta.8", - "@sfmc-bds/db-server": "^0.2.0-beta.4", - "@sfmc-bds/qq-bridge": "^0.1.1-beta.3", - "@sfmc-bds/sdk": "^0.2.0-beta.8" + "@sfmc-bds/bds-tools": "^0.2.0-beta.9", + "@sfmc-bds/cli": "^0.2.0-beta.9", + "@sfmc-bds/db-server": "^0.2.0-beta.5", + "@sfmc-bds/qq-bridge": "^0.2.0-beta.4", + "@sfmc-bds/sdk": "^0.2.0-beta.9" }, "bin": { "sfmc": "bin/sfmc.mjs" @@ -4702,10 +4702,10 @@ }, "packages/qq-bridge": { "name": "@sfmc-bds/qq-bridge", - "version": "0.1.1-beta.3", + "version": "0.2.0-beta.4", "license": "AGPL-3.0-only", "dependencies": { - "@sfmc-bds/sdk": "^0.2.0-beta.8", + "@sfmc-bds/sdk": "^0.2.0-beta.9", "ws": "^8.21.1" }, "engines": { @@ -4717,7 +4717,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@sfmc-bds/devkit": "^0.1.0-beta.1", - "@sfmc-bds/sdk": "^0.2.0-beta.8" + "@sfmc-bds/sdk": "^0.2.0-beta.9" }, "devDependencies": { "@types/vscode": "^1.85.0", @@ -4729,15 +4729,13 @@ }, "packages/tools": { "name": "@sfmc-bds/tools", - "version": "0.2.0-beta.9", + "version": "0.2.0-beta.10", "license": "AGPL-3.0-only", "dependencies": { - "@sfmc-bds/bds-tools": "^0.2.0-beta.8", - "@sfmc-bds/sdk": "^0.2.0-beta.8" + "@sfmc-bds/bds-tools": "^0.2.0-beta.9", + "@sfmc-bds/sdk": "^0.2.0-beta.9" }, "bin": { - "sfmc-catalog-sync": "catalog-sync.mjs", - "sfmc-check-modules": "check-modules.mjs", "sfmc-esbuild-transpile": "esbuild-transpile.mjs", "sfmc-verify": "verify.mjs", "tsc7": "tsc7.mjs" diff --git a/package.json b/package.json index 0c3d5dd4..e5bde79b 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,11 @@ "scripts": { "build": "npm run build --workspaces --if-present", "build:publishable": "node packages/tools/build-publishable.mjs", - "catalog-sync": "node packages/tools/catalog-sync.mjs", "changeset": "changeset", - "check-modules": "node packages/tools/check-modules.mjs", "ci-release-packages": "node packages/tools/run-release.mjs --ci", "docs": "node packages/tools/docs.mjs", "lint": "npm run build --workspace @sfmc-bds/eslint-plugin && eslint .", "pack:verify": "node packages/tools/pack-verify.mjs", - "prerelease-packages": "node packages/tools/run-release.mjs --pre", - "release-packages": "node packages/tools/run-release.mjs --stable", "start": "node packages/cli/dist/main.js", "syncpack:fix": "node packages/tools/syncpack-fix.mjs", "typecheck": "npm run typecheck --workspaces --if-present", diff --git a/packages/tools/README.md b/packages/tools/README.md index 6967bb12..dd9cd8cb 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -11,16 +11,11 @@ ```bash npm run verify # 平台集成自检(CI) -npm run catalog-sync -npm run check-modules npx sfmc-esbuild-transpile # 各包 build npx tsc7 --noEmit # typecheck npm run docs -- serve|build ``` -构建 bin 经 workspace 链接到根 `node_modules/.bin/`;包内脚本请用 bin 名,勿写 `../../../tools/...`。 +发版走 Changesets:**push `main` → Version Packages PR → 合并后 CI 自动 `ci-release-packages`**。本地一般不手动发。 -## 约定 - -- 脚本一律 `.mjs` + `// @ts-check` -- changeset / docs / pack-verify 等发版基建也在此目录,仅 CI / 维护者使用 +构建 bin 经 workspace 链接;包内脚本请用 bin 名,勿写 `../../../tools/...`。 diff --git a/packages/tools/catalog-sync.mjs b/packages/tools/catalog-sync.mjs deleted file mode 100755 index 6d3b2239..00000000 --- a/packages/tools/catalog-sync.mjs +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * 薄封装 → catalog sync(权威实现在 @sfmc-bds/cli) - * 用法: node packages/tools/catalog-sync.mjs - */ -import { syncCatalogFromPackages } from "../cli/scripts/module-install/lib/catalog.mjs"; -import { pruneOrphanModuleLocks, readLock, setModuleLockEnabled } from "../cli/scripts/module-install/lib/lock.mjs"; -import { die } from "./lib/io.mjs"; - -try { - const { catalog, count } = syncCatalogFromPackages(); - const ids = new Set(catalog.modules.map((m) => m.id)); - pruneOrphanModuleLocks(ids); - - const lock = readLock(); - for (const m of catalog.modules) { - if (!lock.modules[m.id]) { - setModuleLockEnabled(m.id, m.enabledByDefault !== false); - } - } - - console.log(`[catalog-sync] OK — ${count} module(s) → modules/catalog.json`); - for (const m of catalog.modules) { - console.log(` ${m.id.padEnd(28)} folder=${m.entry.path.split("/")[2]}`); - } -} catch (e) { - const message = e instanceof Error ? e.message : String(e); - die("catalog-sync", message); -} diff --git a/packages/tools/changeset-assert-mode.mjs b/packages/tools/changeset-assert-mode.mjs index bd9bab32..05dc8df6 100644 --- a/packages/tools/changeset-assert-mode.mjs +++ b/packages/tools/changeset-assert-mode.mjs @@ -23,8 +23,8 @@ const state = readPreState(); if (mode === "pre" && !pre) { console.error( - "[changeset] 当前不在 pre mode。正式发版请用 npm run release-packages;" + - "若要重新进入 beta: npx changeset pre enter beta" + "[changeset] 当前不在 pre mode。正式通道请先 `npx changeset pre exit`;" + + "日常发版走 CI(push main → Version PR)。若要重新进入 beta: npx changeset pre enter beta" ); process.exit(1); } @@ -32,7 +32,7 @@ if (mode === "pre" && !pre) { if (mode === "stable" && pre) { console.error( `[changeset] 仓库仍处于 pre mode (tag=${state?.tag ?? "beta"})。` + - `请用 npm run prerelease-packages,或达标后执行 npx changeset pre exit 再 release-packages。` + `达标后执行 npx changeset pre exit;日常发版走 CI Version PR。` ); process.exit(1); } diff --git a/packages/tools/check-minecraft-versions.mjs b/packages/tools/check-minecraft-versions.mjs deleted file mode 100644 index 427f0154..00000000 --- a/packages/tools/check-minecraft-versions.mjs +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * tools/check-minecraft-versions.mjs — 校验 @minecraft/* 版本与主仓权威 pin 一致 - * - * 权威来源:根 package.json devDependencies + overrides(见 tools/lib/minecraft-versions.mjs) - * - * 用法: - * node tools/check-minecraft-versions.mjs - * node tools/check-minecraft-versions.mjs --modules-root ../sfmc-modules - * SFMC_MODULES_ROOT=/path/to/sfmc-modules node tools/check-minecraft-versions.mjs - */ -import fs from "node:fs"; -import path from "node:path"; -import { ROOT } from "./lib/paths.mjs"; -import { - findPackageJsonFiles, - readCanonicalFromRootPkg, - readMinecraftDeps, - validateCanonicalPins, - validateOverrides, - validatePackageMinecraftDeps, -} from "./lib/minecraft-versions.mjs"; - -/** @param {string} msg */ -function fail(msg) { - console.error(`[check-minecraft] FAIL: ${msg}`); - process.exit(1); -} - -function resolveModulesRoot() { - const argIdx = process.argv.indexOf("--modules-root"); - if (argIdx !== -1 && process.argv[argIdx + 1]) { - return path.resolve(process.argv[argIdx + 1]); - } - if (process.env.SFMC_MODULES_ROOT) { - return path.resolve(process.env.SFMC_MODULES_ROOT); - } - const sibling = path.resolve(ROOT, "..", "sfmc-modules"); - if (fs.existsSync(path.join(sibling, "package.json"))) return sibling; - return null; -} - -function main() { - const rootPkgPath = path.join(ROOT, "package.json"); - const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); - const canonical = readCanonicalFromRootPkg(ROOT); - - if (Object.keys(canonical).length === 0) { - fail("根 package.json 未声明 @minecraft/* devDependencies"); - } - - /** @type {string[]} */ - const errors = []; - errors.push(...validateCanonicalPins(rootPkg.devDependencies)); - errors.push(...validateOverrides(rootPkg.overrides ?? {}, canonical)); - - /** @type {string[]} */ - const scanRoots = [ROOT]; - const modulesRoot = resolveModulesRoot(); - if (modulesRoot) scanRoots.push(modulesRoot); - - /** @type {string[]} */ - const pkgFiles = []; - for (const scanRoot of scanRoots) { - pkgFiles.push(...findPackageJsonFiles(scanRoot)); - } - - for (const pkgPath of pkgFiles.sort()) { - const { name, deps } = readMinecraftDeps(pkgPath); - if (Object.keys(deps).length === 0) continue; - errors.push( - ...validatePackageMinecraftDeps({ - canonical, - pkgPath: path.relative(ROOT, pkgPath) || pkgPath, - pkgName: name, - deps, - }) - ); - } - - console.log("[check-minecraft] 权威版本:"); - for (const [name, ver] of Object.entries(canonical).sort()) { - console.log(` ${name}: ${ver}`); - } - if (modulesRoot) { - console.log(`[check-minecraft] 已扫描 sfmc-modules: ${modulesRoot}`); - } - - if (errors.length > 0) { - console.error(`[check-minecraft] ${errors.length} 个问题:`); - for (const e of errors) console.error(` - ${e}`); - process.exit(1); - } - - console.log(`[check-minecraft] OK (${pkgFiles.length} package.json scanned)`); -} - -main(); diff --git a/packages/tools/check-modules.mjs b/packages/tools/check-modules.mjs deleted file mode 100755 index fe35336a..00000000 --- a/packages/tools/check-modules.mjs +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * 薄封装 → @sfmc-bds/cli module-install/check-modules(权威实现) - */ -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; -import { runCheckModules } from "../cli/scripts/module-install/check-modules.mjs"; - -export { runCheckModules }; - -const __main = fileURLToPath(import.meta.url); -if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__main)) { - const doSync = process.argv.includes("--sync"); - const result = runCheckModules({ sync: doSync }); - if (!result.ok) { - console.error(`[check-modules] FAIL: ${result.error}`); - process.exit(1); - } - console.log(`[check-modules] ${result.summary}`); -} diff --git a/packages/tools/lib/minecraft-versions.mjs b/packages/tools/lib/minecraft-versions.mjs deleted file mode 100644 index d2dcbc4c..00000000 --- a/packages/tools/lib/minecraft-versions.mjs +++ /dev/null @@ -1,163 +0,0 @@ -// @ts-check -/** - * tools/lib/minecraft-versions.mjs — @minecraft/* 版本权威来源与扫描 - * - * 权威版本:主仓根 package.json 的 devDependencies(@minecraft/*), - * 安装时以 overrides 为准;校验时 devDependencies 须为精确版本(无 ^/~/>)。 - */ -import fs from "node:fs"; -import path from "node:path"; - -/** SDK 允许 peer 使用最低兼容范围;业务模块不得自行声明 @minecraft/* */ -export const SDK_PEER_RANGE_ALLOWED = new Set(["@sfmc-bds/sdk", "@sfmc/sdk"]); - -const MIN_RANGE_RE = /^>=/; - -/** 业务模块包名:@sfmc-bds/module-* */ -export function isBusinessModulePkg(pkgName) { - return typeof pkgName === "string" && pkgName.startsWith("@sfmc-bds/module-"); -} - -/** - * @param {Record} devDeps - * @param {Record} [overrides] - * @returns {Record} - */ -export function readCanonicalVersions(devDeps, overrides = {}) { - /** @type {Record} */ - const canonical = {}; - for (const [name, ver] of Object.entries(devDeps ?? {})) { - if (name.startsWith("@minecraft/")) canonical[name] = ver; - } - for (const [name, ver] of Object.entries(overrides ?? {})) { - if (name.startsWith("@minecraft/")) canonical[name] = ver; - } - return canonical; -} - -/** - * @param {string} root 仓库根 - * @returns {Record} - */ -export function readCanonicalFromRootPkg(root) { - const pkgPath = path.join(root, "package.json"); - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - return readCanonicalVersions(pkg.devDependencies, pkg.overrides); -} - -/** - * @param {string} dir - * @param {string[]} [acc] - * @returns {string[]} - */ -export function findPackageJsonFiles(dir, acc = []) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const ent of entries) { - if (ent.name === "node_modules" || ent.name === "dist" || ent.name === ".git") continue; - const full = path.join(dir, ent.name); - if (ent.isDirectory()) { - findPackageJsonFiles(full, acc); - } else if (ent.name === "package.json") { - acc.push(full); - } - } - return acc; -} - -/** - * @param {string} pkgPath - * @returns {{ rel: string, name: string, deps: Record }} - */ -export function readMinecraftDeps(pkgPath) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - /** @type {Record} */ - const deps = {}; - for (const section of ["dependencies", "devDependencies", "peerDependencies"]) { - for (const [name, ver] of Object.entries(pkg[section] ?? {})) { - if (name.startsWith("@minecraft/")) deps[name] = ver; - } - } - return { rel: pkgPath, name: pkg.name ?? pkgPath, deps }; -} - -/** - * devDependencies 中的 @minecraft/* 必须为精确版本(约束写入点) - * @param {Record} devDeps - * @returns {string[]} - */ -export function validateCanonicalPins(devDeps) { - /** @type {string[]} */ - const errors = []; - for (const [name, ver] of Object.entries(devDeps ?? {})) { - if (!name.startsWith("@minecraft/")) continue; - if (/^[\^~>]/.test(ver) || ver.includes("||") || ver.includes(" - ")) { - errors.push(`根 devDependencies ${name}="${ver}" 须改为精确版本`); - } - } - return errors; -} - -/** - * @param {Record} overrides - * @param {Record} canonical - * @returns {string[]} - */ -export function validateOverrides(overrides, canonical) { - /** @type {string[]} */ - const errors = []; - for (const name of Object.keys(canonical)) { - if (!overrides[name]) { - errors.push(`根 overrides 缺少 ${name}(应与 devDependencies 一致)`); - } else if (overrides[name] !== canonical[name]) { - errors.push( - `根 overrides ${name}="${overrides[name]}" 与 devDependencies="${canonical[name]}" 不一致` - ); - } - } - return errors; -} - -/** - * @param {object} opts - * @param {Record} opts.canonical - * @param {string} opts.pkgPath - * @param {string} opts.pkgName - * @param {Record} opts.deps - * @returns {string[]} - */ -export function validatePackageMinecraftDeps({ canonical, pkgPath, pkgName, deps }) { - /** @type {string[]} */ - const errors = []; - const allowRange = SDK_PEER_RANGE_ALLOWED.has(pkgName); - - // 业务模块:类型来自仓库根 hoisted devDependencies,不得再声明 @minecraft/* - if (isBusinessModulePkg(pkgName) && Object.keys(deps).length > 0) { - for (const [name, ver] of Object.entries(deps)) { - errors.push( - `${pkgPath}: 业务模块勿声明 ${name}="${ver}"(请只用仓库根 devDependencies pin)` - ); - } - return errors; - } - - for (const [name, ver] of Object.entries(deps)) { - const expected = canonical[name]; - if (!expected) { - errors.push(`${pkgPath}: 未在权威清单中声明的 ${name}="${ver}"`); - continue; - } - if (allowRange && MIN_RANGE_RE.test(ver)) continue; - if (ver !== expected) { - errors.push(`${pkgPath}: ${name}="${ver}" 应为 "${expected}"`); - } - if (!allowRange && /^[\^~>]/.test(ver)) { - errors.push(`${pkgPath}: ${name}="${ver}" 须精确 pin,勿用范围`); - } - } - return errors; -} diff --git a/packages/tools/lib/verify/offline.mjs b/packages/tools/lib/verify/offline.mjs index dc20dcb6..b6577380 100644 --- a/packages/tools/lib/verify/offline.mjs +++ b/packages/tools/lib/verify/offline.mjs @@ -1,22 +1,19 @@ // @ts-check /** - * 离线阶段:Node、仓库文件、schema、configs 播种、已装模块 catalog 一致性 + * 离线阶段:Node、仓库文件、schema、configs 播种、CLI / fetch-module 冒烟 * - * 主仓默认无业务模块 — 不自动 catalog-sync;仅当 packages/ 有目录时校验 catalog。 + * 主仓默认无业务模块 — 不做 catalog-sync / check-modules。 */ import path from "node:path"; import process from "node:process"; import { NPM_PUBLISH_PACKAGES, assertPublishPackageInWorkspaces } from "../npm-publish-packages.mjs"; import { exists } from "../io.mjs"; import { - CONFIGS_DIR, FETCH_MODULE, ROOT, SFMC_DIST, - TOOLS_PKG_DIR, } from "../paths.mjs"; import { runSync } from "../proc.mjs"; -import { runCheckModules } from "../../check-modules.mjs"; import { coreConfigsReady, seedCoreConfigs } from "./seed-configs.mjs"; const SCHEMA_FILES = [ @@ -50,8 +47,6 @@ export function runOfflinePhase(reporter) { "modules/catalog.json", "packages/tools/verify.mjs", "packages/cli/scripts/module-install/fetch-module.mjs", - "packages/tools/catalog-sync.mjs", - "packages/tools/check-modules.mjs", ...Object.values(NPM_PUBLISH_PACKAGES), ]; const missing = required.filter((f) => !exists(path.join(ROOT, f))); @@ -88,12 +83,6 @@ export function runOfflinePhase(reporter) { else reporter.fail("configs/ 核心文件齐全", ready.error); } - { - const check = runCheckModules(); - if (check.ok) reporter.pass(check.summary ?? "check-modules 通过"); - else reporter.fail("check-modules", check.error ?? "未知错误"); - } - { const sdkDist = path.join(ROOT, "modules", "sdk", "@sfmc-sdk", "dist"); if (exists(sdkDist)) reporter.pass("@sfmc-bds/sdk dist 存在"); diff --git a/packages/tools/package.json b/packages/tools/package.json index 18dc5e86..d634198c 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,39 +1,37 @@ -{ - "name": "@sfmc-bds/tools", - "version": "0.2.0-beta.10", - "private": true, - "description": "SFMC monorepo-only scripts — verify, catalog-sync, release, build bins (not published to npm)", - "author": "Shiroha7z", - "license": "AGPL-3.0-only", - "type": "module", - "homepage": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/tree/main/packages/tools#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git", - "directory": "packages/tools" - }, - "bugs": { - "url": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/issues" - }, - "engines": { - "node": ">=22.13.0" - }, - "exports": { - "./package.json": "./package.json", - "./tsc7": "./tsc7.mjs" - }, - "bin": { - "sfmc-catalog-sync": "./catalog-sync.mjs", - "sfmc-check-modules": "./check-modules.mjs", - "sfmc-esbuild-transpile": "./esbuild-transpile.mjs", - "sfmc-verify": "./verify.mjs", - "tsc7": "./tsc7.mjs" - }, - "scripts": { - "build": "node -e \"process.exit(0)\"" - }, - "dependencies": { - "@sfmc-bds/bds-tools": "^0.2.0-beta.9", - "@sfmc-bds/sdk": "^0.2.0-beta.9" - } -} +{ + "name": "@sfmc-bds/tools", + "version": "0.2.0-beta.10", + "private": true, + "description": "SFMC monorepo-only scripts — verify, docs, release, build bins (not published to npm)", + "author": "Shiroha7z", + "license": "AGPL-3.0-only", + "type": "module", + "homepage": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/tree/main/packages/tools#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git", + "directory": "packages/tools" + }, + "bugs": { + "url": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/issues" + }, + "engines": { + "node": ">=22.13.0" + }, + "exports": { + "./package.json": "./package.json", + "./tsc7": "./tsc7.mjs" + }, + "bin": { + "sfmc-esbuild-transpile": "./esbuild-transpile.mjs", + "sfmc-verify": "./verify.mjs", + "tsc7": "./tsc7.mjs" + }, + "scripts": { + "build": "node -e \"process.exit(0)\"" + }, + "dependencies": { + "@sfmc-bds/bds-tools": "^0.2.0-beta.9", + "@sfmc-bds/sdk": "^0.2.0-beta.9" + } +} diff --git a/packages/tools/run-release.mjs b/packages/tools/run-release.mjs index 430d3564..7ce44fff 100644 --- a/packages/tools/run-release.mjs +++ b/packages/tools/run-release.mjs @@ -3,9 +3,9 @@ /** * tools/run-release.mjs — 一键发版编排(替代根 package.json 里一长串 run-s) * - * node tools/run-release.mjs --pre # 本地 beta:assert → … → publish → gh prerelease - * node tools/run-release.mjs --stable # 本地正式:assert → … → publish → gh latest - * node tools/run-release.mjs --ci # CI:仅 publish → tag → push → gh(版本已 bump) + * node packages/tools/run-release.mjs --ci # CI:publish → tag → push → gh(版本已 bump) + * node packages/tools/run-release.mjs --pre # 应急本地 beta(日常请走 CI Version PR) + * node packages/tools/run-release.mjs --stable # 应急本地正式 */ import { spawnSync } from "node:child_process"; import path from "node:path"; @@ -24,9 +24,9 @@ const mode = process.argv.includes("--ci") if (!mode) { console.error(`用法: - node packages/tools/run-release.mjs --pre # beta 本地一键发版 - node packages/tools/run-release.mjs --stable # 正式本地一键发版 - node packages/tools/run-release.mjs --ci # CI:publish + tag + push + gh release`); + node packages/tools/run-release.mjs --ci # CI / npm run ci-release-packages + node packages/tools/run-release.mjs --pre # 应急本地 beta(日常请走 CI) + node packages/tools/run-release.mjs --stable # 应急本地正式`); process.exit(2); } diff --git a/packages/tools/verify-module-publish.mjs b/packages/tools/verify-module-publish.mjs deleted file mode 100644 index 395e35f9..00000000 --- a/packages/tools/verify-module-publish.mjs +++ /dev/null @@ -1,462 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * tools/verify-module-publish.mjs — 模块包发布前的全方位预检 - * - * 在 `npm publish` 之前跑一遍;模板 CI / 本地均可触发: - * - * 1) manifest v2 字段完整性(id / configKey / permissions / services) - * 2) package.json#name 与 manifest.id 一致(折叠规则:feature-* → folder) - * 3) package.json#files 含 sapi/(避免发空 tarball) - * 4) npm pack --dry-run 解析 tarball 清单(确认 sapi/ 实际入包) - * 5) sapi/src/** 至少 1 个 .ts 文件,且含 ModuleRegistry.register 调用 - * 6) sdk 依赖 pin 在 @sfmc-bds/sdk(避免拼错 @sfmc/sdk 旧名) - * 7) 跨模块源码 import 检查(避免深挖其它模块业务代码) - * - * 退出码:0 全部通过;1 任一项失败。失败时打印每条 cause + 修复建议。 - */ - -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { spawn } from "node:child_process"; - -const CWD = process.cwd(); - -/** - * @param {any} msg - */ -function die(msg, code = 1) { - console.error(`[verify-module-publish] ${msg}`); - process.exit(code); -} - -const KEYS = { - schemaVersion: 2, - required: ["id", "name", "type", "configKey", "permissions", "services", "requires"], -}; - -/** - * @type {{ kind: any; name: any; msg: any; }[]} - */ -const checks = []; -let pass = 0; -let warn = 0; -let fail = 0; - -/** - * @param {string} kind - * @param {string} name - * @param {string} msg - */ -function record(kind, name, msg) { - checks.push({ kind, name, msg }); - if (kind === "pass") pass++; - else if (kind === "warn") warn++; - else fail++; -} - -/* ────────────────────────────────────────────────────────────────── - * 1) manifest.json v2 字段完整性 - * ──────────────────────────────────────────────────────────────── */ -async function checkManifest() { - const manifestPath = path.join(CWD, "sapi", "manifest.json"); - if (!fs.existsSync(manifestPath)) { - record("fail", "manifest 存在", `未找到 sapi/manifest.json(在 ${CWD})`); - return null; - } - let manifest; - try { - manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8")); - } catch (e) { - // @ts-ignore - record("fail", "manifest 可解析", `sapi/manifest.json 解析失败: ${e.message}`); - return null; - } - if (manifest.schemaVersion !== KEYS.schemaVersion) { - record("fail", "schemaVersion=2", `当前 ${manifest.schemaVersion ?? "未设置"};固定为 2`); - } else { - record("pass", "schemaVersion=2", "OK"); - } - for (const k of KEYS.required) { - if (manifest[k] === undefined) { - record("fail", `manifest.${k}`, `缺失`); - } else { - record("pass", `manifest.${k}`, "存在"); - } - } - if (manifest.services && typeof manifest.services === "object") { - if (!Array.isArray(manifest.services.provides)) { - record("fail", "manifest.services.provides 数组", `当前类型 ${typeof manifest.services.provides}`); - } - if (!Array.isArray(manifest.services.requires)) { - record("fail", "manifest.services.requires 数组", `当前类型 ${typeof manifest.services.requires}`); - } else { - const refs = manifest.services.requires.map((/** @type {{ name: any; }} */ r) => (r && typeof r === "object" ? r.name : r)); - const dup = refs.filter((/** @type {any} */ n, /** @type {any} */ i) => refs.indexOf(n) !== i); - if (dup.length) record("fail", "manifest.services.requires 无重复", `重复: ${[...new Set(dup)].join(", ")}`); - } - } - return manifest; -} - -/* ────────────────────────────────────────────────────────────────── - * 2) package.json#name 与 manifest.id 折叠一致 - * folder → @scope/module- - * feature- → 同上 - * ──────────────────────────────────────────────────────────────── */ -/** - * @param {{ id: any; }} manifest - */ -function folderFromManifest(manifest) { - const id = manifest?.id; - if (!id) return null; - if (id.startsWith("feature-") || id.startsWith("core-")) return id.slice(id.indexOf("-") + 1); - return id; -} - -/** - * @param {any} manifest - */ -async function checkPackageNameAlignment(manifest) { - const pkgPath = path.join(CWD, "package.json"); - if (!fs.existsSync(pkgPath)) { - record("fail", "package.json 存在", "未找到 package.json"); - return; - } - let pkg; - try { - pkg = JSON.parse(await fsp.readFile(pkgPath, "utf8")); - } catch (e) { - // @ts-ignore - record("fail", "package.json 可解析", e.message); - return; - } - const expectedFolder = folderFromManifest(manifest); - if (!expectedFolder) return; /* checkManifest 已报缺 id */ - /* 接受 @/module- 或 @/sfmc-module- */ - const re = new RegExp(`^@[^/]+/(?:sfmc-)?module-${expectedFolder}$`); - if (!re.test(pkg.name ?? "")) { - record( - "fail", - "package.name 与 manifest.id 一致", - `name=${pkg.name},期望匹配 ${re}(folder=${expectedFolder})` - ); - } else { - record("pass", "package.name 与 manifest.id 一致", pkg.name); - } - /* version */ - if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(pkg.version ?? "")) { - record("fail", "package.version semver", `当前 ${pkg.version}`); - } else { - record("pass", "package.version semver", pkg.version); - } -} - -/* ────────────────────────────────────────────────────────────────── - * 3) package.json#files 含 sapi/ - * ──────────────────────────────────────────────────────────────── */ -async function checkFilesField() { - const pkgPath = path.join(CWD, "package.json"); - if (!fs.existsSync(pkgPath)) { - record("fail", "package.files 数组", "未找到 package.json"); - return; - } - let pkg; - try { - pkg = JSON.parse(await fsp.readFile(pkgPath, "utf8")); - } catch { - return; /* 已在 checkPackageNameAlignment 报 */ - } - const files = pkg.files; - if (!Array.isArray(files)) { - record("fail", "package.files 数组", `未声明 files;npm pack 会按 .gitignore 默认 → tarball 可能不含 sapi/`); - return; - } - if (!files.includes("sapi")) { - record("fail", "package.files 含 sapi/", `当前 ${JSON.stringify(files)}`); - } else { - record("pass", "package.files 含 sapi/", "OK"); - } -} - -/* ────────────────────────────────────────────────────────────────── - * 4) npm pack --dry-run 解析 tarball 内容 - * ──────────────────────────────────────────────────────────────── */ -/* 极简 tar 头解析:512-byte 块,name 在 offset 0(100 字节 null-padded), - * size 在 offset 124(12 字节八进制 null-padded);typeflag 在 offset 156('0' = 普通文件,'5' = 目录)。 - * 末尾两个全零 512 块终止。返回 entries 名字数组(相对 tar 根,含目录与文件)。 */ -/** - * @param {NonSharedBuffer} buf - */ -function listTarEntries(buf) { - const files = []; - const BLOCK = 512; - for (let off = 0; off + BLOCK <= buf.length; off += BLOCK) { - const header = buf.subarray(off, off + BLOCK); - /* name 是 null 结尾 */ - const nameEnd = header.indexOf(0); - if (nameEnd < 0) break; /* 全零块,终止 */ - const name = header.subarray(0, nameEnd).toString("utf8"); - /* size 是 octal 字符串 */ - const sizeStr = header.subarray(124, 136).toString("utf8").replace(/\0/g, "").trim(); - const size = parseInt(sizeStr, 8) || 0; - const typeflag = String.fromCharCode(header[156]); - if (name === "") break; - if (typeflag !== "5") files.push(name); /* 跳过纯目录 */ - /* size blocks + 1 header */ - off += Math.ceil(size / BLOCK) * BLOCK; - } - return files; -} - -/** - * @param {readonly string[]} args - */ -function runNpm(args) { - const isWin = process.platform === "win32"; - const npmCli = path.join(process.execPath.replace(/[^\\/]+$/, ""), "node_modules", "npm", "bin", "npm-cli.js"); - if (isWin && fs.existsSync(npmCli)) { - return new Promise((resolve) => { - const proc = spawn(process.execPath, [npmCli, ...args], { - cwd: CWD, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, npm_config_loglevel: "notice" }, - }); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (d) => (stdout += d.toString())); - proc.stderr?.on("data", (d) => (stderr += d.toString())); - proc.on("exit", (code) => { - /* npm 11+ notice 输出到 stderr、tgz 文件名输出到 stdout; - * 合并时 stderr 放前,让 stdout 的 tgz 文件名落在最后一行(pop 可得)。 */ - const merged = stderr + stdout; - resolve({ code, stdout: merged, stderr }); - }); - proc.on("error", (e) => resolve({ code: -1, stdout: "", stderr: e.message })); - }); - } - return new Promise((resolve) => { - const proc = spawn("npm", args, { cwd: CWD, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (d) => (stdout += d.toString())); - proc.stderr?.on("data", (d) => (stderr += d.toString())); - proc.on("exit", (code) => resolve({ code, stdout: stdout + stderr, stderr })); - proc.on("error", (e) => resolve({ code: -1, stdout: "", stderr: e.message })); - }); -} - -async function checkTarballContents() { - const os = await import("node:os"); - const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "sfmc-vm-tgz-")); - try { - const r = await runNpm(["pack"]); - if (process.env.VERIFY_DEBUG) console.error("[debug] npm pack code=", r.code, "stdout=", JSON.stringify(r.stdout)); - if (r.code !== 0) { - record("fail", "npm pack 成功", r.stderr.trim() || `exit ${r.code}`); - return; - } - const tgzName = r.stdout.trim().split(/\r?\n/).pop() ?? ""; - const tgzPath = path.join(CWD, tgzName); - if (!tgzName || !fs.existsSync(tgzPath)) { - record("fail", "tarball 生成", `未找到产物: cwd=${CWD}, stdout=${r.stdout.trim().split("\n").slice(-3).join(" | ")}`); - return; - } - const buffer = await fsp.readFile(tgzPath); - /* npm pack 产物是 .tar.gz(不是 zip)。用 zlib 解 gzip + 极简 tar 头解析。 */ - const zlib = await import("node:zlib"); - const decompressed = zlib.gunzipSync(buffer); - const files = listTarEntries(decompressed); - if (files.length === 0) { - record("fail", "tarball 含文件", `tgz 大小 ${buffer.length}B 但无 entries`); - return; - } - record("pass", "tarball 含文件", `${files.length} 文件入包`); - const must = ["package.json", "sapi/manifest.json"]; - for (const f of must) { - if (!files.some((t) => t === `package/${f}` || t === f)) { - record("fail", `tarball 含 ${f}`, `缺失;当前前 5 个: ${files.slice(0, 5).join(", ")}`); - } else { - record("pass", `tarball 含 ${f}`, "OK"); - } - } - const srcFiles = files.filter((f) => f.includes("sapi/src/")); - if (srcFiles.length === 0) { - record("warn", "tarball 含 sapi/src/**", "无源码文件;模块可能空跑"); - } else { - record("pass", "tarball 含 sapi/src/**", `${srcFiles.length} 文件`); - } - } finally { - /* 清理 CWD 下的临时 tgz(npm pack 写到这里) */ - try { - for (const e of fs.readdirSync(CWD)) { - if (e.endsWith(".tgz") && e.includes("module")) { - try { fs.unlinkSync(path.join(CWD, e)); } catch {} - } - } - } catch {} - await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {}); - } -} - -/* ────────────────────────────────────────────────────────────────── - * 5) sapi/src/** 至少 1 个 .ts,且含 ModuleRegistry.register 调用 - * ──────────────────────────────────────────────────────────────── */ -async function checkEntryRegistration() { - const srcDir = path.join(CWD, "sapi", "src"); - if (!fs.existsSync(srcDir)) { - record("fail", "sapi/src/ 存在", `未找到目录 ${srcDir}`); - return; - } - /** - * @type {string[]} - */ - const tsFiles = []; - const walk = (/** @type {fs.PathLike} */ dir) => { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - // @ts-ignore - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.isFile() && /\.tsx?$/.test(e.name)) tsFiles.push(p); - } - }; - walk(srcDir); - if (tsFiles.length === 0) { - record("fail", "sapi/src 含 .ts", "无"); - return; - } - let hasRegister = false; - for (const f of tsFiles) { - const text = await fsp.readFile(f, "utf8"); - if (/ModuleRegistry\.register\s*\(/.test(text)) { - hasRegister = true; - break; - } - } - if (!hasRegister) { - record("fail", "至少一个 .ts 含 ModuleRegistry.register", "全部命中不到;模块不会被 BP 装载"); - } else { - record("pass", "sapi/src 含 ModuleRegistry.register", "OK"); - } -} - -/* ────────────────────────────────────────────────────────────────── - * 6) sdk 依赖 pin 正确 - * ──────────────────────────────────────────────────────────────── */ -async function checkSdkPin() { - const pkgPath = path.join(CWD, "package.json"); - if (!fs.existsSync(pkgPath)) { - record("fail", "无 @sfmc/sdk 旧别名", "未找到 package.json"); - record("fail", "声明 @sfmc-bds/sdk 依赖", "未找到 package.json"); - return; - } - let pkg; - try { - pkg = JSON.parse(await fsp.readFile(pkgPath, "utf8")); - } catch { - return; /* 已在前面报 */ - } - const sections = ["dependencies", "devDependencies", "peerDependencies"]; - const issues = []; - for (const s of sections) { - const deps = pkg[s] ?? {}; - if (deps["@sfmc/sdk"]) issues.push(`${s}.@sfmc/sdk(旧别名)`); - } - if (issues.length) { - record("fail", "无 @sfmc/sdk 旧别名", `发现 ${issues.join(", ")}`); - } else { - record("pass", "无 @sfmc/sdk 旧别名", "OK"); - } - const sdkVer = pkg.dependencies?.["@sfmc-bds/sdk"] ?? pkg.devDependencies?.["@sfmc-bds/sdk"] ?? pkg.peerDependencies?.["@sfmc-bds/sdk"]; - if (!sdkVer) { - record("fail", "声明 @sfmc-bds/sdk 依赖", `未在 dependencies/devDependencies/peerDependencies 声明`); - } else { - record("pass", "声明 @sfmc-bds/sdk 依赖", `version=${sdkVer}`); - } -} - -/* ────────────────────────────────────────────────────────────────── - * 7) 跨模块源码 import 检查(不要 import 其它模块私有代码) - * 启发式:@sfmc-bds/module-* 以外的相对 ../ 深挖一律警告 - * ──────────────────────────────────────────────────────────────── */ -async function checkCrossModuleImports() { - const srcDir = path.join(CWD, "sapi", "src"); - if (!fs.existsSync(srcDir)) return; - /** - * @type {string[]} - */ - const tsFiles = []; - const walk = (/** @type {fs.PathLike} */ dir) => { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - // @ts-ignore - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.isFile() && /\.(ts|tsx)$/.test(e.name)) tsFiles.push(p); - } - }; - walk(srcDir); - /* 启发式规则: - * - 允许:import "@sfmc-bds/sdk/..." 与 "node:*" - * - 警告:相对路径超过 2 级 (../../..) - * - 警告:import 含 "module-" 但非 "@sfmc-bds/module-" 自身 - */ - for (const f of tsFiles) { - const text = await fsp.readFile(f, "utf8"); - const matches = text.matchAll(/from\s+["']([^"']+)["']/g); - for (const m of matches) { - const spec = m[1]; - if (spec.startsWith("@sfmc-bds/sdk")) continue; - if (spec.startsWith("node:")) continue; - if (spec.startsWith("@minecraft/")) continue; - if (spec.startsWith(".")) { - const upLevels = (spec.match(/\.\.\//g) || []).length; - if (upLevels >= 2) { - record("warn", `${path.relative(CWD, f)} 相对深挖`, `${spec}(${upLevels} 级;建议改用 typed client)`); - } - } - if (spec.includes("module-") && !spec.startsWith(".")) { - record("warn", `${path.relative(CWD, f)} 引用未授权模块`, `${spec}(应通过 service.get 或 typed client)`); - } - } - } -} - -/* ────────────────────────────────────────────────────────────────── - * 入口 - * ──────────────────────────────────────────────────────────────── */ -async function main() { - console.log(`[verify-module-publish] cwd: ${CWD}`); - /* 包一层:单个 check 抛错不影响其它(之前是直接 await 串行,整体崩) */ - const wrap = (/** @type {string} */ name, /** @type {{ (): Promise; (): Promise; (): Promise; (): Promise; (): Promise; (): Promise; (): Promise; (): Promise; }} */ fn) => fn().catch((/** @type {{ message: any; }} */ e) => record("fail", name, `抛错: ${e?.message ?? String(e)}`)); - const manifest = await wrap("manifest 读取", () => checkManifest()); - if (manifest) { - await wrap("packageNameAlignment", () => checkPackageNameAlignment(manifest)); - await wrap("filesField", checkFilesField); - await wrap("entryRegistration", checkEntryRegistration); - await wrap("sdkPin", checkSdkPin); - } - await wrap("tarballContents", checkTarballContents); - await wrap("crossModuleImports", checkCrossModuleImports); - - /* 输出 */ - for (const c of checks) { - const icon = c.kind === "pass" ? "✓" : c.kind === "warn" ? "!" : "✗"; - const color = c.kind === "pass" ? "\x1b[32m" : c.kind === "warn" ? "\x1b[33m" : "\x1b[31m"; - const reset = "\x1b[0m"; - console.log(` ${color}${icon}${reset} ${c.name} ${c.kind === "pass" ? "" : c.msg}`); - } - - console.log(""); - console.log(`[verify-module-publish] ${pass} pass · ${warn} warn · ${fail} fail`); - if (fail > 0) { - console.error(`[verify-module-publish] FAILED(${fail} 项硬错)。修完再跑 npm publish。`); - process.exit(1); - } else { - console.log(`[verify-module-publish] OK(${warn} 项警告;不阻塞 publish)。`); - process.exit(0); - } -} - -main().catch((e) => die(e?.message ?? String(e))); \ No newline at end of file diff --git a/packages/tools/verify-module-publish.test.mjs b/packages/tools/verify-module-publish.test.mjs deleted file mode 100644 index edd7527c..00000000 --- a/packages/tools/verify-module-publish.test.mjs +++ /dev/null @@ -1,168 +0,0 @@ -// @ts-check -/** - * verify-module-publish.test.mjs — pre-publish 守门脚本测试 - * - * 用真实模板仓路径(已 commit 的 sfmc-module-template)作为 good fixture; - * 用临时目录构造 bad fixture(缺 manifest、缺 files、错误 sdk 等)。 - */ -import assert from "node:assert/strict"; -import test from "node:test"; -import fs from "node:fs/promises"; -import path from "node:path"; -import os from "node:os"; -import { spawn } from "node:child_process"; - -const TEMPLATE = path.resolve("D:/", "#WorkPlace", "#MCBEProjects", "sfmc-module-template"); -const TOOLS = path.resolve("D:/", "#WorkPlace", "#MCBEProjects", "ScriptsForMinecraftServer", "tools"); -const VERIFY_SCRIPT = path.join(TOOLS, "verify-module-publish.mjs"); - -/** - * @param {string} cmd - * @param {readonly string[]} args - */ -function run(cmd, args, opts = {}) { - return new Promise((resolve) => { - // @ts-ignore - const proc = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (d) => (stdout += d.toString())); - proc.stderr?.on("data", (d) => (stderr += d.toString())); - proc.on("exit", (code) => resolve({ code, stdout, stderr })); - proc.on("error", (e) => resolve({ code: -1, stdout, stderr: `${stderr}${e.message}` })); - }); -} - -/** - * @param {string} cwd - */ -async function runVerify(cwd) { - return run(process.execPath, [VERIFY_SCRIPT], { cwd }); -} - -test("模板仓:所有硬检查通过(warn 可允许)", async () => { - const r = await runVerify(TEMPLATE); - assert.equal(r.code, 0, `unexpected exit; stdout=${r.stdout}\nstderr=${r.stderr}`); - assert.match(r.stdout, /OK(/); -}); - -test("坏 fixture:缺 manifest.json → exit 1", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "sfmc-vm-bad-")); - try { - const r = await runVerify(tmp); - assert.notEqual(r.code, 0); - /* manifest 缺失会先报;script 还在 manifest 检查阶段,未必进入后续检查 */ - assert.ok(/未找到 sapi\/manifest\.json|npm pack 未报告/.test(r.stdout), `got stdout: ${r.stdout}`); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } -}); - -test("坏 fixture:manifest schemaVersion=1 → fail", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "sfmc-vm-bad-")); - try { - await fs.mkdir(path.join(tmp, "sapi", "src"), { recursive: true }); - await fs.writeFile( - path.join(tmp, "sapi", "manifest.json"), - JSON.stringify({ - schemaVersion: 1, - id: "feature-x", - name: "x", - type: "feature", - configKey: "x", - permissions: [], - services: { provides: [], requires: [] }, - requires: [], - }) - ); - await fs.writeFile(path.join(tmp, "sapi", "src", "index.ts"), "export const x = 1;\n"); - await fs.writeFile( - path.join(tmp, "package.json"), - JSON.stringify({ - name: "@scope/module-x", - version: "1.0.0", - type: "module", - files: ["sapi"], - dependencies: { "@sfmc-bds/sdk": "^0.2.0-beta.5" }, - }) - ); - const r = await runVerify(tmp); - assert.notEqual(r.code, 0); - assert.match(r.stdout, /schemaVersion=2/); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } -}); - -test("坏 fixture:package.name 不匹配 manifest.id → fail", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "sfmc-vm-bad-")); - try { - await fs.mkdir(path.join(tmp, "sapi", "src"), { recursive: true }); - await fs.writeFile( - path.join(tmp, "sapi", "manifest.json"), - JSON.stringify({ - schemaVersion: 2, - id: "feature-foo", - name: "foo", - type: "feature", - configKey: "foo", - permissions: ["config:read:foo"], - services: { provides: [], requires: [] }, - requires: [], - }) - ); - await fs.writeFile(path.join(tmp, "sapi", "src", "index.ts"), "export const x = 1;\n"); - await fs.writeFile( - path.join(tmp, "package.json"), - JSON.stringify({ - name: "@alice/sfmc-module-other", - version: "1.0.0", - type: "module", - files: ["sapi"], - dependencies: { "@sfmc-bds/sdk": "^0.2.0-beta.5" }, - }) - ); - const r = await runVerify(tmp); - assert.notEqual(r.code, 0); - assert.match(r.stdout, /package\.name 与 manifest\.id 一致/); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } -}); - -test("坏 fixture:使用旧别名 @sfmc/sdk → fail", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "sfmc-vm-bad-")); - try { - await fs.mkdir(path.join(tmp, "sapi", "src"), { recursive: true }); - await fs.writeFile( - path.join(tmp, "sapi", "manifest.json"), - JSON.stringify({ - schemaVersion: 2, - id: "feature-foo", - name: "foo", - type: "feature", - configKey: "foo", - permissions: ["config:read:foo"], - services: { provides: [], requires: [] }, - requires: [], - }) - ); - await fs.writeFile(path.join(tmp, "sapi", "src", "index.ts"), "export const x = 1;\n"); - await fs.writeFile( - path.join(tmp, "package.json"), - JSON.stringify({ - name: "@alice/sfmc-module-foo", - version: "1.0.0", - type: "module", - files: ["sapi"], - dependencies: { "@sfmc/sdk": "^0.1.0" }, - }) - ); - const r = await runVerify(tmp); - assert.notEqual(r.code, 0); - /* old alias 检查可能在 npm pack 报错前/后执行;任一提示出现即 OK */ - assert.ok(/@sfmc\/sdk 旧别名|package\.name 与 manifest\.id|schemaVersion|npm pack/.test(r.stdout), `got: ${r.stdout}`); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } -}); \ No newline at end of file diff --git a/packages/tools/verify.mjs b/packages/tools/verify.mjs index 1bbf5377..3f9ab9cc 100644 --- a/packages/tools/verify.mjs +++ b/packages/tools/verify.mjs @@ -3,8 +3,7 @@ /** * tools/verify.mjs — 平台集成自检(主仓唯一 CI 门禁) * - * 主仓默认无业务模块:不测 catalog-sync;已装包时 check-modules 校验 catalog 一致性。 - * 一次 db 生命周期覆盖平台 API + 模块 REST(有模块时才 enable/disable)。 + * 主仓默认无业务模块(业务装在独立环境)。 * * 用法: * node packages/tools/verify.mjs # 全量(离线 + db + 隔离 SFMC_ROOT) @@ -34,7 +33,7 @@ function printHelp() { console.log(`用法: node packages/tools/verify.mjs [选项] 选项: - --offline 仅离线检查(Node、文件、schema、configs、check-modules、CLI) + --offline 仅离线检查(Node、文件、schema、configs、CLI) --skip-isolated 跳过隔离 SFMC_ROOT 模拟 -h, --help 显示帮助 From 917904ba2b67d654e3c0aa3403c39638fea309cb Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Fri, 21 Aug 2026 19:34:16 +0800 Subject: [PATCH 2/8] update readme --- README.md | 40 ++++++---------------------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0fdbaf00..f4f389e6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 一套 Minecraft Bedrock Script API (SAPI) 行为包 + Node.js 仓顶服务的 monorepo。 > -> 🎉 在**原生BDS**即可获得类似插件服的**高效、安全、扩展丰富**的体验 +> 在**原生BDS**即可获得类似插件服的**高效、安全、扩展丰富**的体验 - 提供基于[Minecraft Script API](https://learn.microsoft.com/zh-cn/minecraft/creator/scriptapi/?view=minecraft-bedrock-stable)的**原生脚本SDK** - 外置可拆卸的**模块化管理**服务,拥有类似插件服的舒适体验;目前已开发[22+实用模块](https://github.com/Tanya7z/sfmc-modules) @@ -24,30 +24,9 @@ --- -## 🔍 架构图 +## 快速开始 -```mermaid -flowchart LR - REG["sfmc-modules"] -->|fetch| PKG["packages/"] - PKG -->|build · deploy| BDS["BDS / SAPI"] - BDS <-->|HTTP| DB["db-server"] - Bot <-->|WS · HTTP| QQ["qq-bridge"] --> DB - SFMC["sfmc CLI"] -. 管理 .-> BDS & DB & QQ -``` - -### 数据流摘要 - -- **模块**:注册表 → `modules/packages/` → esbuild 装配 → 写入 BDS 行为包 -- **游戏内**:SAPI 经 HTTP 访问 db-server(配置 / 数据 / 模块启停) - -> **为什么用外置数据库?** -> SAPI 只发请求,读写 SQLite 在 Node 里完成。经济、领地这类操作可以走事务和幂等,比纯游戏内处理更稳,也更好备份。 - -详细说明见 [文档中心](./docs/zh/index.mdx)(Rspress:`npm run docs -- serve`)。 - -## ⚡️ 快速开始 - -### npm 聚合包(推荐) +### npm ```bash > node -v # 需要 v22.13+ @@ -56,11 +35,11 @@ flowchart LR > sfmc ``` -> 当前阶段仅发 **beta**;请带 `@beta` 安装。模块发包见 [发布你的模块](./docs/zh/dev/publish.md)。 +> 当前阶段仅发 **beta**;请带 `@beta` 安装。 开发者也可克隆本仓 monorepo,见 [入门](./docs/zh/guide/index.mdx)。 -## 📖 快速入门 +## 快速入门 | 分类 | 入口 | | ------------ | ----------------------------------------------------------------------------------- | @@ -71,7 +50,7 @@ flowchart LR | SDK 类型参考 | [docs/zh/reference/](./docs/zh/reference/index.md)(构建时 TypeDoc) | | 本地预览 | `cd website && npm i` → 仓根 `npm run docs -- serve` | -## 🧾 许可证 +## 许可证 整体平台遵循 [AGPL-3.0](./LICENSE)(见 [LICENSES.md](./LICENSES.md) 各包对照表)。 @@ -86,11 +65,4 @@ flowchart LR --- -> ⚠️ AI辅助内容声明 -> 该资源部分使用人工智能(AI)工具协助进行研究、起草、格式化、优化和开发工作流程。 -> -> 所有AI生成的内容在发布前均经过人工审查、编辑和验证。 -> -> AI用于提高生产力、可访问性和工作流程效率,而非取代人工监督、专业或判断。 - [English version →](./README.en.md) From 7f1721b785d6a9e3b18c709c1d7df3148817daf0 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Fri, 21 Aug 2026 23:57:29 +0800 Subject: [PATCH 3/8] checkpoint before checking out main --- packages/cli/src/services.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/services.ts b/packages/cli/src/services.ts index ae5c5d3a..b80e4dc7 100644 --- a/packages/cli/src/services.ts +++ b/packages/cli/src/services.ts @@ -332,7 +332,14 @@ function createServices(): Record { "db_config", { ...DEFAULT_DB_CONFIG } as Record ) as DBConfig; - const bdsPath = bdsCfg.bds_path ?? ROOT; + const rawBdsPath = String(bdsCfg.bds_path ?? "").trim(); + if (process.platform !== "win32" && /^[A-Za-z]:[\\/]/.test(rawBdsPath)) { + throw new Error( + `configs/bds_updater.json 的 bds_path 是 Windows 路径(${rawBdsPath})。` + + `请改成相对 SFMC_ROOT 的路径,例如 "BDS"。` + ); + } + const bdsPath = path.resolve(ROOT, rawBdsPath || "."); const useLlbotBackend = qqCfg.qq_backend === "llbot"; const qqEnabled = qqCfg.qq_enabled !== false; const llbotEnabled = qqCfg.llbot_enabled !== false; @@ -342,7 +349,7 @@ function createServices(): Record { const dbPort = dbCfg.db_port ?? 3001; dbHealthPort = dbPort; qqBackendMode = useLlbotBackend ? "llbot" : "official"; - const bdsExe = bdsExePath(path.resolve(bdsPath)); + const bdsExe = bdsExePath(bdsPath); const qqTitle = useLlbotBackend ? "QQ (llbot)" : "QQ (official)"; return { From 0e85e5e421cc3726a403d43b646c4756435daefd Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Sun, 27 Sep 2026 13:14:35 +0800 Subject: [PATCH 4/8] chore: prepare pnpm stable release workflow --- .changeset/pnpm-stable-release-prep.md | 7 + .github/workflows/module-publish.yml | 148 +++++++++++ CONTRIBUTING.md | 1 - README.en.md | 6 +- README.md | 9 +- docs/en/guide/backup-upgrade.mdx | 3 +- docs/en/guide/index.mdx | 7 +- docs/superpowers/specs/modules/AGENT_BRIEF.md | 3 +- docs/zh/CONTRIBUTING-DOCS.mdx | 8 +- docs/zh/api/sdk/index.md | 2 +- docs/zh/dev/architecture.md | 2 +- docs/zh/dev/contributing.md | 40 ++- docs/zh/dev/eslint.md | 6 +- docs/zh/dev/index.mdx | 4 +- docs/zh/dev/module-author.mdx | 10 +- docs/zh/dev/publish.md | 8 +- docs/zh/dev/stable-release-plan.md | 86 ++++++ docs/zh/dev/style-sample.mdx | 9 +- docs/zh/dev/testing.md | 2 +- docs/zh/dev/tools.md | 6 +- docs/zh/guide/backup-upgrade.mdx | 4 +- docs/zh/guide/index.mdx | 17 +- docs/zh/reference/index.md | 2 +- modules/sdk/@sfmc-eslint-plugin/README.md | 4 +- modules/sdk/@sfmc-sdk/README.md | 2 +- modules/sdk/@sfmc-sdk/build.mjs | 2 +- packages/bds-tools/README.md | 2 +- packages/bds-tools/recovery.js | 2 +- packages/cli/README.md | 9 +- packages/cli/package.json | 1 + .../scripts/module-install/fetch-module.mjs | 246 +++++++++++------- packages/cli/src/i18n/locales/en.ts | 2 +- packages/cli/src/i18n/locales/zh-CN.ts | 2 +- packages/cli/src/module-commands.ts | 4 +- packages/cli/src/module-pack-build.ts | 2 +- packages/cli/src/wizard.ts | 4 +- packages/create-module/README.md | 3 +- packages/create-module/package.json | 7 +- packages/create-module/src/cli.ts | 15 +- .../templates/base/.prettierignore | 2 +- .../create-module/templates/base/README.md | 10 +- .../create-module/templates/base/package.json | 8 +- .../templates/base/pnpm-workspace.yaml | 5 + packages/db-server/README.md | 2 +- packages/devkit/README.md | 2 +- packages/devkit/package.json | 5 + packages/devkit/src/index.ts | 2 +- packages/meta/README.md | 10 +- packages/qq-bridge/README.md | 2 +- packages/tools/README.md | 11 +- packages/tools/changeset-assert-mode.mjs | 6 +- packages/tools/changeset-ensure.mjs | 2 +- packages/tools/changeset-publish.mjs | 8 +- packages/tools/lib/npm-publish-packages.mjs | 1 + packages/tools/lib/proc.mjs | 15 -- packages/tools/lib/verify/db-api.mjs | 1 - packages/tools/lib/verify/db-harness.mjs | 66 ++++- packages/tools/lib/verify/offline.mjs | 2 +- packages/tools/pack-verify.mjs | 24 +- packages/tools/run-release.mjs | 12 +- packages/tools/tsc7.mjs | 2 +- packages/tools/verify.mjs | 2 +- pnpm-lock.yaml | 3 + website/AGENTS.md | 4 +- website/data/troubleshoot-tree.en.json | 4 +- website/data/troubleshoot-tree.zh.json | 4 +- 66 files changed, 616 insertions(+), 296 deletions(-) create mode 100644 .changeset/pnpm-stable-release-prep.md create mode 100644 .github/workflows/module-publish.yml create mode 100644 docs/zh/dev/stable-release-plan.md create mode 100644 packages/create-module/templates/base/pnpm-workspace.yaml diff --git a/.changeset/pnpm-stable-release-prep.md b/.changeset/pnpm-stable-release-prep.md new file mode 100644 index 00000000..41ea897c --- /dev/null +++ b/.changeset/pnpm-stable-release-prep.md @@ -0,0 +1,7 @@ +--- +"@sfmc-bds/cli": patch +"@sfmc-bds/create-module": patch +"@sfmc-bds/devkit": patch +--- + +统一模块模板和公开文档的 pnpm 工作流,并将 registry 模块下载改为 pnpm,补齐公开包仓库元数据。 diff --git a/.github/workflows/module-publish.yml b/.github/workflows/module-publish.yml new file mode 100644 index 00000000..40e6e272 --- /dev/null +++ b/.github/workflows/module-publish.yml @@ -0,0 +1,148 @@ +name: module-publish + +# 从主仓发布独立模块。NPM_TOKEN 只配置在主仓;源代码必须是模块仓 main 上的指定提交。 +on: + workflow_dispatch: + inputs: + module: + description: 模块仓后缀 + required: true + type: choice + options: + - activity-log + - afk + - area + - chat + - chat-sounds + - clean + - coop + - data-backup + - economy + - fly-area + - gamemode-area + - inventory-switcher + - land + - monitor + - online-time + - peace-area + - qa + - qq-link + - spawn-protect + commit: + description: 模块仓 main 上的完整提交 SHA + required: true + type: string + version: + description: 与 package.json 一致的正式版本 + required: true + type: string + dry_run: + description: 仅检查并上传打包产物 + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: module-publish-${{ inputs.module }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + repository: Tanya7z/sfmc-module-${{ inputs.module }} + ref: ${{ inputs.commit }} + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + version: 10.8.0 + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: "22.14" + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: 校验来源与版本 + env: + MODULE: ${{ inputs.module }} + COMMIT: ${{ inputs.commit }} + VERSION: ${{ inputs.version }} + run: | + git fetch origin main + node -e 'const fs=require("node:fs");const p=JSON.parse(fs.readFileSync("package.json","utf8"));const m=process.env.MODULE;const v=process.env.VERSION;const c=process.env.COMMIT;if(!/^[0-9a-f]{40}$/.test(c)||!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(v)||p.name!==`@sfmc-bds/module-${m}`||p.version!==v||p.private===true){throw new Error("模块名称、提交或正式版本与输入不符")}'; + test "$(git rev-parse HEAD)" = "${COMMIT}" + git merge-base --is-ancestor HEAD origin/main + + - name: 校验主仓发布凭据 + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.SFMC_GITHUB_TOKEN }} + MODULE: ${{ inputs.module }} + run: | + if [ -z "${NODE_AUTH_TOKEN}" ] || [ -z "${GH_TOKEN}" ]; then + echo "::error::主仓发布凭据未配置" + exit 1 + fi + pnpm whoami >/dev/null + can_push="$(gh api "repos/Tanya7z/sfmc-module-${MODULE}" --jq '.permissions.push')" + if [ "${can_push}" != "true" ]; then + echo "::error::SFMC_GITHUB_TOKEN 无法写入模块仓" + exit 1 + fi + + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm run lint + - run: pnpm run test + - name: 打包候选产物 + run: mkdir -p artifacts && pnpm pack --pack-destination artifacts + - uses: actions/upload-artifact@v4 + with: + name: module-${{ inputs.module }}-${{ inputs.version }} + path: artifacts/*.tgz + retention-days: 14 + + - name: 发布到 npm + if: inputs.dry_run == false + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "${NODE_AUTH_TOKEN}" ]; then + echo "::error::主仓 NPM_TOKEN 未配置" + exit 1 + fi + pnpm publish --access public --tag latest --no-git-checks + + - name: 创建模块版本标签与 GitHub Release + if: inputs.dry_run == false + env: + GH_TOKEN: ${{ secrets.SFMC_GITHUB_TOKEN }} + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + COMMIT: ${{ inputs.commit }} + run: | + repo="Tanya7z/sfmc-module-${MODULE}" + tag="v${VERSION}" + if [ -z "${GH_TOKEN}" ]; then + echo "::error::主仓 SFMC_GITHUB_TOKEN 未配置" + exit 1 + fi + existing="$(gh api "repos/${repo}/git/ref/tags/${tag}" --jq .object.sha 2>/dev/null || true)" + if [ -n "${existing}" ] && [ "${existing}" != "${COMMIT}" ]; then + echo "::error::${tag} 已指向其他提交" + exit 1 + fi + if [ -z "${existing}" ]; then + gh api -X POST "repos/${repo}/git/refs" -f ref="refs/tags/${tag}" -f sha="${COMMIT}" + fi + gh release view "${tag}" --repo "${repo}" >/dev/null 2>&1 || \ + gh release create "${tag}" --repo "${repo}" --target "${COMMIT}" --title "${tag}" --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a42e0a68..19922a42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,6 @@ ```bash pnpm install && pnpm run build && pnpm run verify -npm install && npm run build --workspaces --if-present && npm run verify ``` 文档站维护见 [docs/CONTRIBUTING-DOCS.md](./docs/CONTRIBUTING-DOCS.md)。 diff --git a/README.en.md b/README.en.md index b146cfc2..5f6c6ea2 100644 --- a/README.en.md +++ b/README.en.md @@ -84,7 +84,7 @@ Ensure **Node.js ≥ 22.13.0** is installed (required for native `node:sqlite` s ```bash # 1. Install SFMC CLI globally -npm install -g @sfmc-bds/sfmc +pnpm add -g @sfmc-bds/sfmc@beta # 2. Create and enter your server workspace mkdir my-bedrock-server && cd my-bedrock-server @@ -105,7 +105,7 @@ You don't need to clone this monorepo! Scaffold your standalone module repositor ```bash # 1. Interactively scaffold your author repository -npm create @sfmc-bds/module@latest +pnpm dlx @sfmc-bds/create-module@latest # 2. Open the created project in VS Code or Cursor # 3. Install the official "SFMC Module" extension @@ -127,7 +127,7 @@ This repository houses the SFMC core platform and tooling packages, organized wi | `packages/db-server` | `@sfmc-bds/db-server` | High-performance SQLite HTTP data hub, transaction engine, and token sandbox | | `packages/qq-bridge` | `@sfmc-bds/qq-bridge` | Tencent QQ Official Bot and OneBot 11 two-way communication gateway | | `packages/bds-tools` | `@sfmc-bds/bds-tools` | BDS version updater and add-on inbox deployment pipeline | -| `packages/create-module` | `@sfmc-bds/create-module` | Official module scaffolding engine (`npm create @sfmc-bds/module`) | +| `packages/create-module` | `@sfmc-bds/create-module` | Official module scaffolding engine (`pnpm dlx @sfmc-bds/create-module`) | | `packages/devkit` | `@sfmc-bds/devkit` | Incremental transpiler (esbuild), file watcher, and reload core | | `packages/sfmc-extension`| `@sfmc-bds/sfmc-extension` | Official VS Code / Cursor IDE extension ("SFMC Module") | | `packages/meta` | `@sfmc-bds/sfmc` | Meta-package aggregator distribution | diff --git a/README.md b/README.md index 33802772..bc78698d 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,14 @@ SFMC 希望通过模块化架构补充基岩版的原生开发体验。 ## 快速开始 -### 使用 npm 安装 +### 使用 pnpm 安装 ```bash # 检查 Node.js 版本(需要 v22.13+) node -v -# 安装 SFMC CLI -npm install -g @sfmc-bds/sfmc - -# Beta 版本 -npm install -g @sfmc-bds/sfmc@beta +# 安装当前公测版 SFMC CLI +pnpm add -g @sfmc-bds/sfmc@beta # 创建服务器目录 mkdir my-server && cd my-server diff --git a/docs/en/guide/backup-upgrade.mdx b/docs/en/guide/backup-upgrade.mdx index c065746e..f89d120d 100644 --- a/docs/en/guide/backup-upgrade.mdx +++ b/docs/en/guide/backup-upgrade.mdx @@ -31,7 +31,7 @@ Do not copy `.db` files while db-server is running. ```bash -npm i -g @sfmc-bds/sfmc@beta +pnpm add -g @sfmc-bds/sfmc@beta ``` @@ -40,7 +40,6 @@ npm i -g @sfmc-bds/sfmc@beta ```bash git pull pnpm install && pnpm run build -npm install && npm run build --workspaces --if-present ``` diff --git a/docs/en/guide/index.mdx b/docs/en/guide/index.mdx index 2d779922..7d46d218 100644 --- a/docs/en/guide/index.mdx +++ b/docs/en/guide/index.mdx @@ -35,7 +35,7 @@ flowchart LR ```bash -npm i -g @sfmc-bds/sfmc@beta +pnpm add -g @sfmc-bds/sfmc@beta mkdir my-server && cd my-server sfmc ``` @@ -47,7 +47,6 @@ sfmc git clone https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git cd ScriptsForMinecraftServer pnpm install && pnpm run build -npm install && npm run build --workspaces --if-present sfmc ``` @@ -117,12 +116,10 @@ sfmc> mod reload -Create with `npm create @sfmc-bds/module@latest`: +Create with `pnpm dlx @sfmc-bds/create-module@latest`: ```bash -npm create @sfmc-bds/module@latest cd my-feature && pnpm install && pnpm run typecheck -cd my-feature && npm install && npm run typecheck ``` Link into your SFMC workspace (module short id required). Authors use extension Link / Watch; on the server: diff --git a/docs/superpowers/specs/modules/AGENT_BRIEF.md b/docs/superpowers/specs/modules/AGENT_BRIEF.md index b5d914f5..f045e718 100644 --- a/docs/superpowers/specs/modules/AGENT_BRIEF.md +++ b/docs/superpowers/specs/modules/AGENT_BRIEF.md @@ -5,8 +5,7 @@ ## 1. 脚手架 ```bash -npm create @sfmc-bds/module@latest -- --official -# 或: npx @sfmc-bds/create-module@latest --official +pnpm dlx @sfmc-bds/create-module@latest --official ``` - install id = 规格中的短名(kebab-case) diff --git a/docs/zh/CONTRIBUTING-DOCS.mdx b/docs/zh/CONTRIBUTING-DOCS.mdx index 9c322791..68f4572c 100644 --- a/docs/zh/CONTRIBUTING-DOCS.mdx +++ b/docs/zh/CONTRIBUTING-DOCS.mdx @@ -29,11 +29,8 @@ i18n.json ```bash pnpm install -npm install -pnpm run docs -- serve -npm run docs -- serve -pnpm run docs -- build -npm run docs -- build +pnpm run docs serve +pnpm run docs build ``` ## 配置入口 @@ -52,4 +49,3 @@ npm run docs -- build ## 扩展 TypeDoc 入口 在 `website/rspress.config.ts` 的 `pluginTypeDoc({ entryPoints })` 与根目录 `typedoc.json` 同步追加 SDK 公开入口。 - diff --git a/docs/zh/api/sdk/index.md b/docs/zh/api/sdk/index.md index 723207ee..67a396c5 100644 --- a/docs/zh/api/sdk/index.md +++ b/docs/zh/api/sdk/index.md @@ -1,6 +1,6 @@ # SDK 导读 -包名 `@sfmc-bds/sdk`。本文是**用法导读**;完整签名见 [SDK 类型参考](../reference/index.md)(`pnpm run docs -- api` / `npm run docs -- api`)。 +包名 `@sfmc-bds/sdk`。本文是**用法导读**;完整签名见 [SDK 类型参考](../reference/index.md)(`pnpm run docs api`)。 ## 四抽屉 diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index be5d1914..ceff7972 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -168,7 +168,7 @@ ScriptsForMinecraftServer/ │ ├── devkit/ # 模块文件监听(Watch)与脚手架转译引擎 │ ├── cli/ # @sfmc-bds/cli 终端交互与进程 Supervisor │ ├── meta/ # @sfmc-bds/sfmc 全局命令行入口包装 -│ ├── create-module/ # npm create @sfmc-bds/module 脚手架生成器 +│ ├── create-module/ # pnpm dlx @sfmc-bds/create-module 脚手架生成器 │ ├── sfmc-extension/ # VS Code / Cursor 官方开发专属扩展 │ └── tools/ # 仓内专用测试、验证与文档生成工具集 └── docs/ # 基于 Rspress 2 的多语言文档站源码 diff --git a/docs/zh/dev/contributing.md b/docs/zh/dev/contributing.md index 1f678969..4e9c3ab7 100644 --- a/docs/zh/dev/contributing.md +++ b/docs/zh/dev/contributing.md @@ -7,57 +7,55 @@ ## 环境 - **Node.js ≥ 22.13** -- **pnpm workspace**(`pnpm-workspace.yaml`,主仓推荐)或 **npm workspaces**;本地 `@sfmc-bds/*` 用 `^`,禁止 `workspace:*` +- **pnpm workspace**(`pnpm-workspace.yaml`);本地 `@sfmc-bds/*` 用 `^`,禁止 `workspace:*` - 可选:Python 3.12 + `pip install -r docs/requirements.txt` ```bash pnpm install && pnpm run build && pnpm run verify -npm install && npm run build --workspaces --if-present && npm run verify ``` ## 根脚本 -下文 **pnpm / npm 等价**(单包 filter 写法见第二列)。 +以下命令统一使用 pnpm。 ### 日常 -| pnpm | npm | 说明 | -| ------ | ------ | ------ | -| `pnpm run build` | `npm run build --workspaces --if-present` | 各 workspace build | -| `pnpm run typecheck` | `npm run typecheck` | 各 workspace typecheck | -| `pnpm run lint` | `npm run lint` | build eslint-plugin 后 `eslint .` | -| `pnpm start` | `npm start` | CLI | +| 命令 | 说明 | +| ------ | ------ | +| `pnpm run build` | 各 workspace build | +| `pnpm run typecheck` | 各 workspace typecheck | +| `pnpm run lint` | build eslint-plugin 后 `eslint .` | +| `pnpm start` | CLI | -单包:`pnpm --filter @sfmc-bds/sdk run build` / `npm run build -w @sfmc-bds/sdk`。 +单包:`pnpm --filter @sfmc-bds/sdk run build`。 ### 自检 -| pnpm | npm | 说明 | -| ------ | ------ | ------ | -| `pnpm run verify` | `npm run verify` | 平台集成自检 | +| 命令 | 说明 | +| ------ | ------ | +| `pnpm run verify` | 平台集成自检 | ### 文档 ```bash -pnpm run docs -- api|serve|build -npm run docs -- api|serve|build +pnpm run docs api +pnpm run docs serve +pnpm run docs build ``` ### 依赖对齐 ```bash pnpm run syncpack:fix -npm run syncpack:fix pnpm exec syncpack format --check -npx syncpack format --check ``` ### 平台发包 -| pnpm | npm | 说明 | -| ------ | ------ | ------ | -| `pnpm run changeset` | `npm run changeset` | 添加 changeset;push `main` 后 CI 开 Version PR | -| `node packages/tools/pack-verify.mjs` | 同上 | 可选:`pnpm pack` / `npm pack` 冒烟 | +| 命令 | 说明 | +| ------ | ------ | +| `pnpm run changeset` | 添加 changeset;push `main` 后 CI 开 Version PR | +| `node packages/tools/pack-verify.mjs` | 检查发布包内容 | 日常发版:**合并 Version PR 即可**,无需本地 `prerelease` / `release`。当前 **beta-only**。模块作者发包见 [发布你的模块](./publish.md)。 diff --git a/docs/zh/dev/eslint.md b/docs/zh/dev/eslint.md index cbf42928..16d8f1d2 100644 --- a/docs/zh/dev/eslint.md +++ b/docs/zh/dev/eslint.md @@ -7,7 +7,7 @@ 主仓 workspace 已包含。作者仓: ```bash -npm i -D @sfmc-bds/eslint-plugin eslint @typescript-eslint/parser +pnpm add -D @sfmc-bds/eslint-plugin eslint @typescript-eslint/parser ``` ## Flat config @@ -27,7 +27,7 @@ export default [ 更严:改用 `sfmc.configs.all`(部分 warn 升为 error)。 -主仓根 `eslint.config.js` 已接入;`pnpm run lint` / `npm run lint` 会先 build 插件再跑。 +主仓根 `eslint.config.js` 已接入;`pnpm run lint` 会先 build 插件再跑。 ## 规则(recommended) @@ -63,6 +63,4 @@ SFMC Module 扩展在 `package.json` 中通过 `extensionRecommendations` 推荐 ```bash pnpm --filter @sfmc-bds/eslint-plugin run build pnpm --filter @sfmc-bds/eslint-plugin typecheck -npm run build -w @sfmc-bds/eslint-plugin -npm run typecheck -w @sfmc-bds/eslint-plugin ``` diff --git a/docs/zh/dev/index.mdx b/docs/zh/dev/index.mdx index 459cf7a2..2651a21d 100644 --- a/docs/zh/dev/index.mdx +++ b/docs/zh/dev/index.mdx @@ -29,7 +29,7 @@ flowchart LR | :--- | :--- | :--- | | **操作系统** | Windows 10 / 11 或 Linux (Ubuntu 22.04+) | 建议开发机配置好本地 Node.js 与 Git 环境。 | | **Node.js** | **`≥ 22.13.0`** (LTS) | 必须满足该版本以获得 Node 原生内置的 `node:sqlite` 支持。 | -| **包管理** | **`pnpm`** (推荐) 或 `npm` | 主仓采用 pnpm workspaces;本地开发包严禁使用 `workspace:*` 外部依赖。 | +| **包管理** | **`pnpm`** | 主仓采用 pnpm workspaces;本地开发包严禁使用 `workspace:*` 外部依赖。 | | **编辑器** | [Cursor](https://cursor.com/) 或 [VS Code](https://code.visualstudio.com/) | 搭配专属扩展体验最佳。 | ### 必备与推荐 VS Code / Cursor 扩展 @@ -52,7 +52,7 @@ flowchart LR ```bash # 模块作者开发最小闭环 -npm create @sfmc-bds/module@latest +pnpm dlx @sfmc-bds/create-module@latest cd my-feature && pnpm install && pnpm run typecheck # 联调:通过 VS Code 扩展点击 "Link to SFMC Root" diff --git a/docs/zh/dev/module-author.mdx b/docs/zh/dev/module-author.mdx index ab8f4812..2ed98b69 100644 --- a/docs/zh/dev/module-author.mdx +++ b/docs/zh/dev/module-author.mdx @@ -8,9 +8,7 @@ ```bash # 交互式初始化向导 -npm create @sfmc-bds/module@latest -# 或使用 npx -npx @sfmc-bds/create-module@latest +pnpm dlx @sfmc-bds/create-module@latest ``` 向导将引导您填写并配置: @@ -28,8 +26,6 @@ npx @sfmc-bds/create-module@latest ```bash cd my-feature pnpm install && pnpm run typecheck -# 或使用 npm -npm install && npm run typecheck ``` ## 2. 工程规范与命名矩阵 @@ -50,10 +46,10 @@ SFMC 将模块开发划分为 5 个清晰阶段: ```mermaid flowchart LR - A[1. 脚手架建仓
npm create] --> B[2. 类型与语法检查
typecheck & lint] + A[1. 脚手架建仓
pnpm dlx] --> B[2. 类型与语法检查
typecheck & lint] B --> C[3. 挂接工作区
--link 软链接] C --> D[4. 实时监听
Watch & Hot Reload] - D --> E[5. 生态发布
npm & 官方索引 PR] + D --> E[5. 生态发布
pnpm publish & 官方索引 PR] ``` ### ① 编写与静态检查(Type Safety & Lint) diff --git a/docs/zh/dev/publish.md b/docs/zh/dev/publish.md index 53044d00..d951e99c 100644 --- a/docs/zh/dev/publish.md +++ b/docs/zh/dev/publish.md @@ -3,7 +3,7 @@ 把作者仓发到 npm,并登记到 [`sfmc-modules`](https://github.com/Tanya7z/sfmc-modules) 薄 index,供 `mod search` / `mod install` 发现。 业务模块 **不走** 主仓 changesets。平台 `@sfmc-bds/*` 发包见文末。 -`sfmc mod publish` 已移除;请用扩展 `SFMC: Publish to npm`,或直接 `pnpm publish` / `npm publish`(及可选 `gh` 开 index PR)。 +`sfmc mod publish` 已移除;请用扩展 `SFMC: Publish to npm`,或直接 `pnpm publish`(及可选 `gh` 开 index PR)。 ## 发布前 @@ -13,14 +13,12 @@ | `name` | `@/sfmc-module-` 或官方 `@sfmc-bds/module-` | | `files` | 含 `sapi` | | 类型检查 / lint | `pnpm run typecheck` 与 `pnpm run lint`;发布前按 [验证指南](./testing.md) 完成实际联调 | -| 登录 | `npm login`(npm registry 账号);官方 scope 另需组织权限 | +| 发布权限 | npm registry 账号或 CI 发布凭据;官方 scope 另需组织权限 | ```bash pnpm publish --access public -npm publish --access public # 或按包上的 dist-tag: pnpm publish --tag beta -npm publish --tag beta ``` 向薄 index 开 PR:在 `sfmc-modules` 仓库的 `index.json` 增加条目(`id` / `npm` / 版本说明)。可用 GitHub CLI 自行开 PR。 @@ -40,4 +38,4 @@ npm publish --tag beta ## 附录:平台包发布(贡献者) -主仓 `@sfmc-bds/*` 走 changesets:`pnpm run changeset` / `npm run changeset` → Version PR → `ci-release-packages`。详见 [贡献指南](./contributing.md)。 +主仓 `@sfmc-bds/*` 走 changesets:`pnpm run changeset` → Version PR → `ci-release-packages`。详见 [贡献指南](./contributing.md)。 diff --git a/docs/zh/dev/stable-release-plan.md b/docs/zh/dev/stable-release-plan.md new file mode 100644 index 00000000..e4fc1746 --- /dev/null +++ b/docs/zh/dev/stable-release-plan.md @@ -0,0 +1,86 @@ +# SFMC 正式版行动计划 + +状态:执行中。本文记录发布门槛与证据;版本、运行状态和线上结果以执行当天的检查为准。 + +## 范围与顺序 + +1. 平台 monorepo 中所有公开的 `@sfmc-bds/*` 包,重点是 SDK、CLI、db-server、qq-bridge、bds-tools、devkit、create-module 和聚合包 `sfmc`。 +2. `D:\WorkPlace\_proj\sfmc` 下的 19 个独立模块仓。保留各仓现有未提交改动,逐仓确认功能与发布内容。 +3. 玩家运行环境中的部署包、BDS 日志和客户端行为。源码、npm 包与运行环境分别验收。 + +按依赖顺序发布:平台基础包 → 聚合包 → 官方模块 → 模块索引和文档。使用 pnpm 完成安装、检查、打包与发布;修正仓库内仍调用 npm 的自动化入口。版本号以各包现有语义化版本和实际兼容性决定,不把所有包强行统一为 `1.0.0`。 + +## 阶段一:冻结候选范围 + +- [ ] 记录各仓 HEAD、工作区差异、待消费 changeset、已发布版本和 npm dist-tag。 +- [ ] 将未提交改动按功能分组;逐项决定纳入候选、延后或撤回,不覆盖无关改动。 +- [ ] 记录模块与平台、Minecraft Script API、BDS 的依赖兼容矩阵。 +- [ ] 确定首个正式版的各包版本与升级说明。 + +完成证据:每个公开包有候选提交或明确排除说明;候选源与构建源一致。 + +## 阶段二:标准项目与发布链检查 + +- [ ] 核对公开包的名称、版本、入口与 exports、files、许可、README、仓库与问题反馈地址、运行环境要求、依赖和 peerDependencies。 +- [ ] 核对 pnpm 锁文件与可重复安装;CI 不隐式改锁或混用包管理器。 +- [ ] 核对 pack 内容:源码与声明文件、可执行入口、默认配置和必要资源齐全,不含运行数据或凭据。 +- [ ] 核对平台与模块的 CI、发布权限、Git tag、npm dist-tag、GitHub Release 和模块索引流程。 +- [ ] 更新中英文安装、升级、发布和回退文档,移除过时的 beta-only 说明。 + +完成证据:在干净检出中用 pnpm 安装、构建、检查并验证打包内容;发布流程能生成可审查的候选版本。 + +## 阶段三:功能、升级与运行验收 + +- [ ] 对受影响包运行类型检查、构建、平台自检和相称的静态检查;确认 Windows 与 Ubuntu CI 通过。 +- [ ] 用候选包在隔离的 SFMC_ROOT 中完成安装、初始化、模块安装、构建与加载演练。 +- [ ] 对现有数据做备份、升级、恢复和回退演练,检查数据库迁移及配置兼容。 +- [ ] 在目标 BDS 版本上检查部署包、重启后的新日志和客户端关键路径;运行中 BDS 的重启须先确认影响窗口。 +- [ ] 记录所有未通过项及修复后的复验结果,不以静态构建代替运行验收。 + +完成证据:候选包、部署包和实际运行的版本可追溯;升级与回退均有成功记录。 + +## 阶段四:发布与复核 + +- [ ] 通过前三阶段后退出 Changesets `beta` 预发布模式,审查 Version PR 的版本与包间依赖。 +- [ ] 按依赖顺序发布平台、聚合包和选定模块;核对 npm `latest`、Git tag、GitHub Release 和模块索引。 +- [ ] 从公共 registry 进行全新 pnpm 安装,复验 CLI、模块安装和基本功能。 +- [ ] 发布正式版公告,列明兼容范围、升级步骤、已知限制和回退版本。 + +完成证据:公共渠道可安装正式版,版本与产物一致,且发布后的最小验收通过。 + +## 当前基线(2026-09-27) + +- 平台主仓 HEAD `fe453d4`,本轮工作区已有多项未提交改动;该提交的 `ootb` Windows/Ubuntu 和 `changeset-release` CI 成功。 +- 平台仍在 Changesets `pre` / `beta` 模式;待消费 changeset 为 0。 +- 19 个模块仓均有未提交改动。初查其中 17 个只替换 JSON Schema URL;`chat` 与 `qq-link` 有功能改动,需要单独审查。 +- `chat`、`qq-link` 仍有 npm 驱动的脚本或 CI;pnpm 类型检查未真正运行,依赖准备分别被构建脚本许可和非交互目录确认挡住。 + +以上只是起点,不构成正式版验收结论。 + +## 执行记录(2026-09-27) + +- 19 个模块仓已补齐 `license`、`repository`、`publishConfig` 和 `packageManager`;用 pnpm 锁文件替换了未改动的 npm 锁文件,CI 改用 pnpm;均新增或统一了先验证、打包、发布,再创建标签的发布工作流。模块索引登记仍需在发布阶段逐项完成。 +- 平台发布辅助脚本的直接 `npx` / npm 调用已改为 pnpm;`create-module` 模板与两个公开包的仓库元数据已修正,并添加 changeset。相关包类型检查与 Changesets 状态检查通过。 +- 19 个模块均已通过 pnpm 冻结安装、类型检查和 lint。`qq-link` 的旧管理 API 引用已移除,踢人路径改为服务端命令;该行为仍需在 BDS 中验收。 +- `qq-link` 的现有测试在本机 Node 26 环境中因 `uv_os_get_passwd` 返回 `ENOMEM` 未运行成功,不能据此判定功能通过;需在目标 Node 22 CI 中复验。 +- 公共 registry 查询时,`@sfmc-bds/sfmc` 与 SDK 的 `latest` 均为 `0.1.0`,beta 分别为 `0.2.0-beta.16` 与 `0.2.0-beta.18`;`create-module` 的 `latest` 仍指向 `0.1.0-beta.4`。正式发布后须复核这些 dist-tag。 +- 19 个模块的发布工作流已通过 YAML 解析;发布阶段仍需实际 dry run、凭据可用性检查和远程 CI。现有两个模块的旧索引自动登记逻辑被统一工作流取代,发布后必须单独完成索引更新。 +- 19 个模块均通过 pnpm 打包内容检查:包含 package.json、SAPI manifest、README、许可文件,未包含运行配置、数据或依赖目录。 +- 平台 `pnpm run build`、`pnpm run lint`、`pnpm run typecheck`、`pnpm run verify` 和 9 个公开包的 pnpm 打包检查通过。`verify` 首次在隔离模拟目录清理时遇到沙箱 `EPERM`,在同一代码上使用所需文件权限复验通过 12/12。 +- 后续复核发现 `@sfmc-bds/create-module` 漏在平台发布包清单之外,已纳入清单并添加 changeset;pnpm 打包检查现覆盖全部 9 个公开包。 +- 本轮补齐了贡献指南、脚手架帮助和开发文档中的 npm 命令示例;`pnpm run typecheck` 通过,文档站以 `pnpm run docs build` 构建成功(跳过已有 TypeDoc 生成物)。 +- 最终复核中,主仓 `pnpm run build` 与 `node packages/tools/pack-verify.mjs` 通过(9 个公开包);`pnpm run lint` 通过且报告 35 条既有 warning、0 error。移除了未使用的 npm 子进程 helper。 +- 主仓全量命令入口扫描已清除 npm/npx 安装或脚本替代示例,并修正英文文档、CLI 提示和排障指南。最新 `pnpm run typecheck`、`pnpm run build`、`pnpm run verify` 通过;集成自检 12/12。db-server 自检现使用仓库 `tmp/` 下的一次性隔离根目录与动态端口,不读写真实模块目录;模块接口断言按 catalog schema 移除了不存在的 `type` 字段。 +- 对 19 个独立模块仓再次扫描后,README 与仓库生成器残留的安装/检查命令也已改为 pnpm;全部 19 个均有 `pnpm-lock.yaml`。 +- CLI 的 registry 与 tarball 模块获取路径已从内部 npm 调用切换为 pnpm 临时安装后复制包文件;新增 Windows 子进程兼容依赖,关闭安装脚本和临时工作区 peer 自动安装。使用 `activity-log` 0.2.1 tarball 在隔离 SFMC_ROOT 中验证 tgz 路径与 registry 分支(以本地 tarball 模拟包来源),二者均完成配置播种、catalog/lock 更新及 `check-modules OK`。这不替代正式 registry 查询和 BDS 客户端运行验收。 +- GitHub CLI 当前登录 token 无效(`gh auth status`);远程推送、PR/CI 和发布工作流尚未验证或执行。 +- 当前公共查询中 19 个 `@sfmc-bds/module-*` 包均未查到公开版本,需按首次发布准备 npm scope 权限与逐包登记。 +- 目标 BDS 可执行文件版本为 `1.26.51.1`;检查时未发现运行中的 `bedrock_server` 进程。已有 staged 与部署聚合包的 Script API 依赖一致,`scripts/main.js` SHA256 一致,但它们不是本轮候选的新构建,不能代表新模块功能通过。 + +- 本轮最后一轮本地门禁复验:`pnpm run build`、9 个公开包的 `node packages/tools/pack-verify.mjs`、`pnpm run verify`(12/12)、`pnpm run docs build` 和 `pnpm exec changeset status` 均通过;lint 为 0 error、35 条既有 warning。`git diff --check` 有若干既存/生成 changelog 空白行提示,尚未为此做大范围格式改写。 +- pnpm CLI 模块安装器已用 `activity-log` tarball 在隔离 SFMC_ROOT 中验证安装、配置播种、catalog/lock 更新及 `check-modules OK`;所谓 registry 分支是用本地 tarball 模拟来源,仍待公共 registry 实际安装复验。 +- GitHub device login 已完成。在可访问系统凭据存储的执行环境中,`gh auth status` 确认以 `Tanya7z` 登录,主仓具备推送和管理权限;默认沙箱内的旧凭据报错不再视为远程操作阻断。当前仍处于 beta,不得跳过验收直接切正式版。 +- 针对两个包含功能改动的模块补做复核:`chat` 的 pnpm 类型检查与 lint 通过;`qq-link` 的 pnpm 类型检查通过。其原有测试在 Node 24/26 均因本机 `os.userInfo()` 的 `uv_os_get_passwd ENOMEM` 无法启动;用仅作用于该次 Node 进程的兼容 preload 绕过系统用户名读取后,原有 3 项测试全部通过。该结果覆盖策略函数和 manifest 契约,不替代 BDS 里的入服门槛、绑定放行、踢人命令运行验收。 +- 发布鉴权采用 GitHub Actions;本机 `pnpm login` / `pnpm whoami` 不是必要发布门禁。GitHub Secrets 清单已核实主仓有 `NPM_TOKEN` 和 `SFMC_GITHUB_TOKEN`(仅确认名称,未读取值)。主仓 Changesets workflow 可使用这些 secret 创建 Version PR 与发布平台包。 +- 19 个独立模块仓的 GitHub Secrets 清单均无 `NPM_TOKEN`,而各仓当前 release workflow 在发布前要求它;因此模块独立发布通道尚不能使用。需在模块仓配置发布凭据,或完成并验证由主仓集中发布的工作流。现有 `id-token: write` 尚未接入 npm Trusted Publishing,不能当作已配置的无 token 发布通道。 +- 主仓新增手动 `module-publish` 工作流:只接受 19 个已知模块、模块仓 `main` 上的完整提交 SHA 和与 `package.json` 一致的正式版本;默认只运行凭据、pnpm 检查与打包。dry run 会用主仓的 `NPM_TOKEN` 查询 npm 登录态,并核对 `SFMC_GITHUB_TOKEN` 对模块仓的推送权限;实际发布时再用它们发布 npm 包、创建标签与 Release。此流程尚待推送后的 dry run 和首包发布复验;各模块仓原有独立发布 workflow 仍依赖本仓未配置的 secret,应在集中发布路径验证后统一收敛。 diff --git a/docs/zh/dev/style-sample.mdx b/docs/zh/dev/style-sample.mdx index 941c42f3..bfb3c3ac 100644 --- a/docs/zh/dev/style-sample.mdx +++ b/docs/zh/dev/style-sample.mdx @@ -34,10 +34,10 @@ flowchart LR - + ```bash -npm i -g @sfmc-bds/sfmc@beta +pnpm add -g @sfmc-bds/sfmc@beta mkdir my-server && cd my-server sfmc ``` @@ -49,7 +49,6 @@ sfmc git clone https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git cd ScriptsForMinecraftServer pnpm install && pnpm run build -npm install && npm run build --workspaces --if-present sfmc ``` @@ -110,12 +109,10 @@ sfmc> mod reload -用 `npm create @sfmc-bds/module@latest` 建仓: +用 `pnpm dlx @sfmc-bds/create-module@latest` 建仓: ```bash -npm create @sfmc-bds/module@latest cd my-feature && pnpm install && pnpm run typecheck -cd my-feature && npm install && npm run typecheck ``` 再在 SFMC 工作目录联调(须写明模块短名);作者侧用扩展 Link / Watch,运维侧: diff --git a/docs/zh/dev/testing.md b/docs/zh/dev/testing.md index c7ef097d..152cf9b4 100644 --- a/docs/zh/dev/testing.md +++ b/docs/zh/dev/testing.md @@ -15,6 +15,6 @@ pnpm run lint ## 平台包 -优先运行改动所在包的 `typecheck` 和 `build`。修改公共 SDK、安装器或发布行为时,再运行根目录 `pnpm run verify` 等相关集成自检。涉及 BDS 世界包或客户端资源时,确认源码、构建产物、部署包、服务端日志和客户端表现分别处于预期状态。 +优先运行改动所在包的 `typecheck` 和 `build`。修改公共 SDK、安装器或发布行为时,再运行根目录 `pnpm run verify` 等相关集成自检。涉及 BDS 资源包或客户端资源时,确认源码、构建产物、部署包、服务端日志和客户端表现分别处于预期状态。 一次性验证命令可以用于排查问题;功能完成后不要留下未被产品运行链路使用的测试脚本。没有执行的运行验收需要在交付说明中明确标注。 diff --git a/docs/zh/dev/tools.md b/docs/zh/dev/tools.md index 1cffc360..6dd50b44 100644 --- a/docs/zh/dev/tools.md +++ b/docs/zh/dev/tools.md @@ -6,15 +6,15 @@ | 命令 | 作用 | |------|------| -| `pnpm run verify` / `npm run verify` | 平台集成自检(CI 默认) | +| `pnpm run verify` | 平台集成自检(CI 默认) | | `sfmc mod install ` | 安装模块(独立 SFMC 根,非主仓) | -| `npm create @sfmc-bds/module@latest` | 建仓(`@sfmc-bds/create-module`) | +| `pnpm dlx @sfmc-bds/create-module@latest` | 建仓(`@sfmc-bds/create-module`) | 主仓默认 **不装业务模块**;catalog / lock 由用户环境里的 `mod install` 维护,主仓不再提供 `catalog-sync` / `check-modules` 入口。 ## 发版 -日常:`pnpm run changeset` / `npm run changeset` → push `main` → Version Packages PR → 合并后 CI `ci-release-packages`。 +日常:`pnpm run changeset` → push `main` → Version Packages PR → 合并后 CI `ci-release-packages`。 ## 共享库 diff --git a/docs/zh/guide/backup-upgrade.mdx b/docs/zh/guide/backup-upgrade.mdx index 676755dd..99a090fa 100644 --- a/docs/zh/guide/backup-upgrade.mdx +++ b/docs/zh/guide/backup-upgrade.mdx @@ -75,11 +75,11 @@ SFMC 平台的各层组件相互解耦,可按需分别升级: ### ① 升级 SFMC CLI 与平台运行时 - + ```bash # 获取最新版官方 CLI -npm install -g @sfmc-bds/sfmc@beta +pnpm add -g @sfmc-bds/sfmc@beta # 验证更新后版本 sfmc version diff --git a/docs/zh/guide/index.mdx b/docs/zh/guide/index.mdx index e6e0a7fc..9b952b16 100644 --- a/docs/zh/guide/index.mdx +++ b/docs/zh/guide/index.mdx @@ -1,5 +1,3 @@ -import { Tab, Tabs } from "@rspress/core/theme"; - # 快速上手 欢迎使用 **ScriptsForMinecraftServer (SFMC)**。SFMC 是专为官方原版 Bedrock Dedicated Server (BDS) 打造的现代化 SAPI 行为包生态与 Node.js 伴生服务架构。 @@ -42,7 +40,7 @@ import { Tab, Tabs } from "@rspress/core/theme"; | :--- | :--- | :--- | | **操作系统** | Windows 10 / 11, Linux (Ubuntu 20.04+, Debian 11+) | Linux (云服务器长期运行) / Windows (本地调试) | | **Node.js** | `v22.13.0` 或更高版本(需内置 SQLite 支持) | 最新 LTS 版本(Node ≥22.13) | -| **包管理器** | `npm` 或 `pnpm` | `pnpm` | +| **包管理器** | `pnpm` | `pnpm` | | **磁盘空间** | ≥ 2 GB(用于存放 BDS 核心、行为包、备份与数据存档) | SSD 固态硬盘 | ## 快速安装与初始化 @@ -51,23 +49,10 @@ SFMC 提供了全局编排 CLI 工具,可实现从 BDS 自动下载配置到 ### 步骤 1:全局安装 SFMC CLI - - - ```bash pnpm add -g @sfmc-bds/sfmc@beta ``` - - - -```bash -npm install -g @sfmc-bds/sfmc@beta -``` - - - - :::tip 提示 当前版本处于公测阶段,请务必添加 `@beta` 标签进行安装。如需在特定文件夹持久化数据,也可通过配置 `SFMC_ROOT` 环境变量指定自定义工作目录。 ::: diff --git a/docs/zh/reference/index.md b/docs/zh/reference/index.md index 32c1d3ee..bfcdb577 100644 --- a/docs/zh/reference/index.md +++ b/docs/zh/reference/index.md @@ -4,4 +4,4 @@ 手写导读见 [接口指南 · SDK](../api/sdk/index.md)。 -生成命令:`pnpm run docs -- api` / `npm run docs -- api` 或 `pnpm run docs -- build` / `npm run docs -- build`。 +生成命令:`pnpm run docs api` 或 `pnpm run docs build`。 diff --git a/modules/sdk/@sfmc-eslint-plugin/README.md b/modules/sdk/@sfmc-eslint-plugin/README.md index 63957112..d17b391f 100644 --- a/modules/sdk/@sfmc-eslint-plugin/README.md +++ b/modules/sdk/@sfmc-eslint-plugin/README.md @@ -7,7 +7,7 @@ SFMC 模块 / SDK 约定的 ESLint 插件,形态对齐 [`eslint-plugin-minecra 主仓 workspace 已包含;sfmc-modules 通过 `file:` 依赖主仓路径。 ```bash -npm install -D @sfmc-bds/eslint-plugin +pnpm add -D @sfmc-bds/eslint-plugin ``` ## Flat config @@ -63,7 +63,7 @@ export default [ ```bash pnpm --filter @sfmc-bds/eslint-plugin run build -npm run build -w @sfmc-bds/eslint-plugin +pnpm --filter @sfmc-bds/eslint-plugin run build ``` 发布构建只包含插件源码;变更后运行构建、类型检查与 lint 验证。 diff --git a/modules/sdk/@sfmc-sdk/README.md b/modules/sdk/@sfmc-sdk/README.md index d2e003eb..a4aacbe3 100644 --- a/modules/sdk/@sfmc-sdk/README.md +++ b/modules/sdk/@sfmc-sdk/README.md @@ -19,7 +19,7 @@ ScriptsForMinecraftServer 平台 SDK。SAPI/Node umbrella,统一导出: ## 安装 ```bash -npm install @sfmc-bds/sdk +pnpm add @sfmc-bds/sdk ``` ## 模块作者使用 diff --git a/modules/sdk/@sfmc-sdk/build.mjs b/modules/sdk/@sfmc-sdk/build.mjs index 92a66fed..ec319402 100644 --- a/modules/sdk/@sfmc-sdk/build.mjs +++ b/modules/sdk/@sfmc-sdk/build.mjs @@ -16,7 +16,7 @@ function runTsc7(args) { try { tsc7Entry = require.resolve("@sfmc-bds/tools/tsc7"); } catch { - throw new Error("无法 resolve @sfmc-bds/tools/tsc7。请在 monorepo 根目录执行 npm install"); + throw new Error("无法 resolve @sfmc-bds/tools/tsc7。请在 monorepo 根目录执行 pnpm install"); } const result = spawnSync(process.execPath, [tsc7Entry, ...args], { stdio: "inherit", diff --git a/packages/bds-tools/README.md b/packages/bds-tools/README.md index ffae05c2..732c2dcb 100644 --- a/packages/bds-tools/README.md +++ b/packages/bds-tools/README.md @@ -5,7 +5,7 @@ SFMC BDS 工具集:自动更新、进程管理、行为包装配(pack-manage ## 安装 ```bash -npm install @sfmc-bds/bds-tools +pnpm add @sfmc-bds/bds-tools ``` ## 依赖 diff --git a/packages/bds-tools/recovery.js b/packages/bds-tools/recovery.js index 5130e020..335081fb 100644 --- a/packages/bds-tools/recovery.js +++ b/packages/bds-tools/recovery.js @@ -17,7 +17,7 @@ try { } catch (e) { const err = /** @type {NodeJS.ErrnoException} */ (e); if (err && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND")) { - console.error("[BDSRecovery] dist/ 未找到,请先运行 `npm run build`"); + console.error("[BDSRecovery] dist/ 未找到,请先运行 `pnpm run build`"); process.exit(2); } throw e; diff --git a/packages/cli/README.md b/packages/cli/README.md index 07544e83..bb25b5f1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5,18 +5,17 @@ SFMC 管理 CLI:REPL、服务启停、模块安装、行为包 build/deploy。 ## 安装 ```bash -npm install -g @sfmc-bds/cli -# 或 -npx @sfmc-bds/cli status +pnpm add -g @sfmc-bds/cli +pnpm dlx @sfmc-bds/cli status ``` 服主推荐: ```bash -npm install -g @sfmc-bds/sfmc +pnpm add -g @sfmc-bds/sfmc ``` -仅 CLI(不含后端服务)仍可用 `npm install -g @sfmc-bds/cli`,完整平台请装聚合包。 +仅 CLI(不含后端服务)可用 `pnpm add -g @sfmc-bds/cli`,完整平台请装聚合包。 ## 依赖 diff --git a/packages/cli/package.json b/packages/cli/package.json index 0eb2eebc..5ce05174 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,6 +59,7 @@ "@sfmc-bds/bds-tools": "workspace:*", "@sfmc-bds/sdk": "workspace:*", "chalk": "^6.0.0", + "cross-spawn": "7.0.6", "esbuild": "^0.28.1" }, "devDependencies": { diff --git a/packages/cli/scripts/module-install/fetch-module.mjs b/packages/cli/scripts/module-install/fetch-module.mjs index 4b3927a5..d8a6b9cc 100644 --- a/packages/cli/scripts/module-install/fetch-module.mjs +++ b/packages/cli/scripts/module-install/fetch-module.mjs @@ -439,74 +439,61 @@ async function fromLocal(id, source, flags) { } /** - * `sfmc mod install ` 走 npm registry 的主路径。 + * `sfmc mod install ` 走包 registry 的主路径,由 pnpm 下载。 * - * 入口:`npm install --prefix packages/ --omit=dev --no-save --no-package-lock ` + * 入口:临时项目中执行 `pnpm add --prod --ignore-scripts `,再复制包文件。 * - 落地到 packages/,不进主仓根 node_modules(隔离安装) * - 成功后 upsert catalog/lock - * - 失败时把常见 npm 错误翻译成中文可读提示 + * - 失败时把常见 registry 错误翻译成中文可读提示 * @param {string} id * @param {string} pkgName * @param {any} flags */ -async function fromNpm(id, pkgName, flags) { +async function fromRegistry(id, pkgName) { const dir = await ensureTarget(id); - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; - const { spawn: spawnChild } = await import("node:child_process"); - await /** @type {Promise} */ ( - new Promise((resolve, reject) => { - const proc = spawnChild( - npmCmd, - [ - "install", - "--prefix", - dir, - "--omit=dev", - "--no-save", - "--no-package-lock", - "--no-audit", - "--no-fund", - pkgName, - ], - { stdio: ["ignore", "pipe", "pipe"] } - ); - let stderr = ""; - proc.stderr?.on("data", (d) => { - stderr += d.toString(); - }); - proc.on("exit", (code) => { - if (code === 0) return resolve(); - reject(new Error(translateNpmInstallError(pkgName, stderr))); - }); - proc.on("error", (e) => reject(new Error(`spawn npm failed: ${e.message}`))); - }) - ); - console.log(`[fetch-module] installed ${id} from npm (${pkgName})`); + const os = await import("node:os"); + const tmpPrefix = await fsp.mkdtemp(path.join(os.tmpdir(), "sfmc-pnpm-install-")); + try { + await installWithPnpm(tmpPrefix, pkgName); + const installed = findInstalledModule(tmpPrefix, id, pkgName); + if (!installed) throw new Error(`pnpm add 完成,但 ${pkgName} 中未找到模块 ${id}`); + await copyDir(installed, dir); + } catch (error) { + await removePackageTarget(dir); + const message = error instanceof Error ? error.message : String(error); + throw new Error(translatePnpmInstallError(pkgName, message)); + } finally { + try { + await fsp.rm(tmpPrefix, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + } + console.log(`[fetch-module] installed ${id} from registry (${pkgName})`); console.log(`[fetch-module] target: ${dir}`); afterInstall(id); } /** - * 翻译常见 npm install 错误为中文可读 + 下一步动作。 + * 翻译常见 pnpm registry 错误为中文可读 + 下一步动作。 * @param {string} pkgName * @param {string} stderr */ -function translateNpmInstallError(pkgName, stderr) { +function translatePnpmInstallError(pkgName, stderr) { const text = String(stderr || ""); - if (/404 Not Found/i.test(text) || /not found/i.test(text)) { - return `npm 包未找到: ${pkgName}。请检查 manifest id / scope 拼写,或换 --from local:./。`; + if (/ERR_PNPM_FETCH_404|404 Not Found|not found/i.test(text)) { + return `registry 中未找到包: ${pkgName}。请检查 manifest id / scope 拼写,或换 --from local:./。`; } - if (/EACCES|permission/i.test(text)) { - return `npm 权限错误: ${pkgName}。检查 npm registry 登录态或 token 权限。`; + if (/ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|EACCES|permission/i.test(text)) { + return `registry 权限错误: ${pkgName}。检查 pnpm registry 登录态或 token 权限。`; } - if (/ERESOLVE/i.test(text)) { - return `npm 依赖冲突: ${pkgName}。提示: 用 --legacy-peer-deps 重试,或先确认 SDK/宿主版本兼容。`; + if (/ERR_PNPM_PEER_DEP_ISSUES|ERESOLVE/i.test(text)) { + return `依赖冲突: ${pkgName}。请先确认 SDK/宿主版本兼容。`; } - if (/ETIMEDOUT|ECONNRESET|ENOTFOUND/i.test(text)) { - return `npm 网络错误: ${pkgName}。检查代理 / 网络;或离线用 --from local:。`; + if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ERR_PNPM_FETCH/i.test(text)) { + return `registry 网络错误: ${pkgName}。检查代理 / 网络;或离线用 --from local:。`; } - /* 默认透传原始 stderr(保留 npm 提示的修复建议) */ - return `npm install ${pkgName} 失败:\n${text.trim()}`; + return `pnpm add ${pkgName} 失败:\n${text.trim()}`; } /** @@ -585,7 +572,7 @@ async function installLocalArtifact(id, absPath, flags, opts = {}) { const dir = await ensureTarget(id); if (isTgz) { - await extractTgz(absPath, dir); + await extractTgz(absPath, dir, id); } else { await unzip(absPath, dir); } @@ -773,69 +760,134 @@ async function unzip(zipPath, dstDir) { } /** - * 解 npm tarball(.tgz)到 packages/。 - * 委托 `npm` 本身(DRY;不重新发明 tar):`npm install ` 落临时 prefix, - * 再拷贝到 packages/。npm 已内置 tar-slip 防护。 + * 解 package tarball(.tgz)到 packages/。 + * 委托 pnpm 在临时项目中安装并安全解包,再将包文件复制到模块目录。 * @param {fs.PathLike} tgzPath * @param {string} dstDir + * @param {string} id */ -async function extractTgz(tgzPath, dstDir) { +async function extractTgz(tgzPath, dstDir, id) { const os = await import("node:os"); - const { spawn: spawnChild } = await import("node:child_process"); const tmpPrefix = await fsp.mkdtemp(path.join(os.tmpdir(), "sfmc-tgz-")); - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + try { + await installWithPnpm(tmpPrefix, tgzPath); + const found = findInstalledModule(tmpPrefix, id); + if (!found) throw new Error(`pnpm add 已解包,但 tarball 中未找到模块 ${id}`); + await copyDir(found, dstDir); + } finally { + try { + await fsp.rm(tmpPrefix, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + } +} + +/** + * 在临时项目中安装包,只读取 registry/tarball 内容,不执行包的生命周期脚本。 + * @param {string} projectDir + * @param {string | fs.PathLike} packageSpec + */ +async function installWithPnpm(projectDir, packageSpec) { + await fsp.writeFile( + path.join(projectDir, "package.json"), + '{"name":"sfmc-module-install-tmp","version":"0.0.0","private":true}\n' + ); + // @ts-ignore - cross-spawn resolves pnpm.cmd safely on Windows. + const spawnChild = (await import("cross-spawn")).default; await new Promise((resolve, reject) => { - // @ts-ignore - const proc = spawnChild(npmCmd, ["install", "--prefix", tmpPrefix, "--omit=dev", "--no-save", tgzPath], { - stdio: ["ignore", "pipe", "pipe"], - }); + const proc = spawnChild( + "pnpm", + [ + "--dir", + projectDir, + "add", + "--ignore-workspace", + "--config.auto-install-peers=false", + "--prod", + "--ignore-scripts", + String(packageSpec), + ], + { stdio: ["ignore", "ignore", "pipe"] } + ); let stderr = ""; - // @ts-ignore - proc.stderr?.on("data", (d) => { - stderr += d.toString(); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); }); - // @ts-ignore - proc.on("exit", (code) => - code === 0 ? resolve() : reject(new Error(`npm install tarball exit ${code}: ${stderr}`)) - ); - // @ts-ignore - proc.on("error", reject); + proc.on("close", (code, signal) => { + if (code === 0) return resolve(); + reject( + new Error( + code === null + ? `pnpm add terminated by ${signal || "unknown signal"}: ${stderr.trim()}` + : `pnpm add failed (${code}): ${stderr.trim()}` + ) + ); + }); + proc.on("error", (error) => reject(new Error(`无法启动 pnpm: ${error.message}`))); }); - /* npm install 把 package.json#name 当作目录名: - * @scope/module-foo → node_modules/@scope/module-foo - * @scope/sfmc-module-foo → node_modules/@scope/sfmc-module-foo - * 我们从 tmpPrefix/node_modules 找出唯一的一个包目录,整体拷贝到 dstDir。 - */ - const installed = path.join(tmpPrefix, "node_modules"); - if (!exists(installed)) { - throw new Error(`npm install tarball produced no node_modules: ${tmpPrefix}`); +} + +/** + * 从 pnpm 临时项目的 node_modules 中解析模块包真实目录。 + * @param {string} projectDir + * @param {string} id + * @param {string} [packageSpec] + */ +function findInstalledModule(projectDir, id, packageSpec) { + const nodeModules = path.join(projectDir, "node_modules"); + if (!exists(nodeModules)) return null; + /** @type {string[]} */ + const candidates = []; + if (packageSpec) { + const packageName = packageNameFromSpec(String(packageSpec)); + if (packageName) candidates.push(path.join(nodeModules, ...packageName.split("/"))); } - let found = null; - for (const e of fs.readdirSync(installed, { withFileTypes: true })) { - if (e.isDirectory()) { - if (e.name.startsWith("@")) { - for (const sub of fs.readdirSync(path.join(installed, e.name), { withFileTypes: true })) { - if (sub.isDirectory()) { - found = path.join(installed, e.name, sub.name); - break; - } - } - } else if (e.isDirectory()) { - found = path.join(installed, e.name); + for (const entry of fs.readdirSync(nodeModules, { withFileTypes: true })) { + if (entry.name === ".bin" || entry.name === ".pnpm" || entry.name.startsWith(".")) continue; + const entryPath = path.join(nodeModules, entry.name); + if (entry.name.startsWith("@")) { + for (const scoped of fs.readdirSync(entryPath, { withFileTypes: true })) { + candidates.push(path.join(entryPath, scoped.name)); } + } else { + candidates.push(entryPath); } - if (found) break; } - if (!found) { - throw new Error(`npm install tarball: no package directory under ${installed}`); + const seen = new Set(); + for (const candidate of candidates) { + let real; + try { + real = fs.realpathSync(candidate); + } catch { + continue; + } + if (seen.has(real)) continue; + seen.add(real); + try { + const manifestPath = path.join(real, "sapi", "manifest.json"); + const moduleId = exists(manifestPath) + ? String(JSON.parse(fs.readFileSync(manifestPath, "utf8")).id || "").replace(/^(feature|core)-/, "") + : ""; + const pkgName = String(JSON.parse(fs.readFileSync(path.join(real, "package.json"), "utf8")).name || ""); + if (moduleId === id || folderFromNpmPackageName(pkgName) === id) return real; + } catch { + /* ignore package entries without module metadata */ + } } - await copyDir(found, dstDir); - /* 清理临时 prefix */ - try { - await fsp.rm(tmpPrefix, { recursive: true, force: true }); - } catch { - /* best-effort */ + return null; +} + +/** @param {string} spec */ +function packageNameFromSpec(spec) { + if (spec.startsWith("@")) { + const slash = spec.indexOf("/"); + if (slash < 0) return null; + const versionSeparator = spec.indexOf("@", slash + 1); + return versionSeparator < 0 ? spec : spec.slice(0, versionSeparator); } + const versionSeparator = spec.indexOf("@"); + return versionSeparator < 0 ? spec : spec.slice(0, versionSeparator); } /** @@ -880,7 +932,7 @@ async function installOne(id, flags) { if (from.startsWith("local:") || from === "local") return fromLocal(id, from, perFlags); if (from.startsWith("tgz:")) return fromTgz(id, from, perFlags); if (from.startsWith("zip:")) return fromZip(id, from, perFlags); - if (from.startsWith("npm:")) return fromNpm(id, from.slice("npm:".length), perFlags); + if (from.startsWith("npm:")) return fromRegistry(id, from.slice("npm:".length)); if (from.startsWith("dir:")) { const base = from.slice("dir:".length); const candidate = path.join(base, id); diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 81b5d228..907591d7 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -198,7 +198,7 @@ export const en = { "wizard.skipped": "Setup skipped", "wizard.step1": "Step 1 - Runtime Environment", "wizard.runtimeRoot": "Runtime root: {root}", - "wizard.npmMissing": "npm runtime is missing required directories: {list}", + "wizard.runtimeMissingAssets": "Runtime is missing required directories: {list}", "wizard.prepFailed": "Runtime preparation failed: {message}", "wizard.step2": "Step 2 - External Runtimes", "wizard.step2Note": "Select paths for BDS, Database, and QQ bridge backend", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index c62de031..88443918 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -195,7 +195,7 @@ export const zhCN: Record = { "wizard.skipped": "已跳过初始化", "wizard.step1": "步骤 1 - 运行环境", "wizard.runtimeRoot": "运行根目录: {root}", - "wizard.npmMissing": "npm 运行时缺少必需目录: {list}", + "wizard.runtimeMissingAssets": "运行环境缺少必需目录: {list}", "wizard.prepFailed": "运行环境准备失败: {message}", "wizard.step2": "步骤 2 - 外部运行时", "wizard.step2Note": "选择 BDS、数据库与 QQ 桥接后端", diff --git a/packages/cli/src/module-commands.ts b/packages/cli/src/module-commands.ts index 46fb392f..83a24f71 100644 --- a/packages/cli/src/module-commands.ts +++ b/packages/cli/src/module-commands.ts @@ -644,7 +644,7 @@ export async function dispatchModuleCommand(sub: string | undefined, args: strin case "watch": case "publish": return c.yellow( - `[mod ${sub}] 已移至扩展「SFMC Module」全周期(Create/Test/Link/Watch/Publish);建仓请用 npm create @sfmc-bds/module@latest;运维请用 mod build|reload|install。` + `[mod ${sub}] 已移至扩展「SFMC Module」全周期(Create/Test/Link/Watch/Publish);建仓请用 pnpm dlx @sfmc-bds/create-module@latest;运维请用 mod build|reload|install。` ); default: return c.yellow(moduleUsage()); @@ -669,4 +669,4 @@ export function listInstalledModuleIdsSync(): string[] { } catch { return []; } -} \ No newline at end of file +} diff --git a/packages/cli/src/module-pack-build.ts b/packages/cli/src/module-pack-build.ts index 611f5ccd..1789a2c7 100644 --- a/packages/cli/src/module-pack-build.ts +++ b/packages/cli/src/module-pack-build.ts @@ -7,7 +7,7 @@ * 实际文件写入(assemble-bp / assemble-rp / deploy / enable-pack / disable-pack / * ensure-permission)通过 spawn bds-tools/cli-pack-manager.ts 的 verb 执行; * 与 module install/uninstall(→ scripts/module-install/fetch-module.mjs)保持同一架构: - * CLI 是 thin wrapper;建仓脚手架在 @sfmc-bds/create-module(npm create)。 + * CLI 是 thin wrapper;建仓脚手架在 @sfmc-bds/create-module(pnpm dlx)。 * * 唯一权威:本文件 = `sfmc mod build|reload` 的派发入口;其他文件不得再 * 直接 import pack-lifecycle 的 cmdPackBuild / deployPacks(确保只有一个 spawn 入口)。 diff --git a/packages/cli/src/wizard.ts b/packages/cli/src/wizard.ts index 39730c10..c0acff4b 100644 --- a/packages/cli/src/wizard.ts +++ b/packages/cli/src/wizard.ts @@ -74,11 +74,11 @@ async function runBdsUpdate(rootDir: string, channel: string): Promise<{ code: n } async function prepareRuntimeAssets(rootDir: string): Promise { - /* monorepo 布局:configs/ modules/ 应由 npm 工作区已提供 */ + /* monorepo 布局:configs/ modules/ 应由 pnpm workspace 提供 */ if (isMonorepoLayout(rootDir)) { const missing = missingRuntimeAssets(rootDir); if (missing.length > 0) { - throw new Error(t("wizard.npmMissing", { list: missing.join(", ") })); + throw new Error(t("wizard.runtimeMissingAssets", { list: missing.join(", ") })); } return; } diff --git a/packages/create-module/README.md b/packages/create-module/README.md index b07d522d..374a257e 100644 --- a/packages/create-module/README.md +++ b/packages/create-module/README.md @@ -3,8 +3,7 @@ 创建 SFMC SAPI 模块骨架。业界入口: ```bash -npm create @sfmc-bds/module@latest -# 等价:npx @sfmc-bds/create-module@latest +pnpm dlx @sfmc-bds/create-module@latest ``` 程序化 API(VS Code/Cursor 扩展使用同一函数): diff --git a/packages/create-module/package.json b/packages/create-module/package.json index fbe72d4d..1c32a49f 100644 --- a/packages/create-module/package.json +++ b/packages/create-module/package.json @@ -1,7 +1,12 @@ { "name": "@sfmc-bds/create-module", "version": "0.1.0-beta.4", - "description": "Create an SFMC SAPI module — npm create @sfmc-bds/module", + "description": "Create an SFMC SAPI module with pnpm", + "repository": { + "type": "git", + "url": "git+https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git", + "directory": "packages/create-module" + }, "type": "module", "license": "AGPL-3.0-only", "engines": { diff --git a/packages/create-module/src/cli.ts b/packages/create-module/src/cli.ts index a4a9b2bf..7644dded 100644 --- a/packages/create-module/src/cli.ts +++ b/packages/create-module/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * npm create @sfmc-bds/module → npx @sfmc-bds/create-module + * pnpm dlx @sfmc-bds/create-module */ import * as p from "@clack/prompts"; @@ -54,13 +54,12 @@ function parseArgs(argv: string[]) { function printHelp(): void { console.log(`用法: - npm create @sfmc-bds/module@latest - npx @sfmc-bds/create-module@latest [id] [dir] + pnpm dlx @sfmc-bds/create-module@latest [id] [dir] 选项: --id 模块短 id(kebab-case) --name 显示名 - --scope 社区 npm scope(不含 @) + --scope 社区包 scope(不含 @) --official 官方包 @sfmc-bds/module- --extra db 附加 db 权限占位 --out 输出目录(默认 ./) @@ -123,7 +122,7 @@ async function main(): Promise { if (!official && !scope) { const kind = await p.select({ - message: "npm 包范围", + message: "包范围", options: [ { value: "community", label: "社区", hint: "@/sfmc-module-" }, { value: "official", label: "官方", hint: "@sfmc-bds/module-" }, @@ -136,7 +135,7 @@ async function main(): Promise { if (kind === "official") official = true; else { const s = await p.text({ - message: "npm scope(不含 @)", + message: "scope(不含 @)", placeholder: "alice", validate: (x) => (!x?.trim() ? "必填" : undefined), }); @@ -211,8 +210,8 @@ async function main(): Promise { p.note( [ `cd ${path.relative(process.cwd(), result.targetDir) || "."}`, - "pnpm install # 或 npm install", - "pnpm run typecheck # 或 npm run typecheck", + "pnpm install", + "pnpm run typecheck", "", "联调:扩展 SFMC: Link to SFMC Root,或:", `sfmc mod install ${result.id} --from dir:${result.targetDir} --link`, diff --git a/packages/create-module/templates/base/.prettierignore b/packages/create-module/templates/base/.prettierignore index b7f08fec..fe733caf 100644 --- a/packages/create-module/templates/base/.prettierignore +++ b/packages/create-module/templates/base/.prettierignore @@ -1,4 +1,4 @@ node_modules/ dist/ *.tgz -package-lock.json +pnpm-lock.yaml diff --git a/packages/create-module/templates/base/README.md b/packages/create-module/templates/base/README.md index 8b8678f4..563526cf 100644 --- a/packages/create-module/templates/base/README.md +++ b/packages/create-module/templates/base/README.md @@ -9,9 +9,9 @@ SAPI 模块 `{{id}}`。 ## 开发 ```bash -pnpm install # 或 npm install -pnpm run typecheck # 或 npm run typecheck -pnpm run lint # 或 npm run lint +pnpm install +pnpm run typecheck +pnpm run lint ``` ## 联调 @@ -23,5 +23,5 @@ pnpm run lint # 或 npm run lint | 命令 | 作用 | | ---------------------------------- | ------------------------------ | -| `pnpm run build` / `npm run build` | tsc --noEmit | -| `pnpm run lint` / `npm run lint` | ESLint / Prettier | +| `pnpm run build` | tsc --noEmit | +| `pnpm run lint` | ESLint / Prettier | diff --git a/packages/create-module/templates/base/package.json b/packages/create-module/templates/base/package.json index ac2d0a9b..a5f0c7c3 100644 --- a/packages/create-module/templates/base/package.json +++ b/packages/create-module/templates/base/package.json @@ -1,8 +1,10 @@ { "name": "{{pkgName}}", "version": "0.1.0", + "packageManager": "pnpm@10.8.0", "type": "module", "description": "SAPI module: {{id}}", + "license": "ISC", "main": "sapi/src/index.ts", "exports": { ".": "./sapi/src/index.ts" @@ -14,7 +16,7 @@ "LICENSE" ], "scripts": { - "build": "npm run typecheck", + "build": "pnpm run typecheck", "typecheck": "tsc --noEmit -p sapi/tsconfig.json", "lint": "eslint \"sapi/**/*.ts\"", "format": "prettier --write ." @@ -37,6 +39,10 @@ "peerDependencies": { "@sfmc-bds/sdk": ">=0.2.0" }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "engines": { "node": ">=22.13.0" } diff --git a/packages/create-module/templates/base/pnpm-workspace.yaml b/packages/create-module/templates/base/pnpm-workspace.yaml new file mode 100644 index 00000000..ab0fb77c --- /dev/null +++ b/packages/create-module/templates/base/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +onlyBuiltDependencies: + - esbuild diff --git a/packages/db-server/README.md b/packages/db-server/README.md index 8473b5e4..c20bbe8b 100644 --- a/packages/db-server/README.md +++ b/packages/db-server/README.md @@ -5,7 +5,7 @@ SFMC SQLite HTTP REST 后端:配置快照、模块启停、游戏数据路由 ## 安装 ```bash -npm install @sfmc-bds/db-server +pnpm add @sfmc-bds/db-server node node_modules/@sfmc-bds/db-server/dist/index.js ``` diff --git a/packages/devkit/README.md b/packages/devkit/README.md index c081de22..ac3d6e44 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -5,7 +5,7 @@ 建仓请用: ```bash -npm create @sfmc-bds/module@latest +pnpm dlx @sfmc-bds/create-module@latest ``` ```ts diff --git a/packages/devkit/package.json b/packages/devkit/package.json index c4a2500b..d5229d58 100644 --- a/packages/devkit/package.json +++ b/packages/devkit/package.json @@ -2,6 +2,11 @@ "name": "@sfmc-bds/devkit", "version": "1.0.0-beta.12", "description": "SFMC module authoring toolkit — watch, rebuild, enable/disable (for VS Code extension & tooling)", + "repository": { + "type": "git", + "url": "git+https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git", + "directory": "packages/devkit" + }, "type": "module", "license": "AGPL-3.0-only", "engines": { diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index 803a2d81..13a90e14 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -1,6 +1,6 @@ /** * @sfmc-bds/devkit — 模块作者工具核心(扩展 / CI 共用) - * Watch / rebuild / 启停;建仓请用 @sfmc-bds/create-module(npm create @sfmc-bds/module)。 + * Watch / rebuild / 启停;建仓请用 pnpm dlx @sfmc-bds/create-module。 */ export { resolveLocalModuleRoot } from "./paths.js"; diff --git a/packages/meta/README.md b/packages/meta/README.md index b6ec4975..48f12c2f 100644 --- a/packages/meta/README.md +++ b/packages/meta/README.md @@ -1,21 +1,21 @@ # @sfmc-bds/sfmc SFMC 聚合包: -✨ 一次性安装 CLI + db-server + qq-bridge + bds-tools + tools + sdk,装完即可在工作目录里初始化并管理全部服务。 +✨ 一次性安装 CLI + db-server + qq-bridge + bds-tools + sdk,装完即可在工作目录里初始化并管理全部服务。 ## 安装 ```bash -npm install -g @sfmc-bds/sfmc@beta +pnpm add -g @sfmc-bds/sfmc@beta ``` 或在项目目录本地安装: ```bash mkdir my-server && cd my-server -npm init -y -npm install @sfmc-bds/sfmc@beta -npx sfmc +pnpm init +pnpm add @sfmc-bds/sfmc@beta +pnpm exec sfmc ``` > 当前仅开放 **beta** 通道。稳定 `latest` 未开放前请始终带 `@beta`。 diff --git a/packages/qq-bridge/README.md b/packages/qq-bridge/README.md index 9e44a473..3209255d 100644 --- a/packages/qq-bridge/README.md +++ b/packages/qq-bridge/README.md @@ -14,7 +14,7 @@ QQ 侧指令(`菜单` / `ping` / `whoami`)在桥内拦截,不依赖 MC;o ## 安装 ```bash -npm install @sfmc-bds/qq-bridge +pnpm add @sfmc-bds/qq-bridge node node_modules/@sfmc-bds/qq-bridge/dist/index.js ``` diff --git a/packages/tools/README.md b/packages/tools/README.md index 2fd4dd90..38eb4283 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -5,21 +5,16 @@ | 对外能力 | 所在包 | |----------|--------| | `sfmc mod install` / fetch-module | `@sfmc-bds/cli` | -| 模块脚手架 `npm create @sfmc-bds/module` | `@sfmc-bds/create-module` | +| 模块脚手架 `pnpm dlx @sfmc-bds/create-module` | `@sfmc-bds/create-module` | ## 仓内常用 ```bash pnpm run verify -npm run verify pnpm exec sfmc-esbuild-transpile -npx sfmc-esbuild-transpile pnpm exec tsc7 --noEmit -npx tsc7 --noEmit -pnpm run docs -- serve -npm run docs -- serve -pnpm run docs -- build -npm run docs -- build +pnpm run docs serve +pnpm run docs build ``` 发版走 Changesets:**push `main` → Version Packages PR → 合并后 CI 自动 `ci-release-packages`**。本地一般不手动发。 diff --git a/packages/tools/changeset-assert-mode.mjs b/packages/tools/changeset-assert-mode.mjs index 28cbf7f1..7f3e5f80 100644 --- a/packages/tools/changeset-assert-mode.mjs +++ b/packages/tools/changeset-assert-mode.mjs @@ -23,8 +23,8 @@ const state = readPreState(); if (mode === "pre" && !pre) { console.error( - "[changeset] 当前不在 pre mode。正式通道请先 `pnpm exec changeset pre exit` / `npx changeset pre exit`;" + - "日常发版走 CI(push main → Version PR)。若要重新进入 beta: `pnpm exec changeset pre enter beta` / `npx changeset pre enter beta`" + "[changeset] 当前不在 pre mode。正式通道请先 `pnpm exec changeset pre exit`;" + + "日常发版走 CI(push main → Version PR)。若要重新进入 beta: `pnpm exec changeset pre enter beta`" ); process.exit(1); } @@ -32,7 +32,7 @@ if (mode === "pre" && !pre) { if (mode === "stable" && pre) { console.error( `[changeset] 仓库仍处于 pre mode (tag=${state?.tag ?? "beta"})。` + - `达标后执行 pnpm exec changeset pre exit / npx changeset pre exit;日常发版走 CI Version PR。` + `达标后执行 pnpm exec changeset pre exit;日常发版走 CI Version PR。` ); process.exit(1); } diff --git a/packages/tools/changeset-ensure.mjs b/packages/tools/changeset-ensure.mjs index 439c0dec..8cba1fcd 100644 --- a/packages/tools/changeset-ensure.mjs +++ b/packages/tools/changeset-ensure.mjs @@ -15,7 +15,7 @@ if (pending.length > 0) { } console.log("[changeset] 无待消费 changeset,启动交互式 changeset …"); -run("npx", ["changeset"]); +run("pnpm", ["exec", "changeset"]); const after = listPendingChangesetFiles(); if (after.length === 0) { diff --git a/packages/tools/changeset-publish.mjs b/packages/tools/changeset-publish.mjs index 92d41ff2..31e202e5 100644 --- a/packages/tools/changeset-publish.mjs +++ b/packages/tools/changeset-publish.mjs @@ -5,8 +5,8 @@ * - 跳过 lifecycle scripts(CI 已先 build;避免多包并行 prepublishOnly 抢 tsc7) * - pre mode 下由 changesets 自动使用 pre.json 的 tag(勿再传 --tag,会报错) */ -import { spawnSync } from "node:child_process"; import { ROOT, isPreMode, readPreState } from "./changeset-release-lib.mjs"; +import { spawnPnpmSync } from "./lib/proc.mjs"; const pre = readPreState(); const preTag = isPreMode() && pre?.tag ? String(pre.tag) : null; @@ -17,15 +17,15 @@ const env = { npm_config_ignore_scripts: "true", }; -const args = ["changeset", "publish"]; +const args = ["exec", "changeset", "publish"]; /* 仅非 pre 时允许显式 --tag;pre mode 会自行打到 pre.json#tag */ if (!preTag && process.env.SFMC_PUBLISH_TAG) { args.push("--tag", process.env.SFMC_PUBLISH_TAG); } console.log( - `[changeset-publish] running: npx ${args.join(" ")}` + + `[changeset-publish] running: pnpm ${args.join(" ")}` + (preTag ? ` (pre mode → npm tag ${preTag})` : "") ); -const r = spawnSync("npx", args, { cwd: ROOT, env, stdio: "inherit", shell: true }); +const r = spawnPnpmSync(args, { cwd: ROOT, env }); process.exit(r.status === null ? 1 : r.status); diff --git a/packages/tools/lib/npm-publish-packages.mjs b/packages/tools/lib/npm-publish-packages.mjs index f7cc8218..65f1b652 100644 --- a/packages/tools/lib/npm-publish-packages.mjs +++ b/packages/tools/lib/npm-publish-packages.mjs @@ -15,6 +15,7 @@ export const NPM_PUBLISH_PACKAGES = { "@sfmc-bds/db-server": "packages/db-server/package.json", "@sfmc-bds/qq-bridge": "packages/qq-bridge/package.json", "@sfmc-bds/bds-tools": "packages/bds-tools/package.json", + "@sfmc-bds/create-module": "packages/create-module/package.json", "@sfmc-bds/devkit": "packages/devkit/package.json", "@sfmc-bds/sfmc": "packages/meta/package.json", }; diff --git a/packages/tools/lib/proc.mjs b/packages/tools/lib/proc.mjs index ec303b8e..7fdcd7d1 100644 --- a/packages/tools/lib/proc.mjs +++ b/packages/tools/lib/proc.mjs @@ -32,21 +32,6 @@ function quote(s) { return /\s/.test(s) ? `"${s}"` : s; } -/** - * 调用 npm CLI。Windows 上 npm 为 `.cmd`,`spawnSync("npm", …)` 无 shell 会 ENOENT; - * 需 `shell: true`(参数由本仓固定传入,非用户拼接)。 - * @param {string[]} args npm 参数(不含 npm 自身) - * @param {import("node:child_process").SpawnSyncOptions} [opts] - * @returns {import("node:child_process").SpawnSyncReturns} - */ -export function spawnNpmSync(args, opts = {}) { - return spawnSync("npm", args, { - stdio: "inherit", - shell: process.platform === "win32", - ...opts, - }); -} - /** * 调用 pnpm CLI。Windows 上 pnpm 为 `.cmd`,需 `shell: true`。 * @param {string[]} args pnpm 参数(不含 pnpm 自身) diff --git a/packages/tools/lib/verify/db-api.mjs b/packages/tools/lib/verify/db-api.mjs index 03d29d10..7c1bc6b3 100644 --- a/packages/tools/lib/verify/db-api.mjs +++ b/packages/tools/lib/verify/db-api.mjs @@ -8,7 +8,6 @@ const MODULE_FIELDS = [ "id", "name", "config_key", - "type", "description", "default_enabled", "can_disable", diff --git a/packages/tools/lib/verify/db-harness.mjs b/packages/tools/lib/verify/db-harness.mjs index 92b7ab15..8c6bcd96 100644 --- a/packages/tools/lib/verify/db-harness.mjs +++ b/packages/tools/lib/verify/db-harness.mjs @@ -3,7 +3,10 @@ * 拉起 db-server,等 /api/health,执行回调后清理 */ import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import fs from "node:fs"; import process from "node:process"; +import path from "node:path"; import { waitHealth } from "../http.mjs"; import { killProc } from "../proc.mjs"; import { DB_SERVER_DIST, ROOT } from "../paths.mjs"; @@ -23,16 +26,20 @@ import { exists } from "../io.mjs"; * @param {(port: number) => Promise} fn */ export async function withDbServer(opts, fn) { - const dataRoot = opts.dataRoot ?? ROOT; - const port = opts.port ?? parseInt(process.env.DB_PORT || "3001", 10); + const tempRoot = path.join(ROOT, "tmp"); + fs.mkdirSync(tempRoot, { recursive: true }); + const dataRoot = fs.mkdtempSync(path.join(tempRoot, "sfmc-db-verify-")); + const port = opts.port ?? (await findFreePort()); const dbDist = opts.dbDist ?? DB_SERVER_DIST; const cwd = opts.cwd ?? ROOT; const healthTimeoutMs = opts.healthTimeoutMs ?? 20_000; if (!exists(dbDist)) { - throw new Error(`缺少 ${dbDist} — 先 npm run build`); + fs.rmSync(dataRoot, { recursive: true, force: true }); + throw new Error(`缺少 ${dbDist} — 先 pnpm run build`); } + writeVerifyRoot(dataRoot, port); /** @type {import("node:child_process").ChildProcess | null} */ let dbProc = null; try { @@ -42,10 +49,61 @@ export async function withDbServer(opts, fn) { stdio: ["ignore", "pipe", "pipe"], }); const ready = await waitHealth(port, healthTimeoutMs); - if (!ready) throw new Error(`db-server 未就绪 (port=${port})`); + if (!ready || dbProc.exitCode !== null) throw new Error(`db-server 未就绪 (port=${port})`); await fn(port); } finally { if (dbProc) await killProc(dbProc.pid); await new Promise((r) => setTimeout(r, 600)); + fs.rmSync(dataRoot, { recursive: true, force: true }); } } + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("无法分配 db-server 自检端口"))); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function writeVerifyRoot(root, port) { + fs.mkdirSync(path.join(root, "configs"), { recursive: true }); + fs.mkdirSync(path.join(root, "data"), { recursive: true }); + fs.mkdirSync(path.join(root, "modules", "packages"), { recursive: true }); + fs.writeFileSync( + path.join(root, "configs", "db_config.json"), + `${JSON.stringify({ db_port: port, dbDir: "data/sfmc_data.db", modulesDir: "modules" }, null, 2)}\n` + ); + fs.writeFileSync(path.join(root, "configs", "qq_config.json"), "{}\n"); + fs.writeFileSync(path.join(root, "configs", "permissions.json"), "[]\n"); + fs.writeFileSync( + path.join(root, "modules", "catalog.json"), + `${JSON.stringify( + { + version: 1, + modules: [ + { + id: "verify-fixture", + configKey: "verify_fixture", + name: "Verify fixture", + description: "Temporary read-only API contract fixture", + enabledByDefault: true, + canDisable: false, + requires: [], + entry: { kind: "sapi", path: "modules/packages/verify-fixture/sapi/src/index.ts" }, + }, + ], + }, + null, + 2 + )}\n` + ); + fs.writeFileSync(path.join(root, "modules", "module-lock.json"), '{"version":1,"modules":{}}\n'); +} diff --git a/packages/tools/lib/verify/offline.mjs b/packages/tools/lib/verify/offline.mjs index aeda7809..8b164593 100644 --- a/packages/tools/lib/verify/offline.mjs +++ b/packages/tools/lib/verify/offline.mjs @@ -86,7 +86,7 @@ export function runOfflinePhase(reporter) { { const sdkDist = path.join(ROOT, "modules", "sdk", "@sfmc-sdk", "dist"); if (exists(sdkDist)) reporter.pass("@sfmc-bds/sdk dist 存在"); - else reporter.warn("@sfmc-bds/sdk dist 缺失 — 运行 pnpm --filter @sfmc-bds/sdk run build / npm run build -w @sfmc-bds/sdk"); + else reporter.warn("@sfmc-bds/sdk dist 缺失 — 运行 pnpm --filter @sfmc-bds/sdk run build"); } { diff --git a/packages/tools/pack-verify.mjs b/packages/tools/pack-verify.mjs index 44c4d0c0..8b497aec 100644 --- a/packages/tools/pack-verify.mjs +++ b/packages/tools/pack-verify.mjs @@ -9,21 +9,29 @@ import { assertPublishPackageInWorkspaces, } from "./lib/npm-publish-packages.mjs"; import { ROOT } from "./lib/paths.mjs"; -import { spawnNpmSync } from "./lib/proc.mjs"; +import { spawnPnpmSync } from "./lib/proc.mjs"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; /** @param {string[]} args */ -function runNpm(args) { - const r = spawnNpmSync(args, { cwd: ROOT }); +function runPnpm(args, cwd = ROOT) { + const r = spawnPnpmSync(args, { cwd }); if (r.error) throw r.error; if (r.status !== 0) process.exit(r.status ?? 1); } -runNpm(["run", "build", "--workspaces", "--if-present"]); +runPnpm(["run", "build"]); -for (const name of Object.keys(NPM_PUBLISH_PACKAGES)) { - assertPublishPackageInWorkspaces(name, ROOT); - console.log(`\n[pack:verify] npm pack -w ${name}`); - runNpm(["pack", "-w", name]); +const packDir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pack-verify-")); +try { + for (const [name, rel] of Object.entries(NPM_PUBLISH_PACKAGES)) { + assertPublishPackageInWorkspaces(name, ROOT); + console.log(`\n[pack:verify] pnpm pack ${name}`); + runPnpm(["pack", "--pack-destination", packDir], path.dirname(path.join(ROOT, rel))); + } +} finally { + fs.rmSync(packDir, { recursive: true, force: true }); } console.log(`\n[pack:verify] ok — ${Object.keys(NPM_PUBLISH_PACKAGES).length} packages`); diff --git a/packages/tools/run-release.mjs b/packages/tools/run-release.mjs index 380a2e42..acba40eb 100644 --- a/packages/tools/run-release.mjs +++ b/packages/tools/run-release.mjs @@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { ROOT, TOOLS_PKG_DIR } from "./lib/paths.mjs"; -import { spawnNpmSync } from "./lib/proc.mjs"; +import { spawnPnpmSync } from "./lib/proc.mjs"; const TOOLS = TOOLS_PKG_DIR; @@ -46,10 +46,10 @@ function runNode(rel, args = []) { if (r.status !== 0) process.exit(r.status ?? 1); } -/** @param {string[]} args npm 参数(不含 npm 自身) */ -function runNpm(args) { - console.log(`\n[run-release] npm ${args.join(" ")}`); - const r = spawnNpmSync(args, { cwd: ROOT, env: process.env }); +/** @param {string[]} args pnpm 参数(不含 pnpm 自身) */ +function runPnpm(args) { + console.log(`\n[run-release] pnpm ${args.join(" ")}`); + const r = spawnPnpmSync(args, { cwd: ROOT, env: process.env }); if (r.error) throw r.error; if (r.status !== 0) process.exit(r.status ?? 1); } @@ -71,7 +71,7 @@ if (mode === "pre") { } runNode("changeset-ensure.mjs"); -runNpm(["run", "version-packages"]); +runPnpm(["run", "version-packages"]); runNode("changeset-commit-version.mjs"); runNode("changeset-tag.mjs"); runNode("changeset-push.mjs"); diff --git a/packages/tools/tsc7.mjs b/packages/tools/tsc7.mjs index 375b1900..459eaf45 100755 --- a/packages/tools/tsc7.mjs +++ b/packages/tools/tsc7.mjs @@ -33,7 +33,7 @@ export function resolveNativeTsc() { try { pkgJson = require.resolve("@typescript/native/package.json"); } catch { - throw new Error("未找到 @typescript/native(TypeScript 7)。请在仓库根目录执行 npm install。"); + throw new Error("未找到 @typescript/native(TypeScript 7)。请在仓库根目录执行 pnpm install。"); } return path.join(path.dirname(pkgJson), "bin", "tsc"); } diff --git a/packages/tools/verify.mjs b/packages/tools/verify.mjs index 3f9ab9cc..6c37265e 100644 --- a/packages/tools/verify.mjs +++ b/packages/tools/verify.mjs @@ -60,7 +60,7 @@ export async function runVerify(argv = process.argv.slice(2)) { if (reporter.ok) { try { - await withDbServer({ dataRoot: ROOT }, async (port) => { + await withDbServer({}, async (port) => { const platform = await assertPlatformDbApi(port); reporter.pass(`db-server 平台 API (modules=${platform.moduleCount})`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3485dbd1..6cc355ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: chalk: specifier: ^6.0.0 version: 6.0.0 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 esbuild: specifier: ^0.28.1 version: 0.28.2 diff --git a/website/AGENTS.md b/website/AGENTS.md index 73345e86..3078b2d5 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -3,7 +3,7 @@ - 本目录规则继承仓库根 `AGENTS.md`。 - 中文内容放在 `docs/zh/**`;仅在任务明确要求时同步 `docs/en/**`。 - 站点配置位于 `website/rspress.config.ts`,组件和插件分别位于 `website/components/`、`website/plugins/`。 -- 不手工编辑 TypeDoc 生成内容;使用根目录 `pnpm run docs -- api` 重新生成。 -- 文档或站点变更优先运行 `pnpm run docs -- build`;局部开发可运行 `pnpm run docs -- serve`。 +- 不手工编辑 TypeDoc 生成内容;使用根目录 `pnpm run docs api` 重新生成。 +- 文档或站点变更优先运行 `pnpm run docs build`;局部开发可运行 `pnpm run docs serve`。 - `doc_build/` 是生成目录,不提交构建产物。 - 路由排除项以 `website/rspress.config.ts` 当前配置为准,不在本文件重复维护。 diff --git a/website/data/troubleshoot-tree.en.json b/website/data/troubleshoot-tree.en.json index 562cd3a2..23270a58 100644 --- a/website/data/troubleshoot-tree.en.json +++ b/website/data/troubleshoot-tree.en.json @@ -7,7 +7,7 @@ "steps": [ { "text": "Run platform integration verify at repo root to validate packages and dependencies:", - "code": "pnpm run verify\nnpm run verify" + "code": "pnpm run verify" } ] }, @@ -87,7 +87,7 @@ "steps": [ { "text": "Build monorepo first, then pack modules:", - "code": "pnpm run build\nnpm run build --workspaces --if-present\nsfmc> mod build" + "code": "pnpm run build\nsfmc> mod build" }, { "text": "Legacy modules importing \"@sfmc/sdk\" → reinstall using the latest CLI, or verify SDK package resolution" diff --git a/website/data/troubleshoot-tree.zh.json b/website/data/troubleshoot-tree.zh.json index f7c82d98..0a031d11 100644 --- a/website/data/troubleshoot-tree.zh.json +++ b/website/data/troubleshoot-tree.zh.json @@ -7,7 +7,7 @@ "steps": [ { "text": "在仓根运行平台集成自检,验证核心包与依赖一致性:", - "code": "pnpm run verify\nnpm run verify" + "code": "pnpm run verify" } ] }, @@ -87,7 +87,7 @@ "steps": [ { "text": "先构建 monorepo,再组装模块包:", - "code": "pnpm run build\nnpm run build --workspaces --if-present\nsfmc> mod build" + "code": "pnpm run build\nsfmc> mod build" }, { "text": "旧模块若仍 import \"@sfmc/sdk\",请使用当前 CLI 重新安装,或确认 SDK 能够正确解析" From a089392ed1567f98e4857074858537610e07996c Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Sun, 27 Sep 2026 19:57:10 +0800 Subject: [PATCH 5/8] fix: handle missing module release tags --- .github/workflows/module-publish.yml | 4 +++- docs/zh/dev/stable-release-plan.md | 2 ++ docs/zh/guide/backup-upgrade.mdx | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/module-publish.yml b/.github/workflows/module-publish.yml index 40e6e272..7b706338 100644 --- a/.github/workflows/module-publish.yml +++ b/.github/workflows/module-publish.yml @@ -136,7 +136,9 @@ jobs: echo "::error::主仓 SFMC_GITHUB_TOKEN 未配置" exit 1 fi - existing="$(gh api "repos/${repo}/git/ref/tags/${tag}" --jq .object.sha 2>/dev/null || true)" + if ! existing="$(gh api "repos/${repo}/git/ref/tags/${tag}" --jq .object.sha 2>/dev/null)"; then + existing="" + fi if [ -n "${existing}" ] && [ "${existing}" != "${COMMIT}" ]; then echo "::error::${tag} 已指向其他提交" exit 1 diff --git a/docs/zh/dev/stable-release-plan.md b/docs/zh/dev/stable-release-plan.md index c5c7fee1..f87033f8 100644 --- a/docs/zh/dev/stable-release-plan.md +++ b/docs/zh/dev/stable-release-plan.md @@ -103,3 +103,5 @@ - 旧 beta Version PR #110 已改为 Draft,避免候选验收期间误合并。 - 隔离 BDS 和独立 db-server 再次启动,日志确认 `activity-log`、`feature-qq-link` 启动,且 HttpDB 连接独立端口 `30131`。本机客户端暂时无法连接测试服;用户要求跳过这次客户端实测,因此未绑定入服、绑定放行、踢人和重新进服仍是未验证的运行项,不记为通过。隔离服务已正常关闭,正式服未重启。 - 在隔离服务关闭后,将测试数据、配置、模块目录及测试世界冷备份并解压至另一测试目录;54 个文件逐项 SHA256 相同,恢复数据库 `PRAGMA integrity_check` 为 `ok`。从恢复目录启动候选 db-server 后,健康检查与 QQ 管理队列均返回成功,再正常停止。这只验证隔离样本的备份/恢复,不代表正式服历史数据迁移已经验收。 +- 平台正式版 PR #111 合并后,Changesets 工作流成功发布 9 个公开包,并建立对应 Git tag 和 GitHub Release;公共 registry 的精确版本与 `latest` 已逐项核对。隔离空目录以 pnpm 从公共 registry 安装 `@sfmc-bds/sfmc@0.2.0` 成功,直接运行其 CLI 的 `--version` 输出 `0.2.0`。升级文档中的旧 `sfmc version` 命令已改正。 +- 首个模块 `activity-log@0.2.1` 的集中工作流完成 npm 发布,但标签步骤误把 GitHub API 的 404 当作已有标签而失败;公共 registry 已返回该版本,缺失的 `v0.2.1` 标签和 GitHub Release 已补到发布提交 `35c33e4`。工作流正在修复后再发布其余模块。`qq-link` 主分支的集中 dry run 已成功。 diff --git a/docs/zh/guide/backup-upgrade.mdx b/docs/zh/guide/backup-upgrade.mdx index b0268363..9a84cf50 100644 --- a/docs/zh/guide/backup-upgrade.mdx +++ b/docs/zh/guide/backup-upgrade.mdx @@ -82,7 +82,7 @@ SFMC 平台的各层组件相互解耦,可按需分别升级: pnpm add -g @sfmc-bds/sfmc # 验证更新后版本 -sfmc version +sfmc --version ``` From f28685afe9495b82e51f6f000f04dbf62e370c2d Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Mon, 28 Sep 2026 15:07:04 +0800 Subject: [PATCH 6/8] docs: record stable release outcome and runtime gaps --- docs/zh/dev/stable-release-plan.md | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/zh/dev/stable-release-plan.md b/docs/zh/dev/stable-release-plan.md index c8218fad..fd8697bd 100644 --- a/docs/zh/dev/stable-release-plan.md +++ b/docs/zh/dev/stable-release-plan.md @@ -1,6 +1,6 @@ # SFMC 正式版行动计划 -状态:执行中。本文记录发布门槛与证据;版本、运行状态和线上结果以执行当天的检查为准。 +状态:npm/GitHub 正式版已发布;生产运行与客户端验收待补。本文记录发布门槛与证据;版本、运行状态和线上结果以执行当天的检查为准。 ## 范围与顺序 @@ -21,30 +21,30 @@ ## 阶段二:标准项目与发布链检查 -- [ ] 核对公开包的名称、版本、入口与 exports、files、许可、README、仓库与问题反馈地址、运行环境要求、依赖和 peerDependencies。 -- [ ] 核对 pnpm 锁文件与可重复安装;CI 不隐式改锁或混用包管理器。 -- [ ] 核对 pack 内容:源码与声明文件、可执行入口、默认配置和必要资源齐全,不含运行数据或凭据。 -- [ ] 核对平台与模块的 CI、发布权限、Git tag、npm dist-tag、GitHub Release 和模块索引流程。 -- [ ] 更新中英文安装、升级、发布和回退文档,移除过时的 beta-only 说明。 +- [x] 核对公开包的名称、版本、入口与 exports、files、许可、README、仓库与问题反馈地址、运行环境要求、依赖和 peerDependencies。 +- [x] 核对 pnpm 锁文件与可重复安装;CI 不隐式改锁或混用包管理器。 +- [x] 核对 pack 内容:源码与声明文件、可执行入口、默认配置和必要资源齐全,不含运行数据或凭据。 +- [x] 核对平台与模块的 CI、发布权限、Git tag、npm dist-tag、GitHub Release 和模块索引流程。 +- [x] 更新中英文安装、升级、发布和回退文档,移除过时的 beta-only 说明。 完成证据:在干净检出中用 pnpm 安装、构建、检查并验证打包内容;发布流程能生成可审查的候选版本。 ## 阶段三:功能、升级与运行验收 -- [ ] 对受影响包运行类型检查、构建、平台自检和相称的静态检查;确认 Windows 与 Ubuntu CI 通过。 +- [x] 对受影响包运行类型检查、构建、平台自检和相称的静态检查;确认 Windows 与 Ubuntu CI 通过。 - [ ] 用候选包在隔离的 SFMC_ROOT 中完成安装、初始化、模块安装、构建与加载演练。 - [ ] 对现有数据做备份、升级、恢复和回退演练,检查数据库迁移及配置兼容。 - [ ] 在目标 BDS 版本上检查部署包、重启后的新日志和客户端关键路径;运行中 BDS 的重启须先确认影响窗口。 -- [ ] 记录所有未通过项及修复后的复验结果,不以静态构建代替运行验收。 +- [x] 记录所有未通过项及修复后的复验结果,不以静态构建代替运行验收。 完成证据:候选包、部署包和实际运行的版本可追溯;升级与回退均有成功记录。 ## 阶段四:发布与复核 - [ ] 通过前三阶段后退出 Changesets `beta` 预发布模式,审查 Version PR 的版本与包间依赖。 -- [ ] 按依赖顺序发布平台、聚合包和选定模块;核对 npm `latest`、Git tag、GitHub Release 和模块索引。 -- [ ] 从公共 registry 进行全新 pnpm 安装,复验 CLI、模块安装和基本功能。 -- [ ] 发布正式版公告,列明兼容范围、升级步骤、已知限制和回退版本。 +- [x] 按依赖顺序发布平台、聚合包和选定模块;核对 npm `latest`、Git tag、GitHub Release 和模块索引。 +- [x] 从公共 registry 进行全新 pnpm 安装,复验 CLI、模块安装和基本功能。 +- [x] 发布正式版公告,列明兼容范围、升级步骤、已知限制和回退版本。 完成证据:公共渠道可安装正式版,版本与产物一致,且发布后的最小验收通过。 @@ -106,3 +106,12 @@ - 平台正式版 PR #111 合并后,Changesets 工作流成功发布 9 个公开包,并建立对应 Git tag 和 GitHub Release;公共 registry 的精确版本与 `latest` 已逐项核对。隔离空目录以 pnpm 从公共 registry 安装 `@sfmc-bds/sfmc@0.2.0` 成功,直接运行其 CLI 的 `--version` 输出 `0.2.0`。升级文档中的旧 `sfmc version` 命令已改正。 - 首个模块 `activity-log@0.2.1` 的集中工作流完成 npm 发布,但标签步骤误把 GitHub API 的 404 当作已有标签而失败;公共 registry 已返回该版本,缺失的 `v0.2.1` 标签和 GitHub Release 已补到发布提交 `35c33e4`。工作流正在修复后再发布其余模块。`qq-link` 主分支的集中 dry run 已成功。 - `activity-log` 的标签缺失判断已在 PR #112 修复并合并。第二个模块 `afk@0.2.0` 完成 npm 发布后,GitHub 返回 403,证明主仓 `SFMC_GITHUB_TOKEN` 仍缺少模块仓创建标签所需的 Contents 写权限;它的标签和 Release 已由维护者登录态补齐。集中工作流改为默认只完成 npm 发布并明确提示维护者补齐跨仓元数据,待 token 权限补齐后可启用自动创建。 + +## 发布结果(2026-09-28) + +- 平台 9 个公开包已在 npm `latest` 发布正式版本;最终聚合包 `@sfmc-bds/sfmc@0.2.1`、CLI `@sfmc-bds/cli@0.2.1`、devkit `@sfmc-bds/devkit@1.0.1`。其余公开平台包保持首发的正式版本。主仓 Changesets 工作流、Git tag 和 GitHub Release 成功。 +- 19 个官方模块的精确版本在公共 npm registry 均返回成功,`latest` 对应正式版;GraphQL 一次性复核 19 个模块的 `v` 标签全部指向发布时锁定的主分支提交,且均有 GitHub Release。主仓跨仓 token 不具备模块仓 Contents 写权限,因此模块 GitHub 元数据由维护者登录态逐项补齐;工作流已明确把此项分开。 +- [模块索引 PR #2](https://github.com/Tanya7z/sfmc-modules/pull/2) 合并后自动构建成功;公开 `index.json` 包含 19 个模块,`activity-log=0.2.1`、`data-backup=0.2.1`、`qq-link=0.1.0`。索引对 19/19 个公开 npm 精确版本的联网校验通过。 +- 公共版 CLI `0.2.0` 的 `mod search` 曾把薄索引的 npm 条目显示为 `undefined`,默认安装也未采用索引版本。补丁 PR #114 与正式 Version PR #110 均已合并,Windows/Ubuntu CI 通过,发布 `0.2.1`。全新隔离目录用 pnpm 安装公共 `@sfmc-bds/sfmc@0.2.1` 后,`sfmc --version`、`mod search qq-link` 与从索引安装精确的 `@sfmc-bds/module-qq-link@0.1.0` 均通过,`check-modules OK`。 +- [SFMC 0.2.1 正式版公告](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/%40sfmc-bds/sfmc%400.2.1) 已发布,列出 pnpm 安装、兼容范围、升级前冷备份、未验收项与回退步骤。 +- 生产服未在本轮重启或升级。本机客户端无法连接隔离测试服,维护者要求跳过本次客户端实测;现有服务器历史数据的升级/回退演练、`qq-link` 的客户端绑定/踢人和 QQ 实际投递仍未验收,不能将公开包发布等同于生产运行通过。因此阶段三相关项及阶段四的前置门槛保留未勾选。 From a227fb6d4ff837aef0e0a2cf11f07e4208f8da56 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Mon, 28 Sep 2026 22:48:28 +0800 Subject: [PATCH 7/8] feat: Enhance QQ message handling and improve database interactions - Refactor message routing in db-server to support QQ message forwarding. - Introduce new interfaces for better type safety in message handling. - Implement logic to filter channels accepting QQ messages. - Update transaction runner to support after-commit hooks for message inserts. - Modify QQ bridge to remove deprecated channelId usage and improve message forwarding logic. - Update changelog and documentation to reflect changes in resource pack handling. - Improve error handling and logging for message forwarding failures. - Refactor command handlers to clarify functionality related to QQ messages. --- .changeset/build-deps-world-packs-solid.md | 2 +- .changeset/chat-channel-qq-forwarding.md | 2 +- .changeset/qq-platform-refresh.md | 3 +- _test_archive/cli/command-palette.test.mjs | 42 ++- docs/zh/guide/config.md | 25 +- modules/sdk/@sfmc-sdk/CHANGELOG.md | 2 +- .../@sfmc-sdk/schemas/bds_updater.schema.json | 14 +- .../schemas/pack_sources.schema.json | 2 +- .../@sfmc-sdk/schemas/pack_update.schema.json | 6 +- .../sdk/@sfmc-sdk/src/node/config/index.ts | 5 - .../src/sapi/runtime/menu-navigator.ts | 11 +- packages/bds-tools/CHANGELOG.md | 2 +- packages/bds-tools/src/bds-manager.ts | 10 +- packages/bds-tools/src/check-update.ts | 2 +- .../bds-tools/src/pack-update/bindings.ts | 2 +- packages/bds-tools/src/pack-update/types.ts | 2 +- packages/bds-tools/src/types.ts | 3 - packages/bds-tools/src/world-packs.ts | 11 +- packages/cli/CHANGELOG.md | 2 +- packages/cli/src/command-palette.ts | 21 +- packages/cli/src/command-surface.ts | 161 +++++++- packages/cli/src/commands.ts | 108 ++++-- packages/cli/src/daemon/client.ts | 354 ++++++++++++++++++ packages/cli/src/daemon/paths.ts | 103 +++++ packages/cli/src/daemon/protocol.ts | 89 +++++ packages/cli/src/daemon/role.ts | 21 ++ packages/cli/src/daemon/server.ts | 265 +++++++++++++ packages/cli/src/help-text.ts | 2 + packages/cli/src/i18n/locales/en.ts | 32 +- packages/cli/src/i18n/locales/zh-CN.ts | 32 +- packages/cli/src/logs.ts | 19 +- packages/cli/src/main.ts | 33 +- packages/cli/src/module-commands.ts | 15 +- packages/cli/src/module-fingerprint.ts | 19 +- packages/cli/src/module-sidecars.ts | 152 ++++++++ packages/cli/src/repl.ts | 98 +++-- packages/cli/src/runtime.ts | 5 +- packages/cli/src/services.ts | 41 +- packages/cli/src/world-packs.ts | 4 +- packages/create-module/CHANGELOG.md | 2 +- packages/db-server/CHANGELOG.md | 2 +- packages/db-server/src/domain/bridge.ts | 4 +- .../db-server/src/domain/installed-content.ts | 7 +- packages/db-server/src/index.ts | 61 ++- packages/db-server/src/routes/messages.ts | 109 +++++- packages/db-server/src/tx-runner.ts | 42 +++ packages/qq-bridge/CHANGELOG.md | 2 +- packages/qq-bridge/index.js | 6 +- .../qq-bridge/src/commands/content-format.ts | 40 +- packages/qq-bridge/src/commands/db-api.ts | 2 +- packages/qq-bridge/src/commands/handlers.ts | 20 +- .../qq-bridge/src/commands/menu-format.ts | 2 +- .../qq-bridge/src/commands/message-text.ts | 6 +- .../qq-bridge/src/commands/reply-official.ts | 2 +- packages/qq-bridge/src/console.ts | 2 +- packages/qq-bridge/src/dbserver.ts | 17 +- packages/qq-bridge/src/index.ts | 7 +- packages/qq-bridge/src/official/events.ts | 35 +- packages/qq-bridge/src/onebot.ts | 2 +- packages/qq-bridge/src/types.ts | 3 +- .../sfmc-extension/src/panels/commands.ts | 6 +- packages/tools/CHANGELOG.md | 2 +- 62 files changed, 1798 insertions(+), 305 deletions(-) create mode 100644 packages/cli/src/daemon/client.ts create mode 100644 packages/cli/src/daemon/paths.ts create mode 100644 packages/cli/src/daemon/protocol.ts create mode 100644 packages/cli/src/daemon/role.ts create mode 100644 packages/cli/src/daemon/server.ts create mode 100644 packages/cli/src/module-sidecars.ts diff --git a/.changeset/build-deps-world-packs-solid.md b/.changeset/build-deps-world-packs-solid.md index a0a50ab5..f6a3cbbe 100644 --- a/.changeset/build-deps-world-packs-solid.md +++ b/.changeset/build-deps-world-packs-solid.md @@ -3,4 +3,4 @@ "@sfmc-bds/bds-tools": patch --- -发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 +发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。资源包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 diff --git a/.changeset/chat-channel-qq-forwarding.md b/.changeset/chat-channel-qq-forwarding.md index 77d2d0d0..a1582681 100644 --- a/.changeset/chat-channel-qq-forwarding.md +++ b/.changeset/chat-channel-qq-forwarding.md @@ -4,4 +4,4 @@ "@sfmc-bds/qq-bridge": patch --- -Use each chat channel's prefix and QQ forwarding switch for MC messages. Route QQ group messages into the read-only game channel and remove the former bridge channel and MC prefix configuration fields. +Use each chat channel's prefix and QQ forwarding switch for MC messages. Route QQ group messages into every game channel whose message source is QQ, instead of a hardcoded channel id. diff --git a/.changeset/qq-platform-refresh.md b/.changeset/qq-platform-refresh.md index d1d8cb96..d7661d81 100644 --- a/.changeset/qq-platform-refresh.md +++ b/.changeset/qq-platform-refresh.md @@ -6,4 +6,5 @@ "@sfmc-bds/qq-bridge": patch --- -更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 + diff --git a/_test_archive/cli/command-palette.test.mjs b/_test_archive/cli/command-palette.test.mjs index 1155f99e..39d8c75c 100644 --- a/_test_archive/cli/command-palette.test.mjs +++ b/_test_archive/cli/command-palette.test.mjs @@ -15,7 +15,7 @@ import { resolvePaletteView, } from "../../packages/cli/dist/command-palette.js"; import { visibleWidth } from "../../packages/cli/dist/logs.js"; -import { listPaletteRoots } from "../../packages/cli/dist/command-surface.js"; +import { listNextPaletteTokens, listPaletteRoots } from "../../packages/cli/dist/command-surface.js"; describe("clipPad 等宽", () => { it("垫到 PANEL_WIDTH", () => { @@ -80,6 +80,46 @@ describe("resolvePaletteView", () => { it("叶命令 /start bds 到末尾立即关闭", () => { assert.equal(resolvePaletteView("/start bds", [0], [0]).hidden, true); }); + it("/ui 展开 studio 与 stop", () => { + const v = resolvePaletteView("/ui ", [0], [0]); + assert.equal(v.hidden, false); + const tokens = v.columns.at(-1).items.map((n) => n.token); + assert.deepEqual(tokens, ["studio", "stop"]); + }); + it("/packs doctor 展开实验性开关", () => { + const v = resolvePaletteView("/packs doctor ", [0], [0]); + assert.equal(v.hidden, false); + const tokens = v.columns.at(-1).items.map((n) => n.token); + assert.ok(tokens.includes("--fix")); + assert.ok(tokens.includes("--all-experiments")); + assert.ok(tokens.includes("--experiments=")); + assert.ok(tokens.includes("")); + }); + it("doctor 直接运行提交无参命令", () => { + const v = resolvePaletteView("/packs doctor ", [0], [0]); + const col = v.columns.at(-1); + const idx = col.items.findIndex((n) => n.token === ""); + const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); + const acc = commitSelection("/packs doctor ", view); + assert.equal(acc.line, "/packs doctor"); + assert.equal(acc.submit, true); + }); + it("--experiments= 粘在同一 token 上且随后收起面板", () => { + const v0 = resolvePaletteView("/packs doctor ", [0], [0]); + const idx = v0.columns.at(-1).items.findIndex((n) => n.token === "--experiments="); + const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); + const acc = commitSelection("/packs doctor ", view); + assert.equal(acc.line, "/packs doctor --experiments="); + assert.equal(acc.submit, false); + assert.equal(resolvePaletteView("/packs doctor --experiments=beta,upcoming", [0], [0]).hidden, true); + }); + it("灰字补全与面板共用 ui / doctor 的下一参", () => { + assert.deepEqual(listNextPaletteTokens(["ui"]), ["studio", "stop"]); + const doctor = listNextPaletteTokens(["packs", "doctor"]); + assert.ok(doctor?.includes("--all-experiments")); + assert.ok(doctor?.includes("--fix")); + assert.equal(doctor?.includes(""), false); + }); it("未到末尾 /start 仍显示", () => { assert.equal(resolvePaletteView("/start", [0], [0]).hidden, false); assert.ok(resolvePaletteView("/start", [0], [0]).columns.length >= 1); diff --git a/docs/zh/guide/config.md b/docs/zh/guide/config.md index f1d5b525..6551b929 100644 --- a/docs/zh/guide/config.md +++ b/docs/zh/guide/config.md @@ -98,9 +98,10 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 --- -### `bds_updater.json`:BDS 核心与进程守护 +### `bds_updater.json`:BDS 核心更新 -管理 Bedrock Dedicated Server 的自动版本检测、热补丁下载、升级前安全备份与进程崩溃自启。 +管理 Bedrock Dedicated Server 的自动版本检测、热补丁下载与升级前安全备份。 +进程崩溃拉起由 `sfmc` 后台守护进程负责,不再在此配置开关。 ```json title="configs/bds_updater.json" { @@ -109,23 +110,19 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 "backup_dir": "./backups", "channel": "release", "auto_check": true, - "crash_restart": true, - "crash_restart_delay": 5, "preserve": ["server.properties", "whitelist.json", "permissions.json", "worlds"], "qq_notify": true } ``` -| 字段 | 类型 | 说明 | -| :-------------------- | :--------- | :---------------------------------------------------------------------------------- | -| `bds_path` | `string` | BDS 核心可执行程序所在目录。 | -| `backup_dir` | `string` | 升级或回滚时的全量备份落盘路径(必须位于 `bds_path` 目录之外)。 | -| `channel` | `string` | 更新通道:`"release"`(正式稳定版)或 `"preview"`(预览测试版)。 | -| `auto_check` | `boolean` | 服务启动时是否静默请求 Mojang 官方源检测最新版本。 | -| `crash_restart` | `boolean` | 当 `bedrock_server` 异常崩溃退出时,是否自动尝试拉起恢复。 | -| `crash_restart_delay` | `number` | 崩溃重启前的缓冲等待时间(秒),避免因端口未释放引发频繁颠簸。 | -| `preserve` | `string[]` | 覆盖更新时**绝对予以保留并自动复原**的文件/目录白名单(避免存档或基础配置被冲洗)。 | -| `qq_notify` | `boolean` | 更新启动、完成或崩溃告警时,是否通过 QQ 群同步广播进度。 | +| 字段 | 类型 | 说明 | +| :----------- | :--------- | :---------------------------------------------------------------------------------- | +| `bds_path` | `string` | BDS 核心可执行程序所在目录。 | +| `backup_dir` | `string` | 升级或回滚时的全量备份落盘路径(必须位于 `bds_path` 目录之外)。 | +| `channel` | `string` | 更新通道:`"release"`(正式稳定版)或 `"preview"`(预览测试版)。 | +| `auto_check` | `boolean` | 服务启动时是否静默请求 Mojang 官方源检测最新版本。 | +| `preserve` | `string[]` | 覆盖更新时**绝对予以保留并自动复原**的文件/目录白名单(避免存档或基础配置被冲洗)。 | +| `qq_notify` | `boolean` | 更新启动、完成或崩溃告警时,是否通过 QQ 群同步广播进度。 | --- diff --git a/modules/sdk/@sfmc-sdk/CHANGELOG.md b/modules/sdk/@sfmc-sdk/CHANGELOG.md index 170277b2..9cf21aa2 100644 --- a/modules/sdk/@sfmc-sdk/CHANGELOG.md +++ b/modules/sdk/@sfmc-sdk/CHANGELOG.md @@ -17,7 +17,7 @@ - efa6e73: 将 Minecraft Script API 类型与新模块模板更新到 BDS 1.26.51 使用的 server 2.11.0-beta、server-ui 2.3.0-beta,并同步 vanilla-data 版本。 - aadd2de: 将官方 QQ Webhook 的 SSH 反向隧道纳入 SFMC CLI 服务编排,提供配置、启停、断线重试、状态和日志。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - efa6e73: 为 QQ 管理菜单增加可即时生效的服务器事件推送开关,并补充 BDS 正常停服通知。 - 1a4ddda: dropdown 的 options 支持从 load 数据源注入(source/as/label/value),与 each 对齐。 - 1a4ddda: 声明式 UI 危险确认支持可选校验文本 challenge(固定字或 {{path}}),未匹配前确认按钮禁用。 diff --git a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json index 6fbb8267..9bb40d82 100644 --- a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/bds_updater.schema.json", "title": "SFMC bds_updater", - "description": "BDS 自动更新与进程管理配置。由 bds-tools(check-update / bds-manager)读取;路径请用双反斜杠或正斜杠。", + "description": "BDS 自动更新配置。由 bds-tools(check-update)读取;崩溃拉起由 sfmc daemon 负责。路径请用双反斜杠或正斜杠。", "type": "object", "additionalProperties": false, "properties": { @@ -39,18 +39,6 @@ "type": "boolean", "description": "启动时是否自动检查 BDS 更新" }, - "crash_restart": { - "type": "boolean", - "description": "BDS 异常退出时是否自动拉起" - }, - "crash_restart_delay": { - "type": "number", - "description": "崩溃重启前等待秒数(可选)" - }, - "auto_restart": { - "type": "boolean", - "description": "旧字段:兼容保留,用于推断是否启用 QQ 等相关自动行为" - }, "version_mode": { "type": "string", "enum": ["bedrock-oss", "endstone"], diff --git a/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json b/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json index f6c8abef..d453f637 100644 --- a/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/pack_sources.schema.json", "title": "SFMC pack-sources", - "description": "世界包远端源绑定清单,位于 packs/pack-sources.json(非 configs/)。键为行为包 UUID(小写);由探测安装 / `packs bind` / 更新流程写入。", + "description": "资源包远端源绑定清单,位于 packs/pack-sources.json(非 configs/)。键为行为包 UUID(小写);由探测安装 / `packs bind` / 更新流程写入。", "type": "object", "additionalProperties": false, "properties": { diff --git a/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json b/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json index c2b8d601..5f903d0d 100644 --- a/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/pack_update.schema.json", "title": "SFMC pack-update", - "description": "世界包(addons)自动检查/更新。apiKey 来自 https://console.curseforge.com/(x-api-key);也可用环境变量 CURSEFORGE_API_KEY。Bedrock gameId=78022,Addons classId=4984。官方 /mods/search 若 403 会改用 searchBaseUrl。match 为源无关策略,置于顶层。defaultBindingEnabled=false 时新建绑定默认关闭。", + "description": "资源包(addons)自动检查/更新。apiKey 来自 https://console.curseforge.com/(x-api-key);也可用环境变量 CURSEFORGE_API_KEY。Bedrock gameId=78022,Addons classId=4984。官方 /mods/search 若 403 会改用 searchBaseUrl。match 为源无关策略,置于顶层。defaultBindingEnabled=false 时新建绑定默认关闭。", "type": "object", "additionalProperties": false, "properties": { @@ -28,7 +28,7 @@ }, "probeSourceAfterInstall": { "type": "boolean", - "description": "安装世界包成功后是否探测 CurseForge 等远端源并尝试绑定" + "description": "安装资源包成功后是否探测 CurseForge 等远端源并尝试绑定" }, "defaultBindingEnabled": { "type": "boolean", @@ -160,7 +160,7 @@ "properties": { "recycleBin": { "type": "boolean", - "description": "true=移入 trashRelativeDir;false=直接删除世界包目录。可用 CLI --purge 强制删除" + "description": "true=移入 trashRelativeDir;false=直接删除资源包目录。可用 CLI --purge 强制删除" }, "trashRelativeDir": { "type": "string", diff --git a/modules/sdk/@sfmc-sdk/src/node/config/index.ts b/modules/sdk/@sfmc-sdk/src/node/config/index.ts index aef4fd76..67d851e6 100644 --- a/modules/sdk/@sfmc-sdk/src/node/config/index.ts +++ b/modules/sdk/@sfmc-sdk/src/node/config/index.ts @@ -6,10 +6,7 @@ export interface BdsUpdaterConfig { bds_path?: string; backup_dir?: string; channel?: "release" | "preview" | string; - auto_restart?: boolean; auto_check?: boolean; - crash_restart?: boolean; - crash_restart_delay?: number; qq_notify?: boolean; qq_config?: string; preserve?: string[]; @@ -239,8 +236,6 @@ export const DEFAULT_BDS_UPDATER_CONFIG: BdsUpdaterConfig = { qq_notify: false, qq_config: "../configs/qq_config.json", auto_check: true, - crash_restart: true, - auto_restart: true, }; /** permissions.json 根为数组;默认空表 */ diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts index 71fb2f65..125f8d26 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts @@ -136,7 +136,6 @@ export class MenuNavigator { private player: Player; private form: CustomForm | null = null; private titleObs: ObservableString | null = null; - private backVis: ObservableBoolean | null = null; /** 跨页面共享的状态对象。 */ state: Record = {}; private _confirmIdx = 0; @@ -146,9 +145,8 @@ export class MenuNavigator { constructor(player: Player) { this.player = player; - if (ObservableStringCtor && ObservableBooleanCtor) { + if (ObservableStringCtor) { this.titleObs = new ObservableStringCtor(""); - this.backVis = new ObservableBooleanCtor(false); } } @@ -157,7 +155,7 @@ export class MenuNavigator { return this.history; } - /** 原生「回到上一级」弹栈后回调,供声明式运行时同步 session。 */ + /** `back()` 弹栈后回调,供声明式运行时同步 session。 */ setOnBack(handler: () => void): this { this.onBack = handler; return this; @@ -177,7 +175,6 @@ export class MenuNavigator { const token = ++this.sessionToken; this.history = [sectionId]; this.applySection(sectionId); - this.backVis?.setData(false); await this.buildAndShow(token); } @@ -367,11 +364,10 @@ export class MenuNavigator { private async buildAndShow(token = this.sessionToken): Promise { if (token !== this.sessionToken) return; if (this.form?.isShowing()) this.form.close(); - if (!CustomFormCtor || !this.titleObs || !this.backVis) { + if (!CustomFormCtor || !this.titleObs || !ObservableBooleanCtor) { throw new Error("当前环境下的 @minecraft/server-ui 不支持 CustomForm (DDUI),请在支持 DDUI 的 BDS 版本运行"); } this.form = new CustomFormCtor(this.player, this.titleObs); - this.form.button("← 回到上一级", () => this.back(), { visible: this.backVis }); for (const [id, def] of this.sections) { if (token !== this.sessionToken) return; const vis = this.sectionVis.get(id); @@ -408,7 +404,6 @@ export class MenuNavigator { private applySection(sectionId: string): void { for (const [id, vis] of this.sectionVis) vis.setData(id === sectionId); - this.backVis?.setData(this.history.length > 1); this.updateTitle(); } diff --git a/packages/bds-tools/CHANGELOG.md b/packages/bds-tools/CHANGELOG.md index df38c5a0..71e8fa65 100644 --- a/packages/bds-tools/CHANGELOG.md +++ b/packages/bds-tools/CHANGELOG.md @@ -210,7 +210,7 @@ ### Patch Changes -- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 +- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。资源包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 - 5ada90e: 修复 #80/#81 合并后 `scanDestOccupancy` 未赋值 `facts`、引用未声明标识符导致 tsc 构建失败;恢复经 `readPackDirOccupancy` 的 DRY 占用扫描。 ## 0.2.0-beta.1 diff --git a/packages/bds-tools/src/bds-manager.ts b/packages/bds-tools/src/bds-manager.ts index 127b8fbc..0aaf394b 100644 --- a/packages/bds-tools/src/bds-manager.ts +++ b/packages/bds-tools/src/bds-manager.ts @@ -57,7 +57,7 @@ export interface BdsManagerOptions { interface CachedProc { process: ReturnType | null; isManualStop: boolean; - crashRestart: boolean; + /** 独立运行 bds-manager 时的崩溃拉起延迟(秒);守护由 sfmc daemon 负责时不读配置 */ crashDelayMs: number; exePath: string; bdsPath: string; @@ -65,6 +65,9 @@ interface CachedProc { let cached: CachedProc | null = null; +/** 独立入口崩溃拉起默认等待(毫秒);配置项已移除,固定值避免与 daemon 监管重复暴露开关 */ +const STANDALONE_CRASH_DELAY_MS = 5_000; + function ensureProc(): CachedProc { if (cached) return cached; const cfg = loadConfig(); @@ -74,8 +77,7 @@ function ensureProc(): CachedProc { cached = { process: null, isManualStop: false, - crashRestart: cfg.crash_restart !== false, - crashDelayMs: (cfg.crash_restart_delay ?? 5) * 1000, + crashDelayMs: STANDALONE_CRASH_DELAY_MS, exePath, bdsPath: bds_path, }; @@ -224,7 +226,7 @@ export function createBdsManager(options: BdsManagerOptions = {}): BdsManager { } else { void postBdsLifecycleEvent("stop"); } - if (!wasManual && p.crashRestart && isMain()) { + if (!wasManual && isMain()) { log.info(`BDS 意外退出,${p.crashDelayMs / 1000}s 后自动重启...`); setTimeout(() => { start().catch((e) => log.error(`自动重启失败: ${e.message}`)); diff --git a/packages/bds-tools/src/check-update.ts b/packages/bds-tools/src/check-update.ts index d66a9a66..fdff5355 100644 --- a/packages/bds-tools/src/check-update.ts +++ b/packages/bds-tools/src/check-update.ts @@ -80,7 +80,7 @@ function buildContext(): UpdateContext { bdsPath: bds_path, backupDir: backup_dir, preserve, - autoRestart: !args["no-start"] && cfg.auto_restart !== false, + autoRestart: !args["no-start"], qqNotify: !!cfg.qq_notify, }; } diff --git a/packages/bds-tools/src/pack-update/bindings.ts b/packages/bds-tools/src/pack-update/bindings.ts index ac79366b..abb4fff3 100644 --- a/packages/bds-tools/src/pack-update/bindings.ts +++ b/packages/bds-tools/src/pack-update/bindings.ts @@ -1,5 +1,5 @@ /** - * packs/pack-sources.json — 世界包远程源绑定 + * packs/pack-sources.json — 资源包远程源绑定 */ import { withConfigSchema } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; diff --git a/packages/bds-tools/src/pack-update/types.ts b/packages/bds-tools/src/pack-update/types.ts index 315a15c9..d47d2b78 100644 --- a/packages/bds-tools/src/pack-update/types.ts +++ b/packages/bds-tools/src/pack-update/types.ts @@ -50,7 +50,7 @@ export interface StartupUpdateConfig { failMode: "continue" | "abort"; } -/** 世界包卸载策略(与 CF 更新同属 packs 生命周期配置) */ +/** 资源包卸载策略(与 CF 更新同属 packs 生命周期配置) */ export interface PackUninstallConfig { /** true:移入回收站;false:直接删除目录 */ recycleBin: boolean; diff --git a/packages/bds-tools/src/types.ts b/packages/bds-tools/src/types.ts index 12c60478..a9cdc7fc 100644 --- a/packages/bds-tools/src/types.ts +++ b/packages/bds-tools/src/types.ts @@ -6,10 +6,7 @@ export interface BdsUpdaterConfig { bds_path?: string; backup_dir?: string; channel?: "release" | "preview" | string; - auto_restart?: boolean; auto_check?: boolean; - crash_restart?: boolean; - crash_restart_delay?: number; qq_notify?: boolean; qq_config?: string; preserve?: string[]; diff --git a/packages/bds-tools/src/world-packs.ts b/packages/bds-tools/src/world-packs.ts index 5320f6d4..65cdff60 100644 --- a/packages/bds-tools/src/world-packs.ts +++ b/packages/bds-tools/src/world-packs.ts @@ -194,7 +194,10 @@ export function resolvePackLocalizedText(packDir: string, value: string): string for (const file of languageFiles) { if (!fs.existsSync(file)) continue; try { - const lines = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split(/\r?\n/); + const lines = fs + .readFileSync(file, "utf8") + .replace(/^\uFEFF/, "") + .split(/\r?\n/); for (const line of lines) { const trimmed = line.trimStart(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -231,9 +234,7 @@ export function readPackManifestInfo(packDir: string): PackManifestInfo | null { uuid, version, kind, - ...(typeof description === "string" - ? { description: resolvePackLocalizedText(packDir, description) } - : {}), + ...(typeof description === "string" ? { description: resolvePackLocalizedText(packDir, description) } : {}), }; } catch { return null; @@ -853,7 +854,7 @@ export type UninstallPackResult = { action: "trashed"; dest: string } | { action export type UninstallPackAction = UninstallPackResult["action"]; /** - * 卸载已安装世界包:先 disable enable-list,再移入 trashDir 或直接删除目录。 + * 卸载已安装资源包:先 disable enable-list,再移入 trashDir 或直接删除目录。 * trashDir 跨盘符时 fallback 为 copy + rm。 */ export async function uninstallInstalledPack(opts: { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 479f3ac4..61b1af9e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -8,7 +8,7 @@ - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - aadd2de: 将官方 QQ Webhook 的 SSH 反向隧道纳入 SFMC CLI 服务编排,提供配置、启停、断线重试、状态和日志。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - 4a9b066: 统一 QQ 双后端玩家服务菜单、权限核验、敏感操作确认及错误反馈,修复编号菜单与长名单发送,保留查服、版本和 ip 指令习惯。 新增 public_server 公开连接配置;版本优先使用 CLI 捕获并经状态接口核验的当前 BDS 启动版本。移除在线状态接口的 64 人上限,不改变入服业务状态与审批策略。 diff --git a/packages/cli/src/command-palette.ts b/packages/cli/src/command-palette.ts index f0b4fd4c..d259e7a5 100644 --- a/packages/cli/src/command-palette.ts +++ b/packages/cli/src/command-palette.ts @@ -255,6 +255,15 @@ function findChild(parent: PaletteNode | undefined, token: string): PaletteNode return parent?.children?.find((n) => n.token === token); } +/** + * 当前 token 是否落在「`=` 结尾」的自由参数上(如 `--experiments=beta,upcoming`)。 + * 这类值与前缀是同一个 token,面板应在写完前缀后收起。 + */ +function matchesGluedFreeArg(parent: PaletteNode | undefined, token: string): boolean { + if (!parent?.children?.length || !token) return false; + return parent.children.some((n) => n.freeArgs && n.token.endsWith("=") && token.startsWith(n.token)); +} + /** * 是否已进入自由参数尾部(如 /send bds …)。 * 路径落在 freeArgs 叶上,或叶之后还有无法匹配的多余 token,均视为自由输入。 @@ -284,7 +293,7 @@ export function isCommandAtEnd(committed: string[], partial: string, trailingSpa let node: PaletteNode | undefined; for (let i = 0; i < path.length; i++) { const next = i === 0 ? findRoot(path[i]!) : findChild(node, path[i]!); - if (!next) return false; + if (!next) return matchesGluedFreeArg(node, path[i]!); node = next; } if (!node || (node.children?.length ?? 0) > 0) return false; @@ -444,6 +453,12 @@ export function commitSelection(line: string, view: PaletteView): { line: string const { committed, partial, trailingSpace, active } = view; + /* 空 token = 直接运行父命令,不把占位标签写进命令行 */ + if (node.token === "") { + const base = committed.slice(0, active); + return { line: base.length ? `/${base.join(" ")}` : "/", submit: true }; + } + /* 叶节点且输入已完整匹配 → 提交执行 */ const isLeaf = !node.children?.length && !node.freeArgs; if (isLeaf && !trailingSpace) { @@ -458,7 +473,9 @@ export function commitSelection(line: string, view: PaletteView): { line: string const tokens = committed.slice(0, active); tokens.push(node.token); let next = "/" + tokens.join(" "); - if (node.children?.length || node.freeArgs) next += " "; + /* `=` 结尾的自由参数不补空格,避免把 `--experiments=beta` 拆成两个 token */ + const glue = node.freeArgs && node.token.endsWith("="); + if ((node.children?.length || node.freeArgs) && !glue) next += " "; return { line: next, submit: false }; } diff --git a/packages/cli/src/command-surface.ts b/packages/cli/src/command-surface.ts index f2a89cd6..7bece27e 100644 --- a/packages/cli/src/command-surface.ts +++ b/packages/cli/src/command-surface.ts @@ -9,6 +9,7 @@ * 业务实现不在本文件 / 面板内:分发到 commands、module-commands、world-packs 等。 */ import { stdin } from "node:process"; +import { t } from "./i18n/index.js"; export type CommandChannel = "repl" | "both"; export type CommandMode = "argv" | "repl"; @@ -157,14 +158,20 @@ export function listVisiblePacksSubs(mode: CommandMode): string[] { export type PaletteNode = { /** 面板内显示的标签 */ label: string; - /** 追加到命令行的 token(根节点含或不含前导逻辑由组装负责) */ + /** + * 追加到命令行的 token。 + * 空字符串表示「直接运行」:不追加参数,回车提交已经写好的父命令。 + */ token: string; /** i18n 描述键 */ descKey?: string; accent?: CommandAccent; /** 固定下一参选项;回车后在右侧展开 */ children?: PaletteNode[]; - /** 无 children 时仍需自由输入(回车后填入空格继续打字) */ + /** + * 无 children 时仍需自由输入(回车后填入空格继续打字)。 + * token 以 `=` 结尾时不补空格,后续字符粘在同一 token 上(如 `--experiments=beta,upcoming`)。 + */ freeArgs?: boolean; }; @@ -185,6 +192,7 @@ const TOP_DESC: Record = { locale: "help.locale", debug: "help.debug.status", ui: "help.ui", + daemon: "help.daemon", install: "help.module.install", uninstall: "help.module.uninstall", search: "help.module.search", @@ -244,14 +252,120 @@ function moduleChildNodes(mode: CommandMode): PaletteNode[] { }); } +/** + * 「直接运行」项:token 为空,回车提交已经写好的父命令。 + * 用在无参本身就是合法命令、同时还要展开可选开关的节点上。 + */ +function asIsNode(descKey: string, labelKey: "palette.asIs" | "palette.fixOnly" | "palette.localeCurrent" = "palette.asIs"): PaletteNode { + return { label: t(labelKey), token: "", descKey }; +} + +/** `/ui` 的固定子命令(studio 启动编辑器,stop 停掉当前进程内的服务)。 */ +function uiChildNodes(): PaletteNode[] { + return [ + { label: "studio", token: "studio", descKey: "help.ui.studio" }, + { label: "stop", token: "stop", descKey: "help.ui.stop" }, + ]; +} + +/** `/debug` 的固定子命令;sentry on 之后还要粘上 `--dsn=`。 */ +function debugChildNodes(): PaletteNode[] { + return [ + { label: "status", token: "status", descKey: "help.debug.status" }, + { label: "enable", token: "enable", descKey: "help.debug.toggle" }, + { label: "disable", token: "disable", descKey: "help.debug.toggle" }, + { + label: "sentry", + token: "sentry", + descKey: "help.debug.sentry", + children: [ + { + label: "on", + token: "on", + descKey: "help.debug.sentry.on", + children: [{ label: "--dsn=", token: "--dsn=", descKey: "help.debug.sentry.on", freeArgs: true }], + }, + { label: "off", token: "off", descKey: "help.debug.sentry.off" }, + ], + }, + ]; +} + +/** `/daemon` 的固定子命令。无参等价于 status,面板里仍单列出来。 */ +function daemonChildNodes(): PaletteNode[] { + return [ + { label: "status", token: "status", descKey: "help.daemon.status" }, + { label: "stop", token: "stop", descKey: "help.daemon.stop" }, + ]; +} + +/** `/locale` 的可选语言;第一项不带参数,只打印当前语言。 */ +function localeChildNodes(): PaletteNode[] { + return [ + asIsNode("help.locale", "palette.localeCurrent"), + { label: "zh-CN", token: "zh-CN", descKey: "locale.opt.zh" }, + { label: "en", token: "en", descKey: "locale.opt.en" }, + ]; +} + +/** `/update` 的已知开关。无参就是检查并更新。 */ +function updateChildNodes(): PaletteNode[] { + return [ + asIsNode("help.update"), + { label: "--check-only", token: "--check-only", descKey: "help.update.checkOnly" }, + { label: "--force", token: "--force", descKey: "help.update.force" }, + { label: "--no-start", token: "--no-start", descKey: "help.update.noStart" }, + { label: "--channel=release", token: "--channel=release", descKey: "help.update.channelRelease" }, + { label: "--channel=preview", token: "--channel=preview", descKey: "help.update.channelPreview" }, + ]; +} + +/** + * `packs doctor` 的开关。 + * 无参只诊断;`--experiments=` 后面的别名粘在同一个 token 上。 + */ +function doctorNode(): PaletteNode { + const experimentList: PaletteNode = { + label: "--experiments=", + token: "--experiments=", + descKey: "help.packs.doctor.experimentsList", + freeArgs: true, + }; + const allExperiments: PaletteNode = { + label: "--all-experiments", + token: "--all-experiments", + descKey: "help.packs.doctor.allExperiments", + }; + return { + label: "doctor", + token: "doctor", + descKey: "help.packs.doctor", + children: [ + asIsNode("help.packs.doctor"), + { + label: "--fix", + token: "--fix", + descKey: "help.packs.doctor.fix", + children: [asIsNode("help.packs.doctor.fix", "palette.fixOnly"), allExperiments, experimentList], + }, + allExperiments, + { label: "--experiments", token: "--experiments", descKey: "help.packs.doctor.experiments" }, + experimentList, + ], + }; +} + function packsChildNodes(mode: CommandMode): PaletteNode[] { const noArg = new Set(["list", "path", "sources"]); - return listVisiblePacksSubs(mode).map((sub) => ({ - label: sub, - token: sub, - descKey: PACKS_DESC[sub] ?? "help.addon", - freeArgs: !noArg.has(sub), - })); + return listVisiblePacksSubs(mode).map((sub) => { + if (sub === "doctor") return doctorNode(); + return { + label: sub, + token: sub, + descKey: PACKS_DESC[sub] ?? "help.addon", + freeArgs: !noArg.has(sub), + }; + }); } /** 层级命令树(REPL 面板主列) */ @@ -284,6 +398,16 @@ export function listPaletteRoots(mode: CommandMode = "repl"): PaletteNode[] { token: n, freeArgs: true, })); + } else if (s.name === "ui") { + node.children = uiChildNodes(); + } else if (s.name === "debug") { + node.children = debugChildNodes(); + } else if (s.name === "daemon") { + node.children = daemonChildNodes(); + } else if (s.name === "locale") { + node.children = localeChildNodes(); + } else if (s.name === "update") { + node.children = updateChildNodes(); } else if (!NO_ARG_TOP.has(s.name)) { node.freeArgs = true; } @@ -314,6 +438,26 @@ export function listPaletteRoots(mode: CommandMode = "repl"): PaletteNode[] { return out; } +/** + * 已提交路径的下一参 token,供非 `/` 灰字补全与面板共用同一棵树。 + * 没有对应节点时返回 null;节点存在但没有子项时返回空数组。 + * 「直接运行」的空 token 不出现在补全里。 + */ +export function listNextPaletteTokens(path: readonly string[], mode: CommandMode = "repl"): string[] | null { + if (path.length === 0) return null; + let current: PaletteNode | undefined = listPaletteRoots(mode).find((n) => n.token === path[0]); + if (!current) return null; + for (let i = 1; i < path.length; i++) { + const token = path[i] ?? ""; + const children: readonly PaletteNode[] = current.children ?? []; + current = children.find((child) => child.token === token); + if (!current) return null; + } + const kids = current.children; + if (!kids?.length) return []; + return kids.map((child) => child.token).filter((token) => token.length > 0); +} + export function isDevAccent(spec: CommandSpec | undefined): boolean { return spec?.accent === "dev"; } @@ -346,6 +490,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ { id: "init", name: "init", channel: "both", needsTty: true }, { id: "update", name: "update", channel: "both" }, { id: "locale", name: "locale", channel: "both" }, + { id: "daemon", name: "daemon", channel: "both" }, { id: "debug", name: "debug", channel: "both", accent: "dev" }, { id: "ui", name: "ui", channel: "both", accent: "dev" }, diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ec80b7ac..5ec7391d 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -12,13 +12,9 @@ import { clearBdsPidFile, killBedrockServerByImage, probeBdsStatus } from "@sfmc-bds/bds-tools/process-probe"; import { stripTaskbarOsc } from "@sfmc-bds/bds-tools/taskbar"; import { didUpdateDeploy } from "@sfmc-bds/bds-tools/update-result"; -import { - DEFAULT_QQ_CONFIG, - loadEnsuredConfig, - qqRuntimeStatusPath, - type QQBridgeConfig, -} from "@sfmc-bds/sdk/node/config"; +import { qqRuntimeStatusPath } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; +import { isDaemonServer } from "./daemon/role.js"; import { t } from "./i18n/index.js"; import { pushLog as pushUnifiedLog } from "./logs.js"; import { findNodeServicePids, killNodeServiceByScript, type NodeServiceName } from "./node-service-probe.js"; @@ -26,6 +22,21 @@ import { spawnService } from "./runtime.js"; import { queryServicesRuntime, ROOT, SERVICE_NAMES, services, type ServiceName } from "./services.js"; import { c, DIVIDER, highlightLogLine, padRight } from "./theme.js"; +/** + * CLI 侧经守护进程执行并取回 text;守护进程内直接跑 localFn。 + * 使用场景:status/start/stop/send/update 等运维命令的统一分流,避免 CLI 进程持有子进程。 + */ +async function viaDaemonText( + method: "status" | "start" | "stop" | "restart" | "startAll" | "stopAll" | "send" | "update", + localFn: () => Promise, + params?: { name?: string; message?: string; args?: string[] } +): Promise { + if (isDaemonServer()) return localFn(); + const { textViaDaemon, statusTextViaDaemon } = await import("./daemon/client.js"); + if (method === "status") return statusTextViaDaemon(); + return textViaDaemon(method, params); +} + function parseService(raw: string): ServiceName | null { const s = raw.toLowerCase() as ServiceName; if (SERVICE_NAMES.includes(s)) return s; @@ -49,44 +60,25 @@ function statusLine( return ` ${dot} ${c.bold(padRight(name, 14))} ${padRight(state, 8)} ${padRight(owner, 6)} ${padRight(pidStr, 8)} ${upStr}`; } -/** status 页脚:QQ 后端与关键摘要(密钥脱敏) */ -function qqBridgeStatusFooter(): string { - const qqCfg = loadEnsuredConfig(ROOT, "qq_config.json", "qq_config", { ...DEFAULT_QQ_CONFIG } as Record< - string, - unknown - >) as QQBridgeConfig; - const backend = qqCfg.qq_backend === "llbot" ? "llbot" : "official"; - const enabled = qqCfg.qq_enabled !== false; - if (backend === "llbot") { - const group = qqCfg.llbot?.group_id || "—"; - const pathHint = qqCfg.llbot?.path ? String(qqCfg.llbot.path) : "—"; - return `\n${c.dim(t("svc.qq.footer.llbot", { enabled: enabled ? "on" : "off", group, path: pathHint }))}\n`; - } - const appId = String(qqCfg.official?.app_id ?? "").trim(); - const appIdHint = appId ? (appId.length > 8 ? `${appId.slice(0, 4)}…${appId.slice(-4)}` : appId) : "—"; - const openid = String(qqCfg.official?.group_openid ?? "").trim() || "—"; - const sandbox = qqCfg.official?.sandbox ? "sandbox" : "prod"; - const creds = appId && String(qqCfg.official?.app_secret ?? "").trim() ? "ok" : "missing"; - return `\n${c.dim(t("svc.qq.footer.official", { enabled: enabled ? "on" : "off", appId: appIdHint, openid, sandbox, creds }))}\n`; -} - /** * 查询并格式化展示所有后台服务的当前运行状态矩阵。 * * @returns 格式化后的状态摘要文本表格。 */ export async function cmdStatus(): Promise { - const rows = await queryServicesRuntime(); - const lines = rows.map((row) => statusLine(row.title, row.running, row.pid, row.uptime, row.ownership)); - const nameH = t("svc.col.name"); - const statusH = t("svc.col.status"); - const ownerH = t("svc.col.owner"); - const pidH = t("svc.col.pid"); - const upH = t("svc.col.uptime"); - const header = ` ${padRight(nameH, 16)}${padRight(statusH, 8)}${padRight(ownerH, 6)}${padRight(pidH, 8)}${upH}`; - return ( - `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n") + qqBridgeStatusFooter() - ); + return viaDaemonText("status", async () => { + const rows = await queryServicesRuntime(); + const lines = rows.map((row) => statusLine(row.title, row.running, row.pid, row.uptime, row.ownership)); + const nameH = t("svc.col.name"); + const statusH = t("svc.col.status"); + const ownerH = t("svc.col.owner"); + const pidH = t("svc.col.pid"); + const upH = t("svc.col.uptime"); + const header = ` ${padRight(nameH, 16)}${padRight(statusH, 8)}${padRight(ownerH, 6)}${padRight(pidH, 8)}${upH}`; + return ( + `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n") + ); + }); } /** @@ -168,6 +160,11 @@ function clearQqRuntimeFile(): void { * @returns 启动结果描述文本。 */ export async function cmdStart(raw: string): Promise { + return viaDaemonText("start", () => cmdStartLocal(raw), { name: raw }); +} + +/** 守护进程内启动单服务(含外部探活防双开) */ +async function cmdStartLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); const svcObj = services[svc]; @@ -215,6 +212,11 @@ export async function cmdStart(raw: string): Promise { * @returns 停止操作结果文本。 */ export async function cmdStop(raw: string): Promise { + return viaDaemonText("stop", () => cmdStopLocal(raw), { name: raw }); +} + +/** 守护进程内停止单服务(含外部实例清理) */ +async function cmdStopLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); const svcObj = services[svc]; @@ -281,6 +283,11 @@ export async function cmdStop(raw: string): Promise { } export async function cmdSend(raw: string, message: string): Promise { + return viaDaemonText("send", () => cmdSendLocal(raw, message), { name: raw, message }); +} + +/** 守护进程内向服务 stdin 写入一行 */ +async function cmdSendLocal(raw: string, message: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); if (svc === "tunnel") return c.yellow(t("svc.stdinUnavailable", { title: services[svc].title })); @@ -314,12 +321,17 @@ export async function cmdSend(raw: string, message: string): Promise { * @returns 重启操作结果文本。 */ export async function cmdRestart(raw: string): Promise { + return viaDaemonText("restart", () => cmdRestartLocal(raw), { name: raw }); +} + +/** 守护进程内重启单服务 */ +async function cmdRestartLocal(raw: string): Promise { const svc = parseService(raw); if (!svc) return c.red(t("svc.unknown", { name: raw, list: SERVICE_NAMES.join(", ") })); // bds / db / qq:统一走 stop→start,确保外部实例也被清掉 if (svc === "bds" || isNodeSvc(svc)) { - const stopMsg = await cmdStop(svc); - const startMsg = await cmdStart(svc); + const stopMsg = await cmdStopLocal(svc); + const startMsg = await cmdStartLocal(svc); // 若 stop 已报错文案含「失败」,原样带回 if (/失败|failed/i.test(stopMsg) && !/已停止|stopped|already/i.test(stopMsg)) { return stopMsg; @@ -345,6 +357,11 @@ export async function cmdRestart(raw: string): Promise { * @returns 批量启动结果统计报告文本。 */ export async function cmdStartAll(): Promise { + return viaDaemonText("startAll", cmdStartAllLocal); +} + +/** 守护进程内批量启动 */ +async function cmdStartAllLocal(): Promise { const { startAll } = await import("./services.js"); const result = await startAll(); const parts: string[] = []; @@ -382,6 +399,11 @@ export async function cmdStartAll(): Promise { * @returns 批量停止确认文本。 */ export async function cmdStopAll(): Promise { + return viaDaemonText("stopAll", cmdStopAllLocal); +} + +/** 守护进程内批量停止 */ +async function cmdStopAllLocal(): Promise { const { stopAll } = await import("./services.js"); await stopAll(); return c.dim(t("svc.allStopped")); @@ -398,6 +420,11 @@ export async function cmdStopAll(): Promise { * @returns 更新执行结果摘要文本。 */ export async function cmdUpdate(args: string[] = []): Promise { + return viaDaemonText("update", () => cmdUpdateLocal(args), { args }); +} + +/** 守护进程内执行 BDS 更新(需持有 bds Service 句柄以 beginUpdate/endUpdate) */ +async function cmdUpdateLocal(args: string[] = []): Promise { const userNoStart = args.includes("--no-start"); const checkOnly = args.includes("--check-only"); const spawnArgs = userNoStart ? [...args] : [...args, "--no-start"]; @@ -417,6 +444,7 @@ export async function cmdUpdate(args: string[] = []): Promise { const proc = spawnService("update", spawnArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); let out = ""; const pushChunk = (raw: string, level: "info" | "error"): void => { diff --git a/packages/cli/src/daemon/client.ts b/packages/cli/src/daemon/client.ts new file mode 100644 index 00000000..b877859e --- /dev/null +++ b/packages/cli/src/daemon/client.ts @@ -0,0 +1,354 @@ +/** + * client.ts — CLI 侧守护进程客户端:ensureDaemon + RPC + 日志订阅 + * + * 若 `.sfmc/daemon.json` 对应进程不在或管道不可连,则 spawn `sfmc --daemon` + *(detached + windowsHide),再轮询直到 ping 成功。 + */ + +import { isProcessAlive } from "@sfmc-bds/bds-tools/process-probe"; +import { logsDir } from "@sfmc-bds/sdk/node/config"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import process from "node:process"; +import { EventEmitter } from "node:events"; +import { ingestRemoteLog } from "../logs.js"; +import { ROOT } from "../runtime.js"; +import type { ServiceStateEvent, ServiceStatus } from "../services.js"; +import { + clearDaemonMeta, + daemonLogPath, + daemonMetaPath, + readDaemonMeta, + type DaemonMeta, +} from "./paths.js"; +import type { + DaemonEvent, + DaemonFrame, + DaemonMethod, + DaemonParams, + DaemonRequest, + DaemonResult, +} from "./protocol.js"; +import { isLogPayload } from "./protocol.js"; +import { isDaemonServer } from "./role.js"; + +/** 单次 RPC 默认超时(毫秒) */ +const CALL_TIMEOUT_MS = 120_000; +/** ensureDaemon 等待守护进程就绪的总时长 */ +const READY_TIMEOUT_MS = 15_000; +/** 轮询间隔 */ +const READY_POLL_MS = 150; + +/** 共享连接(同一 CLI 进程复用) */ +let shared: DaemonConnection | null = null; + +/** 本地状态事件总线:把 daemon 推送的 state 转给 CLI 侧 onServiceStateChange 订阅者 */ +const remoteStateBus = new EventEmitter(); + +/** + * 订阅守护进程推送的服务启停事件(CLI 进程用)。 + * 使用场景:REPL 根据 running 集合刷新发送目标 / 服务窗,无需本地持有 ChildProcess。 + */ +export function onRemoteServiceStateChange(fn: (ev: ServiceStateEvent) => void): () => void { + remoteStateBus.on("state", fn); + return () => { + remoteStateBus.off("state", fn); + }; +} + +/** 单条连接:请求/响应匹配 + 可选事件订阅 */ +class DaemonConnection { + private sock: net.Socket; + private meta: DaemonMeta; + private buf = ""; + private pending = new Map< + string, + { resolve: (r: DaemonResult) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + private subscribed = false; + closed = false; + + constructor(sock: net.Socket, meta: DaemonMeta) { + this.sock = sock; + this.meta = meta; + sock.setEncoding("utf8"); + sock.on("data", (chunk: string) => this.onData(chunk)); + sock.on("close", () => this.onClose(new Error("daemon connection closed"))); + sock.on("error", (e) => this.onClose(e instanceof Error ? e : new Error(String(e)))); + } + + private onData(chunk: string): void { + this.buf += chunk; + let idx: number; + while ((idx = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, idx).trim(); + this.buf = this.buf.slice(idx + 1); + if (!line) continue; + let frame: DaemonFrame; + try { + frame = JSON.parse(line) as DaemonFrame; + } catch { + continue; + } + if (frame.type === "event") { + this.handleEvent(frame); + continue; + } + if (frame.type === "res") { + const wait = this.pending.get(frame.id); + if (!wait) continue; + this.pending.delete(frame.id); + clearTimeout(wait.timer); + if (frame.ok && frame.result) wait.resolve(frame.result); + else wait.reject(new Error(frame.error ?? "daemon error")); + } + } + } + + private handleEvent(ev: DaemonEvent): void { + if (ev.event === "log" && isLogPayload(ev.payload)) { + ingestRemoteLog(ev.payload.text, ev.payload.source, ev.payload.level); + return; + } + if (ev.event === "state") { + remoteStateBus.emit("state", ev.payload as ServiceStateEvent); + } + } + + private onClose(err: Error): void { + if (this.closed) return; + this.closed = true; + for (const [, wait] of this.pending) { + clearTimeout(wait.timer); + wait.reject(err); + } + this.pending.clear(); + if (shared === this) shared = null; + } + + /** 发送 RPC 并等待匹配 id 的响应 */ + call(method: DaemonMethod, params?: DaemonParams, timeoutMs = CALL_TIMEOUT_MS): Promise { + if (this.closed || this.sock.destroyed) { + return Promise.reject(new Error("daemon not connected")); + } + const id = randomBytes(8).toString("hex"); + const req: DaemonRequest = params + ? { id, token: this.meta.token, method, params } + : { id, token: this.meta.token, method }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`daemon call timeout: ${method}`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { + this.sock.write(JSON.stringify(req) + "\n"); + } catch (e) { + this.pending.delete(id); + clearTimeout(timer); + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + } + + /** 订阅日志与状态事件(同一连接上继续发 RPC) */ + async subscribe(): Promise { + if (this.subscribed) return; + await this.call("subscribe"); + this.subscribed = true; + } + + /** 断开连接(不停守护进程) */ + disconnect(): void { + this.closed = true; + try { + this.sock.end(); + } catch { + /* ignore */ + } + if (shared === this) shared = null; + } +} + +function connectSocket(pipe: string, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection(pipe); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("connect timeout")); + }, timeoutMs); + sock.once("connect", () => { + clearTimeout(timer); + resolve(sock); + }); + sock.once("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + +/** + * 尝试连接已有 daemon;成功则返回连接,失败返回 null。 + */ +async function tryConnectExisting(): Promise { + const meta = readDaemonMeta(); + if (!meta) return null; + if (!(await isProcessAlive(meta.pid))) { + clearDaemonMeta(); + return null; + } + try { + const sock = await connectSocket(meta.pipe); + const conn = new DaemonConnection(sock, meta); + await conn.call("ping", undefined, 3000); + return conn; + } catch { + return null; + } +} + +/** + * 拉起守护进程子进程(detached,日志写入 daemon.log)。 + * 入口脚本复用当前 CLI 的 argv[1](即 sfmc 主入口)。 + */ +function spawnDaemonProcess(): void { + const entry = process.argv[1]; + if (!entry) throw new Error("cannot resolve sfmc entry for daemon spawn"); + fs.mkdirSync(logsDir(ROOT), { recursive: true }); + const logFd = fs.openSync(daemonLogPath(), "a"); + const child = spawn(process.execPath, [entry, "--daemon"], { + detached: true, + windowsHide: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, SFMC_ROOT: ROOT }, + cwd: ROOT, + }); + child.unref(); + try { + fs.closeSync(logFd); + } catch { + /* ignore */ + } +} + +/** + * 确保守护进程可用并返回共享连接。 + * 守护进程进程内调用会抛错(应直接走本地 cmd*)。 + */ +export async function ensureDaemon(): Promise { + if (isDaemonServer()) { + throw new Error("ensureDaemon must not run inside daemon server"); + } + if (shared && !shared.closed) return shared; + + let conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + + const existing = readDaemonMeta(); + if (existing && (await isProcessAlive(existing.pid))) { + /* PID 仍在但暂时连不上:短暂重试,禁止再 spawn 第二套守护进程 */ + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + } + throw new Error(`daemon pid ${existing.pid} is alive but pipe is unreachable`); + } + + clearDaemonMeta(); + spawnDaemonProcess(); + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + if (!fs.existsSync(daemonMetaPath())) continue; + conn = await tryConnectExisting(); + if (conn) { + shared = conn; + return conn; + } + } + throw new Error("failed to start or connect sfmc daemon"); +} + +/** + * 调用守护进程方法;自动 ensureDaemon。 + * 使用场景:CLI 侧 status/start/stop/send/update 等运维命令的统一出口。 + */ +export async function callDaemon(method: DaemonMethod, params?: DaemonParams): Promise { + const conn = await ensureDaemon(); + return conn.call(method, params); +} + +/** + * 确保已连接并订阅远程日志/状态;返回断开函数(不停守护进程)。 + * 使用场景:REPL 启动时挂上实时日志流,退出时只 disconnect。 + */ +export async function ensureDaemonSubscription(): Promise<() => void> { + const conn = await ensureDaemon(); + await conn.subscribe(); + return () => { + conn.disconnect(); + }; +} + +/** + * 请求守护进程 stopAll 后退出(`sfmc daemon stop`)。 + */ +export async function shutdownDaemon(): Promise { + try { + const conn = await ensureDaemon(); + await conn.call("shutdown"); + conn.disconnect(); + } catch (e) { + /* 守护进程可能已不在 */ + clearDaemonMeta(); + return e instanceof Error ? e.message : String(e); + } + /* 短暂等待 meta 清理 */ + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!readDaemonMeta()) break; + await new Promise((r) => setTimeout(r, 100)); + } + clearDaemonMeta(); + return ""; +} + +/** + * 从守护进程拉取运行态行;失败时返回空数组由调用方降级。 + */ +export async function queryRuntimeViaDaemon(): Promise { + const result = await callDaemon("status"); + if (result.kind === "status") return result.rows; + return []; +} + +/** + * 从守护进程拉取格式化 status 文本。 + */ +export async function statusTextViaDaemon(): Promise { + const result = await callDaemon("status"); + if (result.kind === "status") return result.text; + if (result.kind === "text") return result.text; + return ""; +} + +/** + * 通用「返回 text」类 RPC 辅助。 + */ +export async function textViaDaemon(method: DaemonMethod, params?: DaemonParams): Promise { + const result = await callDaemon(method, params); + if (result.kind === "text") return result.text; + if (result.kind === "status") return result.text; + return ""; +} diff --git a/packages/cli/src/daemon/paths.ts b/packages/cli/src/daemon/paths.ts new file mode 100644 index 00000000..51abdb37 --- /dev/null +++ b/packages/cli/src/daemon/paths.ts @@ -0,0 +1,103 @@ +/** + * paths.ts — 守护进程连接信息与管道路径 + * + * 权威落盘:`/.sfmc/daemon.json`(与 stateDir 一致)。 + * 同一 ROOT 只允许一个守护进程;CLI 通过本文件解析管道并校验 token。 + */ + +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { logsDir, stateDir } from "@sfmc-bds/sdk/node/config"; +import { ROOT } from "../runtime.js"; + +/** 守护进程元数据:PID、管道路径、鉴权 token */ +export interface DaemonMeta { + /** 守护进程 PID */ + pid: number; + /** 命名管道(Windows)或 Unix socket 绝对路径 */ + pipe: string; + /** 连接鉴权 token(随机) */ + token: string; + /** ISO 启动时间 */ + startedAt: string; +} + +/** 守护进程元数据文件路径 */ +export function daemonMetaPath(root: string = ROOT): string { + return path.join(stateDir(root), "daemon.json"); +} + +/** 守护进程自身日志(stdout/stderr 追加) */ +export function daemonLogPath(root: string = ROOT): string { + return path.join(logsDir(root), "daemon.log"); +} + +/** + * 为本机 ROOT 生成唯一管道名。 + * Windows:`\\.\pipe\sfmc-`;POSIX:`.sfmc/daemon.sock` + */ +export function daemonPipePath(root: string = ROOT): string { + const hash = createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 16); + if (process.platform === "win32") { + return `\\\\.\\pipe\\sfmc-${hash}`; + } + return path.join(stateDir(root), "daemon.sock"); +} + +/** 生成连接鉴权 token */ +export function createDaemonToken(): string { + return randomBytes(24).toString("hex"); +} + +/** 读取守护进程元数据;文件缺失或损坏时返回 null */ +export function readDaemonMeta(root: string = ROOT): DaemonMeta | null { + const file = daemonMetaPath(root); + try { + if (!fs.existsSync(file)) return null; + const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial; + if ( + typeof raw.pid !== "number" || + typeof raw.pipe !== "string" || + typeof raw.token !== "string" || + !raw.pipe || + !raw.token + ) { + return null; + } + return { + pid: raw.pid, + pipe: raw.pipe, + token: raw.token, + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "", + }; + } catch { + return null; + } +} + +/** 写入守护进程元数据(启动 listen 成功后调用) */ +export function writeDaemonMeta(meta: DaemonMeta, root: string = ROOT): void { + const dir = stateDir(root); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(logsDir(root), { recursive: true }); + fs.writeFileSync(daemonMetaPath(root), JSON.stringify(meta, null, 2) + "\n", "utf-8"); +} + +/** 清除守护进程元数据与 POSIX socket 文件 */ +export function clearDaemonMeta(root: string = ROOT): void { + const meta = readDaemonMeta(root); + try { + fs.unlinkSync(daemonMetaPath(root)); + } catch { + /* ignore */ + } + if (process.platform !== "win32") { + const sock = meta?.pipe ?? daemonPipePath(root); + try { + if (fs.existsSync(sock)) fs.unlinkSync(sock); + } catch { + /* ignore */ + } + } +} diff --git a/packages/cli/src/daemon/protocol.ts b/packages/cli/src/daemon/protocol.ts new file mode 100644 index 00000000..36fb8d0e --- /dev/null +++ b/packages/cli/src/daemon/protocol.ts @@ -0,0 +1,89 @@ +/** + * protocol.ts — CLI ↔ 守护进程 换行分隔 JSON 协议类型 + * + * 请求/响应成对匹配 id;subscribe 后服务端可推送 event 帧。 + * 所有请求须携带与 daemon.json 一致的 token。 + */ + +import type { UnifiedLog, LogLevel, LogSource } from "../logs.js"; +import type { ServiceName, ServiceStateEvent, ServiceStatus } from "../services.js"; + +/** 协议方法名(固定集合,OCP:新增能力加此处与 server 分发) */ +export type DaemonMethod = + | "ping" + | "status" + | "start" + | "stop" + | "restart" + | "startAll" + | "stopAll" + | "send" + | "update" + | "subscribe" + | "shutdown"; + +/** 客户端 → 服务端请求帧 */ +export interface DaemonRequest { + id: string; + token: string; + method: DaemonMethod; + params?: DaemonParams; +} + +/** 各方法参数(按 method 选用) */ +export type DaemonParams = { + name?: string; + message?: string; + args?: string[]; +}; + +/** 服务端 → 客户端响应帧 */ +export interface DaemonResponse { + type: "res"; + id: string; + ok: boolean; + result?: DaemonResult; + error?: string; +} + +/** 服务端推送事件(subscribe 之后) */ +export interface DaemonEvent { + type: "event"; + event: "log" | "state"; + payload: DaemonLogPayload | ServiceStateEvent; +} + +/** 日志事件载荷(与 UnifiedLog 对齐,便于 CLI 侧 pushLog) */ +export interface DaemonLogPayload { + text: string; + source: LogSource; + level: LogLevel; + time?: string; +} + +/** status 等方法的结构化结果 */ +export type DaemonResult = + | { kind: "pong" } + | { kind: "text"; text: string } + | { kind: "status"; text: string; rows: ServiceStatus[] } + | { kind: "subscribed" } + | { kind: "shutdown" }; + +export type DaemonFrame = DaemonResponse | DaemonEvent; + +/** 类型守卫:日志事件载荷 */ +export function isLogPayload(p: DaemonEvent["payload"]): p is DaemonLogPayload { + return typeof (p as DaemonLogPayload).text === "string" && typeof (p as DaemonLogPayload).source === "string"; +} + +/** 从 UnifiedLog 构造可序列化日志载荷 */ +export function toLogPayload(log: UnifiedLog): DaemonLogPayload { + return { + text: log.text, + source: log.source, + level: log.level, + time: log.time instanceof Date ? log.time.toISOString() : String(log.time ?? ""), + }; +} + +export type { ServiceName, ServiceStateEvent, ServiceStatus }; diff --git a/packages/cli/src/daemon/role.ts b/packages/cli/src/daemon/role.ts new file mode 100644 index 00000000..3f37c8b5 --- /dev/null +++ b/packages/cli/src/daemon/role.ts @@ -0,0 +1,21 @@ +/** + * role.ts — 标识当前进程是守护进程服务端还是 CLI 客户端 + * + * 使用场景:commands / services 在导出入口处分流——守护进程内直接操作 + * Service 子进程;CLI 进程则经本机管道 RPC 转发到守护进程。 + */ + +/** 进程角色:cli 为默认客户端;daemon 为持有监管权的服务端 */ +export type DaemonRole = "cli" | "daemon"; + +let role: DaemonRole = "cli"; + +/** 将当前进程标记为守护进程服务端(仅 `--daemon` 入口调用一次) */ +export function markDaemonServer(): void { + role = "daemon"; +} + +/** 当前是否为守护进程服务端(持有 ChildProcess 与自动重启定时器) */ +export function isDaemonServer(): boolean { + return role === "daemon"; +} diff --git a/packages/cli/src/daemon/server.ts b/packages/cli/src/daemon/server.ts new file mode 100644 index 00000000..131b4a9c --- /dev/null +++ b/packages/cli/src/daemon/server.ts @@ -0,0 +1,265 @@ +/** + * server.ts — 守护进程服务端:本机管道 listen + 将 RPC 分发给现有监管逻辑 + * + * 仅在 `sfmc --daemon` 进程内运行;持有 Service 子进程、自动重启与 BDS stdin。 + * CLI 退出不断开本进程;`shutdown` 方法会先 stopAll 再退出。 + */ + +import net from "node:net"; +import process from "node:process"; +import { + cmdRestart, + cmdSend, + cmdStart, + cmdStartAll, + cmdStatus, + cmdStop, + cmdStopAll, + cmdUpdate, +} from "../commands.js"; +import { onLog } from "../logs.js"; +import { onServiceStateChange, queryServicesRuntimeLocal, stopAll } from "../services.js"; +import { + clearDaemonMeta, + createDaemonToken, + daemonPipePath, + writeDaemonMeta, + type DaemonMeta, +} from "./paths.js"; +import type { DaemonEvent, DaemonRequest, DaemonResponse, DaemonResult } from "./protocol.js"; +import { toLogPayload } from "./protocol.js"; +import { markDaemonServer } from "./role.js"; + +/** 当前已订阅事件推送的 socket 集合 */ +const subscribers = new Set(); + +/** 是否已开始优雅退出(避免重复 shutdown) */ +let shuttingDown = false; + +/** + * 向所有订阅者广播事件帧。 + * 使用场景:服务日志与启停状态需实时出现在已连接的 REPL。 + */ +function broadcast(event: DaemonEvent): void { + const line = JSON.stringify(event) + "\n"; + for (const sock of subscribers) { + if (sock.destroyed) { + subscribers.delete(sock); + continue; + } + try { + sock.write(line); + } catch { + subscribers.delete(sock); + } + } +} + +function writeRes(sock: net.Socket, res: DaemonResponse): void { + if (sock.destroyed) return; + try { + sock.write(JSON.stringify(res) + "\n"); + } catch { + /* ignore */ + } +} + +/** + * 分发单条 RPC 到现有 cmd* / services API(本进程已是 daemon,cmd* 走本地实现)。 + */ +async function dispatch(req: DaemonRequest): Promise { + switch (req.method) { + case "ping": + return { kind: "pong" }; + case "status": { + const text = await cmdStatus(); + const rows = await queryServicesRuntimeLocal(); + return { kind: "status", text, rows }; + } + case "start": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + return { kind: "text", text: await cmdStartAll() }; + } + return { kind: "text", text: await cmdStart(name) }; + } + case "stop": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + return { kind: "text", text: await cmdStopAll() }; + } + return { kind: "text", text: await cmdStop(name) }; + } + case "restart": { + const name = req.params?.name; + if (!name) throw new Error("missing params.name"); + if (name === "all" || name === "-all" || name === "--all") { + await cmdStopAll(); + return { kind: "text", text: await cmdStartAll() }; + } + return { kind: "text", text: await cmdRestart(name) }; + } + case "startAll": + return { kind: "text", text: await cmdStartAll() }; + case "stopAll": + return { kind: "text", text: await cmdStopAll() }; + case "send": { + const name = req.params?.name; + const message = req.params?.message; + if (!name || message === undefined) throw new Error("missing params.name/message"); + return { kind: "text", text: await cmdSend(name, message) }; + } + case "update": + return { kind: "text", text: await cmdUpdate(req.params?.args ?? []) }; + case "subscribe": + return { kind: "subscribed" }; + case "shutdown": { + if (!shuttingDown) { + shuttingDown = true; + await stopAll(); + setTimeout(() => { + clearDaemonMeta(); + process.exit(0); + }, 50).unref(); + } + return { kind: "shutdown" }; + } + default: + throw new Error(`unknown method: ${(req as DaemonRequest).method}`); + } +} + +/** + * 处理单条连接上的请求行;鉴权失败则关闭。 + */ +async function handleLine(sock: net.Socket, line: string, expectedToken: string): Promise { + let req: DaemonRequest; + try { + req = JSON.parse(line) as DaemonRequest; + } catch { + writeRes(sock, { type: "res", id: "?", ok: false, error: "invalid json" }); + return; + } + if (!req?.id || !req.method) { + writeRes(sock, { type: "res", id: req?.id ?? "?", ok: false, error: "invalid request" }); + return; + } + if (req.token !== expectedToken) { + writeRes(sock, { type: "res", id: req.id, ok: false, error: "unauthorized" }); + sock.end(); + return; + } + + if (req.method === "subscribe") { + subscribers.add(sock); + sock.once("close", () => subscribers.delete(sock)); + writeRes(sock, { type: "res", id: req.id, ok: true, result: { kind: "subscribed" } }); + return; + } + + try { + const result = await dispatch(req); + writeRes(sock, { type: "res", id: req.id, ok: true, result }); + } catch (e) { + writeRes(sock, { + type: "res", + id: req.id, + ok: false, + error: e instanceof Error ? e.message : String(e), + }); + } +} + +/** + * 启动守护进程:标记角色、listen 管道、落盘 daemon.json,并转发日志/状态事件。 + * 本函数在成功 listen 后永不返回(进程常驻)。 + */ +export async function runDaemonServer(): Promise { + markDaemonServer(); + clearDaemonMeta(); + + const token = createDaemonToken(); + const pipe = daemonPipePath(); + const meta: DaemonMeta = { + pid: process.pid, + pipe, + token, + startedAt: new Date().toISOString(), + }; + + const unsubLog = onLog((log) => { + broadcast({ type: "event", event: "log", payload: toLogPayload(log) }); + }); + const unsubState = onServiceStateChange((ev) => { + broadcast({ type: "event", event: "state", payload: ev }); + }); + + const server = net.createServer((sock) => { + let buf = ""; + sock.setEncoding("utf8"); + sock.on("data", (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + void handleLine(sock, line, token); + } + }); + sock.on("error", () => { + subscribers.delete(sock); + }); + sock.on("close", () => { + subscribers.delete(sock); + }); + }); + + await new Promise((resolve, reject) => { + const onErr = (err: NodeJS.ErrnoException): void => { + /* 另一实例已占用管道:安静退出,避免双守护进程 */ + if (err.code === "EADDRINUSE") { + process.exit(0); + } + reject(err); + }; + server.once("error", onErr); + server.listen(pipe, () => { + server.off("error", onErr); + resolve(); + }); + }); + + writeDaemonMeta(meta); + + const cleanup = (): void => { + unsubLog(); + unsubState(); + try { + server.close(); + } catch { + /* ignore */ + } + clearDaemonMeta(); + }; + + process.on("exit", cleanup); + process.on("SIGTERM", () => { + if (shuttingDown) return; + shuttingDown = true; + void stopAll().finally(() => { + cleanup(); + process.exit(0); + }); + }); + /* 守护进程忽略 SIGINT:脱离终端后不应因信号停服;显式 shutdown 退出 */ + process.on("SIGINT", () => { + /* no-op */ + }); + + await new Promise(() => { + /* park forever */ + }); +} diff --git a/packages/cli/src/help-text.ts b/packages/cli/src/help-text.ts index 22a9e69a..f2f6c782 100644 --- a/packages/cli/src/help-text.ts +++ b/packages/cli/src/help-text.ts @@ -121,6 +121,7 @@ ${helpLine(`${c.blue("sfmc debug")} status|enable|disable|sentry …`, "")} const general = [ show("init") ? helpLine(p(mode, "init"), t("help.init")) : "", show("locale") ? helpLine(pArgs(mode, "locale", " [zh|en]"), t("help.locale")) : "", + show("daemon") ? helpLine(pArgs(mode, "daemon", " [status|stop]"), t("help.daemon")) : "", ] .filter(Boolean) .join("\n"); @@ -165,6 +166,7 @@ ${packsLines} : ""; const general = [ + show("daemon") ? helpLine(pArgs(mode, "daemon", " [status|stop]"), t("help.daemon")) : "", show("version") ? helpLine(p(mode, "version"), t("help.version")) : "", show("help") ? helpLine(p(mode, "help"), t("help.help")) : "", show("quit") ? helpLine(p(mode, "quit"), t("help.quit")) : "", diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 907591d7..ea528392 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -70,12 +70,20 @@ export const en = { "help.send": "Send command to a service (Tab cycles targets; QQ prompt shows official / llbot)", "help.section.update": "─── BDS Update ───", "help.update": "Check / apply BDS update", + "help.update.checkOnly": "Check for an update without downloading", + "help.update.force": "Force update even when versions match", + "help.update.noStart": "Do not start BDS after the update", + "help.update.channelRelease": "Use the release channel", + "help.update.channelPreview": "Use the preview channel", "help.section.debug": "─── BDS debug ───", "help.debug.status": "View sfmc_debug / SENTRY_DSN state", "help.debug.toggle": "Toggle sfmc_debug", "help.debug.sentry.on": "Set SENTRY_DSN", "help.debug.sentry.off": "Remove SENTRY_DSN", + "help.debug.sentry": "Configure Sentry DSN", "help.ui": "UI Studio — visual editor for declarative UI", + "help.ui.studio": "Start UI Studio and open the browser", + "help.ui.stop": "Stop UI Studio in this process", "ui.usage": "Usage: ui studio | ui stop", "ui.studio.started": "UI Studio started: {url}", "ui.studio.hint": "Press Ctrl+C to stop the server.", @@ -100,13 +108,18 @@ export const en = { "help.packs.enable": "Enable a pack", "help.packs.disable": "Disable a pack", "help.packs.doctor": "Diagnose pack wiring", + "help.packs.doctor.fix": "Heal wiring and grant missing Beta APIs", + "help.packs.doctor.allExperiments": "Enable all known experimental gameplay", + "help.packs.doctor.experiments": "Show experiment toggles only", + "help.packs.doctor.experimentsList": + "Enable by alias, comma-separated: beta, upcoming, cameras, voxel, villager, drop3, edu", "help.packs.path": "Show pack paths", "help.section.general": "─── General ───", "help.init": "Setup wizard", "help.locale": "Show / set CLI language", "help.version": "Show version", "help.help": "Show this help", - "help.quit": "Exit", + "help.quit": "Exit CLI (services keep running)", "help.shortcuts": "Shortcuts:", "help.shortcut.tab": "Send target / list select", "help.shortcut.right": "Accept suggestion", @@ -115,6 +128,7 @@ export const en = { /* ─── repl ─── */ "repl.stopping": "stopping services...", + "repl.detach": "CLI detached (services keep running; use stop / daemon stop to halt)", "repl.bye": "bye", "repl.forceStop": "force stopping services...", "repl.nonInteractive": " Non-interactive mode (pipe detected)\n", @@ -133,6 +147,19 @@ export const en = { "repl.history.last1hour": "last 1 hour", "repl.history.all": "all buffered", + /* ─── daemon ─── */ + "daemon.stopped": "daemon stopped", + "daemon.notRunning": "daemon is not running", + "daemon.running": "daemon running (PID {pid})", + "daemon.usage": "usage: daemon [status|stop]", + "daemon.connectFailed": "failed to connect daemon: {message}", + "help.daemon": "Show/stop background daemon", + "help.daemon.status": "Show whether the daemon is running", + "help.daemon.stop": "Stop the background daemon", + "palette.asIs": "run", + "palette.fixOnly": "fix only", + "palette.localeCurrent": "current", + /* ─── services / commands ─── */ "svc.header": "Services", "svc.col.name": "Name", @@ -178,9 +205,6 @@ export const en = { "svc.startAll.skipped": "Skipped: {list}", "svc.startAll.failed": "Failed: {list}", "svc.startAll.empty": "Nothing to start", - "svc.qq.footer.official": - "QQ bridge: official · {enabled} · AppID {appId} · group_openid {openid} · {sandbox} · creds={creds}", - "svc.qq.footer.llbot": "QQ bridge: llbot · {enabled} · group {group} · path {path}", "svc.updateComplete": "update complete", "svc.updateExited": "update exited with code {code}", "svc.updateError": "update error: {message}", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index 88443918..e4b52b0a 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -67,12 +67,20 @@ export const zhCN: Record = { "help.send": "向服务发送命令(Tab 切换目标;QQ 提示符区分 official / llbot)", "help.section.update": " BDS 更新 ──────", "help.update": "检查BDS 更新", + "help.update.checkOnly": "只检查更新,不下载", + "help.update.force": "即使版本相同也强制更新", + "help.update.noStart": "更新后不启动 BDS", + "help.update.channelRelease": "使用正式版通道", + "help.update.channelPreview": "使用预览版通道", "help.section.debug": " 调试 ──────", "help.debug.status": "SFMC_DEBUG 状态", "help.debug.toggle": "开关 SFMC_DEBUG", "help.debug.sentry.on": "设置 SENTRY_DSN", "help.debug.sentry.off": "删除 SENTRY_DSN", + "help.debug.sentry": "配置 Sentry DSN", "help.ui": "UI Studio 声明式界面可视化编辑器", + "help.ui.studio": "启动 UI Studio 并打开浏览器", + "help.ui.stop": "停止当前进程里的 UI Studio", "ui.usage": "用法: ui studio | ui stop", "ui.studio.started": "UI Studio 已启动: {url}", "ui.studio.hint": "按 Ctrl+C 停止服务。", @@ -97,13 +105,18 @@ export const zhCN: Record = { "help.packs.enable": "启用资源包", "help.packs.disable": "禁用资源包", "help.packs.doctor": "诊断资源包接线", + "help.packs.doctor.fix": "修复接线并补齐缺失的测试版 API", + "help.packs.doctor.allExperiments": "开启全部已知实验性玩法", + "help.packs.doctor.experiments": "仅查看实验性开关状态", + "help.packs.doctor.experimentsList": + "按别名开启,逗号分隔:beta, upcoming, cameras, voxel, villager, drop3, edu", "help.packs.path": "显示资源包路径", "help.section.general": " 通用 ──────", "help.init": "初始化向导", "help.locale": "设置 CLI 语言", "help.version": "显示版本", "help.help": "显示帮助", - "help.quit": "退出", + "help.quit": "退出 CLI(后台服务继续运行)", "help.shortcuts": "快捷键:", "help.shortcut.tab": "切换发送目标 / 命令列表选择", "help.shortcut.right": "接受建议", @@ -112,9 +125,23 @@ export const zhCN: Record = { /* ─── REPL ─── */ "repl.stopping": "正在停止服务...", + "repl.detach": "已断开 CLI(后台服务继续运行;停服用 stop / daemon stop)", "repl.bye": "bye...", "repl.forceStop": "正在强制停止服务...", "repl.nonInteractive": " 非交互模式(检测到管道)\n", + + /* ─── 守护进程 ─── */ + "daemon.stopped": "守护进程已停止", + "daemon.notRunning": "守护进程未运行", + "daemon.running": "守护进程运行中 (PID {pid})", + "daemon.usage": "用法: daemon [status|stop]", + "daemon.connectFailed": "无法连接守护进程: {message}", + "help.daemon": "查看/停止后台守护进程", + "help.daemon.status": "查看守护进程是否在运行", + "help.daemon.stop": "停止后台守护进程", + "palette.asIs": "直接运行", + "palette.fixOnly": "仅修复", + "palette.localeCurrent": "当前语言", "repl.historyHeader": "── 历史 {count} 行 ──", "repl.filter": "过滤: {level} / {source} / {history}", "repl.followRequiresTty": "跟踪模式需要 TTY", @@ -175,9 +202,6 @@ export const zhCN: Record = { "svc.startAll.skipped": "已跳过: {list}", "svc.startAll.failed": "启动失败: {list}", "svc.startAll.empty": "没有服务需要启动", - "svc.qq.footer.official": - "QQ 桥: official · {enabled} · AppID {appId} · group_openid {openid} · {sandbox} · creds={creds}", - "svc.qq.footer.llbot": "QQ 桥: llbot · {enabled} · 群 {group} · path {path}", "svc.updateComplete": "更新完成", "svc.updateExited": "更新进程退出,代码 {code}", "svc.updateError": "更新错误: {message}", diff --git a/packages/cli/src/logs.ts b/packages/cli/src/logs.ts index d62a29fa..85d5359a 100644 --- a/packages/cli/src/logs.ts +++ b/packages/cli/src/logs.ts @@ -10,6 +10,8 @@ * - bds / llbot → `.sfmc/logs/{bds,llbot}.log`(外部进程无自带 file sink) * - 其余(system / pack / …) → `.sfmc/logs/sfmc.log` * + * CLI 重连后历史回放以落盘为准(readDiskLogs);内存缓冲只服务本进程实时订阅。 + * * formatLog 保留 theme.ts (chalk) 配色,比共享包的纯 ANSI 版本视觉更丰富。 */ @@ -128,6 +130,15 @@ export function pushLog(text: string, source: LogSource, level: LogLevel): void } } +/** + * 摄入守护进程转发的远程日志:只写入本进程内存缓冲并通知订阅者,不再落盘。 + * 使用场景:CLI 客户端 subscribe 后把 daemon 推送的 log 事件灌入本地,供 REPL 主面板实时展示; + * 落盘已由守护进程完成,此处跳过避免重复写 `.sfmc/logs/*`。 + */ +export function ingestRemoteLog(text: string, source: LogSource, level: LogLevel): void { + buffer.pushDirect(text, source, level); +} + /** 订阅新日志事件,返回取消订阅函数 */ export function onLog(fn: (log: UnifiedLog) => void): () => void { return buffer.subscribe(fn); @@ -197,11 +208,13 @@ export type ReadDiskLogsOpts = { sources?: LogSource[]; /** 覆盖日志目录(测试用);默认 `/.sfmc/logs` */ dir?: string; + /** 仅保留时间序最后 N 条;省略则返回全部解析结果 */ + limit?: number; }; /** * 从 `.sfmc/logs/*.log` 读取并解析为 UnifiedLog(按时间升序)。 - * 供 /logs 筛选窗回放;与内存缓冲无关。 + * CLI 重连后主面板 /logs 的历史权威来源;内存缓冲仅承载本进程订阅到的实时增量。 */ export function readDiskLogs(opts: ReadDiskLogsOpts = {}): UnifiedLog[] { const levels = opts.levels ?? []; @@ -233,6 +246,10 @@ export function readDiskLogs(opts: ReadDiskLogsOpts = {}): UnifiedLog[] { } } out.sort((a, b) => a.time.getTime() - b.time.getTime()); + const limit = opts.limit; + if (limit !== undefined && limit > 0 && out.length > limit) { + return out.slice(out.length - limit); + } return out; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 2aa126e6..6dba9bed 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -10,7 +10,6 @@ import { cmdLocale } from "./locale-command.js"; import { dispatchModuleCommand, isModuleCommand, scanAndWarnUnknown } from "./module-commands.js"; import { getHelp, playWelcomeAnimation, startRepl } from "./repl.js"; import { ROOT } from "./runtime.js"; -import { setArgvDaemonize } from "./services.js"; import { c } from "./theme.js"; import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; @@ -32,12 +31,18 @@ function deny(msg: string): never { async function main(): Promise { const stripped = stripLangArgs(process.argv.slice(2)); initLocale({ root: ROOT, flag: stripped.lang }); - const { packsMode, args } = parseGlobalArgv(stripped.args); + let args = stripped.args; - if (args.length > 0 || packsMode) { - setArgvDaemonize(true); + /* 内部入口:后台守护进程(不解析用户命令) */ + if (args[0] === "--daemon") { + const { runDaemonServer } = await import("./daemon/server.js"); + await runDaemonServer(); + return; } + const { packsMode, args: parsedArgs } = parseGlobalArgv(args); + args = parsedArgs; + if (args.length === 0 && !packsMode) { const { isRuntimeInitialized } = await import("./runtime.js"); if (!isRuntimeInitialized()) { @@ -98,6 +103,26 @@ async function main(): Promise { console.log(await cmdUi(rest, { block: true })); break; } + case "daemon": { + const sub = rest[0]; + if (sub === "stop") { + const { shutdownDaemon } = await import("./daemon/client.js"); + const err = await shutdownDaemon(); + console.log(err ? c.red(err) : c.dim(t("daemon.stopped"))); + } else if (sub === "status" || !sub) { + const { readDaemonMeta } = await import("./daemon/paths.js"); + const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); + const meta = readDaemonMeta(); + if (!meta || !(await isProcessAlive(meta.pid))) { + console.log(c.dim(t("daemon.notRunning"))); + } else { + console.log(c.green(t("daemon.running", { pid: String(meta.pid) }))); + } + } else { + console.log(c.yellow(t("daemon.usage"))); + } + break; + } case "status": console.log(await cmdStatus()); break; diff --git a/packages/cli/src/module-commands.ts b/packages/cli/src/module-commands.ts index 83a24f71..d3c5d417 100644 --- a/packages/cli/src/module-commands.ts +++ b/packages/cli/src/module-commands.ts @@ -28,7 +28,7 @@ import { failResult, okResult, type CliResult } from "./cli-result.js"; import { t } from "./i18n/index.js"; import { c } from "./theme.js"; import { ROOT, resolveFetchModule } from "./runtime.js"; -import { dirFingerprint } from "./module-fingerprint.js"; +import { dirFingerprint, shouldSkipModuleEntry } from "./module-fingerprint.js"; import { applyLockEnabled, finalizeToggle, @@ -130,9 +130,14 @@ async function scanInstalled(): Promise { /* corrupt manifest is not fatal — just skip its fields */ } } - const { totalBytes, fileCount } = await dirSize(modPath); - const fingerprint = await dirFingerprint(modPath); - out.push({ id, path: modPath, manifest, totalBytes, fileCount, fingerprint }); + try { + const { totalBytes, fileCount } = await dirSize(modPath); + const fingerprint = await dirFingerprint(modPath); + out.push({ id, path: modPath, manifest, totalBytes, fileCount, fingerprint }); + } catch { + // 坏链接或目录已消失时跳过,不让 CLI 起不来 + continue; + } } out.sort((a, b) => a.id.localeCompare(b.id)); return out; @@ -143,7 +148,7 @@ async function dirSize(dir: string): Promise<{ totalBytes: number; fileCount: nu let fileCount = 0; async function walk(p: string): Promise { for (const e of await fs.readdir(p, { withFileTypes: true })) { - if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue; + if (shouldSkipModuleEntry(path.basename(p), e.name)) continue; const child = path.join(p, e.name); if (e.isDirectory()) await walk(child); else if (e.isFile()) { diff --git a/packages/cli/src/module-fingerprint.ts b/packages/cli/src/module-fingerprint.ts index dc34cb4f..ecf1d90c 100644 --- a/packages/cli/src/module-fingerprint.ts +++ b/packages/cli/src/module-fingerprint.ts @@ -7,6 +7,15 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +/** + * 模块目录扫描时要跳过的名字。 + * 使用场景:指纹和体积统计共用。map-server/data 是地图快照,扫进去会让 CLI 启动卡死。 + */ +export function shouldSkipModuleEntry(parentDirName: string, name: string): boolean { + if (name.startsWith(".") || name === "node_modules" || name === "dist") return true; + return name === "data" && parentDirName === "map-server"; +} + /** Compute the canonical SHA-256 of a module directory (POSIX `find ... | sha256sum` compatible). */ export async function dirFingerprint(rootDir: string): Promise { const hash = createHash("sha256"); @@ -16,7 +25,7 @@ export async function dirFingerprint(rootDir: string): Promise { const items = await fs.readdir(full, { withFileTypes: true }); const sorted = items.sort((a, b) => a.name.localeCompare(b.name)); for (const it of sorted) { - if (it.name.startsWith(".") || it.name === "node_modules" || it.name === "dist") continue; + if (shouldSkipModuleEntry(path.posix.basename(rel), it.name)) continue; const child = rel ? `${rel}/${it.name}` : it.name; if (it.isDirectory()) await walk(child); else if (it.isFile()) entries.push(child); @@ -25,7 +34,13 @@ export async function dirFingerprint(rootDir: string): Promise { await walk(""); entries.sort(); for (const rel of entries) { - const data = await fs.readFile(path.join(rootDir, rel)); + let data: Buffer; + try { + data = await fs.readFile(path.join(rootDir, rel)); + } catch { + // 单个文件打不开时跳过,避免整次启动失败 + continue; + } hash.update(rel.replaceAll("\\", "/")); hash.update("\n"); hash.update(data); diff --git a/packages/cli/src/module-sidecars.ts b/packages/cli/src/module-sidecars.ts new file mode 100644 index 00000000..68ee1ac2 --- /dev/null +++ b/packages/cli/src/module-sidecars.ts @@ -0,0 +1,152 @@ +/** + * 模块随 BDS 启动的附属进程。 + * 使用场景:开服前扫描 modules/packages//sidecar.json。 + * 目前 Bluemap 用它拉起地图进程;日志正文由脚本打进 BDS,这里只报告启动和退出。 + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; + +/** 模块包根目录里的 sidecar.json。 */ +interface SidecarSpec { + id: string; + /** 相对模块包根目录的 Node 入口。 */ + entry: string; + /** 相对 SFMC 根目录的配置文件。缺省不判断开关。 */ + configFile?: string; + /** 配置里的点路径。值为 false 时不启动。缺省视为开启。 */ + when?: string; +} + +/** 本轮 BDS 拉起的附属进程。停服时只结束这些,不碰用户另外开的进程。 */ +const children = new Map(); + +function readJson(file: string): Record | null { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** 沿点路径取值。中途不是对象则返回 undefined。 */ +function readPath(source: Record, dotted: string): unknown { + let current: unknown = source; + for (const part of dotted.split(".")) { + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +function isEnabled(root: string, spec: SidecarSpec): boolean { + if (!spec.when || !spec.configFile) return true; + const config = readJson(path.join(root, spec.configFile)); + if (!config) return true; + const flag = readPath(config, spec.when); + return flag !== false; +} + +function configuredPort(root: string, spec: SidecarSpec): number { + if (!spec.configFile) return 0; + const config = readJson(path.join(root, spec.configFile)); + const port = config?.port; + return typeof port === "number" && port > 0 && port < 65536 ? port : 0; +} + +/** 端口已有进程监听时不再启动第二个。 */ +function portOpen(port: number): Promise { + if (!port) return Promise.resolve(false); + return new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port }); + const done = (open: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(open); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(500, () => done(false)); + }); +} + +function loadSpecs(root: string): Array<{ spec: SidecarSpec; dir: string }> { + const packagesDir = path.join(root, "modules", "packages"); + if (!fs.existsSync(packagesDir)) return []; + const found: Array<{ spec: SidecarSpec; dir: string }> = []; + for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const dir = path.join(packagesDir, entry.name); + const file = path.join(dir, "sidecar.json"); + if (!fs.existsSync(file)) continue; + const raw = readJson(file); + if (!raw || typeof raw.id !== "string" || typeof raw.entry !== "string") continue; + const lock = readJson(path.join(root, "modules", "module-lock.json")); + const modules = lock?.modules && typeof lock.modules === "object" ? (lock.modules as Record) : {}; + const row = modules[raw.id]; + const enabled = !row || typeof row !== "object" || (row as { enabled?: boolean }).enabled !== false; + if (!enabled) continue; + const spec: SidecarSpec = { id: raw.id, entry: raw.entry }; + if (typeof raw.configFile === "string") spec.configFile = raw.configFile; + if (typeof raw.when === "string") spec.when = raw.when; + found.push({ spec, dir }); + } + return found; +} + +/** + * 在 BDS 进程拉起之前启动附属进程。 + * 使用场景:services.ts 里 bds 的 start,在 beforeStart 之后调用。 + */ +export async function startModuleSidecars( + root: string, + log: (line: string, stream: "stdout" | "stderr") => void +): Promise { + for (const { spec, dir } of loadSpecs(root)) { + if (children.has(spec.id)) continue; + if (!isEnabled(root, spec)) continue; + const entry = path.join(dir, spec.entry); + if (!fs.existsSync(entry)) { + log(`[${spec.id}] 未找到附属进程入口: ${entry}`, "stderr"); + continue; + } + const port = configuredPort(root, spec); + if (await portOpen(port)) { + log(`[${spec.id}] 端口 ${port} 已在监听,跳过启动。`, "stdout"); + continue; + } + const child = spawn(process.execPath, [entry], { + cwd: dir, + env: { ...process.env, SFMC_ROOT: root }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + children.set(spec.id, child); + log(`[${spec.id}] 已随服务器启动附属进程 (PID ${child.pid ?? 0})`, "stdout"); + child.on("exit", (code) => { + if (children.get(spec.id) === child) children.delete(spec.id); + log(`[${spec.id}] 附属进程退出 (code ${code ?? "?"})`, code === 0 ? "stdout" : "stderr"); + }); + child.on("error", (err) => { + log(`[${spec.id}] 附属进程启动失败: ${err.message}`, "stderr"); + }); + } +} + +/** + * 结束本轮由开服拉起的附属进程。 + * 使用场景:BDS 服务 cleanup 时调用。用户自己另开的地图进程不会被关掉。 + */ +export function stopModuleSidecars(): void { + for (const [id, child] of children) { + children.delete(id); + try { + child.kill(); + } catch { + /* 进程已经结束时忽略。 */ + } + } +} diff --git a/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index ad2a38e9..3b2617bb 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -13,6 +13,7 @@ import { resolvePaletteView, } from "./command-palette.js"; import { + listNextPaletteTokens, listVisiblePacksSubs, listVisibleTopLevelNames, resolveModuleTopShorthand, @@ -25,7 +26,6 @@ import { t } from "./i18n/index.js"; import { cmdLocale } from "./locale-command.js"; import { formatLog, - getAllLogs, logPrefixWidth, onLog, readDiskLogs, @@ -54,7 +54,7 @@ import { type WindowKeyResult, } from "./repl-windows/index.js"; import { SEND_TARGET_ORDER, paintSendPrompt, paintSfmcPrompt, plainPrompt } from "./send-target.js"; -import { forceStopAll, onServiceStateChange, queryServicesRuntime, SERVICE_NAMES, stopAll, type ServiceName } from "./services.js"; +import { onServiceStateChange, queryServicesRuntime, SERVICE_NAMES, type ServiceName } from "./services.js"; import { c, T } from "./theme.js"; import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; @@ -344,6 +344,9 @@ function getCompletions(parsed: ParsedLine): string[] { const cmds = getCommands().map((n) => (n.startsWith("/") ? n : n)); return ["/", ...cmds].filter(sw); } + /* 面板树里已经有固定下一参时,灰字与面板共用同一份选项 */ + const paletteNext = listNextPaletteTokens([cmd, ...words.slice(0, argIndex)], REPL_MODE); + if (paletteNext && paletteNext.length > 0) return paletteNext.filter(sw); switch (cmd) { case "logs": case "log": @@ -357,7 +360,7 @@ function getCompletions(parsed: ParsedLine): string[] { if (argIndex === 0) return SERVICE_NAMES.filter(sw); return []; case "update": - return ["--check-only", "--force", "--channel=release", "--channel=preview"].filter(sw); + return ["--check-only", "--force", "--no-start", "--channel=release", "--channel=preview"].filter(sw); case "packs": case "addon": if (argIndex === 0) return [...listVisiblePacksSubs(REPL_MODE)].filter(sw); @@ -815,9 +818,13 @@ function writeLogLineToTty(wrapped: string): void { export async function startRepl(): Promise { let stopping = false; + /** 断开与守护进程的订阅(不停服) */ + let disconnectDaemon: (() => void) | null = null; + const onSigint = (): void => { - stdout.write(c.yellow("\n" + t("repl.forceStop") + "\n")); - forceStopAll(); + stdout.write(c.yellow("\n" + t("repl.detach") + "\n")); + disconnectDaemon?.(); + disconnectDaemon = null; process.exit(130); }; const shutdown = async (): Promise => { @@ -825,8 +832,9 @@ export async function startRepl(): Promise { stopping = true; process.off("SIGINT", onSigint); openLogsWindowHook = null; - stdout.write(c.dim(t("repl.stopping") + "\n")); - await stopAll(); + stdout.write(c.dim(t("repl.detach") + "\n")); + disconnectDaemon?.(); + disconnectDaemon = null; try { setRaw(false); stdin.pause(); @@ -838,6 +846,15 @@ export async function startRepl(): Promise { }; process.on("SIGINT", onSigint); + /* 连接(或拉起)守护进程并订阅远程日志/状态 */ + try { + const { ensureDaemonSubscription } = await import("./daemon/client.js"); + disconnectDaemon = await ensureDaemonSubscription(); + } catch (e) { + stdout.write(c.red(t("daemon.connectFailed", { message: (e as Error).message })) + "\n"); + process.exit(1); + } + if (!stdin.isTTY) { console.log(c.dim(t("repl.nonInteractive"))); for await (const line of (await import("node:readline/promises")).createInterface({ @@ -878,31 +895,25 @@ export async function startRepl(): Promise { stdout.write("\x1B[3J"); } - /** 首次进入窗口:从内存/落盘拉一次作为种子缓存 */ + /** 首次进入窗口:从落盘拉历史作为种子(CLI 重连后内存为空,权威在 `.sfmc/logs/*.log`) */ function seedWindowBuffer(w: { id: string; acceptLog: (l: UnifiedLog) => boolean; formatLogLine: (l: UnifiedLog) => { text: string; indent: number }; getReplayFilter?: () => { levels: string[]; sources: string[] }; }): string[] { + const f = w.getReplayFilter?.() ?? { levels: [], sources: [] }; + /* 与旧内存环大小对齐,避免超大 log 文件拖垮清屏回放 */ + const disk = readDiskLogs({ + levels: f.levels as LogLevel[], + sources: f.sources, + limit: 5000, + }); const lines: string[] = []; - if (w.id === "logs") { - const f = w.getReplayFilter?.() ?? { levels: [], sources: [] }; - const disk = readDiskLogs({ - levels: f.levels as LogLevel[], - sources: f.sources, - }); - for (const log of disk) { - if (!w.acceptLog(log)) continue; - const { text, indent } = w.formatLogLine(log); - lines.push(wrapLogLine(text, indent)); - } - } else { - for (const log of getAllLogs()) { - if (!w.acceptLog(log)) continue; - const { text, indent } = w.formatLogLine(log); - lines.push(wrapLogLine(text, indent)); - } + for (const log of disk) { + if (!w.acceptLog(log)) continue; + const { text, indent } = w.formatLogLine(log); + lines.push(wrapLogLine(text, indent)); } host.setBuffer(w.id, lines); return lines; @@ -910,8 +921,8 @@ export async function startRepl(): Promise { /** * 展示活动窗:先清屏,再恢复/seed。 - * - /logs:用落盘种子缓存(改筛选才 reseed),避免重复读文件 - * - 服务主面板:每次从内存重 seed,保证离开 /logs 或 Tab 回来时内容实时 + * 历史一律落盘 seed;实时行由 onLog → routeLog 追加进各窗缓存。 + * 改筛选时传 reseed 强制重读盘。 */ function showActiveWindow(opts?: { reseed?: boolean }): void { clearTerminal(); @@ -920,15 +931,8 @@ export async function startRepl(): Promise { currentRedraw?.(); return; } - let lines: readonly string[]; - if (w.id === "logs") { - if (opts?.reseed) host.invalidateBuffer(w.id); - lines = host.hasBuffer(w.id) ? host.getBuffer(w.id) : seedWindowBuffer(w); - } else { - /* 主面板实时:不作废也无妨,直接重拉内存缓冲 */ - host.invalidateBuffer(w.id); - lines = seedWindowBuffer(w); - } + if (opts?.reseed) host.invalidateBuffer(w.id); + const lines = host.hasBuffer(w.id) ? host.getBuffer(w.id) : seedWindowBuffer(w); for (const l of lines) stdout.write(l + "\n"); currentRedraw?.(); } @@ -963,7 +967,7 @@ export async function startRepl(): Promise { } function openLogsWindow(): void { - /* 离开主面板:其缓存可留着,但展示上先清屏再进 /logs;Esc 回来会按实时重 seed 主面板 */ + /* 离开主面板:缓存保留;Esc 回来若主面板已有缓存则直接恢复(含期间 routeLog 追加) */ host.open("logs"); showActiveWindow(); } @@ -1179,6 +1183,26 @@ async function execCmd(parts: string[]): Promise { stdout.write((await cmdUi(args, { block: false })) + "\n"); break; } + case "daemon": { + const sub = args[0]; + if (sub === "stop") { + const { shutdownDaemon } = await import("./daemon/client.js"); + const err = await shutdownDaemon(); + stdout.write((err ? c.red(err) : c.dim(t("daemon.stopped"))) + "\n"); + } else if (sub === "status" || !sub) { + const { readDaemonMeta } = await import("./daemon/paths.js"); + const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); + const meta = readDaemonMeta(); + if (!meta || !(await isProcessAlive(meta.pid))) { + stdout.write(c.dim(t("daemon.notRunning")) + "\n"); + } else { + stdout.write(c.green(t("daemon.running", { pid: String(meta.pid) })) + "\n"); + } + } else { + stdout.write(c.yellow(t("daemon.usage") + "\n")); + } + break; + } case "init": { const wasRaw = stdin.isRaw ?? false; setRaw(false); diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 0211ad79..d00b4c3f 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -258,16 +258,17 @@ function serviceChildEnv(service: ServiceId, optsEnv?: NodeJS.ProcessEnv | null) * * 透传给子进程的 env: * - SFMC_SERVICE / SFMC_ROOT / SFMC_PACKAGES_DIR + * 默认 windowsHide:Windows 上隐藏子进程控制台,避免守护进程拉起 db/qq/bds 时弹出空白窗口。 */ export function spawnService(service: ServiceId, args: string[] = [], opts: SpawnOptions = {}) { const env = serviceChildEnv(service, opts.env as NodeJS.ProcessEnv | undefined); const script = resolveServiceScript(service); - return spawn(nodeBinary(), [script, ...args], { ...opts, env }); + return spawn(nodeBinary(), [script, ...args], { windowsHide: true, ...opts, env }); } /** spawnService 的同步版本 */ export function spawnServiceSync(service: ServiceId, args: string[] = [], opts: SpawnSyncOptions = {}) { const env = serviceChildEnv(service, opts.env as NodeJS.ProcessEnv | undefined); const script = resolveServiceScript(service); - return spawnSync(nodeBinary(), [script, ...args], { ...opts, env }); + return spawnSync(nodeBinary(), [script, ...args], { windowsHide: true, ...opts, env }); } diff --git a/packages/cli/src/services.ts b/packages/cli/src/services.ts index e02c30c9..97f2785f 100644 --- a/packages/cli/src/services.ts +++ b/packages/cli/src/services.ts @@ -35,10 +35,12 @@ import { writeBdsPidFile, } from "@sfmc-bds/bds-tools/process-probe"; import { postBdsLifecycleEvent } from "@sfmc-bds/bds-tools/qq-events-notify"; -import { spawn, type ChildProcess, type IOType } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; +import { isDaemonServer } from "./daemon/role.js"; +import { onRemoteServiceStateChange } from "./daemon/client.js"; import { t } from "./i18n/index.js"; import { resolveLlbotLaunch } from "./llbot-launch.js"; import { inferLevel, pushLog as pushUnifiedLog } from "./logs.js"; @@ -46,6 +48,7 @@ import { findNodeServicePids } from "./node-service-probe.js"; import { ensurePackUpdateConfigFile } from "./pack-update/index.js"; import { recordBdsVersion } from "./bds-runtime-version.js"; import { reportBdsPlayerSession } from "./player-session.js"; +import { startModuleSidecars, stopModuleSidecars } from "./module-sidecars.js"; import { ROOT, spawnService, type ServiceId } from "./runtime.js"; export { ROOT } from "./runtime.js"; @@ -64,12 +67,6 @@ export function nonBlankOutputLines(text: string): string[] { export type ServiceName = "bds" | "db" | "tunnel" | "qq" | "llbot"; export const SERVICE_NAMES: ServiceName[] = ["bds", "db", "tunnel", "qq", "llbot"]; -/** argv 一次性 start 在 POSIX 上须 daemonize,否则父进程退出会带走子进程(Windows 不需要) */ -let argvDaemonize = false; -export function setArgvDaemonize(on: boolean): void { - argvDaemonize = on; -} - /** 单服务 start 结果:optional 服务 validate 失败记为 skipped,不抛错 */ export type StartOutcome = { status: "started" } | { status: "already" } | { status: "skipped"; reason: string }; @@ -239,20 +236,24 @@ class Service { if (this.def.beforeStart) { await this.def.beforeStart(); } + if (this.name === "bds") { + await startModuleSidecars(ROOT, (line, stream) => this.pushLog(line, stream)); + } // beforeStart 含异步检查;更新可能在等待期间开始。 if (this.updateInProgress) throw new Error("BDS 正在更新,暂不可启动"); this.manualStop = false; - const daemonize = argvDaemonize && process.platform !== "win32"; + /* 子进程始终由守护进程用管道持有(stdin 发 stop、stdout 日志、autoRestart); + * 不再对子服务做 detached/unref——脱离终端由 daemon 进程自身负责。 + * windowsHide:Windows 上避免为每个服务弹出空白控制台(stdout 已 pipe,窗内无输出)。 */ const spawnOpts = { cwd: this.def.cwd, - stdio: (daemonize ? "ignore" : ["pipe", "pipe", "pipe"]) as "ignore" | Array, + stdio: ["pipe", "pipe", "pipe"] as Array<"pipe">, env: this.def.env ? { ...process.env, ...this.def.env } : process.env, - detached: daemonize, + windowsHide: true, }; const child = this.def.service ? spawnService(this.def.service, this.def.args ?? [], spawnOpts) : spawn(this.def.cmd as string, this.def.args ?? [], spawnOpts); - if (daemonize) child.unref(); this.proc = child; this.pid = child.pid ?? 0; this.running = true; @@ -421,6 +422,7 @@ class Service { if (filePid === exitingPid) { clearBdsPidFile(ROOT); } + stopModuleSidecars(); } if (this.name === "tunnel" && exitingPid > 0) clearTunnelRuntime(exitingPid); if (wasRunning) { @@ -474,7 +476,8 @@ function createServices(): Record { env: bdsSpawnEnvExtra(bdsPath), stopCommand: "stop", stopTimeout: 30000, - autoRestart: bdsCfg.crash_restart !== false, + /* 崩溃拉起由 sfmc daemon 固定开启;不再读 bds_updater.crash_restart */ + autoRestart: true, restartDelay: 5000, validate: () => { if (!fs.existsSync(bdsExe)) return `not found: ${bdsExe}`; @@ -625,6 +628,10 @@ export type ServiceStateEvent = { name: ServiceName; running: boolean; pid: numb /** 订阅任意服务启停(含探测回写);返回取消函数 */ export function onServiceStateChange(fn: (ev: ServiceStateEvent) => void): () => void { + /* CLI 进程不持有 Service 子进程:改订守护进程推送的远程状态事件 */ + if (!isDaemonServer()) { + return onRemoteServiceStateChange(fn); + } const handler = (ev: ServiceStateEvent): void => { fn(ev); }; @@ -651,8 +658,18 @@ async function reconcileManagedAlive(service: Service): Promise { /** * 统一运行态查询(权威入口):OS/健康探测 + 回写 Service 内存标志。 * status / Tab 发送目标 / reload 等均应走此接口,勿直接读 `service.running`。 + * CLI 进程经守护进程 RPC 获取;守护进程内走本地探活。 */ export async function queryServicesRuntime(): Promise { + if (!isDaemonServer()) { + const { queryRuntimeViaDaemon } = await import("./daemon/client.js"); + return queryRuntimeViaDaemon(); + } + return queryServicesRuntimeLocal(); +} + +/** 守护进程内本地探活实现(亦供 server status 组装 rows) */ +export async function queryServicesRuntimeLocal(): Promise { return Promise.all( SERVICE_NAMES.map(async (name) => { const service = services[name]; diff --git a/packages/cli/src/world-packs.ts b/packages/cli/src/world-packs.ts index 04f402d9..4d324f67 100644 --- a/packages/cli/src/world-packs.ts +++ b/packages/cli/src/world-packs.ts @@ -72,7 +72,7 @@ function rejoinHint(): string { return t("packs.rejoinHint"); } -/** 世界包编排日志:统一走 sfmc 聚合层(source=pack) */ +/** 资源包编排日志:统一走 sfmc 聚合层(source=pack) */ function logPack(text: string, level: LogLevel = "info"): void { pushLog(text, "pack", level); } @@ -185,7 +185,7 @@ function formatUninstallOutcome(r: UninstallOneOutcome): string { } /** - * 卸载单个已装世界包(disable + 移回收站/删除)。 + * 卸载单个已装资源包(disable + 移回收站/删除)。 * 不负责配对 RP / 绑定 —— 由上层编排。返回结构化结果(DIP:展示与策略分离)。 */ async function uninstallOneInstalledPack( diff --git a/packages/create-module/CHANGELOG.md b/packages/create-module/CHANGELOG.md index d0a98c70..7c827f08 100644 --- a/packages/create-module/CHANGELOG.md +++ b/packages/create-module/CHANGELOG.md @@ -6,7 +6,7 @@ - 4a9b066: 模块脚手架不再生成常驻测试目录或测试命令,改以类型检查、构建和实际联调验收。 - efa6e73: 将 Minecraft Script API 类型与新模块模板更新到 BDS 1.26.51 使用的 server 2.11.0-beta、server-ui 2.3.0-beta,并同步 vanilla-data 版本。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 ## 0.1.0-beta.3 diff --git a/packages/db-server/CHANGELOG.md b/packages/db-server/CHANGELOG.md index f4dc4906..9016173a 100644 --- a/packages/db-server/CHANGELOG.md +++ b/packages/db-server/CHANGELOG.md @@ -7,7 +7,7 @@ - aadd2de: Use each chat channel's prefix and QQ forwarding switch for MC messages. Route QQ group messages into the read-only game channel and remove the former bridge channel and MC prefix configuration fields. - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - 4a9b066: 统一 QQ 双后端玩家服务菜单、权限核验、敏感操作确认及错误反馈,修复编号菜单与长名单发送,保留查服、版本和 ip 指令习惯。 新增 public_server 公开连接配置;版本优先使用 CLI 捕获并经状态接口核验的当前 BDS 启动版本。移除在线状态接口的 64 人上限,不改变入服业务状态与审批策略。 diff --git a/packages/db-server/src/domain/bridge.ts b/packages/db-server/src/domain/bridge.ts index d8112503..29833931 100644 --- a/packages/db-server/src/domain/bridge.ts +++ b/packages/db-server/src/domain/bridge.ts @@ -2,8 +2,8 @@ * domain/bridge.ts — MC → QQ 桥接(双后端) * * 数据流: - * SAPI ──POST──→ db-server:3001/api/sfmc/messages - * └─ 写库 + forwardToQQBridge() + * 游戏发言 ──db.tx 插入──→ afterCommit 按频道 forward_to_qq 调用 forwardToQQBridge() + * QQ 入站 ──POST──→ /api/sfmc/messages,写入消息来源为 QQ 的频道(不回推群) * ├─ llbot → LLBot:3004/send_group_msg * └─ official → OpenAPI /v2/groups/{openid}/messages * diff --git a/packages/db-server/src/domain/installed-content.ts b/packages/db-server/src/domain/installed-content.ts index 1af769af..3e9a4660 100644 --- a/packages/db-server/src/domain/installed-content.ts +++ b/packages/db-server/src/domain/installed-content.ts @@ -1,13 +1,13 @@ /** * domain/installed-content.ts — 只读汇总已装模块与世界行为包、资源包。 * - * 供 QQ「模块 / 世界包」查询。模块启停仍以 catalog + lock 为准; - * 世界包名单与显示名跟 CLI 同一条路径:listInstalledWorldPacks 会解析 pack.name 这类语言键。 + * 供 QQ「模块 / 资源包」查询。模块启停仍以 catalog + lock 为准; + * 资源包名单与显示名跟 CLI 同一条路径:listInstalledWorldPacks 会解析 pack.name 这类语言键。 */ +import { listInstalledWorldPacks } from "@sfmc-bds/bds-tools/world-packs"; import fs from "node:fs"; import path from "node:path"; -import { listInstalledWorldPacks } from "@sfmc-bds/bds-tools/world-packs"; /** 一条已安装模块。has_resource_pack 表示该模块目录下有 resource_pack。 */ export type ContentModule = { @@ -232,3 +232,4 @@ export function loadContentSnapshot(opts: { resource_packs: readEnabledWorldResourcePacks(opts.bdsRoot, levelName, packs), }; } + diff --git a/packages/db-server/src/index.ts b/packages/db-server/src/index.ts index dfb9c358..4ca59e5a 100644 --- a/packages/db-server/src/index.ts +++ b/packages/db-server/src/index.ts @@ -22,8 +22,8 @@ import http from "node:http"; import { SQL } from "sql-template-strings"; import { createPlatformTables } from "./db-tables.js"; -import { initSchema } from "./domain/schema.js"; import { syncPlayerSession } from "./domain/player-session.js"; +import { initSchema } from "./domain/schema.js"; import { loadEnv } from "./env.js"; import { createIdempotencyStore } from "./lib/idempotency-store.js"; import { log } from "./lib/log.js"; @@ -47,14 +47,14 @@ import { createModuleConfigRoutes } from "./routes/module-config-routes.js"; import { createServiceRoutes } from "./routes/service-routes.js"; import { forwardToQQBridge, makeOutboundConfig } from "./domain/bridge.js"; +import { loadContentSnapshot } from "./domain/installed-content.js"; import { createQqEventsAggregator, resolveQqEventsConfig, type ResolvedQqEventsConfig } from "./domain/qq-events.js"; import { body as sharedBody, json as sharedJson } from "./lib/http.js"; import { isEnabled, loadModuleLock, saveModuleLock, updateModuleState } from "./lib/module-state.js"; -import { loadContentSnapshot } from "./domain/installed-content.js"; import { createConfigRoutes } from "./routes/config.js"; import { createContentRoutes } from "./routes/content.js"; import { createHealthRoutes } from "./routes/health.js"; -import { createMessagesRoutes } from "./routes/messages.js"; +import { createMessagesRoutes, gameChatForwardPayload, type ChatChannelQQFlags } from "./routes/messages.js"; import { createModuleRoutes } from "./routes/modules.js"; import { createQqBindRoutes } from "./routes/qq-bind.js"; import { createQqEventsRoutes } from "./routes/qq-events.js"; @@ -184,7 +184,7 @@ function buildModuleList() { } /** - * QQ「模块 / 世界包」快照。每次请求现读 catalog 与磁盘,装包后不用重启 db-server。 + * QQ「模块 / 资源包」快照。每次请求现读 catalog 与磁盘,装包后不用重启 db-server。 */ function loadContentSnapshotForRequest() { const modules = (buildModuleList() ?? []).flatMap((raw) => { @@ -368,27 +368,60 @@ const qqEventsRoutes = createQqEventsRoutes({ isAdmin: (openid, asGroupAdmin) => { const admins = (env.qqconfig["official"] as { admin_openids?: unknown } | undefined)?.admin_openids; return ( - (Array.isArray(admins) && admins.includes(openid)) || - (asGroupAdmin && readJoinFlags().treatGroupAdminsAsAdmins) + (Array.isArray(admins) && admins.includes(openid)) || (asGroupAdmin && readJoinFlags().treatGroupAdminsAsAdmins) ); }, }); +/** 读取单个聊天频道。表尚未由聊天模块建出时返回 null,不让请求 500。 */ +function readChatChannel(channelId: string): ChatChannelQQFlags | null { + try { + const rows = query(SQL`SELECT * FROM sfmc_chat_channels WHERE id = ${channelId}`); + return Array.isArray(rows) ? ((rows[0] as ChatChannelQQFlags | undefined) ?? null) : null; + } catch (err) { + log.warn(`读取聊天频道失败: ${(err as Error).message}`); + return null; + } +} + +/** 消息来源为 QQ 的频道。QQ 入站只投到这些频道。 */ +function listQQSourceChannels(): ChatChannelQQFlags[] { + try { + const rows = query(SQL`SELECT * FROM sfmc_chat_channels`); + if (!Array.isArray(rows)) return []; + return (rows as ChatChannelQQFlags[]).filter((row) => row.source_qq === 1); + } catch (err) { + log.warn(`读取 QQ 来源频道失败: ${(err as Error).message}`); + return []; + } +} + const messagesRoutes = createMessagesRoutes({ query, body, json, - getChannelForQQ: (channelId: string) => { - const rows = query( - SQL`SELECT * FROM sfmc_chat_channels WHERE id = ${channelId}` - ); - return Array.isArray(rows) - ? (rows[0] as { prefix: string; forward_to_qq: number; source_game?: number; source_qq?: number; source_system?: number } | undefined) ?? null - : null; - }, + getChannelForQQ: readChatChannel, + listQQSourceChannels, forwardToQQBridge: (channelId: string, prefix: string, fromName: string, content: string, fromId: string) => forwardToQQBridge(currentOutbound(), channelId, prefix, fromName, content, fromId), }); + +// 游戏发言走 db.tx 插入,不经过 /api/sfmc/messages。提交后再按频道开关转发到 QQ。 +txRunner.setAfterCommit((inserts) => { + for (const item of inserts) { + if (item.table !== "sfmc_chat_messages") continue; + const payload = gameChatForwardPayload(item.row, readChatChannel(String(item.row.channel_id ?? ""))); + if (!payload) continue; + forwardToQQBridge( + currentOutbound(), + payload.channelId, + payload.prefix, + payload.fromName, + payload.content, + payload.fromId, + ); + } +}); const configRoutes = createConfigRoutes({ json, projectRoot: env.PROJECT_ROOT, diff --git a/packages/db-server/src/routes/messages.ts b/packages/db-server/src/routes/messages.ts index 319a21df..3ef742bd 100644 --- a/packages/db-server/src/routes/messages.ts +++ b/packages/db-server/src/routes/messages.ts @@ -7,21 +7,30 @@ */ import { SQL } from "sql-template-strings"; +import { log } from "../lib/log.js"; import type { QueryFn } from "../lib/sqlite.js"; +/** 频道上与 QQ 互通相关的列。游戏发言出站和 QQ 入站都读这份结构。 */ +export interface ChatChannelQQFlags { + id?: string; + prefix: string; + forward_to_qq: number; + source_game?: number; + source_qq?: number; + source_system?: number; + /** 私聊不提供转发开关,提交后的出站要跳过。 */ + type?: string; +} + interface Deps { query: QueryFn; body: (req: import("http").IncomingMessage) => Promise>; json: (res: import("http").ServerResponse, data: Record, status?: number) => void; forwardToQQBridge: (channelId: string, prefix: string, fromName: string, content: string, fromId: string) => void; /** 查询来源频道的前缀和 QQ 转发开关。 */ - getChannelForQQ: (channelId: string) => { - prefix: string; - forward_to_qq: number; - source_game?: number; - source_qq?: number; - source_system?: number; - } | null; + getChannelForQQ: (channelId: string) => ChatChannelQQFlags | null; + /** 消息来源为 QQ 的频道。入站不看请求里的频道 id。 */ + listQQSourceChannels: () => ChatChannelQQFlags[]; } /** 是否应将本条消息推到 QQ 群 */ @@ -38,7 +47,44 @@ export function shouldForwardChatToQQ( return true; } -function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannelForQQ }: Deps) { +/** + * 只保留消息来源为 QQ 的频道。 + * 使用场景:QQ 入站扇出,不使用桥接请求里写死的频道 id。 + */ +export function channelsAcceptingQQ(channels: T[]): T[] { + return channels.filter((channel) => channel.source_qq === 1); +} + +/** + * 游戏侧 db.tx 插入的聊天行是否要转发到 QQ。 + * 字段名是蛇形(from_id / channel_id),与 /api/sfmc/messages 的驼峰载荷不同。 + */ +export function gameChatForwardPayload( + row: Record, + channel: ChatChannelQQFlags | null, +): { + channelId: string; + prefix: string; + fromName: string; + content: string; + fromId: string; +} | null { + const channelId = String(row.channel_id ?? row.channelId ?? "").trim(); + const fromId = String(row.from_id ?? row.fromid ?? ""); + // 广播 / SYS 不是玩家发言。私聊没有转发开关,避免旧数据把私聊推到群里。 + if (fromId === "system" || fromId === "SYSTEM" || channel?.type === "private") return null; + if (!channelId || !shouldForwardChatToQQ(channel, fromId)) return null; + const prefix = String(channel?.prefix ?? "").trim() || channelId; + return { + channelId, + prefix, + fromName: String(row.from_name ?? row.fromName ?? ""), + content: String(row.content ?? ""), + fromId, + }; +} + +function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannelForQQ, listQQSourceChannels }: Deps) { return async function handle({ path, method, @@ -81,33 +127,56 @@ function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannel json(res, { success: false, error: "too many requests" }, 413); return true; } + const incoming = messages as Array>; + // QQ 入站不看请求频道,只写入消息来源为 QQ 的频道。 + const qqDestinations = channelsAcceptingQQ(listQQSourceChannels()).filter( + (channel) => String(channel.id ?? "").trim() !== "", + ); // 先检查整个批次,避免前几条已写入后才发现被禁用的来源。 - for (const m of messages as Array>) { - const channel = getChannelForQQ(String(m.channelId ?? "")); + for (const m of incoming) { const fromId = String(m.fromid ?? ""); - const sourceField = fromId.startsWith("qq_") - ? "source_qq" - : fromId === "system" || fromId === "SYSTEM" - ? "source_system" - : "source_game"; + if (fromId.startsWith("qq_")) continue; + const channel = getChannelForQQ(String(m.channelId ?? "")); + const sourceField = + fromId === "system" || fromId === "SYSTEM" ? "source_system" : "source_game"; if (channel?.[sourceField] === 0) { json(res, { success: false, error: "channel source disabled" }, 403); return true; } } - // 多行 INSERT 改成循环单条 INSERT OR REPLACE —— 简单优先 - for (const m of messages as Array>) { + const insertMessage = ( + id: unknown, + channelId: unknown, + m: Record, + ) => { query( SQL`INSERT OR REPLACE INTO sfmc_chat_messages ( id, channel_id, from_id, from_name, type, content, attachment, show_timestamp, created_at ) VALUES ( - ${m.id}, ${m.channelId}, ${m.fromid}, ${m.fromName}, + ${id}, ${channelId}, ${m.fromid}, ${m.fromName}, ${m.type ?? "text"}, ${m.content}, ${m.attachment ?? null}, ${m.showTimestamp ? 1 : 0}, ${m.timestamp} )` ); + }; + let sawQQInbound = false; + for (const m of incoming) { + const fromId = String(m.fromid ?? ""); + if (fromId.startsWith("qq_")) { + sawQQInbound = true; + // 每个目标频道一行,主键带上频道 id,避免多频道互相覆盖。 + for (const channel of qqDestinations) { + const channelId = String(channel.id); + insertMessage(`${String(m.id ?? "")}__${channelId}`, channelId, m); + } + continue; + } + insertMessage(m.id, m.channelId, m); + } + if (sawQQInbound && qqDestinations.length === 0) { + log.warn("没有消息来源为 QQ 的频道,QQ 入站未写入"); } - for (const m of messages as Array>) { + for (const m of incoming) { const channelId = String(m.channelId ?? ""); const fromId = String(m.fromid ?? ""); if (!fromId.trim() || fromId.startsWith("qq_")) continue; @@ -121,7 +190,7 @@ function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannel fromId ); } - json(res, { success: true }); + json(res, { success: true, qqChannels: sawQQInbound ? qqDestinations.length : undefined }); } else { json(res, { success: false, error: "not_found" }, 404); } diff --git a/packages/db-server/src/tx-runner.ts b/packages/db-server/src/tx-runner.ts index 20b18d15..fb51d187 100644 --- a/packages/db-server/src/tx-runner.ts +++ b/packages/db-server/src/tx-runner.ts @@ -131,8 +131,16 @@ export interface TxRequest { steps: TxStep[]; } +/** 事务提交成功后的插入行。聊天消息据此决定是否转发到 QQ。 */ +export interface CommittedInsert { + table: string; + row: Record; +} + interface TxSession { moduleId: string; + /** 已成功执行的步骤,提交后用于 afterCommit,不进入响应体。 */ + steps: TxStep[]; results: TxStepResult[]; idleTimer: ReturnType; } @@ -163,6 +171,11 @@ export class TxRunner { private readonly transactionWaiters: TransactionWaiter[] = []; private readonly slotWaitTimeoutMs: number; private readonly sessionIdleTimeoutMs: number; + /** + * 提交成功后的插入通知。游戏聊天经 db.tx 落库,不走 /api/sfmc/messages, + * 因此 MC→QQ 要在这里挂钩。钩子不得抛错影响已提交结果。 + */ + private afterCommit?: (inserts: CommittedInsert[]) => void; constructor( private readonly deps: TxRunnerDeps, @@ -172,6 +185,14 @@ export class TxRunner { this.sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? 15_000; } + /** + * 注册提交成功后的插入回调。 + * 使用场景:db-server 启动后把聊天行转发给 QQ,避免事务层依赖具体桥接实现。 + */ + setAfterCommit(hook: (inserts: CommittedInsert[]) => void): void { + this.afterCommit = hook; + } + async run(req: TxRequest): Promise { const { moduleId, steps } = req; const manifest = this.deps.enabled.get(moduleId); @@ -217,6 +238,7 @@ export class TxRunner { } this.releaseTransactionSlot(); + this.emitAfterCommit(steps); log.info(`[tx ${traceId}] module=${moduleId} ${steps.length} steps OK`); return { ok: true, results }; } @@ -247,6 +269,7 @@ export class TxRunner { const txId = randomUUID(); this.sessions.set(txId, { moduleId, + steps: [], results: [], idleTimer: this.createSessionIdleTimer(txId, moduleId), }); @@ -283,6 +306,7 @@ export class TxRunner { const stepIndex = session.results.length; try { const r = await this.runOne(moduleId, manifest, step); + session.steps.push(step); session.results.push(r); session.idleTimer = this.createSessionIdleTimer(txId, moduleId); return { ok: true, result: r }; @@ -317,12 +341,30 @@ export class TxRunner { return { ok: false, step: -1, error: `COMMIT 失败: ${(e as Error).message}`, code: "internal" }; } const results = session.results; + const steps = session.steps; this.sessions.delete(txId); this.releaseTransactionSlot(); + this.emitAfterCommit(steps); log.info(`[tx-session ${txId.slice(0, 8)}] commit ${results.length} steps OK`); return { ok: true, results }; } + /** 只通知插入行。回调失败只记日志,事务已经提交。 */ + private emitAfterCommit(steps: TxStep[]): void { + const hook = this.afterCommit; + if (!hook) return; + const inserts: CommittedInsert[] = []; + for (const step of steps) { + if (step.op === "insert") inserts.push({ table: step.table, row: step.row }); + } + if (inserts.length === 0) return; + try { + hook(inserts); + } catch (err) { + log.warn(`[tx] afterCommit: ${(err as Error).message}`); + } + } + /** * 回滚当前交互式事务会话,撤销所有未提交的数据库变更。 * 具有幂等性:会话已结束或已回滚时同样返回成功。 diff --git a/packages/qq-bridge/CHANGELOG.md b/packages/qq-bridge/CHANGELOG.md index f9ef6c5a..82164141 100644 --- a/packages/qq-bridge/CHANGELOG.md +++ b/packages/qq-bridge/CHANGELOG.md @@ -16,7 +16,7 @@ - efa6e73: 修正官方群指令面板同步:全局面板不再请求关联群,指定群面板仅在缺少目标群时追加关联。 - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - efa6e73: 官方 QQ 机器人接收群全量消息时,可直接识别无需 @ 的指令,并避免与群 @ 事件重复处理。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - efa6e73: 为 QQ 管理菜单增加可即时生效的服务器事件推送开关,并补充 BDS 正常停服通知。 - Updated dependencies [4a9b066] - Updated dependencies [aadd2de] diff --git a/packages/qq-bridge/index.js b/packages/qq-bridge/index.js index 1ad86735..1e4a3177 100644 --- a/packages/qq-bridge/index.js +++ b/packages/qq-bridge/index.js @@ -11,14 +11,14 @@ * 启动方式: * - `npm start` -> `node dist/index.js` (直接走编译产物) * - `node index.js` -> 经本 shim 委托到 dist/index.js - * - `npm run dev` -> `tsx src/index.ts` (开发态,不走 dist) - * - `npm run build` -> `tsc -p tsconfig.json` 生成 dist/ + * - `pnpm run dev` -> `tsx src/index.ts` (开发态,不走 dist) + * - `pnpm run build` -> `tsc -p tsconfig.json` 生成 dist/ */ import("./dist/index.js").catch((e) => { const msg = e instanceof Error ? e.message : String(e); if (/MODULE_NOT_FOUND|Cannot find module/.test(msg)) { - console.error("[QQBridge] dist/ 未找到,请先运行 `npm run build`"); + console.error("[QQBridge] dist/ 未找到,请先运行 `pnpm run build`"); process.exit(2); } console.error("[QQBridge] 启动失败:", msg); diff --git a/packages/qq-bridge/src/commands/content-format.ts b/packages/qq-bridge/src/commands/content-format.ts index 36affd56..c59b774f 100644 --- a/packages/qq-bridge/src/commands/content-format.ts +++ b/packages/qq-bridge/src/commands/content-format.ts @@ -1,8 +1,8 @@ /** - * content-format.ts — 把模块 / 世界包快照排成 QQ 信息卡正文。 + * content-format.ts — 把模块 / 资源包快照排成 QQ 信息卡正文。 * * 「模块」列出启停和是否自带资源包。 - * 「世界包」的 Markdown 用表格分列行为包和资源包;纯文本仍保留原来的逐行列表,供 Markdown 发送失败时降级。 + * 「资源包」的 Markdown 用表格分列行为包和资源包;纯文本仍保留原来的逐行列表,供 Markdown 发送失败时降级。 */ export type ContentModuleView = { @@ -12,7 +12,7 @@ export type ContentModuleView = { has_resource_pack?: boolean; }; -/** 一条世界包。kind 缺省时按资源包处理,兼容还没分开返回的旧快照。 */ +/** 一条资源包。kind 缺省时按资源包处理,兼容还没分开返回的旧快照。 */ export type WorldPackView = { kind?: "behavior" | "resource"; name?: string; @@ -27,7 +27,12 @@ export function moduleMatches(mod: ContentModuleView, keyword: string): boolean const q = keyword.trim().toLowerCase(); if (!q) return true; const name = String(mod.display_name || mod.id || "").toLowerCase(); - return name.includes(q) || String(mod.id || "").toLowerCase().includes(q); + return ( + name.includes(q) || + String(mod.id || "") + .toLowerCase() + .includes(q) + ); } /** 模块查询正文。已启用排在前面,便于在群里扫一眼当前在跑什么。 */ @@ -54,14 +59,12 @@ export function formatModuleLines(modules: ContentModuleView[], keyword = ""): s return lines; } -/** 单条世界包:真实名称优先,语言键残留时退回文件夹名。 */ +/** 单条资源包:真实名称优先,语言键残留时退回文件夹名。 */ function formatWorldPackLine(pack: WorldPackView): string { const resolved = String(pack.name || "").trim(); const folder = String(pack.folder_name || "").trim(); const name = - !resolved || resolved.startsWith("pack.") || resolved === "pack" - ? folder || pack.pack_id || "未命名" - : resolved; + !resolved || resolved.startsWith("pack.") || resolved === "pack" ? folder || pack.pack_id || "未命名" : resolved; const folderBit = folder && folder !== name ? `${folder} · ` : ""; const version = pack.version ? ` v${pack.version}` : ""; const state = pack.enabled === false ? "关" : "开"; @@ -70,7 +73,11 @@ function formatWorldPackLine(pack: WorldPackView): string { /** 表格单元格:去掉基岩版颜色码,并转义会拆列的竖线。 */ function markdownCell(value: string): string { - const text = value.replace(/§[0-9a-zA-Z]/g, "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); + const text = value + .replace(/§[0-9a-zA-Z]/g, "") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); return text || "—"; } @@ -79,9 +86,7 @@ function packFields(pack: WorldPackView): { state: string; name: string; version const resolved = String(pack.name || "").trim(); const folder = String(pack.folder_name || "").trim(); const name = - !resolved || resolved.startsWith("pack.") || resolved === "pack" - ? folder || pack.pack_id || "未命名" - : resolved; + !resolved || resolved.startsWith("pack.") || resolved === "pack" ? folder || pack.pack_id || "未命名" : resolved; return { state: pack.enabled === false ? "关" : "开", name: markdownCell(name), @@ -98,21 +103,21 @@ function markdownTable(rows: Array<{ state: string; name: string; version: strin } /** - * 世界包的 Markdown 正文。行为包、资源包各一张表,已启用排在前面。 + * 资源包的 Markdown 正文。行为包、资源包各一张表,已启用排在前面。 * 读不到世界时只给说明,不输出空表。 */ export function formatPackMarkdown(packs: WorldPackView[], worldNote?: string): string { if (worldNote === "bds_unconfigured") { - return ["## 世界包", "", "未配置 BDS 路径,暂时无法读取世界行为包和资源包。"].join("\n"); + return ["## 资源包", "", "未配置 BDS 路径,暂时无法读取世界行为包和资源包。"].join("\n"); } if (worldNote === "world_unread") { - return ["## 世界包", "", "世界行为包和资源包暂时读不出来。"].join("\n"); + return ["## 资源包", "", "世界行为包和资源包暂时读不出来。"].join("\n"); } const sections: Array<{ kind: "behavior" | "resource"; title: string; empty: string }> = [ { kind: "behavior", title: "行为包", empty: "世界里没有行为包。" }, { kind: "resource", title: "资源包", empty: "世界里没有资源包。" }, ]; - const lines = ["## 世界包", ""]; + const lines = ["## 资源包", ""]; for (const section of sections) { const group = packs.filter((pack) => (pack.kind ?? "resource") === section.kind); const enabled = group.filter((pack) => pack.enabled !== false); @@ -129,7 +134,7 @@ export function formatPackMarkdown(packs: WorldPackView[], worldNote?: string): } /** - * 世界包查询正文。行为包和资源包分开展示,已启用排在前面。 + * 资源包查询正文。行为包和资源包分开展示,已启用排在前面。 * worldNote 来自 db-server:未配置 BDS 或世界目录读失败时只说明原因。 */ export function formatPackLines(packs: WorldPackView[], worldNote?: string): string[] { @@ -155,3 +160,4 @@ export function formatPackLines(packs: WorldPackView[], worldNote?: string): str } return lines; } + diff --git a/packages/qq-bridge/src/commands/db-api.ts b/packages/qq-bridge/src/commands/db-api.ts index 58ab0bcb..052a5b1c 100644 --- a/packages/qq-bridge/src/commands/db-api.ts +++ b/packages/qq-bridge/src/commands/db-api.ts @@ -279,7 +279,7 @@ export type ContentSnapshotResponse = { }>; }; -/** 查询游戏机上的模块与世界行为包、资源包。当前给 QQ「模块」「世界包」使用。 */ +/** 查询游戏机上的模块与世界行为包、资源包。当前给 QQ「模块」「资源包」使用。 */ export async function fetchContent(ep: DbEndpoint): Promise { const { data } = await requestJson(ep, "GET", "/api/sfmc/content"); return data; diff --git a/packages/qq-bridge/src/commands/handlers.ts b/packages/qq-bridge/src/commands/handlers.ts index c8e20a20..6fd1b19d 100644 --- a/packages/qq-bridge/src/commands/handlers.ts +++ b/packages/qq-bridge/src/commands/handlers.ts @@ -4,6 +4,8 @@ import { getGroupBotState, getGroupInfo } from "@sfmc-bds/sdk/node/qq-official"; import { log } from "../log.js"; +import { formatAccountCard } from "./account-format.js"; +import { formatModuleLines, formatPackLines, formatPackMarkdown } from "./content-format.js"; import { fetchBindMe, fetchContent, @@ -16,8 +18,6 @@ import { type QqEventSettings, type SfmcStatusResponse, } from "./db-api.js"; -import { formatAccountCard } from "./account-format.js"; -import { formatModuleLines, formatPackLines, formatPackMarkdown } from "./content-format.js"; import { formatCard, formatCommandMenu } from "./menu-format.js"; import type { CommandRegistry } from "./registry.js"; import type { CommandContext, CommandHandler, CommandResult } from "./types.js"; @@ -466,7 +466,7 @@ export const eventSettingsHandler: CommandHandler = async (ctx): Promise ({ - text: "QQ群消息进入游戏内只读的 QQ 频道。游戏内各频道可分别设置是否转发到 QQ。", + text: "QQ群消息进入游戏内已开启「QQ」消息来源的频道。游戏内各频道可分别设置是否转发到 QQ。", }); export const doctorHandler: CommandHandler = async (ctx) => { @@ -479,7 +479,7 @@ export const doctorHandler: CommandHandler = async (ctx) => { text: [ "管理自检", `后端:${ctx.inbound.backend}`, - `QQ 入站频道:${ctx.runtimeInfo.bridgeChannelId || "qq"}`, + "QQ 入站:写入消息来源为 QQ 的游戏频道", `主机:${st.host?.hostname || "未知"} · ${st.host?.platform || "未知"}`, `CPU:${st.host?.cpu?.model || "未知"} · ${st.host?.cpu?.cores ?? "未知"} 核`, `内存:${st.host?.memory?.usedMb ?? "未知"}/${st.host?.memory?.totalMb ?? "未知"} MB`, @@ -522,7 +522,7 @@ export const packsHandler: CommandHandler = async (ctx) => { ...(pack.version ? { version: pack.version } : {}), })); return { - text: ["世界包", "", ...formatPackLines(packs, data.note)].join("\n"), + text: ["资源包", "", ...formatPackLines(packs, data.note)].join("\n"), markdown: formatPackMarkdown(packs, data.note), }; } catch (error) { @@ -558,13 +558,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { "home" ); add("account", ["我的账号"], "查看与管理游戏角色绑定", whoamiHandler, "home"); - add( - "entry", - ["入服"], - "直接进服,游戏内绑定后即可游玩", - entryGuideHandler, - "home" - ); + add("entry", ["入服"], "直接进服,游戏内绑定后即可游玩", entryGuideHandler, "home"); add("help", ["帮助"], "查看使用说明和常用命令", helpHandler(registry), "home"); add("admin", ["管理"], "管理菜单", createAdminMenuHandler(registry), "home", true); add("status", ["查服", "状态"], "服务器运行状态", statusHandler, "server"); @@ -572,7 +566,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { add("version", ["版本"], "基岩版入服版本", serverVersionHandler, "server"); add("ip", ["地址"], "公开服务器地址与端口", serverAddressHandler, "server"); add("modules", ["模块"], "查看已安装模块", modulesHandler, "server"); - add("packs", ["资源包", "行为包", "世界包"], "查看世界行为包和资源包", packsHandler, "server"); + add("packs", ["资源包", "行为包", "资源包"], "查看世界行为包和资源包", packsHandler, "server"); add("channel", ["频道"], "聊天互通状态", channelHandler, "server"); add("ping", [], "机器人连通性", pingHandler, "server"); add("whoami", ["我的绑定"], "查看绑定角色", whoamiHandler, "account"); diff --git a/packages/qq-bridge/src/commands/menu-format.ts b/packages/qq-bridge/src/commands/menu-format.ts index a167910a..6b2e0ffa 100644 --- a/packages/qq-bridge/src/commands/menu-format.ts +++ b/packages/qq-bridge/src/commands/menu-format.ts @@ -13,7 +13,7 @@ export function pickDisplayLabel(cmd: RegisteredCommand, maxChars = 8): string { channel: "聊天互通", ping: "连接检查", modules: "模块列表", - packs: "世界包", + packs: "资源包", }; const label = labels[cmd.name]; if (label) return label.slice(0, maxChars); diff --git a/packages/qq-bridge/src/commands/message-text.ts b/packages/qq-bridge/src/commands/message-text.ts index e7203503..36cc8615 100644 --- a/packages/qq-bridge/src/commands/message-text.ts +++ b/packages/qq-bridge/src/commands/message-text.ts @@ -6,9 +6,7 @@ export function officialPlainFallback(result: CommandResult): string { const items = result.buttons .filter((button) => button.command !== "/menu") .map((button) => `${button.label}${button.description ? ` · ${button.description}` : ""}`); - const home = result.buttons.some((button) => button.command === "/menu") - ? ["", "发送「菜单」返回首页。"] - : []; + const home = result.buttons.some((button) => button.command === "/menu") ? ["", "发送「菜单」返回首页。"] : []; return [result.text, "", ...items, ...home].join("\n"); } @@ -31,7 +29,7 @@ function byteLength(lines: string[]): number { /** * 按行拆分 Markdown,并保证每一段里的表格仍然完整。 * 一张表超长时,下一段重复表头,并在小节标题后标明「续」。 - * 当前给 QQ 世界包表格使用:官方消息按段发送,半张表无法渲染。 + * 当前给 QQ 资源包表格使用:官方消息按段发送,半张表无法渲染。 */ export function splitMarkdownMessage(markdown: string, maxBytes = 3000): string[] { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); diff --git a/packages/qq-bridge/src/commands/reply-official.ts b/packages/qq-bridge/src/commands/reply-official.ts index 003f0349..b4d3156b 100644 --- a/packages/qq-bridge/src/commands/reply-official.ts +++ b/packages/qq-bridge/src/commands/reply-official.ts @@ -22,7 +22,7 @@ export function createOfficialReplyPort(creds: QqOfficialCredentials): ReplyPort return { async send(target: ReplyTarget, result: CommandResult, inbound: InboundMessage): Promise { const markdown = result.markdown?.trim() ?? ""; - // 有 Markdown 时按表格边界拆段,避免超长世界包列表被降成纯文本。 + // 有 Markdown 时按表格边界拆段,避免超长资源包列表被降成纯文本。 const parts = markdown ? splitMarkdownMessage(markdown) : splitMessage(result.text); for (let index = 0; index < parts.length; index++) { const content = parts[index]!; diff --git a/packages/qq-bridge/src/console.ts b/packages/qq-bridge/src/console.ts index 4a92a00d..05549a75 100644 --- a/packages/qq-bridge/src/console.ts +++ b/packages/qq-bridge/src/console.ts @@ -65,7 +65,7 @@ function handleCommand(line: string, opts: ConsoleOptions, rl: RLInterface): boo case "reload": { reloadInto(opts.config); log.info( - `配置已重载 (backend=${opts.config.qq_backend}, channel=qq, db=${opts.config.db_host}:${opts.config.db_port})` + `配置已重载 (backend=${opts.config.qq_backend}, db=${opts.config.db_host}:${opts.config.db_port})` ); log.warn("reload 只更新内存配置引用;切换 backend / AppID 需重启进程"); return false; diff --git a/packages/qq-bridge/src/dbserver.ts b/packages/qq-bridge/src/dbserver.ts index 38fd10ed..1c3781c4 100644 --- a/packages/qq-bridge/src/dbserver.ts +++ b/packages/qq-bridge/src/dbserver.ts @@ -13,7 +13,11 @@ import type { IncomingChatMessage, PostMessagesBody } from "./types.js"; export interface DBServerConfig { host: string; port: number; - channelId: string; + /** + * 已不再作为入站目标。QQ 消息由 db-server 写入消息来源为 QQ 的频道。 + * 保留字段只为兼容旧调用方,转发时不会带上。 + */ + channelId?: string; } const REQUEST_TIMEOUT_MS = 5_000; @@ -76,10 +80,9 @@ export async function forwardGroupMessage( fromName: string, content: string, now: number = Date.now() -): Promise { +): Promise<{ qqChannels?: number }> { const message: IncomingChatMessage = { id: `${fromId}_${now}`, - channelId: cfg.channelId, fromid: fromId, fromName, type: "text", @@ -89,7 +92,7 @@ export async function forwardGroupMessage( }; const body: PostMessagesBody = { messages: [message] }; const url = new URL(`http://${cfg.host}:${cfg.port}/api/sfmc/messages`); - await postJSON(url, body, REQUEST_TIMEOUT_MS); + return postJSON<{ qqChannels?: number }>(url, body, REQUEST_TIMEOUT_MS); } /** @@ -107,7 +110,11 @@ export async function tryForward( content: string ): Promise { try { - await forwardGroupMessage(cfg, fromId, fromName, content); + const result = await forwardGroupMessage(cfg, fromId, fromName, content); + if (result && typeof result === "object" && result.qqChannels === 0) { + log.warn("没有消息来源为 QQ 的游戏频道,本条未投递"); + return; + } log.info(`QQ → MC: ${fromName}: ${content.slice(0, 60)}`); } catch (e) { log.error(`转发到 db-server 失败: ${(e as Error).message}`); diff --git a/packages/qq-bridge/src/index.ts b/packages/qq-bridge/src/index.ts index da182d75..57e689cc 100644 --- a/packages/qq-bridge/src/index.ts +++ b/packages/qq-bridge/src/index.ts @@ -39,7 +39,6 @@ async function main(): Promise { const db = { host: cfg.db_host, port: cfg.db_port, - channelId: "qq", }; if (cfg.qq_backend === "official") { @@ -64,7 +63,6 @@ async function main(): Promise { dbPort: cfg.db_port, groupOpenid: cfg.qq_group_openid, adminOpenids: Array.isArray(cfg.qq_admin_openids) ? cfg.qq_admin_openids : [], - bridgeChannelId: "qq", officialCreds: creds, }, startedAt @@ -122,7 +120,7 @@ async function main(): Promise { installQqRuntimeStatusHooks("official"); log.info( - `官方后端已启动 (transport=${cfg.qq_official_transport}, sandbox=${cfg.qq_sandbox}, group_openid=${cfg.qq_group_openid || "未配置"}, channel=qq, db=${cfg.db_host}:${cfg.db_port})` + `官方后端已启动 (transport=${cfg.qq_official_transport}, sandbox=${cfg.qq_sandbox}, group_openid=${cfg.qq_group_openid || "未配置"}, db=${cfg.db_host}:${cfg.db_port})` ); startConsole({ @@ -146,7 +144,6 @@ async function main(): Promise { dbHost: cfg.db_host, dbPort: cfg.db_port, adminOpenids: Array.isArray(cfg.qq_admin_openids) ? cfg.qq_admin_openids : [], - bridgeChannelId: "qq", }, startedAt ); @@ -166,7 +163,7 @@ async function main(): Promise { installQqRuntimeStatusHooks("llbot"); log.info( - `LLBot 后端等待连接 (主群: ${cfg.qq_group_id || "未配置"}, channel: qq, db: ${cfg.db_host}:${cfg.db_port})` + `LLBot 后端等待连接 (主群: ${cfg.qq_group_id || "未配置"}, db: ${cfg.db_host}:${cfg.db_port})` ); startConsole({ diff --git a/packages/qq-bridge/src/official/events.ts b/packages/qq-bridge/src/official/events.ts index b1089382..a29c0988 100644 --- a/packages/qq-bridge/src/official/events.ts +++ b/packages/qq-bridge/src/official/events.ts @@ -134,7 +134,7 @@ export class OfficialAtMessageDispatcher { this.forward = opts.forward ?? tryForward; } - /** 全量群消息只处理指令;普通聊天仍需 @机器人 才转发到游戏。 */ + /** 全量群消息处理指令和普通聊天;与 @ 事件共用消息 ID 去重。 */ async handleGroupMessage(msg: OfficialGroupAtMessage): Promise { if (!msg || typeof msg !== "object" || msg.author?.bot) return; const groupOpenid = String(msg.group_openid ?? ""); @@ -144,28 +144,29 @@ export class OfficialAtMessageDispatcher { msg.mentions?.length && !msg.mentions.some((mention) => mention.is_you) ) return; - const router = this.opts.commandRouter; - if (!router) return; const text = extractOfficialText(msg); if (!text) return; + if (this.dedup.seen(msg.id)) return; + const router = this.opts.commandRouter; const normalized = text.replace(/^[//]+/, "").trim(); const isConfirmation = /^(confirm|确认|cancel|取消)(?:\s+\S+)?$/i.test(normalized); - if (!router.registry.resolve(normalized) && !isConfirmation && !/^[//]/.test(text)) return; - // 全量与 @ 事件可能同时到达;先占用消息 ID,再执行异步指令。 - if (this.dedup.seen(msg.id)) return; - const memberId = String(msg.author?.member_openid || msg.author?.id || "unknown"); const fromName = String(msg.author?.username || `QQ_${memberId.slice(0, 8)}`); - await router.handle({ - backend: "official", - scene: "group", - groupId: groupOpenid, - userId: memberId, - userName: fromName, - text, - isGroupAdmin: detectOfficialGroupAdmin(msg), - ...(msg.id ? { msgId: String(msg.id) } : {}), - }); + if ( + router && + (router.registry.resolve(normalized) || isConfirmation || /^[//]/.test(text)) && + await router.handle({ + backend: "official", + scene: "group", + groupId: groupOpenid, + userId: memberId, + userName: fromName, + text, + isGroupAdmin: detectOfficialGroupAdmin(msg), + ...(msg.id ? { msgId: String(msg.id) } : {}), + }) + ) return; + await this.forward(this.opts.db, `qq_${memberId}`, fromName, text); } async handleGroupAtMessage(msg: OfficialGroupAtMessage): Promise { diff --git a/packages/qq-bridge/src/onebot.ts b/packages/qq-bridge/src/onebot.ts index b4184d9e..8cda078a 100644 --- a/packages/qq-bridge/src/onebot.ts +++ b/packages/qq-bridge/src/onebot.ts @@ -135,7 +135,7 @@ export class RecentMessageDedup { export interface DispatcherOptions { /** 群 ID 字符串,空 = 接收所有群(旧行为:空时不匹配任何群,不会触发) */ qqGroupId: string; - /** db-server 配置 + 目标 channelId */ + /** db-server 连接。入站频道由消息来源为 QQ 的游戏频道决定。 */ db: DBServerConfig; /** QQ 侧指令路由;命中则不转发 MC */ commandRouter?: CommandRouter; diff --git a/packages/qq-bridge/src/types.ts b/packages/qq-bridge/src/types.ts index e3420aa4..cff7a790 100644 --- a/packages/qq-bridge/src/types.ts +++ b/packages/qq-bridge/src/types.ts @@ -133,7 +133,8 @@ export interface QQBridgeConfig { // ── POST 到 db-server 的消息载荷 ────────────────────────────── export interface IncomingChatMessage { id: string; - channelId: string; + /** 游戏侧写入时使用。QQ 入站不带此字段,由 db-server 按消息来源投递。 */ + channelId?: string; fromid: string; fromName: string; type: "text"; diff --git a/packages/sfmc-extension/src/panels/commands.ts b/packages/sfmc-extension/src/panels/commands.ts index 2508156b..a696b56c 100644 --- a/packages/sfmc-extension/src/panels/commands.ts +++ b/packages/sfmc-extension/src/panels/commands.ts @@ -155,8 +155,8 @@ export async function cmdNewModule(): Promise { const kind = await vscode.window.showQuickPick( [ - { label: "社区包", description: "@/sfmc-module-", kind: "community" as const }, - { label: "官方包", description: "@sfmc-bds/module-", kind: "official" as const }, + { label: "社区包", description: "@/sfmc-module-", moduleKind: "community" as const }, + { label: "官方包", description: "@sfmc-bds/module-", moduleKind: "official" as const }, ], { placeHolder: "npm 包范围" } ); @@ -164,7 +164,7 @@ export async function cmdNewModule(): Promise { let scope: string | undefined; let official = false; - if (kind.kind === "official") { + if (kind.moduleKind === "official") { official = true; } else { scope = await vscode.window.showInputBox({ diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md index 96279fc8..697197b4 100644 --- a/packages/tools/CHANGELOG.md +++ b/packages/tools/CHANGELOG.md @@ -184,7 +184,7 @@ ### Patch Changes -- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 +- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。资源包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 - Updated dependencies [3714053] - Updated dependencies [5ada90e] - @sfmc-bds/bds-tools@0.2.0-beta.2 From 2338b9f11d51c12f6878712fc7d930adca200f31 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Tue, 29 Sep 2026 04:29:56 +0800 Subject: [PATCH 8/8] chore: preserve workspace changes for release --- modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts index 5ad34e7d..7cea1697 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts @@ -12,8 +12,10 @@ let _systemMsgHandler: ((player: Player, text: string) => void) | null = null; * * @param handler 接收目标玩家与消息文本的转发回调函数。 */ -export function registerSystemMsgHandler(handler: (player: Player, text: string) => void): void { +export function registerSystemMsgHandler(handler: (player: Player, text: string) => void): () => void { _systemMsgHandler = handler; + const registered = handler; + return () => { if (_systemMsgHandler === registered) _systemMsgHandler = null; }; } /**