@noob-stupid/dsh-plugin-console 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -249,6 +249,7 @@ The floating "Sources" button (right of the title row, semi-transparent) opens t
249
249
 
250
250
  - [AI Empower 与服务器组件控制](docs/ai-empower.md)
251
251
  - [Framework upgrade safety & plugin adapt gate](docs/upgrade-safety-adapt-gate.md)
252
+ - [**契约规则库 Upgrade contract rules**](docs/contracts/README.md) — 框架每次变更会打断什么、怎么发现、怎么修(数据在 `lib/contracts/rules.json`,**欢迎 PR 补充**:每个事故都应该变成一条规则)
252
253
  - [How it works](docs/how-it-works.md) · [Compatibility](docs/compatibility.md) · [Project layout](docs/project-layout.md)
253
254
  - [HTTP endpoints](docs/http-endpoints.md) · [Local AI fallback & consent](docs/ai-fallback.md)
254
255
  - [Framework patch (cordis.patch.yml)](docs/framework-patch.md) · [Security & disclaimer](docs/security-disclaimer.md)
package/lib/client.js CHANGED
@@ -90,6 +90,37 @@ window.__ModuleLoader__.load({
90
90
  //#region locales
91
91
  const zh = {
92
92
  tab: "插件管理",
93
+ safetyTab: "升级安全",
94
+ safetyMode: "运行模式",
95
+ safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
96
+ safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
97
+ safetyModeObserver: "观察者:只做预检、门控与回滚点守护,不接管框架升级",
98
+ safetyModeObserverOn: "已切到观察者模式:不再接管框架升级,只守门",
99
+ safetyModeSwitch: "切换托管 / 观察者",
100
+ safetySwitchFailed: "操作失败",
101
+ safetyFrameworkTitle: "框架版本",
102
+ safetyFrameworkDesc: "当前 {v} —— 换版本、换安装根(npx 缓存 / 桌面端自带运行时)、框架树包数变化,都会记为一次环境变更",
103
+ safetyRollbackTitle: "回滚点",
104
+ safetyRollbackOk: "可用:{from} → {to}",
105
+ safetyRollbackNone: "不可用:{reason}",
106
+ safetyFpTitle: "环境指纹",
107
+ safetyFpStable: "环境未变化(与上次基线一致)",
108
+ safetyFpChanged: "检测到环境变更(谁改的都算):{reasons}",
109
+ safetyFpAck: "知道了,记为基线",
110
+ safetyFpAcked: "已记为基线",
111
+ safetyAdaptTitle: "会话格式契约预检",
112
+ safetyAdaptDesc: "升级前后都能跑:扫本地生产方(agent 预设 + 已装插件)里的旧形状,能改的先备份再改",
113
+ safetyPreflightFound: "预检发现 {n} 处必须适配(点右侧按钮可自动适配并逐个备份)",
114
+ safetyPreflightPatched: "已适配 {n} 处(每个文件都留了 .bak-preflight-*)",
115
+ safetyJumpHint: "打开控制台功能(就地展开,不跳走、不换页)",
116
+ safetyJumpMarket: "插件市场",
117
+ safetyJumpSkills: "技能市场",
118
+ safetyJumpFramework: "框架升级 / 回滚",
119
+ safetyJumpGate: "门控明细",
120
+ safetyJumpSources: "软件源",
121
+ safetyJumpAi: "AI 部署",
122
+ safetyEmbedded: "控制台(就地展开,原有功能一个不少)",
123
+ safetyUnknown: "未知",
93
124
  loading: "正在读取插件…",
94
125
  error: "暂时无法读取插件。",
95
126
  retry: "重试",
@@ -516,6 +547,37 @@ window.__ModuleLoader__.load({
516
547
  };
517
548
  const en = {
518
549
  tab: "Plugin console",
550
+ safetyTab: "Upgrade safety",
551
+ safetyMode: "Operating mode",
552
+ safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
553
+ safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
554
+ safetyModeObserver: "Observer: preflight, gating and rollback-point guard only — does not take over upgrades",
555
+ safetyModeObserverOn: "Switched to observer: no longer takes over framework upgrades, only gates",
556
+ safetyModeSwitch: "Switch managed / observer",
557
+ safetySwitchFailed: "Action failed",
558
+ safetyFrameworkTitle: "Framework version",
559
+ safetyFrameworkDesc: "Currently {v} — a version change, a different install root (npx cache / bundled desktop runtime), or a tree size change all count as an environment change",
560
+ safetyRollbackTitle: "Rollback point",
561
+ safetyRollbackOk: "Available: {from} → {to}",
562
+ safetyRollbackNone: "Unavailable: {reason}",
563
+ safetyFpTitle: "Environment fingerprint",
564
+ safetyFpStable: "Unchanged since the last baseline",
565
+ safetyFpChanged: "Environment changed (whoever did it): {reasons}",
566
+ safetyFpAck: "Got it — set as baseline",
567
+ safetyFpAcked: "Baseline updated",
568
+ safetyAdaptTitle: "Session-format contract preflight",
569
+ safetyAdaptDesc: "Runs before or after an upgrade: scans local producers (agent presets + installed plugins) for legacy shapes; fixes are backed up first",
570
+ safetyPreflightFound: "{n} place(s) must be adapted (use the button to adapt with per-file backups)",
571
+ safetyPreflightPatched: "Adapted {n} place(s); every file kept a .bak-preflight-* backup",
572
+ safetyJumpHint: "Open console features (expanded in place — no navigation away)",
573
+ safetyJumpMarket: "Plugin market",
574
+ safetyJumpSkills: "Skill market",
575
+ safetyJumpFramework: "Framework upgrade / rollback",
576
+ safetyJumpGate: "Gating detail",
577
+ safetyJumpSources: "Registries",
578
+ safetyJumpAi: "AI deploy",
579
+ safetyEmbedded: "Console (expanded in place, nothing removed)",
580
+ safetyUnknown: "unknown",
519
581
  loading: "Reading plugins…",
520
582
  error: "Plugins are temporarily unavailable.",
521
583
  retry: "Retry",
@@ -1240,7 +1302,7 @@ window.__ModuleLoader__.load({
1240
1302
  return false;
1241
1303
  }
1242
1304
  }
1243
- function PluginConsoleTab({ t }) {
1305
+ function PluginConsoleTab({ t, section = null }) {
1244
1306
  const [state, setState] = react.useState({ status: "loading" });
1245
1307
  const [busy, setBusy] = react.useState(null);
1246
1308
  const [adaptUnlocking, setAdaptUnlocking] = react.useState(null);
@@ -1821,6 +1883,17 @@ window.__ModuleLoader__.load({
1821
1883
  const [editGit, setEditGit] = react.useState(null);
1822
1884
  // 软件源弹窗折叠分区:软件源默认展开,其余收起(避免弹窗过长)
1823
1885
  const [openSections, setOpenSections] = react.useState({ registries: true, index: false, git: false, search: false, gitee: false });
1886
+ // 官方风格入口(新 tab)传入 section 时就地展开对应功能。
1887
+ // 不传 section 时(= 原有 tab 的调用方式)行为与改动前**完全一致** —— 纯加法。
1888
+ react.useEffect(() => {
1889
+ if (section === null) return;
1890
+ if (section === "framework") setFwPanelOpen(true);
1891
+ else if (section === "gate") setGateOpen(true);
1892
+ else if (section === "sources") setSourcesOpen(true);
1893
+ else if (section === "ai") setAiOpen(true);
1894
+ else if (section === "market") setMode("plugins");
1895
+ else if (section === "skills") setMode("skills");
1896
+ }, [section]);
1824
1897
  // 搜索源列表(内置 + 自定义,来自配置)
1825
1898
  const [searchSourcesList, setSearchSourcesList] = react.useState(null);
1826
1899
  // Git 源列表(「仓库落地」提示动态显示当前主源,避免写死 GitHub)
@@ -4772,6 +4845,93 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4772
4845
  : null,
4773
4846
  );
4774
4847
  }
4848
+ // 官方风格「升级安全」页(2026-09-24):**纯加法**。
4849
+ // 原 tab(id=console)与悬浮面板一行不动;这里把门控常驻化的状态、模式开关、一键适配与各功能入口
4850
+ // 用官方卡片风呈现,并把控制台功能**就地展开**(官方 settings.plugins.tab 插槽的 owner props 是空的,
4851
+ // 没有「切到兄弟 tab」的 API,所以就地承载而不是跳页)。
4852
+ // 触发源已从「我们的升级动作」换成环境指纹变化(服务端 domain/compat-state.js):
4853
+ // 官方桌面端自带升级器 / 官方只发桌面端时,门控依然守得住。
4854
+ function PluginSafetyTab({ t }) {
4855
+ const [status, setStatus] = react.useState(null);
4856
+ const [busy, setBusy] = react.useState(false);
4857
+ const [note, setNote] = react.useState(null);
4858
+ const [section, setSection] = react.useState(null);
4859
+ const load = async () => {
4860
+ try { const data = await call("/plugin-console/compat-status"); if (data !== null) setStatus(data); } catch {}
4861
+ };
4862
+ react.useEffect(() => { load(); }, []);
4863
+ const switchMode = async (mode) => {
4864
+ if (busy) return;
4865
+ setBusy(true);
4866
+ try {
4867
+ const data = await call("/plugin-console/compat-mode", { mode: mode });
4868
+ setNote({ text: mode === "observer" ? t("safetyModeObserverOn") : t("safetyModeManagedOn") });
4869
+ if (data !== null) setStatus(null);
4870
+ await load();
4871
+ } catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
4872
+ finally { setBusy(false); }
4873
+ };
4874
+ const ack = async () => {
4875
+ try { await call("/plugin-console/compat-stamp", {}); await load(); setNote({ text: t("safetyFpAcked") }); } catch {}
4876
+ };
4877
+ const preflight = async (apply) => {
4878
+ if (busy) return;
4879
+ setBusy(true);
4880
+ setNote({ text: t("fwPreflightRunning") });
4881
+ try {
4882
+ const pre = await call("/plugin-console/framework-preflight", {});
4883
+ const blockers = (pre && pre.scan && pre.scan.blockers) || 0;
4884
+ if (!apply || blockers === 0) {
4885
+ const patched = await call("/plugin-console/framework-preflight-patch", { mode: "plan" });
4886
+ const planCount = (patched && patched.changes && patched.changes.length) || 0;
4887
+ setNote({ text: blockers === 0 ? t("fwPreflightClean") : fill(t("safetyPreflightFound"), [["n", planCount || blockers]]) });
4888
+ } else {
4889
+ const done = await call("/plugin-console/framework-preflight-patch", { mode: "apply" });
4890
+ const changed = (done && done.changes && done.changes.length) || 0;
4891
+ setNote({ text: fill(t("safetyPreflightPatched"), [["n", changed]]) });
4892
+ }
4893
+ } catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
4894
+ finally { setBusy(false); }
4895
+ };
4896
+ const fill = (text, pairs) => {
4897
+ let out = String(text);
4898
+ for (const pair of pairs) out = out.split("{" + pair[0] + "}").join(String(pair[1]));
4899
+ return out;
4900
+ };
4901
+ const card = (icon, title, desc, control) => el("div", { style: { display: "flex", alignItems: "center", gap: "14px", padding: "14px 16px", borderRadius: "14px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-elevated, rgba(127,127,127,.06))" } },
4902
+ el("div", { style: { width: "40px", height: "40px", borderRadius: "12px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--dsw-alias-bg-secondary, rgba(127,127,127,.12))", fontSize: "18px", flex: "0 0 auto" } }, icon),
4903
+ el("div", { style: { flex: "1 1 auto", minWidth: "0" } },
4904
+ el("div", { style: { fontSize: "14px", fontWeight: 500 } }, title),
4905
+ el("div", { style: { fontSize: "12.5px", opacity: .72, marginTop: "2px", wordBreak: "break-word" } }, desc)),
4906
+ control === undefined ? null : control);
4907
+ const sw = (on, onClick, title) => el("button", { type: "button", title: title, onClick: onClick, disabled: busy, style: { width: "42px", height: "24px", borderRadius: "999px", border: "none", padding: 0, cursor: busy ? "default" : "pointer", background: on ? "var(--dsw-alias-bg-accent, #4d6bfe)" : "var(--dsw-alias-bg-secondary, rgba(127,127,127,.35))", position: "relative", flex: "0 0 auto" } },
4908
+ el("span", { style: { position: "absolute", top: "3px", left: on ? "21px" : "3px", width: "18px", height: "18px", borderRadius: "50%", background: "#fff", transition: "left .18s ease" } }));
4909
+ const jump = (id, label) => el("button", { key: id, type: "button", className: styles.toggle, style: { marginRight: "8px", marginBottom: "8px" }, onClick: () => setSection(id) }, label);
4910
+ const mode = status !== null && status.mode === "observer" ? "observer" : "managed";
4911
+ const fp = status !== null && status.fingerprint !== null && status.fingerprint !== undefined ? status.fingerprint : {};
4912
+ const rb = status !== null && status.rollback !== null && status.rollback !== undefined ? status.rollback : {};
4913
+ const fw = status !== null && typeof status.frameworkVersion === "string" && status.frameworkVersion !== "" ? status.frameworkVersion : t("safetyUnknown");
4914
+ return el("div", { style: { display: "flex", flexDirection: "column", gap: "12px", maxWidth: "760px" } },
4915
+ card("🛡️", t("safetyMode"), mode === "managed" ? t("safetyModeManaged") : t("safetyModeObserver"), sw(mode === "observer", () => switchMode(mode === "managed" ? "observer" : "managed"), t("safetyModeSwitch"))),
4916
+ card("📌", t("safetyFrameworkTitle"), fill(t("safetyFrameworkDesc"), [["v", fw]])),
4917
+ card("⏪", t("safetyRollbackTitle"), rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]])),
4918
+ card("🔎", t("safetyFpTitle"), fp.changed === true ? fill(t("safetyFpChanged"), [["reasons", (fp.reasons || []).join(";")]]) : t("safetyFpStable"), fp.changed === true ? el("button", { type: "button", className: styles.toggle, onClick: ack, disabled: busy }, t("safetyFpAck")) : null),
4919
+ card("🩺", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
4920
+ el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(false) }, t("fwPreflightBtn")),
4921
+ el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(true) }, t("fwPreflightPatch")))),
4922
+ note === null ? null : el("div", { style: { fontSize: "12.5px", opacity: .85 } }, note.text),
4923
+ el("div", null,
4924
+ el("div", { style: { fontSize: "12.5px", opacity: .6, margin: "4px 0 8px" } }, t("safetyJumpHint")),
4925
+ jump("market", t("safetyJumpMarket")),
4926
+ jump("skills", t("safetyJumpSkills")),
4927
+ jump("framework", t("safetyJumpFramework")),
4928
+ jump("gate", t("safetyJumpGate")),
4929
+ jump("sources", t("safetyJumpSources")),
4930
+ jump("ai", t("safetyJumpAi"))),
4931
+ section === null ? null : el("div", { style: { marginTop: "4px", borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", paddingTop: "12px" } },
4932
+ el("div", { style: { fontSize: "12.5px", opacity: .6, marginBottom: "8px" } }, t("safetyEmbedded")),
4933
+ el(PluginConsoleTab, { t: t, section: section, key: section })));
4934
+ }
4775
4935
  function apply(ctx) {
4776
4936
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-console: dictionaries");
4777
4937
  const t = ctx.locale.bind(NS);
@@ -4783,6 +4943,15 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4783
4943
  locale: NS,
4784
4944
  inject: () => ({}),
4785
4945
  }, PluginConsoleTab));
4946
+ // 官方风格「升级安全」入口(纯加法):原 tab id=console 保持不变,这里只是多挂一个
4947
+ ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
4948
+ name: "settings.plugins.tab",
4949
+ id: "console-safety",
4950
+ order: 21,
4951
+ label: () => t("safetyTab"),
4952
+ locale: NS,
4953
+ inject: () => ({}),
4954
+ }, PluginSafetyTab));
4786
4955
  }
4787
4956
  exports.NS = NS;
4788
4957
  exports.apply = apply;
@@ -0,0 +1,72 @@
1
+ {
2
+ "version": 1,
3
+ "updatedAt": "2026-09-24",
4
+ "summary": "DSH 升级契约规则库:把「框架某次变更会打断什么」沉淀成数据。预检读它,因此规则库对任何来源的框架变更都生效(我们的升级器、官方桌面端自带升级器、手动 pnpm 都一样)。欢迎 PR 补充——每个事故都应该变成一条规则。",
5
+ "fields": {
6
+ "id": "稳定标识(小写连字符),被测试与文档引用,改名等于破坏兼容",
7
+ "since": "从哪个框架版本开始成立(用于判定「目标版本是否需要这条规则」)",
8
+ "kind": "contract-edge | dependency-api | config-schema | removed-api | loader-contract",
9
+ "severity": "blocker(不改就会炸)| warn(提示,不影响可用)",
10
+ "detect": "怎么发现(人话描述,便于 PR 评审与用户理解)",
11
+ "fix": "怎么修(能自动修的要写清楚改前备份策略)",
12
+ "implementedBy": "本控制台里承担检测的实现(模块名或探针 id);留空表示「仅登记、尚未自动检测」",
13
+ "evidence": "真实报错原文或事故编号——没有证据的规则不收"
14
+ },
15
+ "rules": [
16
+ {
17
+ "id": "session-message-source-kind",
18
+ "since": "0.1.7-rc.1",
19
+ "kind": "contract-edge",
20
+ "severity": "blocker",
21
+ "title": "会话消息来源需要 producer-owned kind(V3 → V4)",
22
+ "detect": "扫描本地生产方(agent 预设 + 已装插件)里仍写 `kind: 'plugin'` 的 V3 包装形状;框架只迁移磁盘历史会话,运行期生产方不迁移",
23
+ "fix": "把 V3 包装改写成 producer-owned kind;本控制台可一键适配,逐文件备份 .bak-preflight-*,名单外文件拒绝写入",
24
+ "implementedBy": "domain/format-scan.js(planSourceKindRewrites)",
25
+ "evidence": "format v4 message requires a producer-owned source kind"
26
+ },
27
+ {
28
+ "id": "schemastery-volatile-api",
29
+ "since": "3.18.4",
30
+ "kind": "dependency-api",
31
+ "severity": "warn",
32
+ "title": "@deepseek-ai/schemastery 的 Schema.prototype.volatile()",
33
+ "detect": "插件源码调用 .volatile(),但它实际解析到的 schemastery 副本低于声明版本(常见于 profile 里被手工铺了陈旧真实副本)",
34
+ "fix": "把该副本对齐到声明版本即可,**不必改插件源码**(带依赖探针,修好后自动静默)",
35
+ "implementedBy": "domain/format-scan.js(KNOWN_BREAKAGES.probe = schemastery-volatile)",
36
+ "evidence": ".volatile is not a function(2026-09-24 实测 7 个插件导入失败)"
37
+ },
38
+ {
39
+ "id": "preset-persona-text-to-prefix",
40
+ "since": "0.1.5-rc.1",
41
+ "kind": "config-schema",
42
+ "severity": "blocker",
43
+ "title": "agent 预设 persona.text → persona.prefix",
44
+ "detect": "升级前扫描全部 agent 预设与 host 组合里的 persona.text 字段",
45
+ "fix": "就地改名并留 .bak 备份(v0.3.33 起内置;包级适配门扫不到预设,这是当时事故的真正短板)",
46
+ "implementedBy": "domain/presets.js(migrateAgentConfigsForUpgrade)",
47
+ "evidence": "issue #18(0.1.2-rc.1 → 0.1.5-rc.1 升级后服务起不来)"
48
+ },
49
+ {
50
+ "id": "dsh-settings-namespace-removed",
51
+ "since": "0.1.5-rc.1",
52
+ "kind": "removed-api",
53
+ "severity": "blocker",
54
+ "title": "dsh-settings 命名空间 API 被移除",
55
+ "detect": "扫描已装包源码里对已删除 API 的引用(要求标识符边界,并排除本地定义同行无引用的情况,避免误伤框架自带的 settings 控制器)",
56
+ "fix": "改用 ctx.get('名字') 读取或声明 inject;未 inject 的名字做属性式访问在真实 ctx 上会同步抛错",
57
+ "implementedBy": "domain/compat.js(checkPluginFrameworkCompat)",
58
+ "evidence": "0.3.59 注入缝缺陷(属性式读取未声明的名字 → 安装全灭)"
59
+ },
60
+ {
61
+ "id": "patch-overlay-toplevel-array",
62
+ "since": "0.1.5-rc.1",
63
+ "kind": "loader-contract",
64
+ "severity": "blocker",
65
+ "title": "cordis.patch.yml 必须是顶层 YAML 数组",
66
+ "detect": "解析 profile 与各 bundle 的补丁层,空文件/只有注释会解析成 null 而被拒",
67
+ "fix": "留 `[]` 占位(或写成 `- insert:` 列表);本控制台的补丁写入已统一走该形态",
68
+ "implementedBy": "domain/patch.js(sanitizePatchText)",
69
+ "evidence": "overlay <path> must be a top-level YAML array of loader patch entries"
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,126 @@
1
+ // L1 · domain —— 门控常驻化的状态层:运行模式(托管 / 观察者)+ 环境指纹 + 回滚点读取
2
+ //
3
+ // 为什么需要它(2026-09-24 桌面端复盘):门控此前**寄生在我们自己的升级流程**上 ——
4
+ // 「我们点升级」才跑兼容扫描、才产出隔离清单。一旦官方桌面端自带升级器,或官方只发桌面端
5
+ // 而不再单独更新框架,我们就失去了触发点。这里把触发源从「升级动作」换成「环境指纹变化」:
6
+ // 任何来源的框架变更(官方升级器 / 桌面端自带运行时 / 手动 pnpm / npx 缓存变化)都会被发现,
7
+ // 门控因此不再依赖我们是否执行了升级。
8
+ //
9
+ // 纯状态层:不做扫描、不装东西、不碰 ctx。扫描仍走 domain/compat.js 与 domain/format-scan.js。
10
+
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+ import { dshHome } from '../infra/paths.js'
14
+
15
+ /** 运行模式。managed = 托管(我们负责框架升级与回滚,既有行为);observer = 观察者(只守门,不碰升级)。 */
16
+ const COMPAT_MODES = ['managed', 'observer']
17
+ const DEFAULT_MODE = 'managed'
18
+
19
+ const stateDir = () => join(dshHome(), 'plugin-console')
20
+ const modeFile = () => join(stateDir(), 'compat-mode.json')
21
+ const fingerprintFile = () => join(stateDir(), 'env-fingerprint.json')
22
+ const rollbackFile = () => join(stateDir(), 'framework-rollback.json')
23
+
24
+ function readJson(path) {
25
+ try {
26
+ return JSON.parse(readFileSync(path, 'utf8'))
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ function writeJson(path, value) {
33
+ mkdirSync(dirname(path), { recursive: true })
34
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
35
+ }
36
+
37
+ /**
38
+ * 读运行模式。**默认托管** —— 纯加法:没有设置过的机器行为与之前完全一致。
39
+ * @returns {{ mode: string, at: number|null, source: 'file'|'default' }}
40
+ */
41
+ function readCompatMode() {
42
+ const rec = readJson(modeFile())
43
+ const mode = typeof rec?.mode === 'string' && COMPAT_MODES.includes(rec.mode) ? rec.mode : DEFAULT_MODE
44
+ return { mode, at: typeof rec?.at === 'number' ? rec.at : null, source: rec === null ? 'default' : 'file' }
45
+ }
46
+
47
+ /**
48
+ * 写运行模式(只接受白名单值,避免拼错导致「模式静默失效」)。
49
+ * @returns {{ ok: boolean, mode: string, error?: string }}
50
+ */
51
+ function writeCompatMode(mode) {
52
+ if (typeof mode !== 'string' || !COMPAT_MODES.includes(mode)) {
53
+ return { ok: false, mode: readCompatMode().mode, error: `未知模式 ${String(mode)}(可选:${COMPAT_MODES.join(' / ')})` }
54
+ }
55
+ writeJson(modeFile(), { mode, at: Date.now() })
56
+ return { ok: true, mode }
57
+ }
58
+
59
+ /**
60
+ * 算一份环境指纹。字段刻意取「便宜且稳定」的信号:
61
+ * · frameworkVersion —— 框架自报版本(换版本必变)
62
+ * · pnpmEntities —— 框架树里 @deepseek-ai 实体数(半装/损坏/换包会变)
63
+ * · pnpmRoot —— 框架树根路径(换运行时/换安装位置会变:npx 缓存 ↔ 桌面端自带运行时)
64
+ * @param {{ frameworkVersion?: string|null, pnpmRoot?: string|null }} input
65
+ */
66
+ function computeFingerprint(input = {}) {
67
+ const frameworkVersion = typeof input.frameworkVersion === 'string' && input.frameworkVersion !== '' ? input.frameworkVersion : null
68
+ const pnpmRoot = typeof input.pnpmRoot === 'string' && input.pnpmRoot !== '' ? input.pnpmRoot : null
69
+ let pnpmEntities = null
70
+ if (pnpmRoot !== null) {
71
+ try {
72
+ pnpmEntities = readdirSync(join(pnpmRoot, '.pnpm'), { withFileTypes: true })
73
+ .filter((d) => d.isDirectory() && d.name.startsWith('@deepseek-ai+')).length
74
+ } catch {
75
+ pnpmEntities = null
76
+ }
77
+ }
78
+ return { frameworkVersion, pnpmEntities, pnpmRoot, at: Date.now() }
79
+ }
80
+
81
+ function readFingerprint() {
82
+ const rec = readJson(fingerprintFile())
83
+ return rec === null ? null : rec
84
+ }
85
+
86
+ /** 把当前指纹记为基线(启动时 / 用户确认变更后调用)。 */
87
+ function stampFingerprint(fp) {
88
+ const rec = { ...fp, at: Date.now() }
89
+ writeJson(fingerprintFile(), rec)
90
+ return rec
91
+ }
92
+
93
+ /**
94
+ * 比对新旧指纹 —— 这就是「环境变更事件」的判定:**谁改的都算**。
95
+ * @returns {{ changed: boolean, reasons: string[], baselineAt: number|null }}
96
+ */
97
+ function compareFingerprint(current, previous) {
98
+ if (previous === null || previous === undefined) return { changed: false, reasons: [], baselineAt: null }
99
+ const reasons = []
100
+ if (previous.frameworkVersion !== current.frameworkVersion) {
101
+ reasons.push(`框架版本 ${previous.frameworkVersion ?? '未知'} → ${current.frameworkVersion ?? '未知'}`)
102
+ }
103
+ if (previous.pnpmRoot !== current.pnpmRoot) reasons.push('框架运行时位置变化(换了安装根:npx 缓存 / 桌面端自带运行时 / 自定义)')
104
+ if (previous.pnpmEntities !== current.pnpmEntities) reasons.push(`框架树包数 ${previous.pnpmEntities ?? '?'} → ${current.pnpmEntities ?? '?'}`)
105
+ return { changed: reasons.length > 0, reasons, baselineAt: typeof previous.at === 'number' ? previous.at : null }
106
+ }
107
+
108
+ /** 回滚点:记录存在 **且** 其 checkpoint 目录仍在(记录在、目录没了 = 实际不可用,不能骗用户)。 */
109
+ function readRollbackRecord() {
110
+ const rec = readJson(rollbackFile())
111
+ if (rec === null) return { from: null, to: null, at: null, applicable: false, reason: '没有回滚记录' }
112
+ const fwRoot = typeof rec.fwRoot === 'string' ? rec.fwRoot : null
113
+ const checkpoint = fwRoot === null ? false : existsSync(join(stateDir(), 'framework-backups'))
114
+ const applicable = fwRoot !== null && existsSync(fwRoot)
115
+ return {
116
+ from: rec.from ?? null,
117
+ to: rec.to ?? null,
118
+ at: typeof rec.at === 'number' ? rec.at : null,
119
+ fwRoot,
120
+ checkpoint,
121
+ applicable,
122
+ reason: applicable ? null : '框架树路径不存在(可能已被清理或换了安装根)',
123
+ }
124
+ }
125
+
126
+ export { COMPAT_MODES, DEFAULT_MODE, compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
@@ -0,0 +1,85 @@
1
+ // L1 · domain —— 契约规则库的读取层
2
+ //
3
+ // 为什么把规则做成「数据」而不是散在代码里(2026-09-24 桌面端复盘):
4
+ // 门控此前寄生在「我们自己的升级流程」上;官方桌面端一旦自带升级器、或官方只发桌面端,
5
+ // 触发点就没了。规则外置之后,**预检对任何来源的框架变更都成立**:只要有人把框架版本从 A
6
+ // 变成 B,就能拿规则库比对(我们的升级器、官方升级器、手动 pnpm 都一样)。
7
+ // 同时规则库是唯一能被社区直接 PR 的产物 —— 事故进规则、规则进测试。
8
+ //
9
+ // 数据文件随包发布(lib/contracts/rules.json,`files` 白名单已含 lib/**),
10
+ // 因此用户装到的是同一份规则;仓库侧另有 docs/contracts/README.md 说明格式与收录方式。
11
+ //
12
+ // 读不到文件时退化为**空规则库**(如实回报 source='unavailable'),绝不让预检因为规则文件
13
+ // 缺失而假装「零发现」——调用方拿 source 字段判断可信度。
14
+
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { pluginRoot } from '../infra/paths.js'
18
+
19
+ const RULES_RELATIVE = join('lib', 'contracts', 'rules.json')
20
+ const KINDS = ['contract-edge', 'dependency-api', 'config-schema', 'removed-api', 'loader-contract']
21
+ const SEVERITIES = ['blocker', 'warn']
22
+
23
+ function rulesPath() {
24
+ return join(pluginRoot(), RULES_RELATIVE)
25
+ }
26
+
27
+ /** 只保留字段齐全、取值合法的规则(脏数据宁可丢一条,也不要让预检报错或误报)。 */
28
+ function normalizeRule(raw) {
29
+ if (raw === null || typeof raw !== 'object') return null
30
+ const id = typeof raw.id === 'string' && raw.id !== '' ? raw.id : null
31
+ const since = typeof raw.since === 'string' && raw.since !== '' ? raw.since : null
32
+ if (id === null || since === null) return null
33
+ return {
34
+ id,
35
+ since,
36
+ kind: KINDS.includes(raw.kind) ? raw.kind : 'contract-edge',
37
+ severity: SEVERITIES.includes(raw.severity) ? raw.severity : 'warn',
38
+ title: typeof raw.title === 'string' ? raw.title : id,
39
+ detect: typeof raw.detect === 'string' ? raw.detect : '',
40
+ fix: typeof raw.fix === 'string' ? raw.fix : '',
41
+ implementedBy: typeof raw.implementedBy === 'string' && raw.implementedBy !== '' ? raw.implementedBy : null,
42
+ evidence: typeof raw.evidence === 'string' ? raw.evidence : '',
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 读规则库。
48
+ * @returns {{ source: 'pack'|'unavailable', version: number|null, updatedAt: string|null, rules: object[], error: string|null }}
49
+ */
50
+ function loadContractRules() {
51
+ const path = rulesPath()
52
+ if (!existsSync(path)) {
53
+ return { source: 'unavailable', version: null, updatedAt: null, rules: [], error: `规则库文件不存在:${RULES_RELATIVE}` }
54
+ }
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
57
+ const rules = Array.isArray(parsed?.rules) ? parsed.rules.map(normalizeRule).filter((r) => r !== null) : []
58
+ return {
59
+ source: 'pack',
60
+ version: typeof parsed?.version === 'number' ? parsed.version : null,
61
+ updatedAt: typeof parsed?.updatedAt === 'string' ? parsed.updatedAt : null,
62
+ rules,
63
+ error: null,
64
+ }
65
+ } catch (error) {
66
+ return { source: 'unavailable', version: null, updatedAt: null, rules: [], error: `规则库解析失败:${error instanceof Error ? error.message : String(error)}` }
67
+ }
68
+ }
69
+
70
+ /** 规则摘要(给面板/接口用,避免把整段说明塞进响应)。 */
71
+ function summarizeContractRules(pack) {
72
+ const blockers = pack.rules.filter((r) => r.severity === 'blocker').length
73
+ return {
74
+ source: pack.source,
75
+ version: pack.version,
76
+ updatedAt: pack.updatedAt,
77
+ total: pack.rules.length,
78
+ blockers,
79
+ autoDetected: pack.rules.filter((r) => r.implementedBy !== null).length,
80
+ ids: pack.rules.map((r) => r.id),
81
+ error: pack.error,
82
+ }
83
+ }
84
+
85
+ export { KINDS, RULES_RELATIVE, SEVERITIES, loadContractRules, normalizeRule, rulesPath, summarizeContractRules }
@@ -7,7 +7,7 @@ import { readFile } from 'node:fs/promises'
7
7
  import { basename, dirname, join, resolve } from 'node:path'
8
8
  import { aiRepair } from './ai-run.js'
9
9
  import { maybeAutoAdaptCompat } from './compat.js'
10
- import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
10
+ import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, declareProfileDependency, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
11
11
  import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
12
12
  import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
13
13
  import { deriveEntryId, listEntries } from './runtime.js'
@@ -490,17 +490,26 @@ async function runInstallJob(job, ports) {
490
490
  } else {
491
491
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
492
492
  }
493
+ // 安装即声明:即便是「已由聚合包提供」的分支也要声明本轮真实安装的版本,
494
+ // 否则官方面板看不见它、pnpm 也会按旧清单把它还原回去(2026-09-24 根因)。
495
+ const declaredBundle = await declareProfileDependency(profileDir, installedName)
496
+ if (declaredBundle.changed) job.declared = declaredBundle.version
493
497
  job.status = 'done'
494
498
  return
495
499
  }
496
500
  if (alreadyServed) {
497
501
  job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
502
+ const declaredServed = await declareProfileDependency(profileDir, installedName)
503
+ if (declaredServed.changed) job.declared = declaredServed.version
498
504
  job.status = 'done'
499
505
  return
500
506
  }
501
507
  const entryId = deriveEntryId(installedName, taken)
502
508
  await appendInsert(patchPath, entryId, installedName)
503
509
  job.entryId = entryId
510
+ // 安装即声明(同上):普通插件装完把依赖写进 profile 清单
511
+ const declared = await declareProfileDependency(profileDir, installedName)
512
+ if (declared.changed) job.declared = declared.version
504
513
  job.status = 'done'
505
514
  } catch (error) {
506
515
  job.status = 'failed'
@@ -6,6 +6,7 @@ import { readFile, writeFile } from 'node:fs/promises'
6
6
  import { execFile } from 'node:child_process'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { tmpdir } from 'node:os'
9
+ import { DEFAULT_BUNDLES, addBundleToManifest, declareProfileDependency, removeBundleFromManifest, undeclareProfileDependency } from './manifest.js'
9
10
  import { sanitizePatchText } from './patch.js'
10
11
  import { orderedRegistries, readSources } from './sources.js'
11
12
  import { execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
@@ -29,23 +30,6 @@ async function detectBundleOnly(profileDir, packageName) {
29
30
  }
30
31
  }
31
32
 
32
- /** 把包追加进 profile 的 dsh.profile.bundles 层(与官方 dsh plugin add 的 reconcile 一致)。 */
33
- async function addBundleToManifest(profileDir, packageName) {
34
- return queuedWrite(async () => {
35
- const manifestPath = join(profileDir, 'package.json')
36
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
37
- const bundles = manifest.dsh?.profile?.bundles ?? []
38
- if (!bundles.includes(packageName)) {
39
- bundles.push(packageName)
40
- manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles } }
41
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
42
- }
43
- })
44
- }
45
-
46
- /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
47
- const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
48
-
49
33
  /** 读取用户额外 bundle 的插入行归属表:行 id / 包名 → 所属 bundle 包名。 */
50
34
  async function readExtraBundleOwners(profileDir) {
51
35
  const owners = new Map()
@@ -80,20 +64,6 @@ async function readExtraBundleOwners(profileDir) {
80
64
  return owners
81
65
  }
82
66
 
83
- /** 从 profile manifest 移除一个 bundle。 */
84
- async function removeBundleFromManifest(profileDir, bundlePkg) {
85
- return queuedWrite(async () => {
86
- const manifestPath = join(profileDir, 'package.json')
87
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
88
- const bundles = manifest.dsh?.profile?.bundles ?? []
89
- const next = bundles.filter((name) => name !== bundlePkg)
90
- if (next.length !== bundles.length) {
91
- manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles: next } }
92
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
93
- }
94
- })
95
- }
96
-
97
67
  /**
98
68
  * 读取 dsh-github-login(独立登录工具)写入的 GitHub 令牌文件。
99
69
  * 只对外暴露登录状态(login),绝不下发令牌本身。
@@ -596,4 +566,4 @@ async function syncAggregateSubpackageVersions(profileDir, packageName, registri
596
566
  return updated
597
567
  }
598
568
 
599
- export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
569
+ export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, declareProfileDependency, undeclareProfileDependency, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
@@ -0,0 +1,120 @@
1
+ // L1 · domain —— profile 清单(<profile>/package.json)的写入层:bundles 层 + dependencies 声明
2
+ //
3
+ // 为什么单独成模块(2026-09-24):安装落点原本散在 install.js 里,而 install.js 已经贴着
4
+ // 架构守卫的 600 行上限;更重要的是,「装了但没声明」这一类问题反复出现(官方面板看不见 /
5
+ // pnpm 按清单还原 / 我们的清理判据误当孤儿),把「怎么写清单」收口到一处才好统一保证。
6
+ //
7
+ // 规矩:
8
+ // · 所有写操作走 fsx 的写队列(queuedWrite),避免并发读改写互相覆盖;
9
+ // · **安装即声明**:装到哪版就把 dependencies 写成哪版(读磁盘真实版本,不猜);
10
+ // · **卸载即撤销**:否则清单留幽灵依赖,下次 pnpm 操作把它装回来;
11
+ // · 来源型 spec(link:/file:/git:/URL)绝不改写成版本号 —— 改写等于丢来源。
12
+
13
+ import { readFile, writeFile } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { queuedWrite } from '../infra/fsx.js'
16
+ import { resolvePackageJson } from '../infra/paths.js'
17
+
18
+ /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
19
+ const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
20
+
21
+ /** 读磁盘上真实安装的版本(读不到返回 null,绝不猜)。 */
22
+ async function installedVersionOf(profileDir, packageName) {
23
+ try {
24
+ const pkgPath = resolvePackageJson(packageName, profileDir)
25
+ if (pkgPath === null) return null
26
+ const version = JSON.parse(await readFile(pkgPath, 'utf8')).version
27
+ return typeof version === 'string' && version !== '' ? version : null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ async function readManifest(profileDir) {
34
+ return JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
35
+ }
36
+
37
+ async function writeManifest(profileDir, manifest) {
38
+ await writeFile(join(profileDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
39
+ }
40
+
41
+ /**
42
+ * 把包追加进 profile 的 `dsh.profile.bundles` 层(与官方 `dsh plugin add` 的 reconcile 一致),
43
+ * 同一把写锁内**顺手声明 dependencies**(安装即声明)。
44
+ * @returns {{ version: string|null }}
45
+ */
46
+ async function addBundleToManifest(profileDir, packageName) {
47
+ return queuedWrite(async () => {
48
+ const manifest = await readManifest(profileDir)
49
+ const bundles = manifest.dsh?.profile?.bundles ?? []
50
+ let touched = false
51
+ if (!bundles.includes(packageName)) {
52
+ bundles.push(packageName)
53
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles } }
54
+ touched = true
55
+ }
56
+ const version = await installedVersionOf(profileDir, packageName)
57
+ if (version !== null) {
58
+ const deps = { ...(manifest.dependencies ?? {}) }
59
+ if (deps[packageName] !== version) { deps[packageName] = version; manifest.dependencies = deps; touched = true }
60
+ }
61
+ if (touched) await writeManifest(profileDir, manifest)
62
+ return { version }
63
+ })
64
+ }
65
+
66
+ /** 从 profile manifest 移除一个 bundle(同时撤销 dependencies 声明,避免留下幽灵依赖)。 */
67
+ async function removeBundleFromManifest(profileDir, bundlePkg) {
68
+ return queuedWrite(async () => {
69
+ const manifest = await readManifest(profileDir)
70
+ const bundles = manifest.dsh?.profile?.bundles ?? []
71
+ const next = bundles.filter((name) => name !== bundlePkg)
72
+ let touched = false
73
+ if (next.length !== bundles.length) {
74
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles: next } }
75
+ touched = true
76
+ }
77
+ const deps = { ...(manifest.dependencies ?? {}) }
78
+ if (bundlePkg in deps) { delete deps[bundlePkg]; manifest.dependencies = deps; touched = true }
79
+ if (touched) await writeManifest(profileDir, manifest)
80
+ return { changed: touched }
81
+ })
82
+ }
83
+
84
+ /**
85
+ * 安装即声明:把包写进 profile 清单的 `dependencies`。
86
+ *
87
+ * 三个现象同一个根因(2026-09-24 复盘):
88
+ * · 官方「设置 → 插件」的**已安装**分区只显示清单里声明过的包 —— 我们手铺的包在那里看不见;
89
+ * · 任何一次 pnpm 操作都会按清单 + lock 重装,未声明的包会被**还原/清掉**;
90
+ * · 连我们自己的「清理残余」也按「有没有被声明」判孤儿 —— 判据病根也在这里。
91
+ * @returns {{ changed: boolean, version: string|null, reason: string|null }}
92
+ */
93
+ async function declareProfileDependency(profileDir, packageName, version = null) {
94
+ return queuedWrite(async () => {
95
+ const manifest = await readManifest(profileDir)
96
+ const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
97
+ if (resolved === null) return { changed: false, version: null, reason: '读不到已安装版本' }
98
+ const deps = { ...(manifest.dependencies ?? {}) }
99
+ if (deps[packageName] === resolved) return { changed: false, version: resolved, reason: null }
100
+ deps[packageName] = resolved
101
+ manifest.dependencies = deps
102
+ await writeManifest(profileDir, manifest)
103
+ return { changed: true, version: resolved, reason: null }
104
+ })
105
+ }
106
+
107
+ /** 卸载即撤销声明。 */
108
+ async function undeclareProfileDependency(profileDir, packageName) {
109
+ return queuedWrite(async () => {
110
+ const manifest = await readManifest(profileDir)
111
+ const deps = { ...(manifest.dependencies ?? {}) }
112
+ if (!(packageName in deps)) return { changed: false }
113
+ delete deps[packageName]
114
+ manifest.dependencies = deps
115
+ await writeManifest(profileDir, manifest)
116
+ return { changed: true }
117
+ })
118
+ }
119
+
120
+ export { DEFAULT_BUNDLES, addBundleToManifest, declareProfileDependency, installedVersionOf, removeBundleFromManifest, undeclareProfileDependency }
@@ -10,7 +10,7 @@
10
10
  // 现在的顺序:① spec 是版本范围 → `pnpm update <pkg>`(spec 不变、lock 提到范围内最新)
11
11
  // ② 仍不是 latest(超出范围 / spec 是 git·file 来源)→ `pnpm add <pkg>@<latest>`(spec 与 lock 同步改写)
12
12
  // ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
13
- import { existsSync, readFileSync } from 'node:fs'
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage } from './dep-source.js'
16
16
  import { pnpmInstall } from './install.js'
@@ -150,10 +150,34 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
150
150
  }
151
151
 
152
152
  const sourceSwitch = spec !== null && !isRegistryRange(spec) ? `安装来源已从 ${spec} 变为 registry 版本 ${latest}` : null
153
+
154
+ // ④ 清单 spec 也要跟上(2026-09-24 实测踩到:lock 写了、spec 还钉着 0.3.57,
155
+ // 于是下一次 pnpm 操作会把控制台「降级」回 0.3.57 —— 包管理器通道 ①② 通常会顺手改写 spec,
156
+ // 但手铺兜底不会,所以这里统一收口:装到哪版就把 spec 写成哪版(来源型 spec 除外,绝不丢来源)。
157
+ let specUpdated = null
158
+ if (installed === latest && (spec === null || isRegistryRange(spec))) {
159
+ try {
160
+ const manifestPath = join(profileDir, 'package.json')
161
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
162
+ const deps = { ...(manifest.dependencies ?? {}) }
163
+ if (deps[CONSOLE_PACKAGE] !== latest) {
164
+ deps[CONSOLE_PACKAGE] = latest
165
+ manifest.dependencies = deps
166
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
167
+ specUpdated = latest
168
+ }
169
+ } catch (error) {
170
+ errors.push(`清单 spec 同步失败:${label(error)}`)
171
+ }
172
+ } else if (installed === latest && spec !== null && !isRegistryRange(spec)) {
173
+ errors.push(`清单 spec 是来源型(${spec}),按约定保留不改写`)
174
+ }
175
+
153
176
  const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
154
177
  if (sourceSwitch !== null) noteParts.push(sourceSwitch)
178
+ if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
155
179
 
156
- return { method, spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
180
+ return { method, spec: specUpdated ?? spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
157
181
  }
158
182
 
159
183
  /**
@@ -0,0 +1,69 @@
1
+ // L2 · routes —— 门控常驻化(观察者/托管模式 · 环境指纹 · 回滚点)
2
+ // GET /plugin-console/compat-status 一次拿齐:模式 + 框架版本 + 指纹变化 + 回滚点可用性
3
+ // POST /plugin-console/compat-mode 切换运行模式(managed / observer)
4
+ // POST /plugin-console/compat-stamp 把当前指纹记为基线(用户确认「环境变更」后调用)
5
+ //
6
+ // 与 /framework-preflight 的分工:预检负责「扫什么」,这里负责「什么时候扫、以什么模式守门」。
7
+ // 触发源不再是我们的升级按钮,而是环境指纹变化 —— 官方桌面端自带升级器 / 官方只发桌面端 /
8
+ // 手动 pnpm 操作,都会在这里被发现(见 domain/compat-state.js 头注释)。
9
+
10
+ import { dirname } from 'node:path'
11
+ import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
12
+ import { currentFrameworkVersion } from '../domain/framework.js'
13
+ import { sendError, sendJson } from '../infra/httpd.js'
14
+ import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
15
+
16
+ /** 框架运行时根(含 .pnpm 的那层):换了安装根(npx 缓存 ↔ 桌面端自带运行时)指纹必变。 */
17
+ function frameworkPnpmRoot(ports, profileDir) {
18
+ try {
19
+ const baseDir = baseDirOf(ports?.baseUrl ?? 'file:///')
20
+ const pkgPath = resolvePackageJson('@deepseek-ai/dsh', baseDir, profileDir)
21
+ if (pkgPath === null) return null
22
+ return dirname(dirname(pkgPath))
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ function currentVersion(rc) {
29
+ try { return currentFrameworkVersion(rc.ctx) } catch { return null }
30
+ }
31
+
32
+ /** 当前环境指纹(每次调用都现场算,不做缓存 —— 缓存会让「变更」永远滞后一拍)。 */
33
+ function currentFingerprint(rc) {
34
+ const profileDir = profileDirOf(rc.ctx)
35
+ return computeFingerprint({
36
+ frameworkVersion: currentVersion(rc),
37
+ pnpmRoot: frameworkPnpmRoot(rc.ctx, profileDir),
38
+ })
39
+ }
40
+
41
+ async function routeCompatStatusGet(req, res, rc) {
42
+ const mode = readCompatMode()
43
+ const current = currentFingerprint(rc)
44
+ const previous = readFingerprint()
45
+ const { changed, reasons, baselineAt } = compareFingerprint(current, previous)
46
+ sendJson(res, 200, {
47
+ ok: true,
48
+ mode: mode.mode,
49
+ modeSource: mode.source,
50
+ modeChangedAt: mode.at,
51
+ frameworkVersion: current.frameworkVersion,
52
+ fingerprint: { current, previous, changed, reasons, baselineAt },
53
+ rollback: readRollbackRecord(),
54
+ })
55
+ }
56
+
57
+ async function routeCompatMode(req, res, rc) {
58
+ const wanted = rc.body?.mode
59
+ const result = writeCompatMode(wanted)
60
+ if (!result.ok) { sendError(res, 400, result.error); return }
61
+ sendJson(res, 200, { ok: true, mode: result.mode, note: result.mode === 'observer' ? '观察者模式:只做预检与门控,不接管框架升级' : '托管模式:框架升级与回滚由本控制台负责' })
62
+ }
63
+
64
+ async function routeCompatStamp(req, res, rc) {
65
+ const stamped = stampFingerprint(currentFingerprint(rc))
66
+ sendJson(res, 200, { ok: true, fingerprint: stamped })
67
+ }
68
+
69
+ export { routeCompatMode, routeCompatStamp, routeCompatStatusGet }
@@ -10,6 +10,7 @@
10
10
  import { readFileSync } from 'node:fs'
11
11
  import { dirname, join } from 'node:path'
12
12
  import { isFrameworkOwnedPackage } from '../domain/compat.js'
13
+ import { loadContractRules, summarizeContractRules } from '../domain/contract-rules.js'
13
14
  import { discoverFormatContract, summarizeContract } from '../domain/format-contract.js'
14
15
  import { applyFormatPatch, collectProducerTargets, scanProducerFiles } from '../domain/format-scan.js'
15
16
  import { currentFrameworkVersion } from '../domain/framework.js'
@@ -107,6 +108,9 @@ async function routeFrameworkPreflight(req, res, rc) {
107
108
  contractWarnings,
108
109
  contractSummary: contract === null ? null : summarizeContract(contract),
109
110
  sessionFormatVersion: contract?.sessionFormatVersion ?? null,
111
+ // 规则库覆盖情况(2026-09-24):预检读的是**数据化规则**,所以它对我们没发过升级包的
112
+ // 框架版本同样有效;source='unavailable' 时说明规则文件缺失,调用方不应把它当「零发现」。
113
+ rules: summarizeContractRules(loadContractRules()),
110
114
  roots: roots.map((r) => ({ root: r.root, kind: r.kind, moduleName: r.moduleName ?? null })),
111
115
  scan,
112
116
  })
@@ -14,6 +14,7 @@ import { runSuiteInstallJob } from '../domain/suite.js'
14
14
  import { readBody, sendError } from '../infra/httpd.js'
15
15
  import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
16
16
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
17
+ import { routeCompatMode, routeCompatStamp, routeCompatStatusGet } from './compat.js'
17
18
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
18
19
  import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
19
20
  import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
@@ -33,6 +34,7 @@ const ROUTES_EARLY = [
33
34
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
34
35
  { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
35
36
  { methods: ['GET'], path: `${ROUTE_PREFIX}/skills-installed`, handler: routeSkillsInstalledGet }, { methods: ['GET'], path: `${ROUTE_PREFIX}/framework-upgrade-status`, handler: routeFrameworkUpgradeStatusGet },
37
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/compat-status`, handler: routeCompatStatusGet },
36
38
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
37
39
 
38
40
  ]
@@ -91,6 +93,8 @@ const ROUTES = [
91
93
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-upgrade`, handler: routeFrameworkUpgrade },
92
94
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight`, handler: routeFrameworkPreflight },
93
95
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight-patch`, handler: routeFrameworkPreflightPatch },
96
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-mode`, handler: routeCompatMode },
97
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
94
98
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
95
99
  { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
96
100
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
@@ -8,6 +8,7 @@ import { checkPluginFrameworkCompat, probePluginImport, readCompatPending, rowId
8
8
  import { pnpmRemove } from '../domain/install-job.js'
9
9
  import { curlManualInstall, readExtraBundleOwners } from '../domain/install.js'
10
10
  import { disableEntry, enableEntry, readPatchState, removeDisableBlock, removeInsertRow } from '../domain/patch.js'
11
+ import { undeclareProfileDependency } from '../domain/manifest.js'
11
12
  import { markPendingAdopted } from '../domain/quarantine.js'
12
13
  import { pendingRestartJobs, packageDirIn, needsPnpmRemove, revokePendingInstall } from '../domain/revoke.js'
13
14
  import { deriveEntryId, isProtectedModule, listEntries } from '../domain/runtime.js'
@@ -280,7 +281,11 @@ async function routeUninstall(req, res, rc) {
280
281
  }
281
282
  }
282
283
  // 任务记录是否作废看**事实**(包目录真的没了),不看 pnpm 的退出码;删不干净时留着让用户重试
283
- if (!existsSync(packageDirIn(profileDir, moduleName))) markJobsRevoked(moduleName, rowId)
284
+ if (!existsSync(packageDirIn(profileDir, moduleName))) {
285
+ // 卸载即撤销声明(2026-09-24):清单里留幽灵依赖的话,下一次 pnpm 操作会把它装回来
286
+ await undeclareProfileDependency(profileDir, moduleName)
287
+ markJobsRevoked(moduleName, rowId)
288
+ }
284
289
  sendJson(res, 200, { ok: true, removed: 'entry', packageName: moduleName, restart: false, uninstallError })
285
290
  return
286
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",