dsh-m 0.3.0 → 0.4.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,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
12
  import { createProgressTracker } from './progress.js';
13
+ import { applyPreciseBuilds, readPendingBuilds } from './build-approval.js';
13
14
  /** pnpm patchedDependencies 条目键与目标包匹配:`pkg` 或 `pkg@任意版本/区间`。 */
14
15
  function matchesPatchedKey(key, pkg) {
15
16
  return key === pkg || key.startsWith(`${pkg}@`);
@@ -297,7 +298,7 @@ export function withDangerouslyAllowAllBuilds(yaml) {
297
298
  return 'dangerouslyAllowAllBuilds: true\n';
298
299
  return `${yaml.replace(/\s*$/u, '\n')}\ndangerouslyAllowAllBuilds: true\n`;
299
300
  }
300
- /** 基线 §17.4:放行构建脚本前必须能被明确报告(返回值带 usedAllowAllBuilds)。 */
301
+ /** 基线 §17.4:放行构建脚本前必须能被明确报告(结果带 buildApprovals)。 */
301
302
  function writeDangerouslyAllowAllBuilds(profileDirectory) {
302
303
  const file = join(profileDirectory, 'pnpm-workspace.yaml');
303
304
  let yaml = '';
@@ -314,6 +315,31 @@ function writeDangerouslyAllowAllBuilds(profileDirectory) {
314
315
  writeFileSync(file, next);
315
316
  return true;
316
317
  }
318
+ /**
319
+ * 精确放行默认实现(ADR-0002):needs-builds 拦截后先读 pending 名单,
320
+ * 非空 → 逐键写 allowBuilds(approvals 携带包名);空/不可读 → 全量兜底。
321
+ * 写入失败 throw(ladder 的 Y2 契约转换为 hard-fail 结果)。
322
+ * 已知风险(不加锁):官方 approveBuilds 前提是 caller 持 manifest 锁;dsh-m 只有
323
+ * 进程内 FIFO,与官方 UI 并发安装时有低概率写竞态,相关整单失败走既有回滚语义。
324
+ */
325
+ function writePreciseBuilds(profileDirectory) {
326
+ const file = join(profileDirectory, 'pnpm-workspace.yaml');
327
+ let yaml = '';
328
+ try {
329
+ yaml = readFileSync(file, 'utf8');
330
+ }
331
+ catch {
332
+ /* 文件缺失 → pending 名单为空 → 走兜底创建 */
333
+ }
334
+ const pending = readPendingBuilds(yaml);
335
+ if (pending.length > 0) {
336
+ mkdirSync(profileDirectory, { recursive: true });
337
+ writeFileSync(file, applyPreciseBuilds(yaml, pending));
338
+ return { approvals: pending, fallbackAll: false };
339
+ }
340
+ writeDangerouslyAllowAllBuilds(profileDirectory);
341
+ return { approvals: [], fallbackAll: true };
342
+ }
317
343
  /**
318
344
  * 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
319
345
  * 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
@@ -550,13 +576,14 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
550
576
  */
551
577
  export function makeAddViaLadder(deps) {
552
578
  const run = deps.runDshPlugin;
553
- const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
579
+ const resolveBuilds = deps.resolveBuilds ?? writePreciseBuilds;
554
580
  const opts = (signal) => (signal !== undefined ? { signal } : undefined);
555
581
  return async (source, profileDir, signal) => {
556
582
  const retryAfterPrepare = async () => {
557
- // Y2(终审复审):allowAll 写入本身失败也必须转换为结果,维持「永不 throw」契约
583
+ // Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
584
+ let decision;
558
585
  try {
559
- allowAllBuilds(profileDir);
586
+ decision = resolveBuilds(profileDir);
560
587
  }
561
588
  catch (err) {
562
589
  const text = errText(err);
@@ -564,7 +591,7 @@ export function makeAddViaLadder(deps) {
564
591
  }
565
592
  try {
566
593
  const output = await run(WEB_PROFILE, ['add', source], opts(signal));
567
- return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: true };
594
+ return { class: 'ok', output: truncateOutput(output), buildApprovals: decision.approvals, fallbackAllBuilds: decision.fallbackAll };
568
595
  }
569
596
  catch (retryErr) {
570
597
  return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
@@ -572,7 +599,7 @@ export function makeAddViaLadder(deps) {
572
599
  };
573
600
  try {
574
601
  const output = await run(WEB_PROFILE, ['add', source], opts(signal));
575
- return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
602
+ return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
576
603
  }
577
604
  catch (err) {
578
605
  const text = errText(err);
@@ -586,7 +613,7 @@ export function makeAddViaLadder(deps) {
586
613
  }
587
614
  try {
588
615
  const output = await run(WEB_PROFILE, ['add', source], opts(signal));
589
- return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
616
+ return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
590
617
  }
591
618
  catch (retryErr) {
592
619
  const retryText = errText(retryErr);
@@ -0,0 +1,92 @@
1
+ import { isSingleRowPlugin } from './patch-yaml.js';
2
+ /** 官方 Cordis FiberState 数字枚举(跨包 const enum 的运行时镜像,inventory 同款)。 */
3
+ const FIBER_PHASE = {
4
+ 0: 'pending',
5
+ 1: 'loading',
6
+ 2: 'active',
7
+ 3: 'failed',
8
+ 5: 'unloading',
9
+ };
10
+ export const PROTECTED_MODULES = [
11
+ 'dsh-m',
12
+ '@deepseek-ai/dsh-plugin-manager',
13
+ '@deepseek-ai/cordis-plugin-loader',
14
+ '@deepseek-ai/cordis-plugin-include',
15
+ '@deepseek-ai/dsh-api-gateway',
16
+ '@deepseek-ai/dsh-host-webserver',
17
+ '@deepseek-ai/dsh-client-modules',
18
+ '@deepseek-ai/dsh-client-ui-settings-plugin-inventory',
19
+ '@deepseek-ai/dsh-client-ui-plugin-manager',
20
+ '@deepseek-ai/dsh-host-plugin-inventory',
21
+ '@deepseek-ai/dsh-typert-registry',
22
+ '@deepseek-ai/dsh-api-remotes',
23
+ '@deepseek-ai/cordis-plugin-timer',
24
+ '@deepseek-ai/dsh-client-connection',
25
+ '@deepseek-ai/dsh-host-frontend-static',
26
+ '@deepseek-ai/dsh-tools',
27
+ '@deepseek-ai/dsh-hmr',
28
+ ];
29
+ function mapPhase(fiber) {
30
+ if (fiber === undefined || fiber === null)
31
+ return null;
32
+ const state = typeof fiber.state === 'string' ? Number(fiber.state) : fiber.state;
33
+ if (typeof state !== 'number' || !Number.isInteger(state))
34
+ return null;
35
+ return FIBER_PHASE[state] ?? null;
36
+ }
37
+ /** 按模块名在 loader 条目中找插件的第一条非 group 条目(官方 moduleName 匹配同款)。 */
38
+ function findLoaderEntry(entries, pkg) {
39
+ for (const entry of entries) {
40
+ if (entry && entry.options?.name === pkg)
41
+ return entry;
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * 已装清单 → 每包 { enabled, phase, granularity, toggleable, lockReason }。
47
+ * toggleable=false 当且仅当:命中保护名单(protected/self)或「不在 bundles 且
48
+ * loader 无 entry」(no-entry——未装载不是被关)。粒度由包内补丁层行枚举决定。
49
+ */
50
+ export function composeEnablement(input) {
51
+ const { items, loaderEntries, bundles, profileOverrides } = input;
52
+ const result = new Map();
53
+ const overrideIndex = new Map(profileOverrides.map((row) => [row.id, row.disabled]));
54
+ for (const item of items) {
55
+ const pkg = item.pkg;
56
+ const entry = loaderEntries !== null ? findLoaderEntry(loaderEntries, pkg) : null;
57
+ const inBundles = bundles.includes(pkg);
58
+ const rowIds = item.patchRows?.inserts.map((row) => row.id) ?? [];
59
+ let enabled;
60
+ let phase = null;
61
+ if (loaderEntries !== null) {
62
+ // loader 是活体权威:无 entry = 未运行(bundle 未选 / 树缺条目),enabled:false
63
+ enabled = entry !== null && !entry.disabled;
64
+ if (entry !== null)
65
+ phase = mapPhase(entry.fiber);
66
+ }
67
+ else {
68
+ // CLI 无 loader 形态:在 bundles 且其补丁行无 disabled 覆盖 → 视为启用
69
+ const disabledByOverride = rowIds.some((id) => overrideIndex.get(id) === true);
70
+ enabled = inBundles && !disabledByOverride;
71
+ }
72
+ let lockReason;
73
+ // 有补丁层却不在 bundles = Bundle 退选(dsh-m / 官方插件页可再开),不是纯依赖
74
+ const hasPatchLayer = Boolean(item.patchRows?.readable && item.patchRows.inserts.length);
75
+ if (pkg === 'dsh-m')
76
+ lockReason = 'self';
77
+ else if (PROTECTED_MODULES.includes(pkg))
78
+ lockReason = 'protected';
79
+ else if (entry === null && !inBundles && !hasPatchLayer)
80
+ lockReason = 'no-entry';
81
+ const granularity = item.patchRows && isSingleRowPlugin(item.patchRows) ? 'row' : 'bundle';
82
+ result.set(pkg, {
83
+ pkg,
84
+ enabled,
85
+ phase,
86
+ granularity,
87
+ toggleable: lockReason === undefined,
88
+ ...(lockReason === undefined ? {} : { lockReason }),
89
+ });
90
+ }
91
+ return result;
92
+ }
@@ -16,6 +16,8 @@ import { RegistryConfigError } from './registry-controller.js';
16
16
  import { checkRegistryEntries } from './registry-check.js';
17
17
  import { CATEGORIES } from './registry.js';
18
18
  import { servingPort, scheduleRestart, trustedRestartRequest } from './restart.js';
19
+ import { togglePlugin, ToggleError } from './toggle.js';
20
+ import { IncompatibleError } from './compat-check.js';
19
21
  export class BadJsonError extends Error {
20
22
  }
21
23
  export class BodyTooLargeError extends Error {
@@ -131,6 +133,14 @@ function errorStatus(err) {
131
133
  }
132
134
  if (err instanceof ApiProtocolError)
133
135
  return { status: err.status, payload: { ok: false, error: err.message } };
136
+ if (err instanceof ToggleError) {
137
+ const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
138
+ return { status, payload: { ok: false, error: err.message, code: err.code } };
139
+ }
140
+ if (err instanceof IncompatibleError) {
141
+ // 结构化 issue:GUI 据此弹「仍要安装」确认(forceIncompatible 重试)
142
+ return { status: 409, payload: { ok: false, error: err.message, issue: err.issue } };
143
+ }
134
144
  if (err instanceof TransactionError) {
135
145
  // detail 白名单投影:结构化事实,不含 raw output(GUI 只读 error,零改动)
136
146
  const r = err.result;
@@ -164,7 +174,15 @@ export function createApiDispatcher(ctx) {
164
174
  runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
165
175
  scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
166
176
  resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
177
+ getService: ctx.deps?.getService,
178
+ toggle: ctx.deps?.togglePlugin ?? togglePlugin,
179
+ probe: ctx.deps?.probe,
180
+ probeSnapshot: ctx.deps?.probeSnapshot,
181
+ probeConfigGet: ctx.deps?.probeConfigGet,
182
+ probeConfigSet: ctx.deps?.probeConfigSet,
167
183
  };
184
+ // 元数据源探测注入(Task 8/14):市场/安装/升级/已装的元数据读取共用
185
+ const probeDeps = d.probe !== undefined ? { probe: d.probe } : {};
168
186
  const cfg = () => ctx.controller.config;
169
187
  // DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
170
188
  const dshVersionP = Promise.resolve()
@@ -210,7 +228,8 @@ export function createApiDispatcher(ctx) {
210
228
  payload = {
211
229
  pkg: ctx.pkg.name,
212
230
  version: latest.version,
213
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
231
+ buildApprovals: result.buildApprovals ?? [],
232
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
214
233
  needsRestart: true,
215
234
  };
216
235
  break;
@@ -240,13 +259,13 @@ export function createApiDispatcher(ctx) {
240
259
  withLatest: true,
241
260
  namespace: 'host',
242
261
  signal,
243
- });
262
+ }, probeDeps);
244
263
  payload = { ...result };
245
264
  break;
246
265
  }
247
266
  case 'installed': {
248
267
  await ctx.controller.ensureReady();
249
- const result = await d.listInstalledWithMeta(cfg(), { namespace: 'host', signal });
268
+ const result = await d.listInstalledWithMeta(cfg(), { namespace: 'host', signal }, probeDeps);
250
269
  payload = { ...result };
251
270
  break;
252
271
  }
@@ -259,14 +278,49 @@ export function createApiDispatcher(ctx) {
259
278
  break;
260
279
  }
261
280
  case 'status':
262
- payload = { ...publicInstallStatus() };
281
+ payload = { ...publicInstallStatus(), probe: d.probeSnapshot?.() ?? null };
282
+ break;
283
+ case 'probe-config': {
284
+ if (!d.probeConfigGet)
285
+ throw new ApiProtocolError(501, 'probe 配置读取不可用');
286
+ payload = { ...d.probeConfigGet(), writable: typeof d.probeConfigSet === 'function' };
263
287
  break;
288
+ }
289
+ case 'probe-config-set': {
290
+ if (!d.probeConfigGet || !d.probeConfigSet)
291
+ throw new ApiProtocolError(501, 'probe 配置写入不可用(设置服务缺席)');
292
+ const patch = {};
293
+ if (body.probeEnabled !== undefined)
294
+ patch.probeEnabled = boolArg(body.probeEnabled);
295
+ if (body.probeTimeoutMs !== undefined) {
296
+ const n = Number(body.probeTimeoutMs);
297
+ if (!Number.isFinite(n) || n < 100 || n > 30_000)
298
+ throw new ApiProtocolError(400, 'probeTimeoutMs 须在 100..30000 之间');
299
+ patch.probeTimeoutMs = Math.floor(n);
300
+ }
301
+ if (body.probeCacheTtlMin !== undefined) {
302
+ const n = Number(body.probeCacheTtlMin);
303
+ if (!Number.isFinite(n) || n < 0 || n > 1440)
304
+ throw new ApiProtocolError(400, 'probeCacheTtlMin 须在 0..1440 之间');
305
+ patch.probeCacheTtlMin = Math.floor(n);
306
+ }
307
+ if (Object.keys(patch).length === 0)
308
+ throw new ApiProtocolError(400, '缺少可更新字段');
309
+ await d.probeConfigSet(patch);
310
+ payload = { ...d.probeConfigGet(), writable: true };
311
+ break;
312
+ }
264
313
  case 'install': {
265
314
  const id = strArg(body, 'id');
266
315
  if (!id)
267
316
  throw new ApiProtocolError(400, '缺少 id');
268
317
  const version = typeof body.version === 'string' ? body.version : undefined;
269
- const result = await d.installFromRegistry(id, cfg(), { version, namespace: 'host', signal });
318
+ const result = await d.installFromRegistry(id, cfg(), {
319
+ version,
320
+ forceIncompatible: boolArg(body.forceIncompatible),
321
+ namespace: 'host',
322
+ signal,
323
+ }, probeDeps);
270
324
  payload = { ...result };
271
325
  break;
272
326
  }
@@ -278,11 +332,25 @@ export function createApiDispatcher(ctx) {
278
332
  payload = { ...result };
279
333
  break;
280
334
  }
335
+ case 'set-enabled': {
336
+ const target = strArg(body, 'pkg');
337
+ if (!target)
338
+ throw new ApiProtocolError(400, '缺少 pkg');
339
+ if (typeof body.enabled !== 'boolean')
340
+ throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
341
+ const result = await d.toggle(target, body.enabled, { getService: d.getService });
342
+ payload = { ...result };
343
+ break;
344
+ }
281
345
  case 'upgrade': {
282
346
  const target = strArg(body, 'pkg');
283
347
  if (!target)
284
348
  throw new ApiProtocolError(400, '缺少 pkg');
285
- const result = await d.upgradePlugin(target, cfg(), { namespace: 'host', signal });
349
+ const result = await d.upgradePlugin(target, cfg(), {
350
+ forceIncompatible: boolArg(body.forceIncompatible),
351
+ namespace: 'host',
352
+ signal,
353
+ }, probeDeps);
286
354
  payload = { ...result };
287
355
  break;
288
356
  }
@@ -12,6 +12,7 @@
12
12
  import { open, readFile } from 'node:fs/promises';
13
13
  import { join, resolve } from 'node:path';
14
14
  import { webProfileDir } from './env.js';
15
+ import { readBundlePatchRows } from './patch-yaml.js';
15
16
  const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
16
17
  export function isSafePkgName(raw) {
17
18
  const name = String(raw || '').trim();
@@ -172,6 +173,11 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
172
173
  continue;
173
174
  }
174
175
  const info = sanitizePkgJson(raw, pkg);
176
+ // 包内补丁层行枚举(开关粒度判定原料):缺失 → readable:false(按多行 → Bundle 级处理)
177
+ const patchText = await readTextLimited(join(dir, 'cordis.patch.yml'), PATCH_MAX_BYTES);
178
+ const patchRows = patchText
179
+ ? readBundlePatchRows(patchText.text)
180
+ : { inserts: [], configRows: 0, readable: false };
175
181
  items.push({
176
182
  pkg,
177
183
  name: info.name,
@@ -183,6 +189,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
183
189
  dsh: true,
184
190
  path: dir,
185
191
  githubRepo: githubRepoFromRepository(raw.repository),
192
+ patchRows,
186
193
  });
187
194
  }
188
195
  return { items, others, complete, profileDir: root };
@@ -190,6 +197,8 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
190
197
  // ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
191
198
  const README_MAX_BYTES = 64 * 1024;
192
199
  const README_FILES = ['README.md', 'README.markdown', 'README'];
200
+ /** 包内 cordis.patch.yml 读取上限(补丁层是小文件,256KB 远超正常体积)。 */
201
+ const PATCH_MAX_BYTES = 256 * 1024;
193
202
  /** 限量读取文本文件:只读前 limit 字节,超限标记 truncated。 */
194
203
  async function readTextLimited(path, limit) {
195
204
  let fh;
@@ -8,12 +8,65 @@
8
8
  * 新发布后 NO_MATCHING_VERSION 的退避重试 + packument 预热(B3)。
9
9
  */
10
10
  import { existsSync } from 'node:fs';
11
+ import { readFile } from 'node:fs/promises';
11
12
  import { join } from 'node:path';
12
13
  import { dshHome } from './env.js';
13
14
  import { runProfileTransaction, makeNpmWarmPackument, TransactionError, } from './profile-transaction.js';
14
15
  import { listInstalledPlugins as defaultListInstalledPlugins, } from './installed.js';
16
+ import { loaderHost } from './live-plugin.js';
17
+ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
18
+ import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
19
+ import { readProfileOverrides } from './patch-yaml.js';
15
20
  import { loadRegistry as defaultLoadRegistry, } from './registry.js';
16
21
  import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
22
+ /** profile package.json 的 dsh.profile.bundles 数组(读不到 → [];Task 13)。 */
23
+ async function readProfileBundles(profileDir) {
24
+ try {
25
+ const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
26
+ const bundles = raw?.dsh?.profile?.bundles;
27
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === 'string') : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ /** profile cordis.patch.yml 覆盖行(读不到 → [];Task 13)。 */
34
+ async function readProfileOverrideRows(profileDir) {
35
+ try {
36
+ return readProfileOverrides(await readFile(join(profileDir, 'cordis.patch.yml'), 'utf8'));
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ /** 宿主内 loader entries(CLI/测试进程 → null;Task 13 读路径始终自读)。 */
43
+ function loaderEntriesOrNull() {
44
+ const entries = loaderHost()?.loader?.entries;
45
+ if (typeof entries !== 'function')
46
+ return null;
47
+ try {
48
+ return [...entries.call(loaderHost().loader)];
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** 探测结果 → versions.ts 的 registry base;探测失败/禁用 → undefined(默认 npmjs)。 */
55
+ async function probeRegistryBase(probe) {
56
+ if (probe === undefined)
57
+ return undefined;
58
+ try {
59
+ const source = await probe.fastest();
60
+ if (source === 'npmjs')
61
+ return 'https://registry.npmjs.org';
62
+ if (source === 'npmmirror')
63
+ return 'https://registry.npmmirror.com';
64
+ }
65
+ catch {
66
+ /* 探测失败 → 默认源 */
67
+ }
68
+ return undefined;
69
+ }
17
70
  // ---------- 通用工具 ----------
18
71
  const DEFAULT_DEADLINE_MS = 60_000;
19
72
  const WITH_LATEST_MAX = 50;
@@ -115,9 +168,9 @@ function writeLatestCache(key, value) {
115
168
  }
116
169
  latestCache.set(key, { at: Date.now(), value });
117
170
  }
118
- function probeTask(item, deps, timeoutMs, signal) {
171
+ function probeTask(item, deps, timeoutMs, signal, registry) {
119
172
  if (item.source === 'npm' && item.npm) {
120
- return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
173
+ return deps.npmLatest(item.npm, timeoutMs, signal, registry).then((r) => ({ version: r.version }));
121
174
  }
122
175
  return deps.githubLatestTag(item.github, timeoutMs, signal).then((r) => ({ tag: r.tag, sha: r.sha }));
123
176
  }
@@ -278,9 +331,10 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
278
331
  latestTimedOut = true;
279
332
  }
280
333
  else {
334
+ const probeRegistry = await probeRegistryBase(d.probe);
281
335
  await mapWithConcurrency(todo, LATEST_WORKERS, async (item) => {
282
336
  const perBudget = Math.max(1, remaining());
283
- const task = probeTask(item, d, Math.min(cfg.timeoutMs ?? 20_000, perBudget), signal);
337
+ const task = probeTask(item, d, Math.min(cfg.timeoutMs ?? 20_000, perBudget), signal, probeRegistry);
284
338
  const outcome = await probeWithBudget(task, perBudget, signal);
285
339
  if (outcome.ok && outcome.value) {
286
340
  applyProbe(item, outcome.value);
@@ -329,7 +383,19 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
329
383
  const installedPromise = d.listInstalledPlugins();
330
384
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
331
385
  const installed = await installedPromise;
332
- const items = installed.items.map((it) => ({ ...it, outdated: false }));
386
+ // enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
387
+ const compose = d.composeEnablement ?? composeEnablement;
388
+ const enablement = await compose({
389
+ items: installed.items,
390
+ loaderEntries: loaderEntriesOrNull(),
391
+ bundles: await readProfileBundles(installed.profileDir),
392
+ profileOverrides: await readProfileOverrideRows(installed.profileDir),
393
+ });
394
+ const items = installed.items.map((it) => ({
395
+ ...it,
396
+ outdated: false,
397
+ ...(enablement.get(it.pkg) ?? { pkg: it.pkg, enabled: true, phase: null, granularity: 'bundle', toggleable: false, lockReason: 'no-entry' }),
398
+ }));
333
399
  let loaded;
334
400
  try {
335
401
  loaded = await deadlineRace(registryTask, remaining());
@@ -414,6 +480,7 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
414
480
  const d = {
415
481
  npmLatest: deps?.npmLatest ?? defaultNpmLatest,
416
482
  npmVersion: deps?.npmVersion ?? npmVersion,
483
+ registry: await probeRegistryBase(deps?.probe),
417
484
  };
418
485
  if (entry.source === 'npm' && entry.npm) {
419
486
  const pkg = entry.npm;
@@ -421,17 +488,32 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
421
488
  let version;
422
489
  let expectedIntegrity;
423
490
  if (opts.version) {
424
- const meta = await d.npmVersion(pkg, opts.version, timeoutMs, opts.signal);
491
+ const meta = await d.npmVersion(pkg, opts.version, timeoutMs, opts.signal, d.registry);
425
492
  version = meta.version;
426
493
  expectedIntegrity = meta.integrity;
427
494
  }
428
495
  else {
429
- const latest = await d.npmLatest(pkg, timeoutMs, opts.signal);
496
+ const latest = await d.npmLatest(pkg, timeoutMs, opts.signal, d.registry);
430
497
  version = latest.version;
431
498
  expectedIntegrity = latest.integrity;
432
499
  }
433
500
  if (!expectedIntegrity)
434
501
  throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
502
+ // peer 兼容预检(Task 11,事务外 metadata 阶段;force = 用户已确认风险)。
503
+ // 预检自身的元数据读取失败(网络错/404)不拦安装——视为未检,安装事务自有失败语义。
504
+ let compat = null;
505
+ try {
506
+ compat = await (deps?.precheck ?? precheckNpmCompat)(pkg, version, {
507
+ timeoutMs,
508
+ signal: opts.signal,
509
+ registry: d.registry,
510
+ });
511
+ }
512
+ catch {
513
+ compat = null;
514
+ }
515
+ if (compat !== null && opts.forceIncompatible !== true)
516
+ throw new IncompatibleError(compat);
435
517
  // 生产预热绑定:未注入时 B3 用 makeNpmWarmPackument(保留 timeout/signal、失败吞错)
436
518
  const result = await runProfileTransaction({ kind: 'install-npm', pkg, version, integrity: expectedIntegrity, signal: opts.signal }, {
437
519
  ...deps?.transaction,
@@ -445,7 +527,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
445
527
  pkg: result.pkg ?? pkg,
446
528
  spec: result.spec ?? `${pkg}@${version}`,
447
529
  version: result.version ?? version,
448
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
530
+ buildApprovals: result.buildApprovals ?? [],
531
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
532
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
533
+ compat: compat ?? null,
449
534
  needsRestart: true,
450
535
  output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
451
536
  healActions: result.healActions,
@@ -464,7 +549,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
464
549
  spec: result.spec ?? `github:${entry.github}#${sha}`,
465
550
  sha: result.sha ?? sha,
466
551
  tag: result.tag ?? tag,
467
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
552
+ buildApprovals: result.buildApprovals ?? [],
553
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
554
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
555
+ compatSkipped: 'github-source',
468
556
  needsRestart: true,
469
557
  output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
470
558
  healActions: result.healActions,
@@ -474,6 +562,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
474
562
  }
475
563
  /** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
476
564
  export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
565
+ // 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
566
+ if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
567
+ throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
568
+ }
477
569
  const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
478
570
  if (!result.ok)
479
571
  throw new TransactionError(result);