dsh-plugin-manager-companion 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +144 -0
  3. package/README.md +142 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/about.d.ts +77 -0
  6. package/dist/about.js +179 -0
  7. package/dist/cli.d.ts +226 -0
  8. package/dist/cli.js +856 -0
  9. package/dist/client/AboutPage.d.ts +75 -0
  10. package/dist/client/ConsolePage.d.ts +79 -0
  11. package/dist/client/KindsPage.d.ts +21 -0
  12. package/dist/client/MarketplacePage.d.ts +36 -0
  13. package/dist/client/OfficialSlots.d.ts +35 -0
  14. package/dist/client/UpgradeRow.d.ts +108 -0
  15. package/dist/client/index.d.ts +26 -0
  16. package/dist/client/locales.d.ts +475 -0
  17. package/dist/client/pmSelect.d.ts +38 -0
  18. package/dist/client/shared.d.ts +928 -0
  19. package/dist/client/upgradeView.d.ts +278 -0
  20. package/dist/client/wire.d.ts +401 -0
  21. package/dist/client.js +9194 -0
  22. package/dist/diagnostics.d.ts +332 -0
  23. package/dist/diagnostics.js +2631 -0
  24. package/dist/envManager.d.ts +1047 -0
  25. package/dist/envManager.js +3214 -0
  26. package/dist/fix.d.ts +60 -0
  27. package/dist/fix.js +168 -0
  28. package/dist/guard.d.ts +133 -0
  29. package/dist/guard.js +232 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +1150 -0
  32. package/dist/installSession.d.ts +111 -0
  33. package/dist/installSession.js +150 -0
  34. package/dist/kinds.d.ts +464 -0
  35. package/dist/kinds.js +1029 -0
  36. package/dist/marketView.d.ts +261 -0
  37. package/dist/marketView.js +406 -0
  38. package/dist/marketplace.d.ts +248 -0
  39. package/dist/marketplace.js +500 -0
  40. package/dist/match.d.ts +67 -0
  41. package/dist/match.js +203 -0
  42. package/dist/net.d.ts +108 -0
  43. package/dist/net.js +163 -0
  44. package/dist/official.d.ts +145 -0
  45. package/dist/official.js +205 -0
  46. package/dist/paths.d.ts +108 -0
  47. package/dist/paths.js +236 -0
  48. package/dist/presets.d.ts +299 -0
  49. package/dist/presets.js +578 -0
  50. package/dist/qualityGate.d.ts +66 -0
  51. package/dist/qualityGate.js +247 -0
  52. package/dist/rank.d.ts +88 -0
  53. package/dist/rank.js +164 -0
  54. package/dist/registry.d.ts +295 -0
  55. package/dist/registry.js +686 -0
  56. package/dist/rest.d.ts +122 -0
  57. package/dist/rest.js +219 -0
  58. package/dist/scan.d.ts +134 -0
  59. package/dist/scan.js +396 -0
  60. package/dist/settings.d.ts +447 -0
  61. package/dist/settings.js +263 -0
  62. package/dist/tags.d.ts +119 -0
  63. package/dist/tags.js +166 -0
  64. package/dist/tools.d.ts +131 -0
  65. package/dist/tools.js +377 -0
  66. package/dist/types.d.ts +651 -0
  67. package/dist/types.js +13 -0
  68. package/dist/upgrade.d.ts +428 -0
  69. package/dist/upgrade.js +1100 -0
  70. package/dist/upgradeView.d.ts +313 -0
  71. package/dist/upgradeView.js +273 -0
  72. package/docs/images/readme/01-console-health.png +0 -0
  73. package/docs/images/readme/02-console-envs.png +0 -0
  74. package/docs/images/readme/03-marketplace.png +0 -0
  75. package/docs/images/readme/04-official-plugin-page.png +0 -0
  76. package/package.json +104 -0
package/dist/index.js ADDED
@@ -0,0 +1,1150 @@
1
+ /**
2
+ * dsh-plugin-manager-companion — 插件入口与 op 分派。
3
+ *
4
+ * 归属:A 类·重写(旧仓库入口 2358 行,把服务、路由、job、缓存闭包全塞在一起)。
5
+ * 官方复用:ctx.profileContext / ctx.pluginManager(经 official.ts 探测)/ ctx.settings /
6
+ * ctx.webServer / ctx.tools / ctx.systemPrompt。
7
+ * 前提检查:旧仓库入口自建 27 个 REST op + job 系统 + patch 写入,前提是"官方只有只读清单"。
8
+ * 0.1.6 之后前提消失:**当前环境**的写操作全部交还官方 Remote;本入口只保留官方不覆盖的部分
9
+ * (深度诊断、跨环境管理、市场、技能与预设、质量门编排)。
10
+ *
11
+ * 硬约束:服务名绝不用 pluginManager(官方已占用,同名会让整个 profile 起不来)。
12
+ */
13
+ import { collectAboutFacts } from "./about.js";
14
+ import { analyzeEnvironment } from "./diagnostics.js";
15
+ import { DEFAULT_ENVIRONMENT_TEMPLATE, backupDiff, backupExport, backupRestore, cleanupTrialEnvironments, copyPlugins, createEnvironment, environmentFingerprint, environmentTemplates, listEnvironments, listTrialEnvironments, planTrialCleanup, processFacts, removeEnvironment, removeTrialEnvironment, renameEnvironment, repairDependencies, runTrialInstall, scanRuns, startEnvironment, stopEnvironment, trialEnvironmentName } from "./envManager.js";
16
+ import { findOrphanKindDirs, kindDirsOf, loadKindRecords, presetsRoot, pruneGhostRecords, removeKindDir, removeKindRecord, skillsRoot } from "./kinds.js";
17
+ import { buildInstalledIndex, cachedMarketplace, invalidateInstalledIndex, registryItems } from "./marketplace.js";
18
+ import { probeOfficialCapabilities, requireManager } from "./official.js";
19
+ import { environmentDir as pathEnvironmentDir, OUR_PACKAGE_NAME, readEnvironmentManifest, sameEnvironment } from "./paths.js";
20
+ import { inspectPackage } from "./qualityGate.js";
21
+ import { applyFix } from "./fix.js";
22
+ import { loadRegistryIndex } from "./registry.js";
23
+ import { checkUpgrades, rollbackUpgrade, upgradePackage } from "./upgrade.js";
24
+ import { findPluginMatches } from "./match.js";
25
+ import { registerGuard } from "./guard.js";
26
+ import { registerCompanionTools } from "./tools.js";
27
+ import { BODY_LIMIT_DEFAULT, JobRegistry, ROUTE_PREFIX, isJsonPost, isTrustedRequest, readJsonBody, sendJson } from "./rest.js";
28
+ import { TRIAL_DISCLOSURE, effectiveTrialConfig, fallbackConfigHandle, registerConfig } from "./settings.js";
29
+ import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ /** 本插件对外的服务名。绝不用 pluginManager —— 那是官方的。 */
32
+ export const SERVICE_NAME = "companion";
33
+ /** 插件配置的 schema,供宿主配置界面与校验使用。 */
34
+ export { ConfigSchema } from "./settings.js";
35
+ /** Loader 行名,等于包名;cordis.patch.yml 必须用同一个 id。 */
36
+ export const name = OUR_PACKAGE_NAME;
37
+ /** 装配时必需的服务;官方能力用 get 探测,因此这里只列真正硬需要的。 */
38
+ export const inject = ["loader"];
39
+ let runtime;
40
+ /** 读取当前装配状态(诊断与 UI 用)。未装配时返回 undefined。 */
41
+ export function currentRuntime() {
42
+ return runtime;
43
+ }
44
+ // ── 质量门编排 ────────────────────────────────────────────────────────────
45
+ /**
46
+ * 当前环境目录:官方事实(profileContext.dir)优先。
47
+ *
48
+ * @param ctx - host 上下文。
49
+ * @param fallbackDir - 拿不到官方 profileContext 时的回退目录。
50
+ * @returns 环境目录。
51
+ */
52
+ function currentProfileDir(ctx, fallbackDir) {
53
+ const profileContext = ctx.get('profileContext');
54
+ return profileContext?.dir ?? fallbackDir;
55
+ }
56
+ /**
57
+ * 环境目录;拿不到(名字为空、名字不安全)时返回 null —— 核对路径不允许抛异常。
58
+ *
59
+ * @param name - 环境名。
60
+ * @returns 目录或 null。
61
+ */
62
+ function environmentDirOrNull(name) {
63
+ try {
64
+ return name.length === 0 ? null : pathEnvironmentDir(name);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * 回滚动作自己的结局(官方 ChangeResult.application)。
72
+ *
73
+ * 官方 change() 把失败折进结果而不是抛异常,所以「调用过 removeBundle」不等于
74
+ * 「回滚成功」——标题必须按官方回报的 application 写,不能默认成功。
75
+ *
76
+ * @param removed - 官方 removeBundle 的返回值。
77
+ * @returns 标题片段。
78
+ */
79
+ function rollbackHeadline(removed) {
80
+ if (removed.application !== 'failed')
81
+ return '已回滚';
82
+ return '回滚没有完成(' + removalFailureText(removed.error?.code) + ')';
83
+ }
84
+ /**
85
+ * 官方移除失败码 → 人话。
86
+ *
87
+ * 用户要的是"发生了什么、我该怎么办",不是官方内部码名(DESIGN §12.6)。
88
+ * 码名本身仍留在结果对象的 error 字段里,需要排查时读得到;**文案里不再出现**。
89
+ * 未知码保留原文——宁可给原始信息,也不编一个可能不对的解释。
90
+ *
91
+ * @param code - 官方错误码。
92
+ * @returns 面向用户的说法。
93
+ */
94
+ function removalFailureText(code) {
95
+ const known = {
96
+ 'not-removable': '官方不允许移除这个组合包',
97
+ 'management-required': '这个包由安装方管理,不能在环境里移除',
98
+ 'stop-profile': '环境正在运行,要先停掉它',
99
+ 'bundle-in-use': '这个包正在被使用',
100
+ 'not-bundle': '它不是组合包',
101
+ };
102
+ if (code === undefined || code.length === 0)
103
+ return '官方没有给出原因';
104
+ return known[code] ?? code;
105
+ }
106
+ /**
107
+ * 一次回滚之后的**磁盘真实状态**。
108
+ *
109
+ * 为什么不写死「已回滚,环境未被改动」:官方 removeBundle 走的是 pnpm remove,本机
110
+ * 实测(官方 add 之后紧接官方 remove,两次 exitCode 都是 0):
111
+ * - package.json 的 dependencies 与 dsh.profile.bundles 都被清干净;
112
+ * - <profile>/node_modules/<name> 这个 link:/file: 安装产生的**符号链接原地留下**。
113
+ * 官方 installBundle 失败时也只恢复 RESTORED_FILES = package.json + pnpm-lock.yaml
114
+ * (官方注释原话:downloaded files can stay),同样不碰 node_modules。
115
+ * 官方没有清理这个链接的通道,我们也不 rm 不是自己创建的链接 —— 只能如实陈述。
116
+ *
117
+ * rolledBack 的取值因此收紧为「磁盘上确实没有留下痕迹」:有残留时返回 false,
118
+ * 客户端据此不再弹「环境未被改动」。
119
+ *
120
+ * @param dir - 环境目录。
121
+ * @param name - 包名。
122
+ * @returns 状态行与「是否干净」。
123
+ */
124
+ function rollbackState(dir, name) {
125
+ if (dir === null || !existsSync(join(dir, 'package.json'))) {
126
+ return {
127
+ lines: ['没能核对回滚结果:读不到这个环境的清单文件'],
128
+ clean: false,
129
+ };
130
+ }
131
+ const manifest = readEnvironmentManifest(dir);
132
+ if (manifest.broken !== undefined) {
133
+ return {
134
+ lines: ['没能核对回滚结果:环境的清单文件读不懂'],
135
+ clean: false,
136
+ };
137
+ }
138
+ const declared = manifest.dependencies.includes(name);
139
+ const layered = manifest.bundles.includes(name);
140
+ // 依赖声明与层栈是**两件事**,各自说各自的状态(旧版把两者拼进一行,读起来是一句长定语)。
141
+ const lines = [];
142
+ if (declared)
143
+ lines.push('依赖声明还在');
144
+ if (layered)
145
+ lines.push('它仍在环境启动时加载的列表里');
146
+ const entry = join(dir, 'node_modules', name);
147
+ let leftover = false;
148
+ try {
149
+ const stat = lstatSync(entry);
150
+ leftover = true;
151
+ // 路径安装留下的链接:说清"文件还在、且我们不会替你删",但不把目录名与箭头当句子主体。
152
+ lines.push(stat.isSymbolicLink()
153
+ ? '安装目录里还留着指向本地来源的链接(本次安装的残留),需要时可以手动删除'
154
+ : '安装目录里还留着它的文件(本次安装的残留),需要时可以手动删除');
155
+ }
156
+ catch {
157
+ // lstat 失败 = 没有残留,这是正常路径。
158
+ }
159
+ if (lines.length === 0)
160
+ lines.push('依赖声明与加载列表都已回到原状,也没有留下安装残留');
161
+ return { lines, clean: !declared && !layered && !leftover };
162
+ }
163
+ /**
164
+ * 四种结论各自的短标签(措辞与 §5.2 一一对应,不得混用)。
165
+ *
166
+ * 用法约定:这些短句直接拼进结果里,**不再套"试装未通过:"之类的前缀**——
167
+ * "无法试装(不算通过),已回滚 X" 比 "试装未通过(无法试装(不算通过)),已回滚 X" 可读得多。
168
+ */
169
+ const TRIAL_LABEL = {
170
+ "passed": "试装通过",
171
+ "baseline-broken": "环境副本的基线起不来(不是候选包的问题)",
172
+ "candidate-broken": "候选包导致启动失败",
173
+ "cannot-trial": "无法试装(不算通过)",
174
+ };
175
+ /**
176
+ * 验证启动失败是不是"端口绑不上"这类基础设施原因。
177
+ *
178
+ * 为什么必须单独认它(真机实测 2026-09-19):含 web app 的环境在验证启动时(不给任务、
179
+ * 不指定端口)会去绑 web-app 补丁里的默认端口 3080;GUI 正跑在那个端口上时必然
180
+ * EADDRINUSE,整棵树因此挂不起来。把它照原样报成"基线起不来"是**错误的归因**——
181
+ * 用户会以为自己的环境坏了(甚至去改环境),而真实原因与他和候选包都无关。
182
+ *
183
+ * 只在**基线**失败时降级:基线里没有候选包的任何代码,端口冲突只可能来自环境自身或外部进程。
184
+ *
185
+ * @param verdict - 一次挂载验证的判定(引擎的 BootVerdict 结构式视图)。
186
+ * @returns 冲突地址(host:port);不是端口冲突时 null。
187
+ */
188
+ function bootPortConflict(verdict) {
189
+ if (verdict === null || verdict.kind !== "failed")
190
+ return null;
191
+ const text = [verdict.reason ?? "", ...(verdict.chain ?? [])].join("\n");
192
+ if (!/EADDRINUSE|address already in use/i.test(text))
193
+ return null;
194
+ const hit = /address already in use[ :]*([0-9a-zA-Z.:\[\]_-]+)/i.exec(text);
195
+ return hit === null ? "(错误里没写地址)" : hit[1];
196
+ }
197
+ /**
198
+ * 「关于」页要的 profile 事实(官方 profileContext 的三个字段)。
199
+ *
200
+ * 与 {@link currentEnvironmentName} / 环境目录那两处同一读法(结构式窄化 + 逐字段判空),
201
+ * 抽出来是因为 about 要一次读三个字段,散在调用处会重复三次同样的窄化。
202
+ *
203
+ * 读不到时**不给空串**:返回 undefined,由 about.ts 如实标 unknown(§12.3.3)。
204
+ *
205
+ * @param ctx - host 上下文。
206
+ * @returns 三个字段(各自可能缺失);profileContext 整个读不到时 undefined。
207
+ */
208
+ function profileFactsOf(ctx) {
209
+ const profileContext = ctx?.get("profileContext");
210
+ if (profileContext === undefined)
211
+ return {};
212
+ const facts = {};
213
+ const put = (value, assign) => {
214
+ if (typeof value === "string" && value.length > 0)
215
+ assign(value);
216
+ };
217
+ put(profileContext.installAnchor, (text) => { facts.installAnchor = text; });
218
+ put(profileContext.name, (text) => { facts.profileName = text; });
219
+ put(profileContext.dir, (text) => { facts.profileDir = text; });
220
+ return facts;
221
+ }
222
+ /** 当前环境名(官方 profileContext 的 name);读不到时为 null。 */
223
+ function currentEnvironmentName(ctx) {
224
+ const profileContext = ctx.get("profileContext");
225
+ const name = profileContext?.name;
226
+ return typeof name === "string" && name.length > 0 ? name : null;
227
+ }
228
+ /** 试装未通过时按设置决定处置(**两种模式都不把"无法试装"当成通过**)。 */
229
+ function trialPolicyFor(trial, conclusion) {
230
+ if (conclusion === "passed")
231
+ return { policy: "passed", policyNote: "试装通过" };
232
+ if (trial.onFailure === "warn") {
233
+ return { policy: "warned", policyNote: TRIAL_LABEL[conclusion] + ",按 warn 模式照常安装" };
234
+ }
235
+ return { policy: "blocked", policyNote: TRIAL_LABEL[conclusion] + ",按 block 模式未安装并已回滚" };
236
+ }
237
+ /**
238
+ * 试装已开启但这次没执行时的摘要(质量门整体关闭 / 包在豁免名单里)。
239
+ *
240
+ * 结论写 cannot-trial、处置写 skipped:它**不是**通过。界面据此说"试装未执行",
241
+ * 而不是让用户以为这个包被验证过了。
242
+ *
243
+ * @param config - 本插件配置。
244
+ * @param reason - 没执行的原因(面向用户)。
245
+ * @returns 结论摘要。
246
+ */
247
+ function trialSkipSummary(config, reason) {
248
+ return {
249
+ conclusion: "cannot-trial", policy: "skipped", depth: undefined, escalated: false,
250
+ baseline: null, candidate: null, elapsedMs: 0,
251
+ output: "试装未执行:" + reason + "。这个包没有经过试装验证——它并没有通过试装。",
252
+ policyNote: reason,
253
+ };
254
+ }
255
+ /**
256
+ * 本次试装要不要为"数量上限"停在门外(§5.3 的最多保留数;0 = 不限)。
257
+ *
258
+ * 上限的语义刻意做成**拒绝执行**而不是"删掉最旧的一个腾位":删除只允许发生在两处
259
+ * (用户自己点删除、或超过保留期的自动清理)。为了腾位而隐式删除,正是本仓库在
260
+ * removeTrialEnvironment 里明确拒绝过的形态("不做先停后删的隐式动作")。
261
+ *
262
+ * @param realName - 真实环境名(测试环境由它派生)。
263
+ * @param maxKept - 上限;0 = 不限。
264
+ * @returns 超限时返回面向用户的说明;否则 null。
265
+ */
266
+ function trialSlotBlocked(realName, maxKept) {
267
+ if (maxKept <= 0)
268
+ return null;
269
+ const listed = listTrialEnvironments();
270
+ const target = trialEnvironmentName(realName);
271
+ const others = listed.candidates.filter(candidate => !sameEnvironment(candidate.name, target));
272
+ if (others.length < maxKept)
273
+ return null;
274
+ return "测试环境已经有 " + String(others.length) + " 个(你设的上限是 " + String(maxKept)
275
+ + "):先删掉不再需要的(每个测试环境都能单独删),或把上限调大。"
276
+ + "试装不会为了腾位偷偷删掉任何一个测试环境。";
277
+ }
278
+ /**
279
+ * 跑一次试装,并把它折成这次安装能用的结论摘要(§5.2 的受控对照四步在引擎里)。
280
+ *
281
+ * 三件事在接进来这一层做,因为它们都是**接入层的判断**,不是引擎的判断:
282
+ * 1. 受控对照的"真实环境"必须是**包真正会落地的那个环境**。官方安装通道只作用于当前环境
283
+ * (ctx.pluginManager 就是当前 profile 的管理器),所以请求里指定了别的环境时,
284
+ * 验证的环境与落地的环境不是同一个——这种结论毫无意义,如实报"无法试装"。
285
+ * 2. 数量上限(§5.3)在起进程之前判,省掉一整轮无用的安装。
286
+ * 3. 试装跑完后按保留期顺手清理过期测试环境(§5.4;可在设置里关)。
287
+ *
288
+ * 任何异常都折成 cannot-trial(**不算通过**),绝不让一次异常变成"静默放行"。
289
+ *
290
+ * @param ctx - host 上下文(引擎用它取官方安装锚点与 pnpm 通道)。
291
+ * @param config - 本插件配置。
292
+ * @param spec - 候选包 spec。
293
+ * @param targetName - 调用方给的安装目标环境名(可能为空字符串 = 当前环境)。
294
+ * @param runner - 试装执行器。
295
+ * @returns 结论摘要。
296
+ */
297
+ async function runTrialStep(ctx, config, spec, targetName, runner) {
298
+ const trial = effectiveTrialConfig(config);
299
+ /** 试装没跑起来时的摘要:一律 cannot-trial(占位字段为 null / 省略)。 */
300
+ const cannotTrial = (reason) => {
301
+ const { policy, policyNote } = trialPolicyFor(trial, "cannot-trial");
302
+ return {
303
+ conclusion: "cannot-trial", policy, depth: undefined, escalated: false,
304
+ baseline: null, candidate: null, elapsedMs: 0, output: "无法试装:" + reason, policyNote,
305
+ };
306
+ };
307
+ const realName = currentEnvironmentName(ctx) ?? runtime?.capabilities.environmentName ?? "";
308
+ if (realName.length === 0) {
309
+ return cannotTrial("读不到当前是哪个环境,无法确定候选包会落进哪里,也就没有可以对照的环境副本");
310
+ }
311
+ if (targetName.length > 0 && !sameEnvironment(targetName, realName)) {
312
+ return cannotTrial("这次安装的目标是 " + targetName + ",但官方安装通道只作用于当前环境 " + realName
313
+ + ":试装验证的环境与包真正落地的环境必须是同一个,所以做不了受控对照。请在当前环境里安装,或改用跨环境通道(dshpmc)。");
314
+ }
315
+ const slot = trialSlotBlocked(realName, trial.maxKept);
316
+ if (slot !== null)
317
+ return cannotTrial(slot);
318
+ let result;
319
+ try {
320
+ result = await runner(spec, realName, {
321
+ ctx,
322
+ depth: trial.depth,
323
+ baseline: trial.baseline,
324
+ allowNetwork: trial.allowNetwork,
325
+ // 层栈事实取官方 listBundles(这台的插件管理器就是当前环境的管理器)。
326
+ // 拿不到时引擎会自己回落到 manifest 并如实标注口径,不需要这里兜。
327
+ listBundles: async () => (await requireManager(ctx).listBundles()).map(bundle => bundle.name),
328
+ });
329
+ }
330
+ catch (error) {
331
+ return cannotTrial("试装执行时出错:" + (error instanceof Error ? error.message : String(error)));
332
+ }
333
+ // 归因修正:基线挂载失败且原因是"端口绑不上"时,这不是环境坏了、也不是候选包的问题。
334
+ // 照原样报 baseline-broken 会误导用户去修一个其实没坏的环境,所以降级为"无法试装"。
335
+ let conclusion = result.conclusion;
336
+ let output = result.output;
337
+ const baselineConflict = bootPortConflict(result.baseline);
338
+ const baselineUndetermined = result.baseline !== null && result.baseline.kind === "undetermined";
339
+ if (baselineConflict !== null && conclusion !== "passed") {
340
+ // 端口冲突这一支:**整段替换**引擎的结论叙述。
341
+ // 为什么替换而不是"在前面加一句":引擎那段会说"环境副本的基线起不来 / 这个环境当前状态有问题",
342
+ // 而端口冲突下这两句都不成立(真机实测:GUI 占着 3080,验证启动必然撞上它)。
343
+ conclusion = "cannot-trial";
344
+ output = trialNarrative(result, [
345
+ "无法试装:验证启动绑不上端口(" + baselineConflict + " 已被占用)",
346
+ "这不是候选包的问题,也不是环境坏了:含 web app 的环境在验证启动时(不给任务、不指定端口)"
347
+ + "会去绑它自己的默认端口,而那个端口正被别的进程占着。占用者是谁需要你自己确认;"
348
+ + "端口空出来之后,这次验证才有意义",
349
+ ]);
350
+ }
351
+ else if (baselineUndetermined) {
352
+ // 基线"判不出来"这一支同样替换:引擎会说"环境副本的基线本身就起不来",但那句话没被任何事实支持
353
+ // (判不出来恰恰是"不知道")。真机实测:含 web app 的环境在验证启动里以**服务形态常驻**,
354
+ // 30s 超时后被杀、stderr 为空——既没启动成功的凭证,也没有失败凭证。
355
+ output = trialNarrative(result, [
356
+ "无法试装:验证启动没有给出判定——它既没启动成功,也没报启动失败"
357
+ + (result.baseline !== null && result.baseline.kind === "undetermined" ? "(" + result.baseline.reason + ")" : "") + "。",
358
+ "这是验证形态给不出结论,不是候选包的问题,也不是环境坏了",
359
+ ]);
360
+ }
361
+ // 候选启动失败时的端口冲突是**有歧义**的(可能是候选包自己要绑那个端口),
362
+ // 所以结论不动,只把这条事实补进输出——让人能判断,而不是由我们替他下结论。
363
+ const candidateConflict = bootPortConflict(result.candidate);
364
+ if (candidateConflict !== null) {
365
+ // R2(DESIGN §12.9):原因不许冒号套冒号。原来那一行是「…(3080 已被占用):可能是…」——
366
+ // 冒号之后再套一层解释。改成分行:第一行给事实,第二行给两种可能。
367
+ output += "\n注意:候选启动的失败形态是端口冲突(" + candidateConflict + " 已被占用)"
368
+ + "\n 可能是候选包自己要绑这个端口,也可能是与环境里已有进程冲突,需要人工判断";
369
+ }
370
+ const { policy, policyNote } = trialPolicyFor(trial, conclusion);
371
+ const cleanupNote = await maybeAutoCleanupTrialEnvironments(config);
372
+ return {
373
+ conclusion,
374
+ policy,
375
+ depth: result.depth,
376
+ escalated: result.escalated,
377
+ escalationReason: result.escalationReason,
378
+ baseline: result.baseline?.kind ?? null,
379
+ candidate: result.candidate?.kind ?? null,
380
+ elapsedMs: result.elapsedMs,
381
+ output: cleanupNote === null ? output : output + "\n" + cleanupNote,
382
+ policyNote,
383
+ };
384
+ }
385
+ /**
386
+ * 试装没能给出结论时,用**接入层拿得到的结构化事实**拼一份结论叙述。
387
+ *
388
+ * 为什么不直接复用引擎的 output:引擎那一段会把"验证启动没跑起来"写成
389
+ * "快照基线起不来 / 这个环境当前状态有问题"——在没有失败凭证的情况下那是**错误的归因**
390
+ * (真机实测两例:GUI 占着 3080 导致的口冲突;以及含 web app 的环境以服务形态常驻、
391
+ * 30s 超时被杀)。这里只用事实:判定说了什么、深度、耗时、原始根因链、构建指纹。
392
+ *
393
+ * @param result - 引擎给的试装结果(结构化事实)。
394
+ * @param head - 这段结论自己要说清的话(面向用户)。
395
+ * @returns 面向用户的结论叙述。
396
+ */
397
+ /**
398
+ * 快照深度 → 用户能读的标签(§12.9 R3:shallow / full 是内部代号)。
399
+ *
400
+ * 抽成模块级函数的原因(task-89 实测):**两处都在拼这句**,一处改了一处没改——
401
+ * 安装叙述那处漏了映射,于是 `实际深度 full` 直接上了屏(trial-gate 的用例抓到的)。
402
+ * 两个调用点共用一份映射,才不会再次漂移。
403
+ *
404
+ * @param depth - 引擎给的深度值。
405
+ * @returns 面向用户的标签;读不懂的值原样返回(不编一个说法)。
406
+ */
407
+ function trialDepthLabel(depth) {
408
+ if (depth === "shallow")
409
+ return "轻量副本";
410
+ if (depth === "full")
411
+ return "完整副本";
412
+ return depth ?? "未建立副本";
413
+ }
414
+ function trialNarrative(result, head) {
415
+ const chain = result.baseline !== null && result.baseline.kind === "failed" ? result.baseline.chain : [];
416
+ // R2:原来写成「实际深度:shallow(由 shallow 升级:原因)」——冒号套冒号。
417
+ // 改成分行:第一行给深度,升级原因另起一行缩进。
418
+ // R3:'shallow' 是内部代号(引擎的 depth 值),换成用户语言。
419
+ const depthLabel = trialDepthLabel(result.depth);
420
+ const depthLine = "实际深度:" + depthLabel
421
+ + (result.escalated ? "\n 由轻量副本升级为完整副本,原因:" + String(result.escalationReason) : "")
422
+ + "|验证耗时 " + String(result.elapsedMs) + "ms";
423
+ const buildLine = "构建:md5=" + (result.build.artifactMd5 === null ? "不可读" : result.build.artifactMd5.slice(0, 12))
424
+ + (result.build.gitHead === null ? "(读不到 git HEAD)" : " head=" + result.build.gitHead.slice(0, 12));
425
+ return [
426
+ ...head,
427
+ depthLine,
428
+ chain.length === 0 ? "" : "根因(验证启动的原始输出):\n" + chain.join("\n"),
429
+ buildLine,
430
+ ].filter(line => line.length > 0).join("\n");
431
+ }
432
+ /**
433
+ * 试装结束后顺手清理过期测试环境(§5.4;开关与天数在设置里)。
434
+ *
435
+ * 只删**超过保留期**且**没在运行**的(判定在引擎的清理计划里,删不动就如实记账)。
436
+ * 没有任何过期项时返回 null——不做无意义的打扰。清理失败**不影响**本次安装结论。
437
+ *
438
+ * @param config - 本插件配置。
439
+ * @returns 面向用户的一句清理结果;没有可清理项时为 null。
440
+ */
441
+ async function maybeAutoCleanupTrialEnvironments(config) {
442
+ const trial = effectiveTrialConfig(config);
443
+ if (!trial.autoCleanup)
444
+ return null;
445
+ try {
446
+ const result = await cleanupTrialEnvironments({ retainDays: trial.retentionDays });
447
+ if (result.removed.length === 0 && result.ok)
448
+ return null;
449
+ return "测试环境自动清理:\n" + result.output;
450
+ }
451
+ catch (error) {
452
+ return "测试环境自动清理失败(不影响本次安装):" + (error instanceof Error ? error.message : String(error));
453
+ }
454
+ }
455
+ /** 目录占地的统计预算(超过就如实说"没统计完",不让页面卡在一次遍历上)。 */
456
+ const USAGE_FILE_BUDGET = 20_000;
457
+ /**
458
+ * 目测一个目录的占地(apparent 字节合计 + 文件数 + 硬链接数)。
459
+ *
460
+ * 口径必须说清:这是 **st_size 的合计**,不是"独占磁盘"。pnpm 的 store 用硬链接,
461
+ * 实测一个 12 MiB 的测试环境独占只有 36 KiB(1243/1247 个文件 nlink>1)——所以
462
+ * sharedFiles 一起给出来,界面才能说清"看着大、实际不占"。
463
+ * 符号链接只计链接本身、不跟进目标(跟进会把 store 里的内容重复算进来)。
464
+ *
465
+ * @param root - 目录。
466
+ * @returns 统计结果;截断或读不到时 bytes 为 null 并给原因。
467
+ */
468
+ function directoryUsage(root) {
469
+ let bytes = 0;
470
+ let files = 0;
471
+ let sharedFiles = 0;
472
+ const stack = [root];
473
+ try {
474
+ while (stack.length > 0) {
475
+ const dir = stack.pop();
476
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
477
+ const path = join(dir, entry.name);
478
+ if (entry.isDirectory()) {
479
+ stack.push(path);
480
+ continue;
481
+ }
482
+ if (files >= USAGE_FILE_BUDGET) {
483
+ return { bytes: null, files, sharedFiles, reason: "目录里文件数超过 " + String(USAGE_FILE_BUDGET) + " 个,没统计完(避免拖住页面)" };
484
+ }
485
+ const stat = lstatSync(path);
486
+ files += 1;
487
+ if (stat.isSymbolicLink())
488
+ continue;
489
+ bytes += stat.size;
490
+ if (stat.nlink > 1)
491
+ sharedFiles += 1;
492
+ }
493
+ }
494
+ }
495
+ catch (error) {
496
+ return { bytes: null, files, sharedFiles, reason: "统计失败:" + (error instanceof Error ? error.message : String(error)) };
497
+ }
498
+ return { bytes, files, sharedFiles };
499
+ }
500
+ /**
501
+ * 测试环境的快照清单是否仍与真实环境一致(§5.4 第二步的"比对"那一步,只读、不改)。
502
+ *
503
+ * 比的是三件套的**内容 hash**(package.json / pnpm-lock.yaml / cordis.patch.yml):
504
+ * 不一致只意味着"下次试装会重新物化",不是错误,所以两态都如实给出。
505
+ *
506
+ * @param trialEnvName - 测试环境名。
507
+ * @param ownerName - 归属的真实环境名。
508
+ * @returns 是否一致;读不到时为 null。
509
+ */
510
+ async function snapshotMatchesOwner(trialEnvName, ownerName) {
511
+ try {
512
+ const [snapshot, owner] = await Promise.all([
513
+ environmentFingerprint(trialEnvName),
514
+ environmentFingerprint(ownerName),
515
+ ]);
516
+ return snapshot.manifestHash === owner.manifestHash
517
+ && snapshot.lockfileHash === owner.lockfileHash
518
+ && snapshot.patchHash === owner.patchHash;
519
+ }
520
+ catch {
521
+ return null;
522
+ }
523
+ }
524
+ /**
525
+ * 测试环境的查询报告(op: trialEnvironments)。
526
+ *
527
+ * 纯读:这个 op **不删任何东西**(计划只作为 preview 给界面),删除只有两个入口——
528
+ * 用户点单个删除(trialRemove)与显式/自动清理(trialCleanup)。
529
+ *
530
+ * @param config - 本插件配置(保留策略从这里读,界面不要自己拼默认值)。
531
+ * @returns 报告。
532
+ */
533
+ async function trialEnvironmentReport(config) {
534
+ const trial = effectiveTrialConfig(config);
535
+ const listed = listTrialEnvironments();
536
+ const plan = planTrialCleanup(listed.candidates, { retainDays: trial.retentionDays });
537
+ const notes = [];
538
+ if (!listed.factsReadable) {
539
+ notes.push("进程事实读不到(" + String(listed.reason ?? "原因未知") + "):运行状态按未知处理,"
540
+ + "清理计划因此不会删任何东西(不在未知状态下动磁盘)");
541
+ }
542
+ const now = Date.now();
543
+ const environments = [];
544
+ let bytes = 0;
545
+ let unknownBytes = 0;
546
+ let running = 0;
547
+ for (const candidate of listed.candidates) {
548
+ const dir = pathEnvironmentDir(candidate.name);
549
+ const ownerDir = environmentDirOrNull(candidate.owner);
550
+ const ownerExists = ownerDir !== null && existsSync(join(ownerDir, "package.json"));
551
+ const usage = directoryUsage(dir);
552
+ if (usage.bytes === null)
553
+ unknownBytes += 1;
554
+ else
555
+ bytes += usage.bytes;
556
+ if (candidate.running)
557
+ running += 1;
558
+ environments.push({
559
+ name: candidate.name,
560
+ owner: candidate.owner,
561
+ ownerExists,
562
+ dir,
563
+ running: candidate.running,
564
+ modifiedAtMs: candidate.modifiedAt,
565
+ modifiedAt: new Date(candidate.modifiedAt).toISOString(),
566
+ ageDays: Math.round((now - candidate.modifiedAt) / 86_400_000 * 10) / 10,
567
+ bytes: usage.bytes,
568
+ files: usage.files,
569
+ sharedFiles: usage.sharedFiles,
570
+ bytesReason: usage.reason,
571
+ snapshotMatchesOwner: ownerExists ? await snapshotMatchesOwner(candidate.name, candidate.owner) : null,
572
+ });
573
+ }
574
+ return {
575
+ environments,
576
+ factsReadable: listed.factsReadable,
577
+ factsReason: listed.reason,
578
+ totals: { count: environments.length, running, bytes, unknownBytes },
579
+ retention: { days: trial.retentionDays, autoCleanup: trial.autoCleanup, maxKept: trial.maxKept },
580
+ plan: { remove: [...plan.remove], keep: [...plan.keep] },
581
+ overCap: trial.maxKept > 0 && environments.length >= trial.maxKept,
582
+ notes,
583
+ };
584
+ }
585
+ /**
586
+ * 受质量门保护的安装。
587
+ *
588
+ * 三步走,全部经官方通道,零竞态:
589
+ * 1. inspect(spec) —— 官方读 spec 指向什么(拒绝非法/已装/非 bundle)
590
+ * 2. installBundle(spec, { enabled: false }) —— 官方装但**不激活**
591
+ * 3. 扫描已安装包(我们的质量门)
592
+ * 合格 → setBundleEnabled(name, true) 激活
593
+ * 不合格 → removeBundle(name) 回滚
594
+ *
595
+ * 为什么不需要"安装前钩子":官方提供了 enabled:false 这个开关(官方 UI 自己就用它)。
596
+ * 装上但不激活,等于把包放进隔离区,扫完再决定是否放行——比"先扫后装"更可靠,
597
+ * 因为扫描对象真实存在于目标位置。
598
+ *
599
+ * 试装(第二步,DESIGN §5.2)接在**静态快筛之后、真正放行之前**:一条路径,Web UI /
600
+ * CLI / agent 工具三边同时生效。试装关闭时(默认)本函数的行为与没有它时逐条相同。
601
+ *
602
+ * 两处刻意的实现细节:
603
+ * · 质量门**整体**关闭或包在豁免名单里时,试装不执行——但会在输出里写明"试装未执行",
604
+ * 不让"我打开了开关却什么都没发生"变成一个看不见的洞。
605
+ * · 试装没通过时的处置由设置决定(默认不装),但**无论哪一档**,"无法试装"都不会被写成通过。
606
+ *
607
+ * @param ctx - host 上下文。
608
+ * @param config - 本插件配置。
609
+ * @param spec - 安装 spec(npm 名 / git 地址 / 本地路径 / tarball)。
610
+ * @param environmentName - 目标环境名;undefined 表示当前环境。
611
+ * @param options - 可选注入(试装执行器)。
612
+ * @returns 结果;失败时输出里说明是官方拒绝、质量门拦截、试装未通过还是激活失败。
613
+ */
614
+ export async function gatedInstall(ctx, config, spec, environmentName, options = {}) {
615
+ const manager = requireManager(ctx);
616
+ const inspected = await manager.inspect(spec);
617
+ if (inspected.status === "refused") {
618
+ return { ok: false, output: `拒绝安装:${inspected.problem} —— ${inspected.reason}`, gateIssues: [] };
619
+ }
620
+ const installed = await manager.installBundle(spec, { enabled: false });
621
+ if (installed.application === "failed" || installed.bundle === undefined) {
622
+ return {
623
+ ok: false,
624
+ output: `安装失败:${installed.error?.code ?? "unknown"}${installed.error?.diagnostic === undefined ? "" : " —— " + installed.error.diagnostic}`,
625
+ gateIssues: [],
626
+ };
627
+ }
628
+ const packageName = installed.bundle;
629
+ const trialConfig = effectiveTrialConfig(config);
630
+ if (!config.qualityGate.enabled || config.qualityGate.allowlist.includes(packageName)) {
631
+ await manager.setBundleEnabled(packageName, true);
632
+ invalidateInstalledIndex(environmentName ?? "");
633
+ // 开关被打开却什么都没发生,必须说出来(否则用户以为试装保护着他)。
634
+ const skippedTrial = trialConfig.enabled
635
+ ? trialSkipSummary(config, config.qualityGate.enabled
636
+ ? "包名在质量门豁免名单里(豁免 = 跳过全部检查)"
637
+ : "质量门整体已关闭")
638
+ : undefined;
639
+ return {
640
+ ok: true,
641
+ output: `已安装并启用 ${packageName}(质量门未启用)`
642
+ + (skippedTrial === undefined ? "" : `(试装未执行:${skippedTrial.policyNote})`),
643
+ packageName, gateIssues: [],
644
+ ...skippedTrial === undefined ? {} : { trial: skippedTrial },
645
+ };
646
+ }
647
+ const targetName = environmentName ?? runtime?.capabilities.environmentName ?? "";
648
+ let gate;
649
+ try {
650
+ // 传 ctx:质量门据此拿官方 installAnchor 作为解析根。
651
+ // 不传的话官方 peer 与 bundle 行会被判成缺包——实测踩过(173 条误报的同一根因)。
652
+ gate = await inspectPackage(pathEnvironmentDir(targetName), packageName, config, ctx);
653
+ }
654
+ catch (error) {
655
+ // 扫描本身失败时不放行:宁可回滚也不让未经校验的包留在环境里。
656
+ const removed = await manager.removeBundle(packageName);
657
+ const state = rollbackState(currentProfileDir(ctx, environmentDirOrNull(targetName)), packageName);
658
+ return {
659
+ ok: false,
660
+ output: [
661
+ '没有安装 ' + packageName + ':扫描没能完成',
662
+ rollbackHeadline(removed),
663
+ '',
664
+ '原因:' + (error instanceof Error ? error.message : String(error)),
665
+ '',
666
+ '环境现状:',
667
+ ...state.lines.map(line => ' ' + line),
668
+ ].join('\n'),
669
+ packageName, gateIssues: [], rolledBack: state.clean,
670
+ };
671
+ }
672
+ if (!gate.ok && config.qualityGate.mode === "block") {
673
+ const removed = await manager.removeBundle(packageName);
674
+ invalidateInstalledIndex(targetName);
675
+ // 文案只陈述核对过的真实状态:manifest 与 node_modules 各说各的,不写「环境未被改动」。
676
+ const state = rollbackState(currentProfileDir(ctx, environmentDirOrNull(targetName)), packageName);
677
+ return {
678
+ ok: false,
679
+ output: [
680
+ '没有安装 ' + packageName + ':质量检查未通过',
681
+ rollbackHeadline(removed),
682
+ '',
683
+ '发现的问题:',
684
+ ...gate.issues.map(i => " - " + i),
685
+ '',
686
+ '环境现状:',
687
+ ...state.lines.map(line => ' ' + line),
688
+ ].join('\n'),
689
+ packageName, gateIssues: gate.issues, rolledBack: state.clean,
690
+ };
691
+ }
692
+ // 第二步:试装(DESIGN §5.2)。只在静态快筛放行之后执行——第一步就挂掉的包没有必要起进程。
693
+ const trial = trialConfig.enabled
694
+ ? await runTrialStep(ctx, config, spec, targetName, options.trial ?? runTrialInstall)
695
+ : undefined;
696
+ if (trial !== undefined && trial.policy === "blocked") {
697
+ const removed = await manager.removeBundle(packageName);
698
+ invalidateInstalledIndex(targetName);
699
+ const state = rollbackState(currentProfileDir(ctx, environmentDirOrNull(targetName)), packageName);
700
+ // 分层呈现(DESIGN §12.6):第一行是结论与后果,细节降到下面。
701
+ // 旧版把「结论 + 回滚状态 + 包名」用逗号拼成一行,再接一大段细节,同一件事说三遍;
702
+ // 这里每层只回答一个问题——发生了什么 / 为什么 / 现在环境是什么样。
703
+ return {
704
+ ok: false,
705
+ output: [
706
+ '没有安装 ' + packageName + ':' + TRIAL_LABEL[trial.conclusion],
707
+ rollbackHeadline(removed),
708
+ '',
709
+ trial.output,
710
+ '',
711
+ '环境现状:',
712
+ ...state.lines.map(line => ' ' + line),
713
+ ].join('\n'),
714
+ packageName, gateIssues: gate.issues, rolledBack: state.clean, trial,
715
+ };
716
+ }
717
+ await manager.setBundleEnabled(packageName, true);
718
+ invalidateInstalledIndex(targetName);
719
+ const warned = gate.issues.length === 0 ? "" : `(质量门有 ${gate.issues.length} 条提示,按 warn 模式放行)`;
720
+ const trialLine = trial === undefined ? "" : trial.policy === "warned"
721
+ ? `(${TRIAL_LABEL[trial.conclusion]},按 warn 模式照常安装 —— 它在验证启动里没通过,环境起不来时先移除它)`
722
+ // R3:这里的深度同样要走映射(原来直接插 trial.depth,于是 "实际深度 full" 上了屏)。
723
+ : `(${TRIAL_LABEL[trial.conclusion]};实际深度 ${trialDepthLabel(trial.depth)},耗时 ${trial.elapsedMs}ms)`;
724
+ return {
725
+ ok: true, output: `已安装并启用 ${packageName}${warned}${trialLine}`,
726
+ packageName, gateIssues: gate.issues,
727
+ ...trial === undefined ? {} : { trial },
728
+ };
729
+ }
730
+ /** 把试装执行器折成 gatedInstall 的可选参数(没注入就不传)。 */
731
+ function gatedInstallOptions(deps) {
732
+ return deps.trial === undefined ? {} : { trial: deps.trial };
733
+ }
734
+ /** 从请求体里取一个字符串字段,缺失即报错。 */
735
+ function requireString(body, field) {
736
+ const value = body[field];
737
+ if (typeof value !== "string" || value.length === 0) {
738
+ throw new Error(`字段 ${field} 必须是非空字符串`);
739
+ }
740
+ return value;
741
+ }
742
+ /** 当前环境对象;取不到时 undefined。 */
743
+ function currentEnvironment(deps) {
744
+ const name = deps.capabilities().environmentName;
745
+ if (name === null)
746
+ return undefined;
747
+ // 用 sameEnvironment 而不是逐字比较:大小写不敏感的文件系统上(Windows/macOS)WEB 与 web 是同一个环境。
748
+ // 独立复验(platform-audit §10.8 N-02)在真 win32 上实测:以 --profile WEB 启动时,列表里 web 行的
749
+ // current 已经是 true(新护栏算对了),但这里逐字比较返回 undefined → 报告降级成"无法确定当前环境"、
750
+ // diagnose 传大小写变体会得到"环境不存在:WEB"。非破坏性,但属于本仓库最在意的"把存在的说成不存在"。
751
+ return listEnvironments(deps.ctx).find(env => sameEnvironment(env.name, name));
752
+ }
753
+ /**
754
+ * 诊断的分析目标。
755
+ *
756
+ * 诊断引擎要求一个真实的 EnvironmentInfo(它据此读 manifest 与 patch)。当前环境
757
+ * 认不出来时给一个空壳:引擎会发现目录不存在并记一条 skipped,报告里如实写着
758
+ * "无法确定当前环境"——而不是伪造一份看起来健康的报告。
759
+ *
760
+ * @param deps - 依赖。
761
+ * @returns 分析目标。
762
+ */
763
+ /**
764
+ * 解析一次诊断的目标环境。
765
+ *
766
+ * 指定了名字就在环境列表里找它——找不到时**报错**而不是悄悄退回当前环境:
767
+ * 用户以为在诊断 A 环境、实际诊断的是 B,是最糟的一类静默错误。
768
+ *
769
+ * @param deps - 依赖。
770
+ * @param name - 请求的环境名;省略即当前环境。
771
+ * @returns 诊断目标。
772
+ * @throws {Error} 指定的环境不存在时。
773
+ */
774
+ function targetEnvironment(deps, name) {
775
+ if (name === undefined || name.length === 0)
776
+ return analysisTarget(deps);
777
+ // 同上:用户给的是环境名,落点是目录,逐字比较在大小写不敏感的文件系统上会误判"不存在"。
778
+ const found = listEnvironments(deps.ctx).find(env => sameEnvironment(env.name, name));
779
+ if (found === undefined)
780
+ throw new Error(`环境不存在:${name}`);
781
+ return found;
782
+ }
783
+ /**
784
+ * 解析一次升级/回滚的入参(**在起 job 之前**调用)。
785
+ *
786
+ * 为什么必须提前:这些字段缺失是"当场能回答的请求错误",而 job 的失败只体现在后续轮询里。
787
+ * 放进 job 里,客户端会拿到 `ok:true + jobId`,然后异步等一个注定失败的任务——
788
+ * 错误被包装成了"看起来开始了"。
789
+ *
790
+ * @param deps - op 依赖(取环境)。
791
+ * @param body - 请求体。
792
+ * @param config - 当前配置。
793
+ * @returns 升级引擎的入参。
794
+ * @throws {Error} name / version 缺失或不是非空字符串时。
795
+ */
796
+ function upgradeInput(deps, body, config) {
797
+ const requested = typeof body["environment"] === "string" ? body["environment"] : undefined;
798
+ return {
799
+ environment: targetEnvironment(deps, requested).name,
800
+ name: requireString(body, "name"),
801
+ version: requireString(body, "version"),
802
+ ...typeof body["spec"] === "string" ? { spec: body["spec"] } : {},
803
+ config,
804
+ };
805
+ }
806
+ function analysisTarget(deps) {
807
+ return currentEnvironment(deps) ?? {
808
+ name: "", dir: "", current: true, builtin: false,
809
+ bundles: [], dependencies: [], runs: [],
810
+ };
811
+ }
812
+ /**
813
+ * 执行一个 op。
814
+ *
815
+ * 分派表刻意扁平:每个 op 一行到几行,复杂编排下沉到各模块(gatedInstall 是唯一例外,
816
+ * 因为它跨官方 Remote 与我们的质量门,属于入口职责)。
817
+ *
818
+ * @param op - 操作名。
819
+ * @param body - 请求体。
820
+ * @param deps - 依赖。
821
+ * @returns 响应信封。
822
+ */
823
+ export async function handleOp(op, body, deps) {
824
+ try {
825
+ const value = await dispatch(op, body, deps);
826
+ return { ok: true, value };
827
+ }
828
+ catch (error) {
829
+ const message = error instanceof Error ? error.message : String(error);
830
+ const code = error instanceof Error && "capability" in error ? "official-unavailable" : "operation-failed";
831
+ return { ok: false, error: { code, message } };
832
+ }
833
+ }
834
+ async function dispatch(op, body, deps) {
835
+ const config = deps.config();
836
+ // 长操作一律走这个包装:REST 契约规定首包是 `{ jobId }`,不是裸 id。
837
+ // (裸 id 会让客户端把它当成结果——实测导致体检页把字符串当报告读,整页崩空白)
838
+ const asJob = (task) => ({ jobId: deps.jobs.start(task) });
839
+ switch (op) {
840
+ case "capabilities":
841
+ // trialDisclosure 放在这里而不是设置页自己的接口:它是**静态事实**(会执行第三方代码、
842
+ // 内存峰值),任何时候都能回答,且客户端启动时已经会拉这个 op——设置页因此不必
843
+ // 为了"告知"再发一次请求,也不会出现"列表读失败所以告知也没了"。
844
+ return { capabilities: deps.capabilities(), config, trialDisclosure: TRIAL_DISCLOSURE };
845
+ case "getConfig":
846
+ return config;
847
+ case "setConfig": {
848
+ const patch = body["patch"];
849
+ if (typeof patch !== "object" || patch === null)
850
+ throw new Error("字段 patch 必须是对象");
851
+ return await deps.configUpdate(patch);
852
+ }
853
+ case "diagnose": {
854
+ // 诊断目标可指定环境:用户要的是"对当前环境做到极致,再用同一能力管理其他环境"。
855
+ // 省略时诊断当前环境;指定时用同一引擎、同一配置,只是换一个 EnvironmentInfo。
856
+ // 注意:官方 pluginManager Remote 只覆盖**当前**环境,所以对其他环境的写操作走 fix 的
857
+ // needs-manual / operations 路径——诊断本身与作用域无关,可以放心跨环境。
858
+ const requested = typeof body["environment"] === "string" ? body["environment"] : undefined;
859
+ return asJob(async () => await analyzeEnvironment(deps.ctx, targetEnvironment(deps, requested), config));
860
+ }
861
+ case "install":
862
+ return asJob(async () => await gatedInstall(deps.ctx, config, requireString(body, "spec"), typeof body["environment"] === "string" ? body["environment"] : undefined, gatedInstallOptions(deps)));
863
+ case "listEnvironments":
864
+ return listEnvironments(deps.ctx);
865
+ case "scanRuns":
866
+ return Object.fromEntries(scanRuns());
867
+ case "environmentTemplates": {
868
+ // 模板清单 = 官方 PROFILE_TEMPLATES 的投影;默认模板由后端给,客户端不要自己猜
869
+ // (客户端猜成 base-only 就会建出一个必然起不来的环境,实测踩过)。
870
+ return { default: DEFAULT_ENVIRONMENT_TEMPLATE, templates: environmentTemplates() };
871
+ }
872
+ case "startEnvironment": {
873
+ const name = requireString(body, "name");
874
+ // background=true 走后台(客户端已有的意图,之前被丢掉,导致永远弹终端窗口)。
875
+ const mode = body["background"] === true ? "background" : "terminal";
876
+ // 必须传 ctx:envManager 用它取官方 installAnchor 判定 web 层;拿不到时如实降级为
877
+ // 「无法预判,仍按就绪探测等待」,而不是拒绝。
878
+ return await startEnvironment(name, { ctx: deps.ctx, mode });
879
+ }
880
+ case "stopEnvironment":
881
+ return await stopEnvironment(requireString(body, "name"));
882
+ case "upgradeCheck": {
883
+ // 检查是**短操作**(缓存命中时零网络);手动检查(refresh)可能出网,
884
+ // 但总预算有上限(见 upgrade.ts 的 CHECK_BUDGET_MS),不 job 化以免前端要多一跳轮询。
885
+ const requested = typeof body["environment"] === "string" ? body["environment"] : undefined;
886
+ const target = targetEnvironment(deps, requested);
887
+ return await checkUpgrades({
888
+ environment: target.name,
889
+ config,
890
+ ...body["refresh"] === true ? { refresh: true } : {},
891
+ ...deps.upgrade ?? {},
892
+ });
893
+ }
894
+ case "upgrade": {
895
+ // 入参校验必须在**起 job 之前**:job 的失败只体现在后续 job op 的轮询结果里,
896
+ // 而"少给一个字段"是当场就能回答的请求错误。放进 job 里会让客户端拿到 ok:true +
897
+ // jobId,然后异步等一个注定失败的任务——错误变成了"看起来开始了"。
898
+ const input = upgradeInput(deps, body, config);
899
+ return asJob(async () => await upgradePackage({ ...input, ...deps.upgrade ?? {} }));
900
+ }
901
+ case "upgradeRollback": {
902
+ // 同上:先校验再起 job。
903
+ const input = upgradeInput(deps, body, config);
904
+ return asJob(async () => await rollbackUpgrade({ ...input, ...deps.upgrade ?? {} }));
905
+ }
906
+ case "about":
907
+ // 纯读:本页要的运行时/安装/文件事实(task-95)。
908
+ //
909
+ // 为什么单开一个 op 而不塞进 capabilities:capabilities 的语义是"官方能力是否可用",
910
+ // 混进"版本/路径"会让它变成杂物袋(Lead 裁决)。
911
+ //
912
+ // 为什么必须由 host 读:客户端是浏览器 bundle,没有 process、没有 node:fs——
913
+ // 这些事实里的大半它一条都拿不到(调研见 docs/private/task76-recon.md)。
914
+ return collectAboutFacts(profileFactsOf(deps.ctx));
915
+ case "trialEnvironments":
916
+ // 纯读:列出测试环境 + 清理计划预览 + 现在生效的保留策略。这个 op 不删任何东西。
917
+ return await trialEnvironmentReport(config);
918
+ case "trialRemove":
919
+ // 删单个测试环境:走引擎的三重纪律(只删 <名>-dpmc / 运行中先拒 / 进程事实不可读就拒)。
920
+ return await removeTrialEnvironment(requireString(body, "name"), { ctx: deps.ctx });
921
+ case "trialCleanup":
922
+ // 一键清理过期(长操作:可能删多个目录,含真实快照的 node_modules)。
923
+ // 与"自动清理"共用引擎的同一个计划与同一个记账(<DSH_HOME>/dpmc-trial-cleanup.log)。
924
+ return asJob(async () => {
925
+ const trial = effectiveTrialConfig(config);
926
+ const result = await cleanupTrialEnvironments({ retainDays: trial.retentionDays });
927
+ return result;
928
+ });
929
+ case "createEnvironment":
930
+ // 省略 template 时由后端默认到官方 web 模板(能起得来),不是官方 base-only 默认。
931
+ return await createEnvironment(requireString(body, "name"), typeof body["template"] === "string" ? body["template"] : undefined);
932
+ case "renameEnvironment":
933
+ return await renameEnvironment(requireString(body, "from"), requireString(body, "to"));
934
+ case "removeEnvironment":
935
+ return await removeEnvironment(requireString(body, "name"));
936
+ case "copyPlugins": {
937
+ const names = body["names"];
938
+ if (!Array.isArray(names))
939
+ throw new Error("字段 names 必须是数组");
940
+ // 必须传 ctx:envManager 从 ctx.profileContext 取官方 installAnchor,
941
+ // 拿不到锚点就拒绝跨环境包操作(拒绝猜路径是对的)。漏传的后果是**功能完全不可用**,
942
+ // 实测踩过——见 docs/private/write-path-audit.md。
943
+ return asJob(async () => await copyPlugins(requireString(body, "from"), requireString(body, "to"), names.map(String), { ctx: deps.ctx }));
944
+ }
945
+ case "backupExport":
946
+ return backupExport(requireString(body, "name"));
947
+ case "backupDiff":
948
+ return backupDiff(body["backup"], requireString(body, "target"));
949
+ case "backupRestore":
950
+ // 同样必须传 ctx(见 copyPlugins 的注释)。注意这条自测容易漏过:
951
+ // 差异为空时会**在取锚点之前**提前返回"没有需要恢复的内容",所以只有真的
952
+ // 有东西要恢复时才会暴露缺锚点。
953
+ return asJob(async () => await backupRestore(body["backup"], requireString(body, "target"), { ctx: deps.ctx }));
954
+ case "marketplace": {
955
+ const marketConfig = config.marketplace;
956
+ const envName = deps.capabilities().environmentName ?? "";
957
+ if (!marketConfig.enabled) {
958
+ // 关闭市场时不联网:只回答"本环境装了什么"。
959
+ // source: 'disabled' 让界面能说"市场在当前配置下已关闭",而不是画成"没有匹配的条目"。
960
+ return {
961
+ items: [], generatedAt: new Date().toISOString(), cached: false, categories: {},
962
+ source: "disabled",
963
+ };
964
+ }
965
+ const index = await loadRegistryIndex({
966
+ refresh: body["refresh"] === true,
967
+ timeoutMs: marketConfig.timeoutMs,
968
+ ttlMs: marketConfig.cacheTtlMinutes * 60_000,
969
+ indexUrl: marketConfig.indexUrl,
970
+ });
971
+ const result = cachedMarketplace({
972
+ profile: envName,
973
+ items: registryItems(index.repos),
974
+ generation: index.generation,
975
+ installed: buildInstalledIndex(envName),
976
+ generatedAt: index.generatedAt,
977
+ cached: index.cached,
978
+ // 索引事实一路带到界面:不可用时要能说"这次没拿到索引",而不是"没有匹配的条目"。
979
+ source: index.source,
980
+ stale: index.stale,
981
+ notes: index.notes,
982
+ });
983
+ return result;
984
+ }
985
+ case "listKinds": {
986
+ await pruneGhostRecords();
987
+ const records = await loadKindRecords();
988
+ const result = {
989
+ records: [...records.values()],
990
+ orphans: await findOrphanKindDirs(),
991
+ };
992
+ return result;
993
+ }
994
+ case "uninstallKind":
995
+ return asJob(async () => {
996
+ const repo = requireString(body, "repo");
997
+ const records = await loadKindRecords();
998
+ const record = records.get(repo);
999
+ if (record === undefined)
1000
+ return { ok: false, output: `没有安装记录:${repo}`, code: "not-found" };
1001
+ const root = record.kind === "skill" ? skillsRoot() : presetsRoot();
1002
+ // 精确目录清单:多目录安装(记录体带 dirs)逐个清;旧记录退回 dir。越界目录由
1003
+ // kindDirsOf 过滤掉,因此不会出现"dir 恰好等于根就静默跳过"的残留。
1004
+ for (const dir of kindDirsOf(record, root))
1005
+ await removeKindDir(root, dir);
1006
+ await removeKindRecord(repo);
1007
+ // 文案全中文:record.kind 的枚举值是 'skill' / 'agent-preset',直接插进中文句子就是中英混排。
1008
+ const kindLabel = record.kind === "skill" ? "技能" : record.kind === "agent-preset" ? "预设" : "未知类型";
1009
+ return { ok: true, output: `已卸载${kindLabel} ${repo}` };
1010
+ });
1011
+ case "fix": {
1012
+ const action = requireString(body, "action");
1013
+ const target = typeof body["target"] === "string" ? body["target"] : undefined;
1014
+ return asJob(async () => await applyFix(action, target, {
1015
+ ctx: deps.ctx,
1016
+ environmentName: () => deps.capabilities().environmentName,
1017
+ // 修复里的安装与市场安装走**同一个** gatedInstall,因此质量门与试装三边一致;
1018
+ // 注入的试装执行器照旧透传,测试才能不动真进程。
1019
+ install: async (spec) => await gatedInstall(deps.ctx, config, spec, undefined, gatedInstallOptions(deps)),
1020
+ // install-dependency 走这条:声明已在、只是没装,官方 add 会以 already-installed 拒绝。
1021
+ repair: async (target) => await repairDependencies(target, { ctx: deps.ctx }),
1022
+ }));
1023
+ }
1024
+ case "job":
1025
+ return deps.jobs.status(requireString(body, "id"));
1026
+ default:
1027
+ throw new Error(`未知操作:${op}`);
1028
+ }
1029
+ }
1030
+ // ── 装配 ──────────────────────────────────────────────────────────────────
1031
+ /**
1032
+ * 注册自有 REST 路由。
1033
+ *
1034
+ * 官方能力(插件启停/安装/卸载/清单)**不在这里**——客户端直连官方 Remote。
1035
+ * 这里只暴露官方不覆盖的部分:诊断、环境管理、市场、技能与预设、配置。
1036
+ *
1037
+ * @param ctx - host 上下文。
1038
+ * @param deps - op 分派依赖。
1039
+ * @returns 路由 disposer 列表。
1040
+ */
1041
+ export function registerRoutes(ctx, deps) {
1042
+ const webServer = ctx.get("webServer");
1043
+ if (webServer === undefined || typeof webServer.register !== "function") {
1044
+ ctx.logger?.info?.("plugin-manager-companion: webServer 服务不可用,自有 REST 未注册(诊断与环境管理将无法从浏览器访问)");
1045
+ return [];
1046
+ }
1047
+ const handler = async (req, res) => {
1048
+ if (!isJsonPost(req)) {
1049
+ sendJson(res, 405, { ok: false, error: { code: "bad-request", message: "只接受 POST + application/json" } });
1050
+ return;
1051
+ }
1052
+ const trusted = isTrustedRequest(req, { allowNonHttpCarrier: true });
1053
+ if (!trusted.ok) {
1054
+ sendJson(res, 403, { ok: false, error: { code: trusted.code, message: trusted.message } });
1055
+ return;
1056
+ }
1057
+ const op = decodeURIComponent(req.url ?? "").slice(ROUTE_PREFIX.length + 1).split("?")[0] ?? "";
1058
+ const body = await readJsonBody(req, BODY_LIMIT_DEFAULT);
1059
+ if (!body.ok) {
1060
+ sendJson(res, 400, { ok: false, error: { code: body.code, message: body.message } });
1061
+ return;
1062
+ }
1063
+ const envelope = await handleOp(op, body.value, deps);
1064
+ sendJson(res, envelope.ok ? 200 : 400, envelope);
1065
+ };
1066
+ return [webServer.register({ kind: "prefix", path: ROUTE_PREFIX, handler })];
1067
+ }
1068
+ /**
1069
+ * 插件装配。
1070
+ *
1071
+ * 只做"必须有"的事:注册配置命名空间、探测官方能力、装 REST 路由与 agent 工具。
1072
+ * 各能力模块在各自的调用点被用到,不做无意义的预先初始化(诊断与市场都是按需触发)。
1073
+ *
1074
+ * @param ctx - host 上下文。
1075
+ */
1076
+ export function apply(ctx) {
1077
+ const capabilities = probeOfficialCapabilities(ctx);
1078
+ const jobs = new JobRegistry();
1079
+ // 配置句柄先用只读降级版:settings 服务可能晚于本插件装配(挂载顺序不保证)。
1080
+ // 实测踩过 ctx.get("settings") 在 apply 时取不到就**永久降级**——descriptor 里永远
1081
+ // 不出现本命名空间、写入被静默丢弃。改用 ctx.inject 等服务就绪再注册。
1082
+ let config = fallbackConfigHandle();
1083
+ ctx.inject(['settings'], (settingsCtx) => {
1084
+ settingsCtx.effect(() => {
1085
+ config = registerConfig(settingsCtx);
1086
+ return () => { config = fallbackConfigHandle(); };
1087
+ }, 'plugin-manager-companion: settings namespace');
1088
+ });
1089
+ runtime = { capabilities, config, jobs };
1090
+ // 能力缺失如实记账,不假装健康:诊断页会把这些原因直接呈现给用户。
1091
+ for (const reason of capabilities.missing) {
1092
+ ctx.logger?.info?.(`plugin-manager-companion: ${reason}`);
1093
+ }
1094
+ const deps = {
1095
+ ctx,
1096
+ config: () => config.current(),
1097
+ configUpdate: (patch) => config.update(patch),
1098
+ capabilities: () => probeOfficialCapabilities(ctx),
1099
+ jobs,
1100
+ // 升级引擎要 ctx 才能拿官方 installAnchor 与试装锚点;其余依赖留空走真实实现。
1101
+ upgrade: { ctx },
1102
+ };
1103
+ // REST 路由:webServer 是官方行,装配顺序不保证,用 inject 等待。
1104
+ ctx.inject(["webServer"], (webCtx) => {
1105
+ webCtx.effect(() => {
1106
+ const disposers = registerRoutes(webCtx, deps);
1107
+ return () => { for (const dispose of disposers)
1108
+ dispose(); };
1109
+ }, "plugin-manager-companion: routes");
1110
+ });
1111
+ // agent 工具:只 plugin_search + plugin_health(其余交还官方 plugin_manager)。
1112
+ ctx.inject(["tools"], (toolsCtx) => {
1113
+ toolsCtx.effect(() => {
1114
+ const disposers = registerCompanionTools(toolsCtx, {
1115
+ market: async ({ refresh }) => {
1116
+ const envName = probeOfficialCapabilities(toolsCtx).environmentName ?? "";
1117
+ const marketConfig = config.current().marketplace;
1118
+ if (!marketConfig.enabled)
1119
+ return { items: [], generatedAt: new Date().toISOString() };
1120
+ const index = await loadRegistryIndex({
1121
+ refresh, timeoutMs: marketConfig.timeoutMs,
1122
+ ttlMs: marketConfig.cacheTtlMinutes * 60_000, indexUrl: marketConfig.indexUrl,
1123
+ });
1124
+ const result = cachedMarketplace({
1125
+ profile: envName, items: registryItems(index.repos), generation: index.generation,
1126
+ installed: buildInstalledIndex(envName), generatedAt: index.generatedAt, cached: index.cached,
1127
+ // 与 marketplace op 保持同一口径:少了这三个,会出现"工具说没有、页面说失败"的不一致。
1128
+ source: index.source, stale: index.stale, notes: index.notes,
1129
+ });
1130
+ return { items: result.items, generatedAt: result.generatedAt, total: result.items.length };
1131
+ },
1132
+ // match.ts 的纯函数:排好序但不截断(条数钳制在 tools.ts 那一侧)。
1133
+ rank: (items, query) => findPluginMatches(items, query, items.length),
1134
+ // 工具执行没有 Context,也未必有环境对象;两者都缺失时按"空目标"分析,
1135
+ // 引擎会如实记 skipped(工具侧绝不返回一个看起来健康的空报告)。
1136
+ analyze: async (rawCtx, env, cfg) => await analyzeEnvironment((rawCtx ?? toolsCtx), env ?? analysisTarget(deps), cfg),
1137
+ environment: () => currentEnvironment(deps),
1138
+ config: () => config.current(),
1139
+ });
1140
+ const guardDisposer = registerGuard(toolsCtx);
1141
+ if (guardDisposer.guard !== null)
1142
+ disposers.push(guardDisposer.guard);
1143
+ if (guardDisposer.prompt !== null)
1144
+ disposers.push(guardDisposer.prompt);
1145
+ return () => { for (const dispose of disposers)
1146
+ dispose(); };
1147
+ }, "plugin-manager-companion: agent tools");
1148
+ });
1149
+ ctx.effect(() => () => { runtime = undefined; }, "plugin-manager-companion: runtime");
1150
+ }