From 7c259c8b0806060df9700ba75b160565c5006895 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 07:43:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E6=A8=A1=E5=9D=97=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=B7=B3=E8=BF=87=E5=B7=B2=E6=94=B6=E7=BC=96=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit monitor 等已收编至平台的模块不再进入升级计划,依赖它们的模块仍可更新。 Co-authored-by: Shiroha --- .changeset/module-update-skip-retired.md | 6 ++ docs/zh/api/monitoring.md | 2 +- .../module-install/lib/module-update.mjs | 23 ++++- .../test/module-update.test.mjs | 94 +++++++++++++++++++ packages/cli/src/i18n/locales/en.ts | 1 + packages/cli/src/i18n/locales/zh-CN.ts | 1 + packages/cli/src/module-update/index.ts | 1 + .../desktop/src/renderer/dev/mock-bridge.ts | 2 +- .../src/renderer/pages/UpdatesPage.tsx | 1 + 9 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 .changeset/module-update-skip-retired.md diff --git a/.changeset/module-update-skip-retired.md b/.changeset/module-update-skip-retired.md new file mode 100644 index 00000000..86ef5926 --- /dev/null +++ b/.changeset/module-update-skip-retired.md @@ -0,0 +1,6 @@ +--- +"@sfmc-bds/cli": patch +"@sfmc-bds/desktop": patch +--- + +模块更新跳过已收编至平台的模块,例如 monitor。 diff --git a/docs/zh/api/monitoring.md b/docs/zh/api/monitoring.md index 12f7debd..85a423a8 100644 --- a/docs/zh/api/monitoring.md +++ b/docs/zh/api/monitoring.md @@ -30,7 +30,7 @@ SDK 的 `@sfmc-bds/sdk/sapi/runtime` 导出 `getRuntimeMetrics()` 和 `getRuntim ## monitor 弃用与兼容 -`monitor` 已收编至平台。新行为包构建和 SDK 生命周期跳过旧模块;CLI 不再允许安装或启用,桌面与模块索引移除安装入口。无需手动删除旧包目录、`monitor.json`、启用记录或 `sfmc_monitor_*` 历史表。 +`monitor` 已收编至平台。新行为包构建、SDK 生命周期和模块更新跳过旧模块;CLI 不再允许安装、启用或更新,桌面与模块索引移除安装入口。无需手动删除旧包目录、`monitor.json`、启用记录或 `sfmc_monitor_*` 历史表。 平台保留 `/c:status`(高级管理员权限)、`tps.current`、`tps.status` 和 `monitor.metrics`。跨模块服务仍要求有效调用方及 `services.requires` 声明。既有 `requires: ["monitor"]` 视为已由平台满足,不能让任意模块声明 `_platform` 身份或抢占平台服务。 diff --git a/packages/cli/scripts/module-install/lib/module-update.mjs b/packages/cli/scripts/module-install/lib/module-update.mjs index 7961a08d..43a4bde8 100644 --- a/packages/cli/scripts/module-install/lib/module-update.mjs +++ b/packages/cli/scripts/module-install/lib/module-update.mjs @@ -4,11 +4,12 @@ * 安装目录若是 symlink / junction,只记 local-source 跳过,不换包。 * 下载与落盘由调用方注入的 install,本文件不复制第二套安装逻辑。 */ +import { isRetiredPlatformModule, RETIRED_PLATFORM_MODULES } from "@sfmc-bds/sdk/contracts"; +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import fsp from "node:fs/promises"; -import path from "node:path"; -import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; +import path from "node:path"; import { upsertCatalogEntry } from "./catalog.mjs"; import { readJson } from "./io.mjs"; import { loadModuleUpdateConfig } from "./module-update-config.mjs"; @@ -54,6 +55,15 @@ import { compareSemver, isMajorBump, satisfiesSdk } from "./semver.mjs"; /** 这些跳过原因在开服日志里不展开,避免每轮刷屏。 */ export const QUIET_SKIP_REASONS = new Set(["auto-off", "local-source", "up-to-date"]); +/** + * 目录名或 manifest.id 是否已收编至平台。 + * @param {string | null | undefined} id + */ +function isRetiredModuleId(id) { + const raw = String(id ?? ""); + return isRetiredPlatformModule(raw) || isRetiredPlatformModule(normalizeModuleId(raw)); +} + /** * 读取本机 @sfmc-bds/sdk 版本,用来对照索引里的 sdk 范围。 * @param {string} [cliPkgDir] @@ -368,6 +378,7 @@ export async function planModuleUpdates(options = {}) { installedNorm.add(normalizeModuleId(folder)); if (pin.manifestId) installedNorm.add(normalizeModuleId(pin.manifestId)); } + for (const id of RETIRED_PLATFORM_MODULES) installedNorm.add(normalizeModuleId(id)); /** @type {ModuleUpgrade[]} */ const upgrades = []; @@ -390,6 +401,10 @@ export async function planModuleUpdates(options = {}) { if (wanted.size > 0 && !keys.some((key) => wanted.has(key))) continue; const dir = packageDirOf(root, folder); const installedVersion = readPackageVersion(dir) ?? pin.installedVersion; + if ([folder, manifestId].some((id) => isRetiredModuleId(id))) { + skipped.push({ id: folder, reason: "retired", fromVersion: installedVersion, toVersion: null, detail: "" }); + continue; + } const indexEntry = index ? index[folder] || index[normalizeModuleId(manifestId || "")] || null : null; /* 目录本身是开发链接时先跳过,避免 pin 写成 npm 后仍去查 dist-tag 并换包。 */ const linkSkip = skipWhenDevLink( @@ -538,6 +553,10 @@ export async function applyModuleUpgrades(upgrades, deps) { /** @type {{ id: string, message: string }[]} */ const failed = []; for (const item of upgrades) { + if (isRetiredModuleId(item.id)) { + skipped.push({ id: item.id, reason: "retired", fromVersion: item.fromVersion, toVersion: item.toVersion, detail: "" }); + continue; + } const linkSkip = skipWhenDevLink(item.id, packageDirOf(deps.root, item.id), item.fromVersion, item.toVersion); if (linkSkip) { skipped.push(linkSkip); diff --git a/packages/cli/scripts/module-install/test/module-update.test.mjs b/packages/cli/scripts/module-install/test/module-update.test.mjs index e901296b..0b80e777 100644 --- a/packages/cli/scripts/module-install/test/module-update.test.mjs +++ b/packages/cli/scripts/module-install/test/module-update.test.mjs @@ -529,3 +529,97 @@ test("新播种的更新配置在开服时不自动应用,已有文件保持 assert.equal(keptPlan.applyOnStart, true); assert.equal(fs.readFileSync(keptFile, "utf8"), before); }); + +test("弃用模块不进入升级,依赖它的模块仍可更新", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-retired-")); + const monitor = path.join(root, "modules", "packages", "monitor"); + fs.mkdirSync(path.join(monitor, "sapi"), { recursive: true }); + fs.writeFileSync(path.join(monitor, "package.json"), JSON.stringify({ name: "@sfmc-bds/module-monitor", version: "0.2.1" })); + fs.writeFileSync(path.join(monitor, "sapi", "manifest.json"), JSON.stringify({ id: "monitor", configKey: "monitor", requires: [] })); + const chat = path.join(root, "modules", "packages", "chat"); + fs.mkdirSync(path.join(chat, "sapi"), { recursive: true }); + fs.writeFileSync(path.join(chat, "package.json"), JSON.stringify({ name: "@sfmc-bds/module-chat", version: "1.0.0" })); + fs.writeFileSync( + path.join(chat, "sapi", "manifest.json"), + JSON.stringify({ id: "chat", configKey: "chat", requires: ["monitor"] }) + ); + const plan = await planModuleUpdates({ + root, + hostSdk: "0.2.5", + ids: ["monitor"], + fetchIndex: async () => ({ + index: { + monitor: { npm: "@sfmc-bds/module-monitor", version: "0.5.0", sdk: ">=0.2.0" }, + chat: { npm: "@sfmc-bds/module-chat", version: "1.1.0", sdk: ">=0.2.2" }, + }, + offline: false, + }), + fetchDistTag: async () => { + throw new Error("retired module should not query dist-tag"); + }, + }); + assert.equal(plan.upgrades.length, 0); + const skipped = plan.skipped.find((item) => item.id === "monitor"); + assert.equal(skipped?.reason, "retired"); + assert.equal(skipped?.fromVersion, "0.2.1"); + assert.equal(skipped?.toVersion, null); + + const withChat = await planModuleUpdates({ + root, + hostSdk: "0.2.5", + fetchIndex: async () => ({ + index: { + monitor: { npm: "@sfmc-bds/module-monitor", version: "0.5.0", sdk: ">=0.2.0" }, + chat: { npm: "@sfmc-bds/module-chat", version: "1.1.0", sdk: ">=0.2.2" }, + }, + offline: false, + }), + fetchDistTag: async () => { + throw new Error("indexed modules should not query dist-tag"); + }, + }); + assert.deepEqual(withChat.upgrades.map((item) => item.id), ["chat"]); + assert.equal(withChat.skipped.some((item) => item.reason === "missing-dependency"), false); + assert.equal(withChat.skipped.find((item) => item.id === "monitor")?.reason, "retired"); + + const dependent = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-retired-dep-")); + const onlyChat = path.join(dependent, "modules", "packages", "chat"); + fs.mkdirSync(path.join(onlyChat, "sapi"), { recursive: true }); + fs.writeFileSync(path.join(onlyChat, "package.json"), JSON.stringify({ name: "@sfmc-bds/module-chat", version: "1.0.0" })); + fs.writeFileSync( + path.join(onlyChat, "sapi", "manifest.json"), + JSON.stringify({ id: "chat", configKey: "chat", requires: ["monitor"] }) + ); + const dependentPlan = await planModuleUpdates({ + root: dependent, + hostSdk: "0.2.5", + fetchIndex: async () => ({ + index: { chat: { npm: "@sfmc-bds/module-chat", version: "1.1.0", sdk: ">=0.2.2" } }, + offline: false, + }), + fetchDistTag: async () => null, + }); + assert.deepEqual(dependentPlan.upgrades.map((item) => item.id), ["chat"]); + assert.equal(dependentPlan.skipped.some((item) => item.reason === "missing-dependency"), false); +}); + +test("换包阶段仍拒绝弃用模块", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-retired-apply-")); + const pkg = path.join(root, "modules", "packages", "monitor"); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, "marker.txt"), "old"); + const result = await applyModuleUpgrades( + [{ id: "monitor", fromVersion: "0.2.1", toVersion: "0.5.0", spec: "npm:@sfmc-bds/module-monitor@0.5.0", requires: [] }], + { + root, + failMode: "continue", + install: async () => { + throw new Error("retired module must not be installed"); + }, + } + ); + assert.equal(result.applied.length, 0); + assert.equal(result.failed.length, 0); + assert.equal(result.skipped[0]?.reason, "retired"); + assert.equal(fs.readFileSync(path.join(pkg, "marker.txt"), "utf8"), "old"); +}); diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 1924f7ba..21649392 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -370,6 +370,7 @@ export const en = { "mod.update.reason.missingModule": "not installed", "mod.update.reason.missingDependency": "required module is not installed", "mod.update.reason.badVersion": "version is not semver", + "mod.update.reason.retired": "included in the platform", "mod.pin.usage": "Usage: mod pin --auto on|off", "mod.pin.ok": "{id} auto-update {state}", "mod.pin.on": "on", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index 317de918..4d626c9c 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -359,6 +359,7 @@ export const zhCN: Record = { "mod.update.reason.missingModule": "未安装", "mod.update.reason.missingDependency": "依赖模块未安装", "mod.update.reason.badVersion": "版本号不是 semver", + "mod.update.reason.retired": "已收编至平台", "mod.pin.usage": "用法: mod pin --auto on|off", "mod.pin.ok": "{id} 自动更新{state}", "mod.pin.on": "已打开", diff --git a/packages/cli/src/module-update/index.ts b/packages/cli/src/module-update/index.ts index 41883f2f..9a8847a6 100644 --- a/packages/cli/src/module-update/index.ts +++ b/packages/cli/src/module-update/index.ts @@ -94,6 +94,7 @@ const REASON_KEYS: Record = { "missing-module": "mod.update.reason.missingModule", "missing-dependency": "mod.update.reason.missingDependency", "bad-version": "mod.update.reason.badVersion", + retired: "mod.update.reason.retired", }; /** 定位 fetch-module.mjs。找不到时返回 null,调用方给出安装提示。 */ diff --git a/packages/desktop/src/renderer/dev/mock-bridge.ts b/packages/desktop/src/renderer/dev/mock-bridge.ts index 1763f06f..e88755c3 100644 --- a/packages/desktop/src/renderer/dev/mock-bridge.ts +++ b/packages/desktop/src/renderer/dev/mock-bridge.ts @@ -349,7 +349,7 @@ export function installMockBridge() { platform: id === "local-main" ? { currentVersion: "0.2.4", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: false, externalServices: ["llbot"], steps: [] } : { currentVersion: "0.2.3", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: true, externalServices: [], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }, - modules: { failMode: "continue", applyOnStart: false, upgrades: [{ id: "sfmc-module-land", fromVersion: "1.4.2", toVersion: "1.5.0", spec: "npm:@sfmc-bds/module-land@1.5.0", requires: [] }], skipped: [{ id: "sfmc-module-activity-log", reason: "dev-link", fromVersion: "0.2.0-dev", toVersion: null, detail: "" }, { id: "sfmc-module-economy", reason: "up-to-date", fromVersion: "2.0.1", toVersion: "2.0.1", detail: "" }] }, + modules: { failMode: "continue", applyOnStart: false, upgrades: [{ id: "sfmc-module-land", fromVersion: "1.4.2", toVersion: "1.5.0", spec: "npm:@sfmc-bds/module-land@1.5.0", requires: [] }], skipped: [{ id: "monitor", reason: "retired", fromVersion: "0.5.3", toVersion: null, detail: "" }, { id: "sfmc-module-activity-log", reason: "dev-link", fromVersion: "0.2.0-dev", toVersion: null, detail: "" }, { id: "sfmc-module-economy", reason: "up-to-date", fromVersion: "2.0.1", toVersion: "2.0.1", detail: "" }] }, bds: { checked: true, updated: false, currentVersion: "1.26.51.01", latestVersion: "1.26.60.02", result: "check-only" }, }; case "updates.run": return runTask(id, method, maintenance); diff --git a/packages/desktop/src/renderer/pages/UpdatesPage.tsx b/packages/desktop/src/renderer/pages/UpdatesPage.tsx index 4c3305f0..6853f8c2 100644 --- a/packages/desktop/src/renderer/pages/UpdatesPage.tsx +++ b/packages/desktop/src/renderer/pages/UpdatesPage.tsx @@ -42,6 +42,7 @@ const SKIP_REASONS: Record = { "dependency-failed": "依赖更新失败", "github-manual": "需手动更新", "no-target": "无目标版本", + retired: "已收编至平台", }; const failed = (value: unknown): value is Failed => Boolean(value && typeof value === "object" && "error" in value);