dsh-m 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  import { existsSync } from 'node:fs';
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
- import { dshHome } from './env.js';
13
+ import { dshHome, webProfileDir } from './env.js';
14
14
  import { runProfileTransaction, makeNpmWarmPackument, TransactionError, } from './profile-transaction.js';
15
15
  import { listInstalledPlugins as defaultListInstalledPlugins, } from './installed.js';
16
16
  import { loaderHost } from './live-plugin.js';
@@ -18,6 +18,12 @@ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
18
18
  import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
19
19
  import { readProfileOverrides } from './patch-yaml.js';
20
20
  import { loadRegistry as defaultLoadRegistry, } from './registry.js';
21
+ import { fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
22
+ import { adaptCommunityCatalog } from './community-adapter.js';
23
+ import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
24
+ import { HttpError } from './httpx.js';
25
+ import { verifyInstalledAdditions } from './install-guard.js';
26
+ import { fetchTextLimited } from './httpx.js';
21
27
  import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
22
28
  /** profile package.json 的 dsh.profile.bundles 数组(读不到 → [];Task 13)。 */
23
29
  async function readProfileBundles(profileDir) {
@@ -189,6 +195,16 @@ function applyProbe(item, value) {
189
195
  if (value.sha !== undefined)
190
196
  item.latestSha = value.sha;
191
197
  }
198
+ /** probe 错误 → 结构化 code(Task 7 ⑨):预算/限流/超时/其他网络错误各自归类,不得一律归因预算。 */
199
+ function classifyLatestError(err) {
200
+ if (err instanceof GithubBudgetExhaustedError)
201
+ return 'budget-exhausted';
202
+ if (err instanceof HttpError && err.status === 403)
203
+ return 'rate-limited';
204
+ if (err instanceof Error && /限额已用尽/.test(err.message))
205
+ return 'rate-limited';
206
+ return 'network-error';
207
+ }
192
208
  function matchInstalledByEntry(entry, installed) {
193
209
  return installed.find((it) => {
194
210
  if (entry.npm && it.pkg === entry.npm)
@@ -203,6 +219,132 @@ function matchInstalledByEntry(entry, installed) {
203
219
  return false;
204
220
  });
205
221
  }
222
+ /**
223
+ * 合并去重(Q41):社区条目依次与主清单的 npm 名集 / github owner-repo 集 / id 集比对,
224
+ * 任一相撞即让位(每条只计一次);社区内部 npm 名重复同样让位(首条优先)。
225
+ * 主清单恒优先——让位只影响社区条目,主条目原样保留。
226
+ */
227
+ export function mergeRegistries(primary, community) {
228
+ const primaryNpm = new Set(primary.filter((e) => e.npm).map((e) => e.npm));
229
+ const primaryGithub = new Set(primary.filter((e) => e.github).map((e) => e.github));
230
+ const primaryIds = new Set(primary.map((e) => e.id));
231
+ const displaced = [];
232
+ const kept = [];
233
+ const seenNpm = new Set();
234
+ for (const c of community) {
235
+ if (c.npm !== undefined) {
236
+ if (primaryNpm.has(c.npm) || seenNpm.has(c.npm)) {
237
+ displaced.push(c.id);
238
+ continue;
239
+ }
240
+ seenNpm.add(c.npm);
241
+ }
242
+ if ((c.github !== undefined && primaryGithub.has(c.github)) || primaryIds.has(c.id)) {
243
+ displaced.push(c.id);
244
+ continue;
245
+ }
246
+ kept.push(c);
247
+ }
248
+ // Q45:主清单置顶(组内原顺序)+ 社区按 30 天下载量降序(无数据按名称)
249
+ const sorted = [...kept].sort((a, b) => {
250
+ const da = a.downloads ?? -1;
251
+ const db = b.downloads ?? -1;
252
+ if (da !== db)
253
+ return db - da;
254
+ return a.name.localeCompare(b.name);
255
+ });
256
+ const warnings = displaced.length > 0 ? [`${displaced.length} 条社区条目与主清单重复,已让位(主清单恒优先)`] : [];
257
+ return { items: [...primary, ...sorted], displaced: displaced.length, warnings };
258
+ }
259
+ function communitySummary(state, counts, extraWarnings) {
260
+ const unavailableLike = state.status === 'disabled' || state.status === 'unavailable';
261
+ return {
262
+ enabled: state.enabled,
263
+ status: state.status,
264
+ version: state.version,
265
+ checkedAt: state.checkedAt,
266
+ fetchedAt: state.fetchedAt,
267
+ route: state.route,
268
+ acceptedCount: unavailableLike ? 0 : (counts.acceptedCount ?? 0),
269
+ upstreamCount: unavailableLike ? (state.status === 'disabled' ? null : 0) : (counts.upstreamCount ?? state.count),
270
+ displaced: unavailableLike ? 0 : (counts.displaced ?? 0),
271
+ skippedDirty: unavailableLike ? 0 : (counts.skippedDirty ?? 0),
272
+ skippedSubpathNoNpm: unavailableLike ? 0 : (counts.skippedSubpathNoNpm ?? 0),
273
+ errors: [...state.errors],
274
+ warnings: [...state.warnings, ...extraWarnings],
275
+ };
276
+ }
277
+ function disabledCommunitySummary() {
278
+ return communitySummary({ enabled: false, status: 'disabled', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
279
+ }
280
+ function communityTimeoutSummary() {
281
+ return communitySummary({ enabled: true, status: 'unavailable', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: ['社区目录状态获取超时,可稍后刷新'], warnings: [] }, {}, []);
282
+ }
283
+ /**
284
+ * 社区 loader waiter 收敛(v9/v10 waiter-scoped 契约):共享 flight 不接收调用者 deadline,
285
+ * 本函数作为 waiter 用剩余 deadline race 自己的等待;到点只结束本 waiter(summary 标超时),
286
+ * 共享 flight 照常继续。primaryOnly/未启用 → loader 零调用(task 传 null)。
287
+ * 导出供 community.ts getCommunitySummary 复用(summary 组装单一产地)。
288
+ */
289
+ export async function communityOutcome(task, deadlineAt, primary) {
290
+ if (!task)
291
+ return { summary: disabledCommunitySummary(), merged: primary };
292
+ let loaded;
293
+ try {
294
+ loaded = await deadlineRace(task, deadlineAt - Date.now());
295
+ }
296
+ finally {
297
+ // race 输出后共享 promise 若仍悬挂(deadline 先到),收尾防 unhandled rejection
298
+ void task.catch(() => undefined);
299
+ }
300
+ if (loaded === 'deadline')
301
+ return { summary: communityTimeoutSummary(), merged: primary };
302
+ const state = loaded.state;
303
+ if (state.status === 'disabled')
304
+ return { summary: disabledCommunitySummary(), merged: primary };
305
+ if (state.status === 'unavailable' || !loaded.catalog) {
306
+ return {
307
+ summary: communitySummary(state, { acceptedCount: 0, upstreamCount: 0, displaced: 0, skippedDirty: 0, skippedSubpathNoNpm: 0 }, []),
308
+ merged: primary,
309
+ };
310
+ }
311
+ const adapted = adaptCommunityCatalog(loaded.catalog);
312
+ const merge = mergeRegistries(primary, adapted.entries);
313
+ const summary = communitySummary(state, {
314
+ acceptedCount: adapted.entries.length,
315
+ upstreamCount: loaded.catalog.plugins.length,
316
+ displaced: merge.displaced,
317
+ skippedDirty: adapted.skippedDirty,
318
+ skippedSubpathNoNpm: adapted.skippedSubpathNoNpm,
319
+ }, [...adapted.warnings, ...merge.warnings]);
320
+ return { summary, merged: merge.items };
321
+ }
322
+ function isCommunityEntry(entry) {
323
+ return entry.descriptionEn !== undefined;
324
+ }
325
+ /** 合并条目 → MarketItem:社区条目带 community 标记与旁路字段。 */
326
+ function toMarketItem(entry, installedItems) {
327
+ const inst = matchInstalledByEntry(entry, installedItems);
328
+ const item = { ...entry, installed: Boolean(inst), outdated: false };
329
+ if (inst) {
330
+ item.installedPkg = inst.pkg;
331
+ item.installedVersion = inst.version;
332
+ }
333
+ if (isCommunityEntry(entry)) {
334
+ item.community = true;
335
+ item.stars = entry.stars;
336
+ item.downloads = entry.downloads;
337
+ item.capabilities = entry.capabilities;
338
+ item.capabilityRedLines = entry.capabilityRedLines;
339
+ item.screenshots = entry.screenshots;
340
+ }
341
+ return item;
342
+ }
343
+ /** 合并条目的搜索串:社区条目附英文描述原文(Q45 搜索同时匹配中英文)。 */
344
+ function searchableText(entry) {
345
+ const base = `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`;
346
+ return isCommunityEntry(entry) ? `${base} ${entry.descriptionEn}` : base;
347
+ }
206
348
  // ---------- 市场列表 ----------
207
349
  function marketDeps() {
208
350
  return {
@@ -210,6 +352,7 @@ function marketDeps() {
210
352
  listInstalledPlugins: defaultListInstalledPlugins,
211
353
  npmLatest: defaultNpmLatest,
212
354
  githubLatestTag: defaultGithubLatestTag,
355
+ fetchCommunityCatalog: defaultFetchCommunityCatalog,
213
356
  };
214
357
  }
215
358
  export async function listMarket(cfg = {}, opts = {}, deps = {}) {
@@ -226,12 +369,16 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
226
369
  const remaining = () => deadlineAt - Date.now();
227
370
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
228
371
  const installedTask = d.listInstalledPlugins().catch(() => null);
372
+ // 社区 flight 并发启动(primaryOnly 零调用);共享 loader 不接收调用者 deadline——
373
+ // listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
374
+ const communityTask = opts.primaryOnly === true ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
229
375
  let loaded;
230
376
  try {
231
377
  loaded = await deadlineRace(registryTask, remaining());
232
378
  }
233
379
  catch (err) {
234
380
  void installedTask;
381
+ void communityTask?.catch(() => undefined);
235
382
  throw err;
236
383
  }
237
384
  if (loaded === 'deadline') {
@@ -246,32 +393,21 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
246
393
  installedComplete: false,
247
394
  latestComplete: false,
248
395
  latestTimedOut: true,
396
+ community: communityTimeoutSummary(),
249
397
  };
250
398
  }
251
399
  const registryState = stateOf(loaded);
252
- if (loaded.status === 'unavailable') {
253
- void installedTask;
254
- return {
255
- items: [],
256
- total: 0,
257
- offset: 0,
258
- limit: maxLimit,
259
- categoryCounts: zeroCounts(),
260
- registryState,
261
- installedComplete: false,
262
- latestComplete: false,
263
- latestTimedOut: false,
264
- };
265
- }
266
400
  const installed = await installedTask;
267
401
  // 完整性两层来源:枚举 throw → null;枚举 partial resolve → complete:false(installed.ts 完整性契约)
268
402
  const installedComplete = installed !== null && installed.complete === true;
269
403
  const installedItems = installed?.items ?? [];
404
+ // 社区 waiter 收敛:主 unavailable 时 merged = 存活社区条目(Q42 出页不返空);两层皆不可用 → 空页契约
405
+ const community = await communityOutcome(communityTask, deadlineAt, loaded.registry.plugins);
270
406
  // 全量统计 + query/category 过滤 + 分页(同步,极轻)
271
- const all = loaded.registry.plugins;
407
+ const all = community.merged;
272
408
  const counts = zeroCounts();
273
409
  for (const entry of all)
274
- counts[entry.category] += 1;
410
+ counts[entry.category] = (counts[entry.category] ?? 0) + 1;
275
411
  const q = (opts.query ?? '').trim().toLowerCase();
276
412
  const cat = opts.category ?? null;
277
413
  const filtered = all.filter((entry) => {
@@ -279,22 +415,14 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
279
415
  return false;
280
416
  if (!q)
281
417
  return true;
282
- return `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`.toLowerCase().includes(q);
418
+ return searchableText(entry).toLowerCase().includes(q);
283
419
  });
284
420
  const total = filtered.length;
285
421
  const limit = clampLimit(opts.limit, maxLimit);
286
422
  let offset = normalizeOffset(opts.offset);
287
423
  if (total > 0 && offset >= total)
288
424
  offset = Math.floor((total - 1) / limit) * limit;
289
- const items = filtered.slice(offset, offset + limit).map((entry) => {
290
- const inst = matchInstalledByEntry(entry, installedItems);
291
- const item = { ...entry, installed: Boolean(inst), outdated: false };
292
- if (inst) {
293
- item.installedPkg = inst.pkg;
294
- item.installedVersion = inst.version;
295
- }
296
- return item;
297
- });
425
+ const items = filtered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
298
426
  let latestComplete = true;
299
427
  let latestTimedOut = false;
300
428
  if (withLatest && items.length > 0) {
@@ -305,12 +433,18 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
305
433
  if (cached)
306
434
  applyProbe(item, cached);
307
435
  }
308
- const todo = items.filter((it) => it.latestVersion === undefined && it.latestTag === undefined && it.latestSha === undefined);
436
+ const todo = items.filter((it) => it.latestVersion === undefined &&
437
+ it.latestTag === undefined &&
438
+ it.latestSha === undefined &&
439
+ // Q46 探测边界:社区 github 条目不做浏览页 REST 探测(配额不可控);社区 npm 条目照常
440
+ !(it.community === true && it.source === 'github'));
309
441
  if (todo.length > 0) {
310
442
  const budget = remaining();
311
443
  if (budget <= 0) {
312
- for (const item of todo)
313
- item.latestErrorCode = 'LATEST_TIMEOUT';
444
+ for (const item of todo) {
445
+ item.latestError = '更新检查未完成:时间预算已用尽';
446
+ item.latestErrorCode = 'timeout';
447
+ }
314
448
  latestComplete = false;
315
449
  latestTimedOut = true;
316
450
  }
@@ -324,7 +458,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
324
458
  writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
325
459
  }
326
460
  else if (outcome.timeout) {
327
- item.latestErrorCode = 'LATEST_TIMEOUT';
461
+ item.latestError = '更新检查未完成:超时';
462
+ item.latestErrorCode = 'timeout';
328
463
  latestComplete = false;
329
464
  latestTimedOut = true;
330
465
  }
@@ -332,7 +467,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
332
467
  if (signal?.aborted)
333
468
  throw abortError();
334
469
  item.latestError = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
335
- item.latestErrorCode = 'LATEST_ERROR';
470
+ item.latestErrorCode = classifyLatestError(outcome.error);
336
471
  }
337
472
  });
338
473
  }
@@ -350,7 +485,18 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
350
485
  }
351
486
  }
352
487
  }
353
- return { items, total, offset, limit, categoryCounts: counts, registryState, installedComplete, latestComplete, latestTimedOut };
488
+ return {
489
+ items,
490
+ total,
491
+ offset,
492
+ limit,
493
+ categoryCounts: counts,
494
+ registryState,
495
+ installedComplete,
496
+ latestComplete,
497
+ latestTimedOut,
498
+ community: community.summary,
499
+ };
354
500
  }
355
501
  // ---------- 已装列表 ----------
356
502
  export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
@@ -365,6 +511,9 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
365
511
  const remaining = () => deadlineAt - Date.now();
366
512
  const installedPromise = d.listInstalledPlugins();
367
513
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
514
+ // 社区 flight 并发启动 + request-scoped GitHub 预算(本次检查 ≤25 个 wire 请求、宿主滚动 50/h)
515
+ const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
516
+ const githubBudget = createGithubRequestBudget();
368
517
  const installed = await installedPromise;
369
518
  // enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
370
519
  const compose = d.composeEnablement ?? composeEnablement;
@@ -386,47 +535,63 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
386
535
  catch {
387
536
  loaded = 'deadline';
388
537
  }
538
+ const community = await communityOutcome(communityTask, deadlineAt, loaded === 'deadline' ? [] : loaded.registry.plugins);
389
539
  if (loaded === 'deadline' || loaded.status === 'unavailable') {
390
- // registry 不可用:不做 matching,直接返回已装列表
540
+ // registry 不可用:不做 matching/探测(原行为),但社区 summary 照常携带(CLI outdated 双源判定)
391
541
  const registryState = loaded === 'deadline' ? timeoutRegistryState(cfg) : stateOf(loaded);
392
- return { items, others: installed.others, profileDir: installed.profileDir, registryState };
542
+ void community.merged;
543
+ return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
393
544
  }
394
545
  const registryState = stateOf(loaded);
395
- const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
546
+ matchInstalled(items, community.merged);
547
+ await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
548
+ return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
549
+ }
550
+ /** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
551
+ function matchInstalled(items, merged) {
552
+ for (const item of items) {
553
+ const entry = merged.find((e) => matchInstalledByEntry(e, [item]));
554
+ if (!entry)
555
+ continue;
556
+ item.registryId = entry.id;
557
+ item.registryGithub = entry.github ?? null;
558
+ item.registryIcon = entry.icon ?? null;
559
+ if (isCommunityEntry(entry))
560
+ item.community = true;
561
+ }
562
+ }
563
+ /** 已装页 latest 探测(Q46 已装页豁免,但 GitHub 计入 request 预算 + 宿主滚动窗口)。 */
564
+ async function probeLatest(items, ctx, d, rt) {
396
565
  await mapWithConcurrency(items, LATEST_WORKERS, async (item) => {
397
- const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [item]));
398
- if (entry) {
399
- item.registryId = entry.id;
400
- item.registryGithub = entry.github ?? null;
401
- item.registryIcon = entry.icon ?? null;
402
- }
566
+ const entry = ctx.merged.find((e) => matchInstalledByEntry(e, [item]));
403
567
  // latest 探测沿用 listMarket 的 TTL cache
404
568
  const cacheKey = entry
405
- ? latestCacheKey(namespace, loaded.configuredAddress, entry)
569
+ ? latestCacheKey(rt.namespace, ctx.registryAddress ?? '', entry)
406
570
  : item.source === 'npm'
407
- ? `npm-only|${namespace}|npm:${item.pkg}`
571
+ ? `npm-only|${rt.namespace}|npm:${item.pkg}`
408
572
  : null;
409
573
  if (cacheKey) {
410
- const cached = readLatestCache(cacheKey, ttlMin);
574
+ const cached = readLatestCache(cacheKey, ctx.ttlMin);
411
575
  if (cached) {
412
576
  applyProbe(item, cached);
413
577
  }
414
- else if (remaining() > 0) {
415
- const budget = Math.max(1, remaining());
578
+ else if (rt.remaining() > 0) {
579
+ // 绝对剩余预算贯穿整个 probe(多跳 githubLatestTag 不逐子请求重置——v9 ②)
580
+ const budget = rt.remaining();
416
581
  try {
417
582
  let value = null;
418
583
  if (!entry && item.source === 'npm') {
419
- const latest = await d.npmLatest(item.pkg, Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
584
+ const latest = await d.npmLatest(item.pkg, Math.min(rt.timeoutMs, budget), rt.signal);
420
585
  value = { version: latest.version };
421
586
  }
422
587
  else if (entry?.source === 'npm' && entry.npm) {
423
- const latest = await d.npmLatest(entry.npm, Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
588
+ const latest = await d.npmLatest(entry.npm, Math.min(rt.timeoutMs, budget), rt.signal);
424
589
  value = { version: latest.version };
425
590
  }
426
591
  else if (item.source === 'github') {
427
592
  const m = /^github:([^#]+)/.exec(item.spec);
428
593
  if (m) {
429
- const latest = await d.githubLatestTag(m[1], Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
594
+ const latest = await d.githubLatestTag(m[1], Math.min(rt.timeoutMs, budget), rt.signal, rt.githubBudget);
430
595
  value = { tag: latest.tag, sha: latest.sha };
431
596
  }
432
597
  }
@@ -437,15 +602,20 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
437
602
  }
438
603
  catch (err) {
439
604
  item.latestError = err instanceof Error ? err.message : String(err);
605
+ item.latestErrorCode = classifyLatestError(err);
440
606
  }
441
607
  }
608
+ else {
609
+ // deadline 已到且未启动:标未完成并停止派发(v9 ②「未启动条目也标未完成」)
610
+ item.latestError = '更新检查未完成:时间预算已用尽';
611
+ item.latestErrorCode = 'timeout';
612
+ }
442
613
  }
443
614
  if (item.latestVersion !== undefined && item.version)
444
615
  item.outdated = isNewerVersion(item.latestVersion, item.version);
445
616
  else if (item.latestSha !== undefined)
446
617
  item.outdated = !item.spec.includes(item.latestSha);
447
618
  });
448
- return { items, others: installed.others, profileDir: installed.profileDir, registryState };
449
619
  }
450
620
  /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
451
621
  export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
@@ -458,14 +628,316 @@ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
458
628
  throw new Error(`registry 中没有该条目: ${id}`);
459
629
  return installEntry(entry, cfg, opts, deps);
460
630
  }
631
+ // ---------- M2 Task 3:统一 mutation session + 装后守卫 + 补偿接线 ----------
632
+ let sessionTail = Promise.resolve();
633
+ /**
634
+ * 统一 mutation 串行区间(模块级 FIFO,非重入):包住全部五个 profile mutation 入口
635
+ * (installFromRegistry / upgradePlugin / uninstallPlugin / selfUpgrade / toggle)。
636
+ * session 单点获取——只在 core façade 获取(toggle 在 togglePlugin 本体),host-api/tools/CLI
637
+ * 层不得重复获取(session 非重入,多层重复获取会死锁)。锁序:mutation session 外层 →
638
+ * toggle file lock / 事务 FIFO 内层。
639
+ */
640
+ export async function withMutationSession(fn) {
641
+ const run = sessionTail.then(() => fn(), () => fn());
642
+ sessionTail = run.then(() => undefined, () => undefined);
643
+ return run;
644
+ }
645
+ /** 守卫拦截的结构化错误(非成功状态):三端按 kind/needsRestart/restartSafe 渲染,无 force 通道。 */
646
+ export class InstallGuardError extends Error {
647
+ kind;
648
+ violations;
649
+ needsRestart;
650
+ restartSafe;
651
+ /** 补偿终态摘要(rolled-back/manual-repair/rejected 的 note) */
652
+ compensation;
653
+ /** manual-repair 的修复依据(旧 manifestSpec/installSpec/mapping) */
654
+ repairBasis;
655
+ constructor(fields) {
656
+ super(fields.message);
657
+ this.name = 'InstallGuardError';
658
+ this.kind = fields.kind;
659
+ this.violations = fields.violations;
660
+ this.needsRestart = fields.needsRestart;
661
+ this.restartSafe = fields.restartSafe;
662
+ this.compensation = fields.compensation;
663
+ this.repairBasis = fields.repairBasis;
664
+ }
665
+ }
666
+ /** lockfile packages 段的目标包 resolution 提取(行扫描:键行 `pkg@ver:` → 收集至下一键行;引号/嵌套键兼容)。 */
667
+ function lockResolutionOf(lockText, pkg) {
668
+ // 只扫顶层 packages: 段(importer 段的同名键不参与;packages: 后条目缩进两空格)
669
+ const pkgsAt = lockText.split('\n').findIndex((l) => l.trim() === 'packages:');
670
+ if (pkgsAt < 0)
671
+ return null;
672
+ const lines = lockText.split('\n').slice(pkgsAt + 1);
673
+ let body = null;
674
+ for (const line of lines) {
675
+ if (body !== null && /^\S/.test(line))
676
+ break; // 顶层新段 → 收集结束
677
+ const keyMatch = /^ '?(?:[^'\n]*node_modules\/)?([^\s':]+):\s*$/.exec(line);
678
+ if (keyMatch) {
679
+ if (body)
680
+ break;
681
+ const key = keyMatch[1] ?? '';
682
+ if (key === pkg || key.startsWith(`${pkg}@`))
683
+ body = [];
684
+ continue;
685
+ }
686
+ if (body)
687
+ body.push(line);
688
+ }
689
+ if (body === null)
690
+ return null;
691
+ const text = body.join('\n');
692
+ const version = /version:\s*'?([^'\n]+)/.exec(text)?.[1]?.trim();
693
+ const integrity = /integrity:\s*(\S+)/.exec(text)?.[1]?.trim();
694
+ const commit = /commit:\s*'?([0-9a-f]{40})/.exec(text)?.[1]?.trim();
695
+ if (!version && !integrity && !commit)
696
+ return null;
697
+ return { ...(version ? { version } : {}), ...(integrity ? { integrity } : {}), ...(commit ? { commit } : {}) };
698
+ }
699
+ /** 变更前全量快照(v6):deps / lock resolutions / 双落点 mapping。读取失败 → unavailable(fail-closed 拒绝安装)。 */
700
+ export async function capturePreMutationState(profileDir) {
701
+ try {
702
+ // manifest ENOENT = 无已装依赖的空 profile(与 guard readDeps 同语义);其他读取/解析失败 → fail-closed
703
+ let manifestText = '';
704
+ try {
705
+ manifestText = await readFile(join(profileDir, 'package.json'), 'utf8');
706
+ }
707
+ catch (err) {
708
+ if (err.code !== 'ENOENT')
709
+ throw err;
710
+ }
711
+ const doc = manifestText
712
+ ? JSON.parse(manifestText)
713
+ : {};
714
+ const deps = {};
715
+ if (doc.dependencies && typeof doc.dependencies === 'object' && !Array.isArray(doc.dependencies)) {
716
+ for (const [name, spec] of Object.entries(doc.dependencies)) {
717
+ if (typeof spec === 'string')
718
+ deps[name] = spec;
719
+ }
720
+ }
721
+ const manifestMapping = [];
722
+ const patched = doc.pnpm?.patchedDependencies;
723
+ if (patched && typeof patched === 'object' && !Array.isArray(patched)) {
724
+ for (const [key, patchPath] of Object.entries(patched)) {
725
+ if (typeof patchPath === 'string')
726
+ manifestMapping.push({ key, patchPath });
727
+ }
728
+ }
729
+ let lockText = '';
730
+ try {
731
+ lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
732
+ }
733
+ catch {
734
+ lockText = '';
735
+ }
736
+ const lockResolutions = {};
737
+ for (const name of Object.keys(deps)) {
738
+ const res = lockResolutionOf(lockText, name);
739
+ if (res)
740
+ lockResolutions[name] = res;
741
+ }
742
+ const workspaceMapping = [];
743
+ try {
744
+ const ws = await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8');
745
+ const inBlock = /^patchedDependencies:/m;
746
+ if (inBlock.test(ws)) {
747
+ const section = ws.split(/^patchedDependencies:/m)[1]?.split(/^\S/m)?.[0] ?? '';
748
+ for (const line of section.split('\n')) {
749
+ const m = /^\s+'?([^':]+)'?:\s*(\S+)/.exec(line);
750
+ if (m)
751
+ workspaceMapping.push({ key: m[1].trim(), patchPath: m[2].trim() });
752
+ }
753
+ }
754
+ }
755
+ catch {
756
+ /* 无 workspace 文件 = 无 workspace mapping */
757
+ }
758
+ return { kind: 'snapshot', snapshot: { deps, lockResolutions, mapping: { workspace: workspaceMapping, manifest: manifestMapping } } };
759
+ }
760
+ catch (err) {
761
+ return { kind: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
762
+ }
763
+ }
764
+ /** mapping 条目按真实 key 精确关联(key === realKey 或版本化 key `realKey@x`);不按 repo/id/相似名猜。 */
765
+ function mappingFor(mapping, realKey) {
766
+ const pick = (rows) => rows.filter((r) => r.key === realKey || r.key.startsWith(`${realKey}@`));
767
+ return { workspace: pick(mapping.workspace), manifest: pick(mapping.manifest) };
768
+ }
769
+ /**
770
+ * prior 派生(add 返回真实 key 后、守卫前):严格按真实 key 关联快照;判定表唯一映射。
771
+ * 来源白名单(独立实现,不复用 parseSpecSource):精确 npm version(含 ^/~ 书写)与
772
+ * pinned github commit + lock identity 可自动恢复;alias/workspace/tarball URL/git+ssh/link/file 一律 restorable:false。
773
+ */
774
+ export function derivePrior(snapshot, realKey) {
775
+ const spec = snapshot.deps[realKey];
776
+ const mapping = mappingFor(snapshot.mapping, realKey);
777
+ if (spec === undefined) {
778
+ if (mapping.workspace.length > 0 || mapping.manifest.length > 0) {
779
+ return { kind: 'mappingOnly', patchMapping: mapping };
780
+ }
781
+ return { kind: 'none' };
782
+ }
783
+ const lock = snapshot.lockResolutions[realKey] ?? null;
784
+ let sourceKind = 'other';
785
+ let restorable = false;
786
+ if (isExactVersion(spec)) {
787
+ sourceKind = 'npm';
788
+ restorable = Boolean(lock?.integrity);
789
+ }
790
+ else if (/^[~^]\d/.test(spec) && isExactVersion(spec.slice(1))) {
791
+ // ^/~ 锚定(0.4.x 旧 CLI 书写):恢复后接受规范化并明示
792
+ sourceKind = 'npm';
793
+ restorable = Boolean(lock?.integrity);
794
+ }
795
+ else if (/^github:[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+#[0-9a-f]{40}$/.test(spec)) {
796
+ sourceKind = 'github';
797
+ restorable = Boolean(lock?.commit);
798
+ }
799
+ return {
800
+ kind: 'dependency',
801
+ state: {
802
+ manifestSpec: spec,
803
+ installSpec: spec,
804
+ resolvedVersion: lock?.version,
805
+ sourceKind,
806
+ ...(lock?.integrity ? { integrity: lock.integrity } : {}),
807
+ ...(lock ? { lockResolution: lock } : {}),
808
+ patchMapping: mapping,
809
+ restorable,
810
+ },
811
+ };
812
+ }
813
+ /** GitHub candidate preflight:只读抓取 pinned SHA 的 package.json 解析 name(预算外 read-only 通道,不写 profile)。 */
814
+ async function candidateKeyOf(repo, sha, timeoutMs) {
815
+ const text = await fetchTextLimited(`https://raw.githubusercontent.com/${repo}/${sha}/package.json`, { timeoutMs, maxBytes: 512 * 1024 });
816
+ const doc = JSON.parse(text);
817
+ return typeof doc.name === 'string' && doc.name.trim() !== '' ? doc.name.trim() : null;
818
+ }
819
+ function errText(err) {
820
+ return err instanceof Error ? err.message : String(err);
821
+ }
822
+ /** 补偿执行 + InstallGuardError 抛出(四态 → needsRestart/restartSafe 映射;rejected 复读 profile 实况)。 */
823
+ async function throwCompensated(args) {
824
+ const { pkg, guard, deps, evidence, prior } = args;
825
+ const violations = guard.violations;
826
+ const violationText = violations.map((v) => `${v.code}: ${v.detail}`).join(';');
827
+ let comp;
828
+ const priorForTx = prior.kind === 'mappingOnly'
829
+ ? { kind: 'mappingOnly', patchMapping: prior.patchMapping }
830
+ : prior.kind === 'dependency'
831
+ ? { kind: 'dependency', state: prior.state }
832
+ : { kind: 'none' };
833
+ try {
834
+ comp = await runProfileTransaction({ kind: 'compensate-install', pkg, evidence, prior: priorForTx }, deps);
835
+ }
836
+ catch (err) {
837
+ throw new InstallGuardError({
838
+ kind: 'manual_required',
839
+ message: `安装违例(${violationText}),补偿事务执行异常:${err instanceof Error ? err.message : String(err)}——需人工处理`,
840
+ violations,
841
+ needsRestart: true,
842
+ restartSafe: false,
843
+ compensation: { status: 'manual-repair', note: errText(err) },
844
+ });
845
+ }
846
+ if (comp.ok) {
847
+ // committed(fresh/restore) + 补偿验证完整 → 可一键重启
848
+ throw new InstallGuardError({
849
+ kind: 'compensated',
850
+ message: `安装违例(${violationText}),已${prior.kind === 'dependency' ? '恢复原版本' : '自动卸载'}(补偿事务 committed)`,
851
+ violations,
852
+ needsRestart: true,
853
+ restartSafe: true,
854
+ compensation: { status: 'committed', note: comp.output },
855
+ ...(prior.kind === 'dependency' ? { repairBasis: `prior: ${prior.state.manifestSpec}(${prior.state.resolvedVersion ?? 'version 未知'})` } : {}),
856
+ });
857
+ }
858
+ const status = comp.status;
859
+ const note = comp.failure?.note ?? '';
860
+ if (status === 'rejected') {
861
+ // 复读 profile 实况决定 restartSafe(不因零写入直接 false)
862
+ let pkgStillThere = false;
863
+ try {
864
+ const now = await capturePreMutationState(deps?.profileDir ?? webProfileDir());
865
+ pkgStillThere = now.kind === 'snapshot' && pkg in now.snapshot.deps;
866
+ }
867
+ catch {
868
+ pkgStillThere = true;
869
+ }
870
+ throw new InstallGuardError({
871
+ kind: 'compensated',
872
+ message: `安装违例(${violationText}),补偿未执行(profile 状态已变化:${note})`,
873
+ violations,
874
+ needsRestart: pkgStillThere,
875
+ restartSafe: !pkgStillThere,
876
+ compensation: { status: 'rejected', note },
877
+ });
878
+ }
879
+ if (status === 'rolled-back') {
880
+ throw new InstallGuardError({
881
+ kind: 'compensated',
882
+ message: `安装违例(${violationText}),补偿未完成:已回到补偿前状态,坏包可能仍在,需人工处理(${note})`,
883
+ violations,
884
+ needsRestart: true,
885
+ restartSafe: false,
886
+ compensation: { status: 'rolled-back', note },
887
+ });
888
+ }
889
+ // manual-repair:状态未知;post-add 兜底场景附修复依据,任何端不显示「已恢复原版本/已安装成功」
890
+ throw new InstallGuardError({
891
+ kind: 'manual_required',
892
+ message: `安装违例(${violationText}),补偿进入 manual-repair:需人工处理(${note})`,
893
+ violations,
894
+ needsRestart: true,
895
+ restartSafe: false,
896
+ compensation: { status: 'manual-repair', note },
897
+ ...(prior.kind === 'dependency'
898
+ ? { repairBasis: `旧 manifestSpec=${prior.state.manifestSpec};installSpec=${prior.state.installSpec};mapping=${JSON.stringify(prior.state.patchMapping)}` }
899
+ : prior.kind === 'mappingOnly'
900
+ ? { repairBasis: `旧 mapping=${JSON.stringify(prior.patchMapping)}` }
901
+ : {}),
902
+ });
903
+ }
904
+ function guardManualRequired(message, violations) {
905
+ // 零副作用:未进 mutation → needsRestart=false、restartSafe=false
906
+ return new InstallGuardError({
907
+ kind: 'manual_required',
908
+ message,
909
+ violations,
910
+ needsRestart: false,
911
+ restartSafe: false,
912
+ compensation: { status: 'pre-mutation', note: '未开始变更,profile 零写入' },
913
+ });
914
+ }
461
915
  export async function installEntry(entry, cfg = {}, opts = {}, deps) {
916
+ return withMutationSession(() => installEntryLocked(entry, cfg, opts, deps));
917
+ }
918
+ /** installEntry 主体(session 区间内;installEntry 是唯一外部入口——upgradePlugin 复用本 helper)。 */
919
+ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
462
920
  const timeoutMs = cfg.timeoutMs ?? 20_000;
921
+ const profileDir = deps?.transaction?.profileDir ?? webProfileDir();
463
922
  const d = {
464
923
  npmLatest: deps?.npmLatest ?? defaultNpmLatest,
465
924
  npmVersion: deps?.npmVersion ?? npmVersion,
466
925
  };
467
926
  if (entry.source === 'npm' && entry.npm) {
468
927
  const pkg = entry.npm;
928
+ // 变更前全量快照(unavailable → fail-closed,零写入)
929
+ const pre = await capturePreMutationState(profileDir);
930
+ if (pre.kind === 'unavailable') {
931
+ throw new Error(`变更前状态捕获失败:${pre.reason};拒绝安装(fail-closed)`);
932
+ }
933
+ // npm key 事务前已知:prior 派生与 restorable 检查在事务前完成
934
+ const prior = derivePrior(pre.snapshot, pkg);
935
+ if (prior.kind === 'unavailable') {
936
+ throw new Error(`prior 关联歧义:${prior.reason};拒绝安装(fail-closed)`);
937
+ }
938
+ if (prior.kind === 'dependency' && !prior.state.restorable) {
939
+ throw guardManualRequired(`「${pkg}」的原安装形态(${prior.state.sourceKind === 'other' ? 'link/file 等非 registry 来源' : '缺少 integrity 的 npm 依赖'})无法自动回退,请先手动处理(卸载或修复后重试)`, [{ pkg, code: 'NO_DSH_MARKER', detail: `prior restorable:false(sourceKind=${prior.state.sourceKind})` }]);
940
+ }
469
941
  // npm:无论 latest 还是用户指定 exact,都先读取该精确版本的 dist metadata(事务外解析)
470
942
  let version;
471
943
  let expectedIntegrity;
@@ -502,6 +974,43 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
502
974
  });
503
975
  if (!result.ok)
504
976
  throw new TransactionError(result);
977
+ // 装后守卫(session 区间内;fail-open 仅限结论不可定)
978
+ const guard = await verifyInstalledAdditions({
979
+ profileDir,
980
+ addedPkgs: [result.pkg ?? pkg],
981
+ ...(prior.kind === 'dependency' ? { priorPkgs: [pkg] } : {}),
982
+ });
983
+ if (guard.unavailable.length > 0) {
984
+ return {
985
+ id: entry.id,
986
+ pkg: result.pkg ?? pkg,
987
+ spec: result.spec ?? `${pkg}@${version}`,
988
+ version: result.version ?? version,
989
+ buildApprovals: result.buildApprovals ?? [],
990
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
991
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
992
+ compat: compat ?? null,
993
+ needsRestart: true,
994
+ output: result.output,
995
+ healActions: result.healActions,
996
+ guardWarning: `装后守卫不可用(${guard.unavailable.map((u) => u.reason).slice(0, 2).join(';')}),未执行三项检查——如异常请人工核查`,
997
+ };
998
+ }
999
+ if (!guard.ok) {
1000
+ await throwCompensated({
1001
+ pkg: result.pkg ?? pkg,
1002
+ guard,
1003
+ deps: deps?.transaction,
1004
+ evidence: {
1005
+ source: 'npm',
1006
+ // manifestSpec = 安装后 manifest deps 值(pnpm add 写入裸 version);validate 对当前 manifest 实读比对
1007
+ manifestSpec: result.version ?? version,
1008
+ resolvedVersion: result.version ?? version,
1009
+ integrity: expectedIntegrity ?? '',
1010
+ },
1011
+ prior,
1012
+ });
1013
+ }
505
1014
  const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
506
1015
  return {
507
1016
  id: entry.id,
@@ -518,15 +1027,98 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
518
1027
  };
519
1028
  }
520
1029
  if (entry.github) {
521
- // 版本解析在事务外(DI 修正:githubLatestTag 此前绕过注入)
1030
+ // 变更前全量快照(fail-closed)
1031
+ const pre = await capturePreMutationState(profileDir);
1032
+ if (pre.kind === 'unavailable') {
1033
+ throw new Error(`变更前状态捕获失败:${pre.reason};拒绝安装(fail-closed)`);
1034
+ }
1035
+ // candidate preflight(M2 Task 0 gate 已证明 name→key 映射):只读抓取 pinned package.json;
1036
+ // 抓取失败/解析歧义 → fail-closed 拒绝安装(零写入);candidate 命中 link/file 或 restorable:false
1037
+ // prior → mutation 前 GUARD_MANUAL_REQUIRED(runner.add 零调用)
522
1038
  const { tag, sha } = await (deps?.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
1039
+ let candidate;
1040
+ try {
1041
+ candidate = await (deps?.candidateKey ?? candidateKeyOf)(entry.github, sha, timeoutMs);
1042
+ }
1043
+ catch (err) {
1044
+ throw new Error(`GitHub candidate key 预解析失败(${entry.github}@${sha.slice(0, 7)}):${err instanceof Error ? err.message : String(err)};拒绝安装(fail-closed)`);
1045
+ }
1046
+ if (candidate === null || !/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(candidate)) {
1047
+ throw new Error(`GitHub candidate key 不可用(${candidate ?? 'name 缺失'}):拒绝安装(fail-closed)`);
1048
+ }
1049
+ const candidatePrior = derivePrior(pre.snapshot, candidate);
1050
+ if (candidatePrior.kind === 'unavailable') {
1051
+ throw new Error(`prior 关联歧义:${candidatePrior.reason};拒绝安装(fail-closed)`);
1052
+ }
1053
+ if (candidatePrior.kind === 'dependency' && !candidatePrior.state.restorable) {
1054
+ throw guardManualRequired(`「${candidate}」的原安装形态(link/file 等非 registry 来源)无法自动回退,请先手动处理(卸载或修复后重试)`, [{ pkg: candidate, code: 'NO_DSH_MARKER', detail: 'candidate 命中 restorable:false prior(link/file)' }]);
1055
+ }
523
1056
  const result = await runProfileTransaction({ kind: 'install-github', repo: entry.github, sha, tag, signal: opts.signal }, deps?.transaction ?? {});
524
1057
  if (!result.ok)
525
1058
  throw new TransactionError(result);
1059
+ const realKey = result.pkg ?? candidate;
1060
+ // post-add derivePrior 复核(兜底网):真实 key 与 candidate 不符且发现不可自动恢复 prior
1061
+ // → manual-repair 非成功状态(不冒充 pre-mutation 拒绝、不承诺 prior 无损)
1062
+ let prior = candidatePrior;
1063
+ if (realKey !== candidate) {
1064
+ const real = derivePrior(pre.snapshot, realKey);
1065
+ if (real.kind === 'unavailable') {
1066
+ throw new Error(`prior 关联歧义(真实 key ${realKey}):${real.reason};拒绝继续(fail-closed)`);
1067
+ }
1068
+ if (real.kind === 'dependency' && !real.state.restorable) {
1069
+ throw new InstallGuardError({
1070
+ kind: 'manual_required',
1071
+ message: `安装违例后复核:真实 dependency key(${realKey})与预解析 candidate(${candidate})不符,且旧 prior 为 link/file 等不可自动恢复来源——旧 prior 可能已被覆盖,需人工恢复`,
1072
+ violations: [{ pkg: realKey, code: 'NO_DSH_MARKER', detail: 'candidate 与真实 key 不符且 prior restorable:false' }],
1073
+ needsRestart: true,
1074
+ restartSafe: false,
1075
+ compensation: { status: 'manual-repair', note: '真实 key ≠ candidate 且旧 prior 不可自动恢复' },
1076
+ repairBasis: `旧 manifestSpec=${real.state.manifestSpec};installSpec=${real.state.installSpec};mapping=${JSON.stringify(real.state.patchMapping)}`,
1077
+ });
1078
+ }
1079
+ if (real.kind !== 'none')
1080
+ prior = real;
1081
+ }
1082
+ // 装后守卫
1083
+ const guard = await verifyInstalledAdditions({
1084
+ profileDir,
1085
+ addedPkgs: [realKey],
1086
+ ...(prior.kind === 'dependency' ? { priorPkgs: [realKey] } : {}),
1087
+ });
1088
+ if (guard.unavailable.length > 0) {
1089
+ const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
1090
+ return {
1091
+ id: entry.id,
1092
+ pkg: realKey,
1093
+ spec: result.spec ?? `github:${entry.github}#${sha}`,
1094
+ sha: result.sha ?? sha,
1095
+ tag: result.tag ?? tag,
1096
+ buildApprovals: result.buildApprovals ?? [],
1097
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
1098
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
1099
+ compatSkipped: 'github-source',
1100
+ needsRestart: true,
1101
+ output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
1102
+ healActions: result.healActions,
1103
+ guardWarning: `装后守卫不可用(${guard.unavailable.map((u) => u.reason).slice(0, 2).join(';')}),未执行三项检查——如异常请人工核查`,
1104
+ };
1105
+ }
1106
+ if (!guard.ok) {
1107
+ // lock commit identity 从实际 pnpm-lock.yaml 解析(InstallResult.sha 是请求时期望值,非独立观测)
1108
+ const lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
1109
+ const lockCommit = lockResolutionOf(lockText, realKey)?.commit ?? sha;
1110
+ await throwCompensated({
1111
+ pkg: realKey,
1112
+ guard,
1113
+ deps: deps?.transaction,
1114
+ evidence: { source: 'github', pinnedSpec: `github:${entry.github}#${sha}`, sha, lockCommitIdentity: lockCommit },
1115
+ prior,
1116
+ });
1117
+ }
526
1118
  const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
527
1119
  return {
528
1120
  id: entry.id,
529
- pkg: result.pkg ?? entry.github,
1121
+ pkg: realKey,
530
1122
  spec: result.spec ?? `github:${entry.github}#${sha}`,
531
1123
  sha: result.sha ?? sha,
532
1124
  tag: result.tag ?? tag,
@@ -542,7 +1134,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
542
1134
  throw new Error(`条目 ${entry.id} 缺少可安装来源`);
543
1135
  }
544
1136
  /** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
545
- export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
1137
+ export function uninstallPlugin(pkg, cfg = {}, opts = {}, deps = {}) {
1138
+ return withMutationSession(() => uninstallPluginLocked(pkg, cfg, opts, deps));
1139
+ }
1140
+ async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
546
1141
  // 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
547
1142
  if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
548
1143
  throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
@@ -561,7 +1156,10 @@ export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
561
1156
  };
562
1157
  }
563
1158
  /** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
564
- export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
1159
+ export function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
1160
+ return withMutationSession(() => upgradePluginLocked(pkg, cfg, opts, deps));
1161
+ }
1162
+ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
565
1163
  const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
566
1164
  if (loaded.status === 'unavailable') {
567
1165
  throw new Error(`收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
@@ -576,6 +1174,31 @@ export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
576
1174
  const result = await installEntry(entry, cfg, opts, deps);
577
1175
  return { ...result, fromVersion: target.version };
578
1176
  }
1177
+ /** 自升级(0.5.0 收编:host-api 直调事务的旁路封死):npmLatest + integrity fail-closed + installEntryLocked(session/守卫经 installEntry)。 */
1178
+ export async function selfUpgrade(pkgName, currentVersion, cfg = {}, opts = {}, deps) {
1179
+ return withMutationSession(async () => {
1180
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
1181
+ const latest = await (deps?.npmLatest ?? defaultNpmLatest)(pkgName, timeoutMs, opts.signal);
1182
+ if (!latest.integrity) {
1183
+ throw new Error(`npm metadata 缺少 dist integrity:${pkgName}@${latest.version},拒绝升级`);
1184
+ }
1185
+ const result = await runProfileTransaction({ kind: 'install-npm', pkg: pkgName, version: latest.version, integrity: latest.integrity, signal: opts.signal }, { ...(deps?.transaction ?? {}), warmPackument: deps?.transaction?.warmPackument ?? makeNpmWarmPackument(timeoutMs) });
1186
+ if (!result.ok)
1187
+ throw new TransactionError(result);
1188
+ return {
1189
+ id: pkgName,
1190
+ pkg: result.pkg ?? pkgName,
1191
+ spec: result.spec ?? `${pkgName}@${latest.version}`,
1192
+ version: result.version ?? latest.version,
1193
+ buildApprovals: result.buildApprovals ?? [],
1194
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
1195
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
1196
+ needsRestart: true,
1197
+ output: result.output,
1198
+ healActions: result.healActions,
1199
+ };
1200
+ });
1201
+ }
579
1202
  /** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
580
1203
  export function leftoverCandidates(pkg) {
581
1204
  const home = dshHome();