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
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * 升级引擎(档三):把"升级已装插件"做成真实可执行、且**说清边界**的能力。
3
+ *
4
+ * 归属:A 类·重写(新代码;旧仓库没有升级能力,只有 CLI 的 `update` 把 spec 重写到 @latest)。
5
+ * 官方复用:
6
+ * · 写通道只有一条 —— 官方 `runPluginCommand(['add', '<name>@<version>'])`(绝不自己调 pnpm,
7
+ * 也绝不写 cordis.patch.yml);官方 CLI 的 plugin 子命令与我们走的是同一条通道。
8
+ * · 版本查询走本仓库既有的出网层(net.ts 的 Fetcher),不新造 HTTP 客户端。
9
+ * · 金丝雀复用 task-50/75 的试装引擎:它就是"在 <环境>-dpmc 里装候选再跑两次启动"。
10
+ * 前提检查:旧前提是"官方只有只读清单,升级得自己想办法"。0.1.6 之后官方给了完整写面与
11
+ * `installed` / `removable` 事实,所以本模块只做官方不覆盖的三件事:版本事实(dist-tags)、
12
+ * 三类单元的分类、金丝雀与回滚的盘上核对。
13
+ *
14
+ * 三类单元(必须分开对待,这是本模块的核心诚实点):
15
+ * 1. `profile-dependency`:在 profile 的 dependencies 里 → 可在 profile 内升级;
16
+ * 2. `installation-provided`:只出现在 dsh.profile.bundles 里(官方运行时层 dsh-base / dsh-web-app)
17
+ * → profile 内**升不了**,只检测 + 给命令,不给按钮;
18
+ * 3. `self`:本插件自身 → 可升级,但**正在运行的就是旧代码**,必须走独立 job 并写明"下次启动生效"。
19
+ *
20
+ * 四态:`update-available` / `up-to-date` / `unknown` / `not-upgradable`。
21
+ * 铁律:拿不到版本事实一律 `unknown`(文案"查不到"),**绝不**显示"已是最新"——"没查到"与
22
+ * "确实没有更新"是两个不同的状态,把前者说成后者就是编一个事实。
23
+ *
24
+ * 出网纪律(Lead 定稿):检查时机 = 进入即查 + TTL + 手动常驻,**不做后台轮询**。
25
+ * "每天一次"的语义是"下次进入时若距上次成功检查超过 24h 就查";失败也记时间戳(1 小时内不自动重试,
26
+ * 免得每次进入都等超时);手动按钮永远可用(不受开关、TTL 与负缓存限制)。
27
+ */
28
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
29
+ import { dirname, isAbsolute, join } from 'node:path';
30
+ import { EnvironmentError, removeTrialEnvironment, runTrialInstall, trialEnvironmentName } from "./envManager.js";
31
+ import { compareVersions } from "./match.js";
32
+ import { registryItems } from "./marketplace.js";
33
+ import { fetchWithProxy } from "./net.js";
34
+ import { OUR_PACKAGE_NAME, dshHome, environmentDir, isSafeEnvironmentName, readEnvironmentManifest } from "./paths.js";
35
+ import { readRegistryCacheFile } from "./registry.js";
36
+ import { effectiveTrialConfig, effectiveUpgradeConfig, upgradeIntervalMs } from "./settings.js";
37
+ // ── 常量 ─────────────────────────────────────────────────────────────────
38
+ /** 配置留空时的 registry(官方 npm)。 */
39
+ export const OFFICIAL_REGISTRY_URL = 'https://registry.npmjs.org';
40
+ /** 单次 registry 查询的超时(一个包文档很小,15s 是宽松上限)。 */
41
+ export const REGISTRY_TIMEOUT_MS = 15_000;
42
+ /** 一次检查里所有出网查询的总预算;超了就停手并如实记账,不拖住页面。 */
43
+ export const CHECK_BUDGET_MS = 20_000;
44
+ /**
45
+ * 检查失败后的静默期:1 小时内不再自动重试。
46
+ *
47
+ * 存在的理由:registry 挂掉时,每次进入关于页都等一次 15s 超时是折磨;手动按钮不受它限制。
48
+ */
49
+ export const NEGATIVE_TTL_MS = 60 * 60 * 1000;
50
+ /** tags 磁盘缓存的格式版本(与 registry 缓存同一纪律:格式不符即视为无缓存)。 */
51
+ export const TAGS_CACHE_FORMAT = 1;
52
+ /** tags 磁盘缓存文件名(放在我们自己的缓存目录里,与市场索引缓存同级)。 */
53
+ export const TAGS_CACHE_FILE = 'upgrade-tags.json';
54
+ /** 官方包操作的服务侧输出上限与锁等待(与 envManager 同量级)。 */
55
+ const OPERATION_OUTPUT_BYTES = 64 * 1024;
56
+ const OPERATION_LOCK_WAIT_MS = 120_000;
57
+ /**
58
+ * 包文档 URL。
59
+ *
60
+ * scoped 名必须整体转义(`@scope/name` → `%40scope%2Fname`),否则 `/` 会被当成路径分隔符。
61
+ *
62
+ * @param registryUrl - registry 基地址(可带尾斜杠)。
63
+ * @param name - 包名。
64
+ * @returns 文档 URL。
65
+ */
66
+ export function registryDocumentUrl(registryUrl, name) {
67
+ const base = (registryUrl.length === 0 ? OFFICIAL_REGISTRY_URL : registryUrl).replace(/\/+$/, '');
68
+ return base + '/' + encodeURIComponent(name);
69
+ }
70
+ /**
71
+ * 从 registry 文档里取 dist-tags(纯函数,便于用固定载荷测试)。
72
+ *
73
+ * 只认 `dist-tags`;缺字段或值不是字符串时返回 null(**不猜**:把 `versions` 里最大的那个
74
+ * 当成 latest 会与 tag 的真实语义脱钩)。
75
+ *
76
+ * @param payload - 解析后的 JSON。
77
+ * @returns tag 表;拿不到时 null。
78
+ */
79
+ export function parseDistTags(payload) {
80
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
81
+ return null;
82
+ const raw = payload['dist-tags'];
83
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
84
+ return null;
85
+ const tags = {};
86
+ for (const [tag, version] of Object.entries(raw)) {
87
+ if (typeof version !== 'string' || version.length === 0)
88
+ continue;
89
+ tags[tag] = version;
90
+ }
91
+ return Object.keys(tags).length === 0 ? null : tags;
92
+ }
93
+ /**
94
+ * 查一个包的 dist-tags。
95
+ *
96
+ * 失败一律以 `ok: false` + reason 返回(调用方据此显示"查不到"),不抛异常:
97
+ * 一次网络抖动不该让整页检查失败。
98
+ *
99
+ * @param name - 包名。
100
+ * @param query - registry 地址 / 超时 / 抓取器。
101
+ * @returns 结果。
102
+ */
103
+ export async function fetchDistTags(name, query = {}) {
104
+ const url = registryDocumentUrl(query.registryUrl ?? '', name);
105
+ const fetcher = query.fetch ?? fetchWithProxy;
106
+ try {
107
+ const response = await fetcher(url, {
108
+ timeoutMs: query.timeoutMs ?? REGISTRY_TIMEOUT_MS,
109
+ headers: { accept: 'application/json' },
110
+ });
111
+ if (response.status === 404)
112
+ return { ok: false, tags: null, reason: 'registry 里没有这个包(404)' };
113
+ if (!response.ok)
114
+ return { ok: false, tags: null, reason: 'registry 返回 HTTP ' + String(response.status) };
115
+ const payload = JSON.parse(await response.text());
116
+ const tags = parseDistTags(payload);
117
+ return tags === null
118
+ ? { ok: false, tags: null, reason: 'registry 响应里没有 dist-tags(可能不是 npm 包)' }
119
+ : { ok: true, tags };
120
+ }
121
+ catch (error) {
122
+ return { ok: false, tags: null, reason: 'registry 查询失败:' + messageOf(error) };
123
+ }
124
+ }
125
+ // ── 版本比较与"线" ───────────────────────────────────────────────────────
126
+ /**
127
+ * 一条版本线(major.minor.patch);非法/缺失时 null。
128
+ *
129
+ * "与当前版本同线"= 同 major.minor.patch(例如 0.1.6-alpha.2 与 0.1.6-alpha.9 同线,
130
+ * 与 0.1.7 不同线)。判定只做字符串切分,不假装懂 semver:比较大小交给 match.ts 的 compareVersions。
131
+ *
132
+ * @param version - 版本串。
133
+ * @returns 线,或 null。
134
+ */
135
+ export function versionLine(version) {
136
+ if (version === null || version.length === 0)
137
+ return null;
138
+ const core = version.split('-')[0] ?? '';
139
+ const parts = core.split('.');
140
+ if (parts.length < 2 || parts.some((part) => !/^\d+$/.test(part)))
141
+ return null;
142
+ return parts.slice(0, 3).join('.');
143
+ }
144
+ /**
145
+ * 一个包是不是本地来源(`link:` / `file:` / 相对或绝对路径)。
146
+ *
147
+ * 本地来源也能"升级"到 registry 版本,但那会**改变来源**(不再是本地那份)——必须显式说出来。
148
+ *
149
+ * @param spec - dependencies 里的 spec。
150
+ * @returns 是本地来源时 true。
151
+ */
152
+ export function isLocalSpec(spec) {
153
+ if (spec === undefined)
154
+ return false;
155
+ const trimmed = spec.trim();
156
+ return /^(?:link|file):/i.test(trimmed) || trimmed.startsWith('.') || isAbsolute(trimmed);
157
+ }
158
+ /** 把 dist-tags 折成界面要的列表(含"同线/另一条线"与默认高亮)。 */
159
+ export function tagReports(tags, currentVersion) {
160
+ const current = versionLine(currentVersion);
161
+ const entries = Object.entries(tags);
162
+ const sameLine = entries.filter(([, version]) => current !== null && versionLine(version) === current);
163
+ let preferredVersion = null;
164
+ for (const [, version] of sameLine) {
165
+ if (preferredVersion === null || compareVersions(version, preferredVersion) > 0)
166
+ preferredVersion = version;
167
+ }
168
+ return entries.map(([tag, version]) => ({
169
+ tag,
170
+ version,
171
+ line: current === null ? 'unknown' : versionLine(version) === current ? 'same-line' : 'other-line',
172
+ preferred: preferredVersion !== null && version === preferredVersion,
173
+ }));
174
+ }
175
+ /**
176
+ * 默认目标版本:与当前同线的**最新**;没有同线候选(或当前版本未知)时取 `latest`,
177
+ * 再退一步取所有 tag 里版本最大的那个(顺序即优先级,界面据此说"会切到哪条线")。
178
+ *
179
+ * @param tags - tag 表。
180
+ * @param currentVersion - 当前版本。
181
+ * @returns 目标版本与提供它的 tag;没有可用 tag 时 null。
182
+ */
183
+ export function pickTarget(tags, currentVersion) {
184
+ const entries = Object.entries(tags);
185
+ if (entries.length === 0)
186
+ return null;
187
+ const current = versionLine(currentVersion);
188
+ if (current !== null) {
189
+ const sameLine = entries.filter(([, version]) => versionLine(version) === current);
190
+ if (sameLine.length > 0) {
191
+ let best = sameLine[0];
192
+ for (const entry of sameLine)
193
+ if (compareVersions(entry[1], best[1]) > 0)
194
+ best = entry;
195
+ return { version: best[1], tag: best[0] };
196
+ }
197
+ }
198
+ const latest = tags['latest'];
199
+ if (typeof latest === 'string' && latest.length > 0)
200
+ return { version: latest, tag: 'latest' };
201
+ let best = entries[0];
202
+ for (const entry of entries)
203
+ if (compareVersions(entry[1], best[1]) > 0)
204
+ best = entry;
205
+ return { version: best[1], tag: best[0] };
206
+ }
207
+ /** 取某个环境的记账;没有时给一份"从未查过"的空账。 */
208
+ function envStateOf(cache, environment) {
209
+ return cache.environments[environment] ?? { lastCheckAt: null, lastAttemptAt: null };
210
+ }
211
+ /** 缓存文件路径(我们自己的缓存目录,与市场索引缓存同级)。 */
212
+ export function tagsCachePath() {
213
+ return join(dshHome(), 'plugin-manager-companion', TAGS_CACHE_FILE);
214
+ }
215
+ /**
216
+ * 读缓存;不存在/损坏/格式不符时返回空壳(**不抛**:缓存是可重建的派生物)。
217
+ *
218
+ * @returns 缓存内容。
219
+ */
220
+ export function readTagsCache() {
221
+ const empty = { environments: {}, packages: {} };
222
+ const path = tagsCachePath();
223
+ if (!existsSync(path))
224
+ return empty;
225
+ try {
226
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
227
+ if (raw['formatVersion'] !== TAGS_CACHE_FORMAT)
228
+ return empty;
229
+ const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
230
+ const environments = {};
231
+ const rawEnvironments = raw['environments'];
232
+ if (rawEnvironments !== null && typeof rawEnvironments === 'object' && !Array.isArray(rawEnvironments)) {
233
+ for (const [name, value] of Object.entries(rawEnvironments)) {
234
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
235
+ continue;
236
+ const state = value;
237
+ environments[name] = {
238
+ lastCheckAt: num(state['lastCheckAt']),
239
+ lastAttemptAt: num(state['lastAttemptAt']),
240
+ };
241
+ }
242
+ }
243
+ const packages = {};
244
+ const rawPackages = raw['packages'];
245
+ if (rawPackages !== null && typeof rawPackages === 'object' && !Array.isArray(rawPackages)) {
246
+ for (const [name, value] of Object.entries(rawPackages)) {
247
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
248
+ continue;
249
+ const entry = value;
250
+ const at = typeof entry['at'] === 'number' && Number.isFinite(entry['at']) ? entry['at'] : null;
251
+ // 没有环境标签的条目一律丢弃:它是旧格式(或被人手改过)的残留,
252
+ // 而"这条事实属于哪个环境"正是我们不敢猜的那件事。
253
+ const environment = typeof entry['environment'] === 'string' && entry['environment'].length > 0
254
+ ? entry['environment'] : null;
255
+ if (at === null || environment === null)
256
+ continue;
257
+ const ok = entry['ok'] === true;
258
+ packages[name] = {
259
+ ok,
260
+ tags: ok ? parseDistTags({ 'dist-tags': entry['tags'] }) : null,
261
+ at,
262
+ environment,
263
+ ...typeof entry['reason'] === 'string' ? { reason: entry['reason'] } : {},
264
+ };
265
+ }
266
+ }
267
+ return { environments, packages };
268
+ }
269
+ catch {
270
+ return empty;
271
+ }
272
+ }
273
+ /**
274
+ * 写缓存(失败不抛,返回是否写成功)。
275
+ *
276
+ * @param file - 要写的内容。
277
+ * @returns 是否写成功。
278
+ */
279
+ export function writeTagsCache(file) {
280
+ try {
281
+ const path = tagsCachePath();
282
+ mkdirSync(dirname(path), { recursive: true });
283
+ writeFileSync(path, JSON.stringify({
284
+ formatVersion: TAGS_CACHE_FORMAT,
285
+ environments: file.environments,
286
+ packages: file.packages,
287
+ }) + '\n');
288
+ return true;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ /**
295
+ * 一条缓存是否仍然可用(纯函数,**逐包判定**)。
296
+ *
297
+ * 成功的条目按配置的检查间隔计时;失败的条目按 {@link NEGATIVE_TTL_MS} 计时
298
+ * (失败也要记时间戳,否则每次进入都会重试一次超时)。
299
+ *
300
+ * 为什么判据是**条目自己的** at 而不是全局时间戳:全局时间戳会让一个包的成功
301
+ * 顺带放行另一个包的负缓存。实测反例(就是这条红测):环境 A 的包查成功之后,
302
+ * 同一个环境 B 里刚失败过的包在"1 小时内不自动重试"的窗口里又被查了一次。
303
+ *
304
+ * @param entry - 缓存条目。
305
+ * @param now - 当前时刻。
306
+ * @param ttlMs - 成功条目的有效期;null = 仅手动(此时对成功条目返回 true,
307
+ * 语义是"不因过期而自动查"——真正的自动检查开关由调用方管)。
308
+ * @returns 可直接使用该条目时 true。
309
+ */
310
+ export function tagsCacheUsable(entry, now, ttlMs) {
311
+ const age = now - entry.at;
312
+ if (entry.ok)
313
+ return ttlMs === null ? true : age <= ttlMs;
314
+ return age <= NEGATIVE_TTL_MS;
315
+ }
316
+ /** 读 manifest 里 dependencies 的 name → spec(原样,不做归一)。 */
317
+ function dependencySpecs(dir) {
318
+ const raw = readEnvironmentManifest(dir).raw;
319
+ const dependencies = raw['dependencies'];
320
+ const out = {};
321
+ if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies))
322
+ return out;
323
+ for (const [name, spec] of Object.entries(dependencies)) {
324
+ if (typeof spec === 'string' && spec.length > 0)
325
+ out[name] = spec;
326
+ }
327
+ return out;
328
+ }
329
+ /**
330
+ * 一个包在当前环境里装着的版本(读 node_modules/<name>/package.json)。
331
+ *
332
+ * 读不到就返回 null(link: 断链、没装、目录不可读都算)——**不猜**,调用方按"当前版本未知"处理。
333
+ *
334
+ * @param environment - 环境名。
335
+ * @param name - 包名。
336
+ * @returns 版本,或 null。
337
+ */
338
+ export function readInstalledVersion(environment, name) {
339
+ const dir = environmentDir(environment);
340
+ try {
341
+ const manifest = JSON.parse(readFileSync(join(dir, 'node_modules', name, 'package.json'), 'utf8'));
342
+ const version = manifest['version'];
343
+ return typeof version === 'string' && version.length > 0 ? version : null;
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
349
+ /**
350
+ * 列出这个环境的全部升级单元(三类)。
351
+ *
352
+ * 分类只认**盘上的结构事实**:出现在 dependencies 里 = profile 依赖(可升);只出现在
353
+ * dsh.profile.bundles 里 = 安装方提供的层(profile 内升不了)。这一条与官方 listBundles 的
354
+ * `installed` 字段同源(已核实:dsh-base / dsh-web-app 只在 bundles 里、不在 dependencies 里)。
355
+ *
356
+ * @param environment - 环境名。
357
+ * @param options - 本插件自身包名(默认取常量;测试可覆盖)。
358
+ * @returns 单元清单(按名字排序,顺序稳定便于断言)。
359
+ * @throws {EnvironmentError} 环境名不合法或环境不存在时。
360
+ */
361
+ export function unitFacts(environment, options = {}) {
362
+ if (!isSafeEnvironmentName(environment)) {
363
+ throw new EnvironmentError('invalid-name', '环境名不合法:' + JSON.stringify(environment));
364
+ }
365
+ const dir = environmentDir(environment);
366
+ if (!existsSync(join(dir, 'package.json'))) {
367
+ throw new EnvironmentError('not-found', '环境不存在:' + environment);
368
+ }
369
+ const manifest = readEnvironmentManifest(dir);
370
+ const specs = dependencySpecs(dir);
371
+ const ours = options.ourPackage ?? OUR_PACKAGE_NAME;
372
+ const names = [...new Set([...manifest.bundles, ...Object.keys(specs)])].sort();
373
+ return names.map((name) => {
374
+ const spec = specs[name];
375
+ const kind = name === ours ? 'self' : spec === undefined ? 'installation-provided' : 'profile-dependency';
376
+ return {
377
+ name,
378
+ kind,
379
+ ...spec === undefined ? {} : { spec },
380
+ specIsLocal: isLocalSpec(spec),
381
+ currentVersion: readInstalledVersion(environment, name),
382
+ };
383
+ });
384
+ }
385
+ /**
386
+ * 安装方提供的层该给什么命令。
387
+ *
388
+ * 刻意**不**给 `dsh plugin add` / `dshpmc update`:那会把这一层变成 profile 的依赖(装出第二份),
389
+ * 是"看起来能修、其实更坏"的建议。真正要做的是升级 dsh 安装本身,而具体命令取决于当初怎么装的,
390
+ * 所以这里只给一种常见形态 + 明确的口径说明。
391
+ *
392
+ * @param name - 包名。
393
+ * @returns 面向用户的命令说明。
394
+ */
395
+ export function installationUpgradeCommand(name) {
396
+ return '升级 dsh 安装本身(' + name + ' 由安装方提供,不在 profile 的依赖里;'
397
+ + '命令取决于当初的安装方式,例如 npm i -g @deepseek-ai/dsh@latest / pnpm add -g @deepseek-ai/dsh@latest)';
398
+ }
399
+ /**
400
+ * 从**已有的**市场索引缓存里找这个包的最新版本(零网络)。
401
+ *
402
+ * 只用缓存,不触发索引下载:关于页的一次检查不该顺手拉一份几 MB 的索引。
403
+ *
404
+ * @param name - 包名。
405
+ * @param options - 注入索引仓库(测试);省略时读磁盘缓存。
406
+ * @returns 事实,或 null。
407
+ */
408
+ export function marketVersionFact(name, options = {}) {
409
+ let repos;
410
+ let at = null;
411
+ if (options.repos !== undefined) {
412
+ repos = options.repos;
413
+ }
414
+ else {
415
+ const cached = readRegistryCacheFile();
416
+ if (cached === null)
417
+ return null;
418
+ repos = cached.repos;
419
+ at = cached.generatedAt ?? new Date(cached.savedAt).toISOString();
420
+ }
421
+ const hit = registryItems(repos).find((item) => item.packageName === name && typeof item.latestVersion === 'string' && item.latestVersion.length > 0);
422
+ if (hit === undefined || typeof hit.latestVersion !== 'string')
423
+ return null;
424
+ return { version: hit.latestVersion, at };
425
+ }
426
+ /**
427
+ * 一个单元的四态(纯函数,便于逐态断言)。
428
+ *
429
+ * @param input - 事实与版本来源。
430
+ * @returns 状态。
431
+ */
432
+ export function upgradeStateFor(input) {
433
+ if (input.kind === 'installation-provided')
434
+ return 'not-upgradable';
435
+ if (input.targetVersion === null)
436
+ return 'unknown';
437
+ if (input.currentVersion === null)
438
+ return 'unknown';
439
+ return compareVersions(input.targetVersion, input.currentVersion) > 0 ? 'update-available' : 'up-to-date';
440
+ }
441
+ /**
442
+ * 检查一个环境的升级情况(三类单元 × 四态)。
443
+ *
444
+ * 事实来源优先级(Lead 定稿):① 市场索引缓存(零网络)② npm registry dist-tags(受 TTL 与开关约束)。
445
+ * 两条都拿不到 = `unknown`("查不到"),**绝不**显示"已是最新"。
446
+ *
447
+ * @param options - 环境、配置与注入缝。
448
+ * @returns 检查结果。
449
+ */
450
+ export async function checkUpgrades(options) {
451
+ const now = options.now ?? Date.now;
452
+ const upgrade = effectiveUpgradeConfig(options.config);
453
+ const ttl = upgradeIntervalMs(upgrade.interval);
454
+ const facts = unitFacts(options.environment, options.ourPackage === undefined ? {} : { ourPackage: options.ourPackage });
455
+ const notes = [];
456
+ const cache = readTagsCache();
457
+ const forced = options.refresh === true;
458
+ const environment = options.environment;
459
+ // 记账与条目都**按环境**取:换环境一律重新出网(见 TagsCacheEnvState 的注释)。
460
+ const envState = envStateOf(cache, environment);
461
+ /**
462
+ * 自动检查的**到期**判定(用本环境自己的记账)。
463
+ *
464
+ * 这里刻意只回答"这一轮要不要出网",不回答"这个包要不要查":后者由每个包自己的
465
+ * 条目时间戳判(见下面 cachedUsable)。两个问题混在一个时间戳上,就会出现
466
+ * "A 包查成功 → 刚失败过的 B 包被顺带放行"(实测反例)。
467
+ */
468
+ const autoDue = upgrade.autoCheck && ttl !== null
469
+ && (envState.lastCheckAt === null || now() - envState.lastCheckAt >= ttl);
470
+ let checked = false;
471
+ let budgetExceeded = false;
472
+ const packages = { ...cache.packages };
473
+ let lastCheckAt = envState.lastCheckAt;
474
+ let lastAttemptAt = envState.lastAttemptAt;
475
+ const deadline = now() + CHECK_BUDGET_MS;
476
+ const units = [];
477
+ for (const fact of facts) {
478
+ const market = marketVersionFact(fact.name, options.repos === undefined ? {} : { repos: options.repos });
479
+ const cachedRaw = cache.packages[fact.name];
480
+ // 条目只在本环境内生效:别的环境取到的事实不是这个环境的事实(换环境一律重新取)。
481
+ const cachedEntry = cachedRaw !== undefined && cachedRaw.environment === environment ? cachedRaw : undefined;
482
+ // 逐包判定(**不是**全局时间戳):成功条目按 TTL、失败条目按 1 小时负缓存,各看自己的 at。
483
+ //
484
+ // 注意负缓存**就是在这里生效的**:失败条目在 1 小时内 tagsCacheUsable 返回 true,
485
+ // 于是直接走"用缓存"那一条分支、不出网;1 小时之后它返回 false,自动检查自然恢复。
486
+ // 曾经另有一个 negativeBlocked 分支想表达同一件事,但它在判定链里**不可达**
487
+ // (能走到那里时 age 必然已超过负缓存窗口)——死分支比没有分支更坏:它看起来在守着什么。
488
+ const cachedUsable = cachedEntry !== undefined && !forced && tagsCacheUsable(cachedEntry, now(), ttl);
489
+ /**
490
+ * 市场索引里已经有版本事实 → 这一轮**不必**为它出网(DESIGN §5.5 的来源优先级 ①)。
491
+ *
492
+ * 判据是"索引里有这个包的版本",而不是"索引整体可用":没有它的条目时该查还得查。
493
+ */
494
+ const marketAnswers = market !== null;
495
+ let registryTags = null;
496
+ let registryAt;
497
+ let registryReason;
498
+ if (cachedUsable) {
499
+ // TTL 内的缓存直接用(成功或失败都算),这也是"进页面不卡"的关键。
500
+ if (cachedEntry.ok) {
501
+ registryTags = cachedEntry.tags;
502
+ registryAt = new Date(cachedEntry.at).toISOString();
503
+ }
504
+ else {
505
+ // 失败条目:原样给出上次的失败原因(那是真正的事实),并说清"暂时不会自动重试"。
506
+ const retryIn = NEGATIVE_TTL_MS - (now() - cachedEntry.at);
507
+ registryReason = (cachedEntry.reason ?? '上次查询失败')
508
+ + (retryIn > 0 ? '(距上次失败不到 1 小时,自动检查暂不重试;手动检查可立即重试)' : '');
509
+ }
510
+ }
511
+ else if (marketAnswers) {
512
+ // 有市场索引事实就不出网:零网络是这一条的全部意义(不查、也不改缓存)。
513
+ registryReason = '市场索引里已有这个包的版本事实,本次没有出网查 registry';
514
+ }
515
+ else if ((forced || autoDue) && (fact.kind !== 'installation-provided' || fact.currentVersion !== null)) {
516
+ if (now() > deadline) {
517
+ budgetExceeded = true;
518
+ }
519
+ else {
520
+ const answer = await fetchDistTags(fact.name, {
521
+ registryUrl: upgrade.registryUrl,
522
+ ...options.fetch === undefined ? {} : { fetch: options.fetch },
523
+ });
524
+ checked = true;
525
+ const at = now();
526
+ lastAttemptAt = at;
527
+ packages[fact.name] = {
528
+ ok: answer.ok,
529
+ tags: answer.tags,
530
+ at,
531
+ environment,
532
+ ...answer.reason === undefined ? {} : { reason: answer.reason },
533
+ };
534
+ if (answer.ok) {
535
+ registryTags = answer.tags;
536
+ registryAt = new Date(at).toISOString();
537
+ lastCheckAt = at;
538
+ }
539
+ else {
540
+ registryReason = answer.reason;
541
+ }
542
+ }
543
+ }
544
+ else if (!upgrade.autoCheck && !forced) {
545
+ registryReason = '自动检查已关闭(手动检查随时可用)';
546
+ }
547
+ else if (ttl === null && !forced) {
548
+ registryReason = '检查间隔设为"仅手动"';
549
+ }
550
+ else if (!autoDue && !forced) {
551
+ const at = envState.lastCheckAt;
552
+ registryReason = '距上次成功检查还不到设置的间隔'
553
+ + (at === null ? '' : '(上次:' + new Date(at).toISOString() + ')');
554
+ }
555
+ if (registryTags === null && registryReason === undefined && market === null) {
556
+ registryReason = '还没到检查时间,且没有可用的版本事实';
557
+ }
558
+ const target = registryTags === null ? null : pickTarget(registryTags, fact.currentVersion);
559
+ let state;
560
+ let targetVersion = null;
561
+ let reason;
562
+ let source;
563
+ let at;
564
+ let tags = null;
565
+ if (registryTags !== null) {
566
+ tags = tagReports(registryTags, fact.currentVersion);
567
+ targetVersion = target?.version ?? null;
568
+ source = 'registry';
569
+ at = registryAt;
570
+ state = upgradeStateFor({ kind: fact.kind, currentVersion: fact.currentVersion, targetVersion });
571
+ if (state === 'up-to-date') {
572
+ reason = 'registry 上没有比当前更新的版本(同线最新:' + String(target?.version ?? '—') + ')';
573
+ }
574
+ else if (state === 'unknown' && fact.currentVersion === null) {
575
+ reason = '读不到当前安装的版本(这个环境里没有它,或读不出来)';
576
+ }
577
+ }
578
+ else if (market !== null) {
579
+ // ① 市场索引(零网络):有版本事实就能判四态;但没有 tag 列表,界面因此不给"挑版本"。
580
+ targetVersion = market.version;
581
+ source = 'market-index';
582
+ at = market.at ?? undefined;
583
+ state = upgradeStateFor({ kind: fact.kind, currentVersion: fact.currentVersion, targetVersion });
584
+ reason = '来自市场索引(' + (market.at ?? '生成时间未知') + ');registry 这次没给结果:'
585
+ + String(registryReason ?? '原因未知');
586
+ }
587
+ else {
588
+ state = fact.kind === 'installation-provided' ? 'not-upgradable' : 'unknown';
589
+ reason = registryReason ?? '没有可用的版本事实';
590
+ }
591
+ units.push({
592
+ name: fact.name,
593
+ kind: fact.kind,
594
+ state,
595
+ currentVersion: fact.currentVersion,
596
+ currentLine: versionLine(fact.currentVersion),
597
+ ...fact.spec === undefined ? {} : { spec: fact.spec },
598
+ targetVersion,
599
+ targetTag: registryTags === null ? null : target?.tag ?? null,
600
+ targetLine: versionLine(targetVersion),
601
+ tags,
602
+ ...source === undefined ? {} : { source },
603
+ ...at === undefined ? {} : { at },
604
+ ...reason === undefined ? {} : { reason },
605
+ ...fact.specIsLocal && fact.kind !== 'installation-provided' ? { changesSource: true } : {},
606
+ ...fact.kind === 'installation-provided' ? { command: installationUpgradeCommand(fact.name) } : {},
607
+ });
608
+ }
609
+ if (budgetExceeded) {
610
+ notes.push('本次检查在 ' + String(CHECK_BUDGET_MS) + 'ms 预算内没查完所有包:没查到的显示"查不到",下次进入会接着查');
611
+ }
612
+ if (!upgrade.autoCheck)
613
+ notes.push('自动检查已关闭:只有手动检查会出网');
614
+ // 记账写回本环境那一格;其它环境的账原样保留(各记各的)。
615
+ const environments = { ...cache.environments };
616
+ if (lastCheckAt !== null || lastAttemptAt !== null) {
617
+ environments[environment] = { lastCheckAt, lastAttemptAt };
618
+ }
619
+ writeTagsCache({ environments, packages });
620
+ return {
621
+ environment: options.environment,
622
+ units,
623
+ checked,
624
+ lastCheckAt: lastCheckAt === null ? null : new Date(lastCheckAt).toISOString(),
625
+ notes,
626
+ };
627
+ }
628
+ /**
629
+ * 组装官方 operations 的调用参数。
630
+ *
631
+ * 只认官方 installAnchor(ctx.profileContext.installAnchor 或调用方显式覆盖);拿不到就抛确定性错误——
632
+ * 猜一个路径去写别人的环境是数据损坏级别的错误,宁可拒绝。
633
+ *
634
+ * 注:与 envManager 里的同名函数是**同一份逻辑的两处实现**(那份没有导出,而本任务不改 envManager)。
635
+ * 后续若把它导出来,这里应当改成 import —— 行为必须保持一致(同样拒绝、同样口径)。
636
+ */
637
+ function officialContext(profile, dir, deps) {
638
+ const context = deps.ctx?.get('profileContext');
639
+ const installAnchor = deps.installAnchor ?? context?.installAnchor;
640
+ if (installAnchor === undefined || installAnchor.length === 0) {
641
+ throw new EnvironmentError('no-profile-context', '这个进程不是以某个环境启动的,'
642
+ + '所以无法定位该环境的安装位置,升级做不了');
643
+ }
644
+ return { profile, dir, installAnchor, cwd: dir, home: context?.home ?? dshHome() };
645
+ }
646
+ /** 取官方 operations 模块(不可用时抛确定性错误,不静默降级)。 */
647
+ async function officialRunner(deps) {
648
+ if (deps.runCommand !== undefined)
649
+ return deps.runCommand;
650
+ try {
651
+ const module = await import('@deepseek-ai/dsh-plugin-manager/operations');
652
+ return module.runPluginCommand;
653
+ }
654
+ catch (error) {
655
+ throw new EnvironmentError('official-unavailable', '官方 @deepseek-ai/dsh-plugin-manager/operations 不可用:' + messageOf(error));
656
+ }
657
+ }
658
+ /**
659
+ * 走官方通道 `add <spec>`(升级与回滚都是它)。
660
+ *
661
+ * @param environment - 目标环境名。
662
+ * @param spec - 官方 spec(`name@version`、`link:...`、绝对路径…)。
663
+ * @param deps - 共享依赖。
664
+ * @returns 官方结果。
665
+ */
666
+ export async function runOfficialAdd(environment, spec, deps = {}) {
667
+ const dir = environmentDir(environment);
668
+ const runner = await officialRunner(deps);
669
+ const options = {
670
+ execution: 'service',
671
+ outputBytes: OPERATION_OUTPUT_BYTES,
672
+ lockWaitMs: OPERATION_LOCK_WAIT_MS,
673
+ };
674
+ return await runner(officialContext(environment, dir, deps), ['add', spec], options);
675
+ }
676
+ // ── 盘上事实 ─────────────────────────────────────────────────────────────
677
+ /**
678
+ * 一个包在这个环境里的盘上事实(升级前后各取一次,用来核对"真的变了吗")。
679
+ *
680
+ * @param environment - 环境名。
681
+ * @param name - 包名。
682
+ * @returns 面向用户的多行事实。
683
+ */
684
+ export function installFacts(environment, name) {
685
+ const dir = environmentDir(environment);
686
+ const manifest = readEnvironmentManifest(dir);
687
+ const specs = dependencySpecs(dir);
688
+ const spec = specs[name];
689
+ const lines = [
690
+ spec === undefined
691
+ ? '依赖声明:没有 ' + name
692
+ : '依赖声明:' + name + ' = ' + spec,
693
+ '启动列表:' + (manifest.bundles.includes(name) ? '含 ' + name : '不含 ' + name),
694
+ ];
695
+ const entry = join(dir, 'node_modules', name);
696
+ let shape = '不存在';
697
+ try {
698
+ const stat = lstatSync(entry);
699
+ shape = stat.isSymbolicLink() ? '符号链接 -> ' + readlinkSync(entry) : stat.isDirectory() ? '目录' : '文件';
700
+ }
701
+ catch {
702
+ // 不存在就是不存在:这是正常路径。
703
+ }
704
+ const version = readInstalledVersion(environment, name);
705
+ lines.push('node_modules/' + name + ':' + shape + (version === null ? '(读不到 version)' : ',版本 ' + version));
706
+ return lines;
707
+ }
708
+ // ── 金丝雀(复用试装引擎)────────────────────────────────────────────────
709
+ /** 金丝雀清理日志路径(与引擎的测试环境清理共用同一份日志与同一行格式)。 */
710
+ export function canaryLogPath() {
711
+ return join(dshHome(), 'dpmc-trial-cleanup.log');
712
+ }
713
+ /** 写一行清理日志(失败不抛:删除本身已经发生,日志不该反过来让升级失败)。 */
714
+ function appendCanaryLog(line, deps) {
715
+ deps.log?.(line);
716
+ try {
717
+ appendFileSync(canaryLogPath(), new Date().toISOString() + ' ' + line + '\n', { mode: 0o600 });
718
+ }
719
+ catch {
720
+ // 见上:日志失败不阻断。
721
+ }
722
+ }
723
+ /**
724
+ * 从 spec 认出**包名**(金丝雀要先卸掉它,才能让候选成为"新装")。
725
+ *
726
+ * 三种形态与 envManager 的同名私有函数同口径:路径 spec 读目标目录的 package.json;
727
+ * registry spec 取最后一个 `@` 之前的部分(scoped 名整体保留)。
728
+ *
729
+ * @param spec - 候选 spec。
730
+ * @returns 包名;认不出来时 null(此时不做 remove,如实退回"直接装")。
731
+ */
732
+ function canaryPackageName(spec) {
733
+ const bare = spec.replace(/^(?:link:|file:|workspace:)/, '');
734
+ const looksLikePath = spec.startsWith('link:') || spec.startsWith('file:') || spec.startsWith('.') || bare.startsWith('/');
735
+ if (looksLikePath) {
736
+ try {
737
+ const manifest = JSON.parse(readFileSync(join(bare, 'package.json'), 'utf8'));
738
+ return typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : null;
739
+ }
740
+ catch {
741
+ return null;
742
+ }
743
+ }
744
+ const at = bare.lastIndexOf('@');
745
+ const name = at > 0 ? bare.slice(0, at) : bare;
746
+ return name.length === 0 ? null : name;
747
+ }
748
+ /**
749
+ * 跑一次金丝雀:在 `<环境>-dpmc` 里把**新版本**装进快照并跑基线/候选两次启动,然后立刻删掉测试环境。
750
+ *
751
+ * 四条纪律:
752
+ * 1. 复用 task-50/75 的试装引擎(含含 web 层环境读官方就绪行的那套形态),不另造验证器;
753
+ * 2. 候选要先成为"新装"(见 activationFor 的说明),否则升级场景下它永远进不了层栈,
754
+ * 金丝雀就退化成"什么都没验证";
755
+ * 3. `cannot-trial`(没验证)**不是** `passed`:调用方据此拒绝升级;
756
+ * 4. 测试环境是**一次性资产**:用完即删(不留 14 天),删不掉也如实说,并写清理日志。
757
+ *
758
+ * @param environment - 真实环境名。
759
+ * @param spec - 候选 spec(`name@version`)。
760
+ * @param config - 本插件配置(试装段决定深度/基线/联网)。
761
+ * @param deps - 共享依赖(试装执行器可注入)。
762
+ * @returns 金丝雀报告。
763
+ */
764
+ export async function runUpgradeCanary(environment, spec, config, deps = {}) {
765
+ const trial = effectiveTrialConfig(config);
766
+ if (!trial.enabled) {
767
+ // R2(DESIGN §12.9):原因不许冒号套冒号。原来那一句是
768
+ // '试装总开关已关闭:未做金丝雀,直接升级(没有验证新版本能否挂载)'
769
+ // —— 一层冒号套一层冒号再套括号,用户原话是"并行与分句,让人的理解很困难"。
770
+ // 改成缩进树状:一层一个因果,读者顺着箭头读下去就知道后果是什么。
771
+ // R3:'金丝雀' 是内部代号(用户第一反应是"什么鸟"),换成用户语言"先验证一遍"。
772
+ return {
773
+ ran: false,
774
+ skippedReason: [
775
+ '试装总开关已关闭',
776
+ ' → 直接升级,没有先验证',
777
+ ' → 新版本能否加载未经验证',
778
+ ].join('\n'),
779
+ cleanup: '没有创建测试环境',
780
+ };
781
+ }
782
+ const runner = deps.trial ?? runTrialInstall;
783
+ const target = trialEnvironmentName(environment);
784
+ // 激活包装(见 activationFor):把候选变成"新装",并留下层栈事实。
785
+ const activation = activationFor(environment, spec, deps);
786
+ let result;
787
+ try {
788
+ result = await runner(spec, environment, {
789
+ ...deps.ctx === undefined ? {} : { ctx: deps.ctx },
790
+ ...deps.installAnchor === undefined ? {} : { installAnchor: deps.installAnchor },
791
+ runCommand: activation.run,
792
+ ...deps.verify === undefined ? {} : { verify: deps.verify },
793
+ ...deps.now === undefined ? {} : { now: deps.now },
794
+ depth: trial.depth,
795
+ baseline: trial.baseline,
796
+ allowNetwork: trial.allowNetwork,
797
+ });
798
+ }
799
+ catch (error) {
800
+ // 引擎抛异常 = 这次没能验证(不是通过);测试环境仍要清掉。
801
+ const cleanup = await removeCanaryEnvironment(environment, deps);
802
+ const thrownEvidence = activation.read();
803
+ return {
804
+ ran: true, conclusion: 'cannot-trial', cleanup,
805
+ output: '试装验证执行时出错:' + messageOf(error),
806
+ ...thrownEvidence === null ? {} : { activation: thrownEvidence },
807
+ };
808
+ }
809
+ const cleanup = await removeCanaryEnvironment(environment, deps);
810
+ const evidence = activation.read();
811
+ // 自己核验一次激活证据:**不信任**上游给的 passed。
812
+ // 上游(试装引擎)确实有同一条守卫,但金丝雀的结论可以来自注入的替身——那时引擎的守卫
813
+ // 根本没跑。这里拿的是自己读的层栈事实,所以"候选没进层栈就绝不算通过"在这一层也成立。
814
+ const unactivated = evidence !== null && !evidence.activated;
815
+ return {
816
+ ran: true,
817
+ conclusion: unactivated ? 'cannot-trial' : result.conclusion,
818
+ depth: result.depth,
819
+ escalated: result.escalated,
820
+ elapsedMs: result.elapsedMs,
821
+ output: unactivated
822
+ // R2(DESIGN §12.9):原因不许冒号套冒号。原来一行里「…(不等于通过):候选…(判定依据:…)」
823
+ // 套了两层冒号还带两层括号。改成分行:结论一行,理由缩进一层,证据再缩进一层。
824
+ ? '试装验证没能得出结论(不等于通过)'
825
+ + '\n 候选 ' + evidence.name + ' 装完之后没有进入启动列表,启动时不会加载它'
826
+ + '\n 这次验证没有验证到新版本'
827
+ + '\n 判定依据:启动列表共 ' + String(evidence.bundles.length) + ' 项,不含它\n'
828
+ + evidence.removeNote + '\n' + result.output
829
+ : result.output,
830
+ cleanup,
831
+ ...evidence === null ? {} : { activation: evidence },
832
+ };
833
+ }
834
+ /**
835
+ * 观察试装环境里的官方通道调用,留下**层栈激活证据**。
836
+ *
837
+ * 职责分工(task-84 之后):
838
+ * · **让候选成为"新装"由试装引擎自己负责**(envManager 的 detachCandidate,走同一条官方
839
+ * remove 通道)。金丝雀因此不再自己卸包——两处各卸一次是重复机制,而重复的机制迟早会分叉。
840
+ * · 这里只**观察**:装完之后测试环境的 `dsh.profile.bundles` 里到底有没有候选。
841
+ * 为什么还需要它:试装执行器是**可注入**的,注入替身时引擎那条守卫根本没跑,
842
+ * 所以金丝雀要能自己读一次盘,才谈得上"不信任上游给的 passed"。
843
+ *
844
+ * 官方通道取不到时**不在这里抛**:把失败推迟到真正调用那一刻,让它走试装引擎原有的
845
+ * `cannot-trial` 路径——"没验证"要如实报成没验证。
846
+ *
847
+ * @param environment - 真实环境名。
848
+ * @param spec - 候选 spec。
849
+ * @param deps - 共享依赖。
850
+ * @returns 透传的运行器与证据句柄。
851
+ */
852
+ function activationFor(environment, spec, deps) {
853
+ const target = trialEnvironmentName(environment);
854
+ let inner = null;
855
+ let pending = null;
856
+ const resolveInner = async () => {
857
+ if (inner !== null)
858
+ return inner;
859
+ pending = pending ?? officialRunner(deps);
860
+ inner = await pending;
861
+ return inner;
862
+ };
863
+ const name = canaryPackageName(spec);
864
+ let captured = null;
865
+ let removedFirst = false;
866
+ let removeNote = '候选不在测试环境的依赖里,装它本来就是"新装"';
867
+ const run = async (context, args, options) => {
868
+ const runner = await resolveInner();
869
+ // remove 是引擎在摘候选(task-84):记下来,供报告里如实说"确实先卸了"。
870
+ if (args[0] === 'remove') {
871
+ const removal = await runner(context, args, options);
872
+ removedFirst = removal.exitCode === 0;
873
+ removeNote = removal.exitCode === 0
874
+ ? '已先把 ' + String(args[1]) + ' 从测试环境移除,再重新安装(否则重复安装不会生效)'
875
+ : '移除旧版本没有成功(退出码 ' + String(removal.exitCode) + '),候选包可能仍是重复安装的状态'
876
+ + removal.output.trim().slice(-200);
877
+ return removal;
878
+ }
879
+ const result = await runner(context, args, options);
880
+ if (args[0] === 'add' && name !== null) {
881
+ // 装完立刻读盘:层栈里有没有它,是这次验证成不成立的唯一判据。
882
+ const dir = context.dir ?? environmentDir(target);
883
+ const bundles = readEnvironmentManifest(dir).bundles;
884
+ captured = { name, bundles: [...bundles], activated: bundles.includes(name), removedFirst, removeNote };
885
+ }
886
+ return result;
887
+ };
888
+ return { run, read: () => captured };
889
+ }
890
+ /**
891
+ * 删掉金丝雀用的测试环境(<环境>-dpmc),并写清理日志。
892
+ *
893
+ * @param environment - 真实环境名。
894
+ * @param deps - 共享依赖。
895
+ * @returns 面向用户的清理结局。
896
+ */
897
+ async function removeCanaryEnvironment(environment, deps) {
898
+ const target = trialEnvironmentName(environment);
899
+ const result = await removeTrialEnvironment(target, deps.ctx === undefined ? {} : { ctx: deps.ctx });
900
+ if (result.ok) {
901
+ appendCanaryLog('removed ' + target + ':升级试装验证用完即删(一次性资产)', deps);
902
+ return '测试环境已删除:' + target;
903
+ }
904
+ appendCanaryLog('kept ' + target + ':删除被拒(' + String(result.code) + ')', deps);
905
+ // R2(DESIGN §12.9):原来写「测试环境没删掉(not-found):<原文>」——冒号套括号。
906
+ // 改成分行:第一行给结论与原因,原文缩进一行。
907
+ return '测试环境没删掉(' + String(result.code) + ')\n ' + result.output;
908
+ }
909
+ /**
910
+ * 升级一个已装包:金丝雀通过(或未做金丝雀)→ 官方 add → 盘上核对。
911
+ *
912
+ * 顺序不可颠倒:先验证再动真环境。金丝雀没通过(含 `cannot-trial`)就**不升级**,
913
+ * 并把试装结论原文作为原因返回——"没验证"绝不当成"通过"。
914
+ *
915
+ * @param input - 目标与依赖。
916
+ * @returns 结果(含 before/after 的盘上事实)。
917
+ */
918
+ export async function upgradePackage(input) {
919
+ const { environment, name, version } = input;
920
+ if (!isSafeEnvironmentName(environment)) {
921
+ return failResult(input, 'invalid-name', '环境名不合法:' + JSON.stringify(environment));
922
+ }
923
+ if (name.length === 0 || version.length === 0) {
924
+ return failResult(input, 'invalid-name', '包名与版本都必须是非空字符串');
925
+ }
926
+ const before = installFacts(environment, name);
927
+ const fromVersion = readInstalledVersion(environment, name);
928
+ const spec = name + '@' + version;
929
+ // 金丝雀的两种"没跑"必须分开说(它们是不同的诚实点):
930
+ // · 调用方显式跳过 → 那是调用方的决定;
931
+ // · 试装总开关关着 → 那是"未验证就升级",文案必须把这件事说出来。
932
+ // 后者交给 runUpgradeCanary 自己回答(它持有那条口径,不在这里抄一份会漂移的措辞)。
933
+ const canary = input.canary === false
934
+ ? { ran: false, skippedReason: '调用方显式跳过了试装验证', cleanup: '没有创建测试环境' }
935
+ : await runUpgradeCanary(environment, spec, input.config, input);
936
+ if (canary.ran && canary.conclusion !== 'passed') {
937
+ const headline = canary.conclusion === 'candidate-broken'
938
+ ? '试装验证没通过:新版本装进环境副本后起不来 —— 没有在真实环境执行升级'
939
+ : canary.conclusion === 'baseline-broken'
940
+ ? '试装验证没有给出判定:环境副本的基线本身就起不来(这不是新版本的问题)—— 没有在真实环境执行升级'
941
+ : '试装验证没能得出结论(不等于通过)—— 没有在真实环境执行升级';
942
+ return {
943
+ ok: false,
944
+ code: 'canary-not-passed',
945
+ output: headline + '\n' + [
946
+ '目标:' + spec,
947
+ canary.cleanup,
948
+ canary.output === undefined ? '' : '试装验证结论:\n' + canary.output,
949
+ ].filter((line) => line.length > 0).join('\n'),
950
+ name,
951
+ fromVersion,
952
+ toVersion: version,
953
+ spec,
954
+ canary,
955
+ diskFacts: before,
956
+ restartRequired: false,
957
+ };
958
+ }
959
+ let result;
960
+ try {
961
+ result = await runOfficialAdd(environment, spec, input);
962
+ }
963
+ catch (error) {
964
+ return {
965
+ ok: false,
966
+ code: error instanceof EnvironmentError ? error.code : 'io-failed',
967
+ output: '升级失败:' + messageOf(error) + '\n' + canary.cleanup,
968
+ name, fromVersion, toVersion: version, spec, canary, diskFacts: before, restartRequired: false,
969
+ };
970
+ }
971
+ const after = installFacts(environment, name);
972
+ const landed = readInstalledVersion(environment, name);
973
+ const tail = result.output.trim().split(/\r?\n/).filter((line) => line.length > 0).slice(-6);
974
+ const isSelf = name === OUR_PACKAGE_NAME;
975
+ const ok = result.exitCode === 0 && landed === version;
976
+ const lines = [
977
+ ok
978
+ ? name + ' 已升级:' + String(fromVersion ?? '(未知)') + ' → ' + version
979
+ : name + ' 升级没有完成:命令退出码 ' + String(result.exitCode) + ',安装版本是 ' + String(landed ?? '读不到') + '(期望 ' + version + ')',
980
+ '本次 spec:' + spec,
981
+ '试装验证:' + (canary.ran
982
+ ? '通过(深度 ' + String(canary.depth ?? '—') + ',耗时 ' + String(canary.elapsedMs ?? 0) + 'ms)'
983
+ : '未做(' + String(canary.skippedReason ?? '原因未知') + ')'),
984
+ canary.cleanup,
985
+ isSelf ? '本插件自身:正在运行的是旧代码,新版本在下次启动时加载' : '生效时机:新版本在下次启动时加载',
986
+ '',
987
+ '升级前:',
988
+ ...before.map((line) => ' ' + line),
989
+ '升级后:',
990
+ ...after.map((line) => ' ' + line),
991
+ ];
992
+ // R5(DESIGN §12.9):原始日志必须有标识。原来只写「官方输出:」——读者不知道那是**哪条命令**的
993
+ // 输出,中英混杂的一堆进度行会被当成我们的说明。现在点明它来自哪条命令。
994
+ //
995
+ // 措辞里不带工具名(pnpm):那对用户是内部工具名(Lead 复核 task-88 时点名)。
996
+ // 这条表头同时是**客户端切分原文的判据**(见 UpgradeRow.officialTail 的 marker)——
997
+ // 改它必须同步改那边,否则客户端那一块会静默不渲染(真机实测踩过)。
998
+ if (tail.length > 0)
999
+ lines.push('', '命令输出(来自升级命令):', ...tail.map((line) => ' ' + line));
1000
+ // 升级成功后该包的版本事实就旧了:让它下次检查重新取(不靠"猜"来更新界面)。
1001
+ invalidateTagsCache(name);
1002
+ return {
1003
+ ok,
1004
+ ...ok ? {} : { code: 'package-operation-failed' },
1005
+ output: lines.join('\n').replace(/\*\*/g, ''),
1006
+ name, fromVersion, toVersion: version, spec, canary, diskFacts: after, restartRequired: true,
1007
+ };
1008
+ }
1009
+ /**
1010
+ * 回滚一个包到指定版本(或回滚到原来的本地来源)。
1011
+ *
1012
+ * 核对按**盘上事实**:依赖行与 node_modules 里的版本都要对上才算干净;对不上就说对不上
1013
+ * (沿用 task-23 的教训:不许声称"环境未被改动"而留下残留)。
1014
+ *
1015
+ * @param input - 目标与依赖。
1016
+ * @returns 结果。
1017
+ */
1018
+ export async function rollbackUpgrade(input) {
1019
+ const { environment, name, version } = input;
1020
+ if (!isSafeEnvironmentName(environment)) {
1021
+ return { ok: false, code: 'invalid-name', output: '环境名不合法:' + JSON.stringify(environment), name, fromVersion: null, toVersion: version, diskFacts: [], clean: false };
1022
+ }
1023
+ const fromVersion = readInstalledVersion(environment, name);
1024
+ const localSpec = isLocalSpec(input.spec) ? input.spec : undefined;
1025
+ // 原来就是本地来源(link:/file:/路径)时,回滚的正解是把**那个来源**装回来,而不是装一个 registry 版本。
1026
+ const spec = localSpec ?? name + '@' + version;
1027
+ const before = installFacts(environment, name);
1028
+ let result;
1029
+ try {
1030
+ result = await runOfficialAdd(environment, spec, input);
1031
+ }
1032
+ catch (error) {
1033
+ return {
1034
+ ok: false,
1035
+ code: error instanceof EnvironmentError ? error.code : 'io-failed',
1036
+ output: '回滚失败:' + messageOf(error),
1037
+ name, fromVersion, toVersion: version, diskFacts: before, clean: false,
1038
+ };
1039
+ }
1040
+ const after = installFacts(environment, name);
1041
+ const landed = readInstalledVersion(environment, name);
1042
+ const clean = result.exitCode === 0 && landed === version;
1043
+ const lines = [
1044
+ clean
1045
+ ? '已回滚 ' + name + ':' + String(fromVersion ?? '(未知)') + ' → ' + version
1046
+ : '回滚没能到位:官方退出码 ' + String(result.exitCode) + ',盘上版本是 ' + String(landed ?? '读不到')
1047
+ + '(期望 ' + version + ')',
1048
+ '本次 spec:' + spec,
1049
+ '生效时机:新版本在下次启动时加载',
1050
+ '',
1051
+ '回滚前:',
1052
+ ...before.map((line) => ' ' + line),
1053
+ '回滚后:',
1054
+ ...after.map((line) => ' ' + line),
1055
+ ];
1056
+ if (!clean)
1057
+ lines.push('', '上面这份当前状态就是现状:残留需要按它处理,界面不该说"环境未被改动"');
1058
+ invalidateTagsCache(name);
1059
+ return {
1060
+ ok: clean,
1061
+ ...clean ? {} : { code: 'rollback-incomplete' },
1062
+ output: lines.join('\n'),
1063
+ name, fromVersion, toVersion: version, diskFacts: after, clean,
1064
+ };
1065
+ }
1066
+ /**
1067
+ * 让某个包的版本事实失效(升级/回滚后必须重新取,不许拿旧事实当新状态)。
1068
+ *
1069
+ * 只删这一个包的条目,**不动**环境记账:记账说的是"这个环境什么时候查过",
1070
+ * 那件事并没有因为一次升级而改变(升级成功只让**这个包**的版本事实过期)。
1071
+ *
1072
+ * @param name - 包名。
1073
+ */
1074
+ export function invalidateTagsCache(name) {
1075
+ const cache = readTagsCache();
1076
+ if (cache.packages[name] === undefined)
1077
+ return;
1078
+ const packages = { ...cache.packages };
1079
+ delete packages[name];
1080
+ writeTagsCache({ environments: cache.environments, packages });
1081
+ }
1082
+ /** 一次失败结果的统一形状(把 fromVersion 与盘上事实一并带上)。 */
1083
+ function failResult(input, code, output) {
1084
+ return {
1085
+ ok: false,
1086
+ code,
1087
+ output,
1088
+ name: input.name,
1089
+ fromVersion: null,
1090
+ toVersion: input.version,
1091
+ spec: input.name + '@' + input.version,
1092
+ canary: { ran: false, skippedReason: '输入不合法,没有跑试装验证', cleanup: '没有创建测试环境' },
1093
+ diskFacts: [],
1094
+ restartRequired: false,
1095
+ };
1096
+ }
1097
+ /** 错误消息(本地小工具)。 */
1098
+ function messageOf(error) {
1099
+ return error instanceof Error ? error.message : String(error);
1100
+ }