dsh-m 0.3.0 → 0.4.1

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 精确构建放行(0.4.0,ADR-0002;plan Task 3):
3
+ * pnpm 11 拦下依赖构建脚本时会在 `pnpm-workspace.yaml` 的 `allowBuilds` 映射里留下
4
+ * 待决占位(值为 "set this to true or false")。本模块提供两个纯函数:
5
+ *
6
+ * - readPendingBuilds:解析待决名单(键不含通配符、值精确等于占位串)——官方
7
+ * `readPendingBuilds`(dsh-plugin-manager L941)同款判定;官方对坏结构 throw,
8
+ * 此处收窄为返回 [](调用方据此走全量兜底,ADR-0002 的保守回退);
9
+ * - applyPreciseBuilds:为名单逐键写 `allowBuilds.<name>: true`(保注释 YAML 编辑)。
10
+ *
11
+ * 官方 `approveBuilds` 的调用前提是「caller 持 profile manifest 锁」;dsh-m 事务
12
+ * 只有进程内 FIFO,与官方 UI 并发安装时此处存在低概率写竞态窗口——按 ADR-0002
13
+ * 声明为已知风险,相关整单失败走既有回滚语义,不额外加锁。
14
+ */
15
+ import { isAlias, isMap, isScalar, isSeq, parseDocument } from 'yaml';
16
+ /** pnpm 11 写下的待决占位值(逐字,官方同款)。 */
17
+ const PENDING_PLACEHOLDER = 'set this to true or false';
18
+ /** 键含通配符的规则不进精确放行(官方同款排除)。 */
19
+ function isPlainKey(key) {
20
+ return !/[*?]/.test(key);
21
+ }
22
+ /**
23
+ * 读 pnpm-workspace.yaml 文本的待决构建名单。
24
+ * 保守口径:坏 YAML / 根非映射 / allowBuilds 非映射 / 条目含锚点或别名 / 值非标量
25
+ * 一律返回 []——调用方走 `withDangerouslyAllowAllBuilds` 全量兜底并如实标注。
26
+ */
27
+ export function readPendingBuilds(text) {
28
+ let document;
29
+ try {
30
+ document = parseDocument(text);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ if (document.errors.length > 0)
36
+ return [];
37
+ if (!isMap(document.contents))
38
+ return [];
39
+ const builds = document.contents.get('allowBuilds');
40
+ if (builds === undefined || builds === null)
41
+ return [];
42
+ if (!isMap(builds))
43
+ return [];
44
+ const pending = [];
45
+ for (const pair of builds.items) {
46
+ const { key, value } = pair;
47
+ if (!isScalar(key) || typeof key.value !== 'string')
48
+ continue;
49
+ if (isAlias(key) || isAlias(value))
50
+ return [];
51
+ // 锚点定义同样拒绝(官方 visit 检查的保守化)
52
+ if ((isMap(value) || isSeq(value)) && 'anchor' in value && value.anchor)
53
+ return [];
54
+ if (!isScalar(value) || value.value !== PENDING_PLACEHOLDER)
55
+ continue;
56
+ if (!isPlainKey(key.value))
57
+ continue;
58
+ pending.push(key.value);
59
+ }
60
+ return pending;
61
+ }
62
+ /**
63
+ * 为名单逐键写 `allowBuilds.<name>: true`(保注释;allowBuilds 缺失时创建)。
64
+ * 坏 YAML / 根非映射 → throw(调用方按放行写入失败处理,走既有 hard-fail 转换契约)。
65
+ * names 为空返回原文。
66
+ */
67
+ export function applyPreciseBuilds(text, names) {
68
+ if (names.length === 0)
69
+ return text;
70
+ const document = parseDocument(text);
71
+ const error = document.errors[0];
72
+ if (error !== undefined)
73
+ throw error;
74
+ if (!isMap(document.contents))
75
+ throw new Error('pnpm-workspace.yaml 必须是 YAML 映射');
76
+ const existing = document.contents.get('allowBuilds');
77
+ if (existing !== undefined && existing !== null && !isMap(existing)) {
78
+ throw new Error('allowBuilds 必须是 YAML 映射');
79
+ }
80
+ for (const name of names) {
81
+ if (typeof name !== 'string' || !isPlainKey(name)) {
82
+ throw new Error(`精确放行拒绝通配/非法键: ${JSON.stringify(name)}`);
83
+ }
84
+ // setIn 自动创建缺失的中间映射(allowBuilds 缺失时一并创建)
85
+ document.setIn(['allowBuilds', name], true);
86
+ }
87
+ return String(document);
88
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * peer 兼容预检(0.4.0,plan Task 6;语义逐条对齐官方 evaluatePluginCompatibility
3
+ * ——dsh-app-boot L286-313,源码在 app-boot 而非 plugin-manager 本体):
4
+ *
5
+ * - 只检 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 键;
6
+ * - `workspace:^` / `workspace:~` / `workspace:*` 视为当前运行时版本(workspace 依赖
7
+ * 在发布时由运行时替换);
8
+ * - 空 range 不满足;semver satisfies 带 includePrerelease(rc 生态必需);
9
+ * - 运行时版本解析失败(null)→ 不拦(结果由调用方注明「未检」);
10
+ * - github 源不在此路径(无 packument),由 market 层提示 compatSkipped;
11
+ * - 无豁免机制(grilling Q6 定稿):确认/force 即通道。
12
+ */
13
+ import { satisfies } from 'semver';
14
+ import { npmVersion } from './versions.js';
15
+ import { resolveDshVersion } from './dsh-version.js';
16
+ const WORKSPACE_SPECS = ['workspace:^', 'workspace:~', 'workspace:*'];
17
+ export class IncompatibleError extends Error {
18
+ issue;
19
+ constructor(issue) {
20
+ super(`插件 ${issue.pkg}@${issue.version} 与 DSH ${issue.runtimeVersion} 不兼容(peerDependencies: ${JSON.stringify(issue.peers)})`);
21
+ this.issue = issue;
22
+ }
23
+ }
24
+ /**
25
+ * 纯函数:peers 中不满足运行时版本的 dsh 系键(空对象 = 兼容)。
26
+ * 非法 range 按 semver 抛错口径让位给 satisfies(异常上抛,调用方如实报告)。
27
+ */
28
+ export function evaluatePeers(peers, runtimeVersion) {
29
+ const failing = {};
30
+ for (const [name, range] of Object.entries(peers)) {
31
+ if (typeof range !== 'string')
32
+ continue;
33
+ if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-'))
34
+ continue;
35
+ const requirement = WORKSPACE_SPECS.includes(range) ? runtimeVersion : range;
36
+ if (requirement.trim() === '' || !satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
37
+ failing[name] = range;
38
+ }
39
+ }
40
+ return failing;
41
+ }
42
+ /**
43
+ * npm 源预检:拉该精确版本的 peerDependencies 与运行时版本比对。
44
+ * 返回 null = 兼容或无法判定(runtime 解析失败 → 不拦)。
45
+ */
46
+ export async function precheckNpmCompat(pkg, version, opts) {
47
+ const runtime = opts.runtimeVersion !== undefined ? opts.runtimeVersion : await resolveDshVersion();
48
+ if (runtime === null || runtime === '')
49
+ return null;
50
+ const fetchVersion = opts.fetchVersion ?? npmVersion;
51
+ const meta = await fetchVersion(pkg, version, opts.timeoutMs, opts.signal);
52
+ const failing = evaluatePeers(meta.peers ?? {}, runtime);
53
+ if (Object.keys(failing).length === 0)
54
+ return null;
55
+ return { pkg, version, runtimeVersion: runtime, peers: failing };
56
+ }
@@ -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,6 +174,8 @@ 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,
167
179
  };
168
180
  const cfg = () => ctx.controller.config;
169
181
  // DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
@@ -210,7 +222,8 @@ export function createApiDispatcher(ctx) {
210
222
  payload = {
211
223
  pkg: ctx.pkg.name,
212
224
  version: latest.version,
213
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
225
+ buildApprovals: result.buildApprovals ?? [],
226
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
214
227
  needsRestart: true,
215
228
  };
216
229
  break;
@@ -266,7 +279,12 @@ export function createApiDispatcher(ctx) {
266
279
  if (!id)
267
280
  throw new ApiProtocolError(400, '缺少 id');
268
281
  const version = typeof body.version === 'string' ? body.version : undefined;
269
- const result = await d.installFromRegistry(id, cfg(), { version, namespace: 'host', signal });
282
+ const result = await d.installFromRegistry(id, cfg(), {
283
+ version,
284
+ forceIncompatible: boolArg(body.forceIncompatible),
285
+ namespace: 'host',
286
+ signal,
287
+ });
270
288
  payload = { ...result };
271
289
  break;
272
290
  }
@@ -278,11 +296,25 @@ export function createApiDispatcher(ctx) {
278
296
  payload = { ...result };
279
297
  break;
280
298
  }
299
+ case 'set-enabled': {
300
+ const target = strArg(body, 'pkg');
301
+ if (!target)
302
+ throw new ApiProtocolError(400, '缺少 pkg');
303
+ if (typeof body.enabled !== 'boolean')
304
+ throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
305
+ const result = await d.toggle(target, body.enabled, { getService: d.getService });
306
+ payload = { ...result };
307
+ break;
308
+ }
281
309
  case 'upgrade': {
282
310
  const target = strArg(body, 'pkg');
283
311
  if (!target)
284
312
  throw new ApiProtocolError(400, '缺少 pkg');
285
- const result = await d.upgradePlugin(target, cfg(), { namespace: 'host', signal });
313
+ const result = await d.upgradePlugin(target, cfg(), {
314
+ forceIncompatible: boolArg(body.forceIncompatible),
315
+ namespace: 'host',
316
+ signal,
317
+ });
286
318
  payload = { ...result };
287
319
  break;
288
320
  }
@@ -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,49 @@
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
+ }
17
54
  // ---------- 通用工具 ----------
18
55
  const DEFAULT_DEADLINE_MS = 60_000;
19
56
  const WITH_LATEST_MAX = 50;
@@ -329,7 +366,19 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
329
366
  const installedPromise = d.listInstalledPlugins();
330
367
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
331
368
  const installed = await installedPromise;
332
- const items = installed.items.map((it) => ({ ...it, outdated: false }));
369
+ // enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
370
+ const compose = d.composeEnablement ?? composeEnablement;
371
+ const enablement = await compose({
372
+ items: installed.items,
373
+ loaderEntries: loaderEntriesOrNull(),
374
+ bundles: await readProfileBundles(installed.profileDir),
375
+ profileOverrides: await readProfileOverrideRows(installed.profileDir),
376
+ });
377
+ const items = installed.items.map((it) => ({
378
+ ...it,
379
+ outdated: false,
380
+ ...(enablement.get(it.pkg) ?? { pkg: it.pkg, enabled: true, phase: null, granularity: 'bundle', toggleable: false, lockReason: 'no-entry' }),
381
+ }));
333
382
  let loaded;
334
383
  try {
335
384
  loaded = await deadlineRace(registryTask, remaining());
@@ -432,6 +481,20 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
432
481
  }
433
482
  if (!expectedIntegrity)
434
483
  throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
484
+ // peer 兼容预检(Task 11,事务外 metadata 阶段;force = 用户已确认风险)。
485
+ // 预检自身的元数据读取失败(网络错/404)不拦安装——视为未检,安装事务自有失败语义。
486
+ let compat = null;
487
+ try {
488
+ compat = await (deps?.precheck ?? precheckNpmCompat)(pkg, version, {
489
+ timeoutMs,
490
+ signal: opts.signal,
491
+ });
492
+ }
493
+ catch {
494
+ compat = null;
495
+ }
496
+ if (compat !== null && opts.forceIncompatible !== true)
497
+ throw new IncompatibleError(compat);
435
498
  // 生产预热绑定:未注入时 B3 用 makeNpmWarmPackument(保留 timeout/signal、失败吞错)
436
499
  const result = await runProfileTransaction({ kind: 'install-npm', pkg, version, integrity: expectedIntegrity, signal: opts.signal }, {
437
500
  ...deps?.transaction,
@@ -445,7 +508,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
445
508
  pkg: result.pkg ?? pkg,
446
509
  spec: result.spec ?? `${pkg}@${version}`,
447
510
  version: result.version ?? version,
448
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
511
+ buildApprovals: result.buildApprovals ?? [],
512
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
513
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
514
+ compat: compat ?? null,
449
515
  needsRestart: true,
450
516
  output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
451
517
  healActions: result.healActions,
@@ -464,7 +530,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
464
530
  spec: result.spec ?? `github:${entry.github}#${sha}`,
465
531
  sha: result.sha ?? sha,
466
532
  tag: result.tag ?? tag,
467
- usedAllowAllBuilds: result.usedAllowAllBuilds === true,
533
+ buildApprovals: result.buildApprovals ?? [],
534
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
535
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
536
+ compatSkipped: 'github-source',
468
537
  needsRestart: true,
469
538
  output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
470
539
  healActions: result.healActions,
@@ -474,6 +543,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
474
543
  }
475
544
  /** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
476
545
  export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
546
+ // 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
547
+ if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
548
+ throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
549
+ }
477
550
  const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
478
551
  if (!result.ok)
479
552
  throw new TransactionError(result);