dsh-m 0.8.5 → 0.9.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.
@@ -2,9 +2,10 @@
2
2
  * /dshm Host API dispatcher(DESIGN.md §4/§5):可注入的 method 分发 + 请求防护 +
3
3
  * HTTP 状态映射,供 host.ts 与契约测试共用。
4
4
  *
5
- * 解析顺序固定:只接受 POST → JSON Content-Type → 有上限读取并 drain body →
6
- * 顶层必须是非 null/非数组对象且有 method → `ping` 跳过 guard,否则
7
- * trustedRestartRequest host-equivalence guard → typed method/业务错误映射 → 其他 500。
5
+ * 解析顺序固定:宿主信任检查(0.9.0 起委派官方 `connection.requestRejection`,
6
+ * 全 method 含 ping 与未知 method,任何 body/side effect 之前,fail-closed)→
7
+ * 只接受 POST → JSON Content-Type → 有上限读取并 drain body →
8
+ * 顶层必须是非 null/非数组对象且有 method → typed method/业务错误映射 → 其他 500。
8
9
  */
9
10
  import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
10
11
  import { resolveDshVersion } from './dsh-version.js';
@@ -16,9 +17,11 @@ import { getCommunitySummary } from './community.js';
16
17
  import { RegistryConfigError } from './registry-controller.js';
17
18
  import { checkRegistryEntries } from './registry-check.js';
18
19
  import { CATEGORIES } from './registry.js';
19
- import { servingPort, scheduleRestart, trustedRestartRequest } from './restart.js';
20
+ import { servingPort, scheduleRestart } from './restart.js';
20
21
  import { togglePlugin, ToggleError } from './toggle.js';
21
22
  import { IncompatibleError } from './compat-check.js';
23
+ import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
24
+ import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle } from './profile-ops.js';
22
25
  export class BadJsonError extends Error {
23
26
  }
24
27
  export class BodyTooLargeError extends Error {
@@ -66,7 +69,20 @@ function readBody(req, maxBytes = BODY_MAX_BYTES) {
66
69
  });
67
70
  });
68
71
  }
69
- async function parseRequest(req) {
72
+ async function parseRequest(req, rejectRequest) {
73
+ // 宿主信任检查第一位(0.9.0 委派官方 requestRejection):先于 POST/Content-Type/body——
74
+ // 被拒请求零 body 消耗、零业务调用(报告 §3.2/§8)。返回 401/403 原样投影。
75
+ let rejection;
76
+ try {
77
+ rejection = rejectRequest(req);
78
+ }
79
+ catch {
80
+ // 宿主检查异常一律 fail-closed,异常细节不外泄(报告 §3.2)
81
+ rejection = 403;
82
+ }
83
+ if (rejection !== undefined) {
84
+ throw new ApiProtocolError(rejection, rejection === 401 ? '请求未通过宿主认证(未登录或凭据无效)' : '请求未通过宿主信任检查(不可信 Host/Origin 或跨站)');
85
+ }
70
86
  if ((req.method || 'GET').toUpperCase() !== 'POST') {
71
87
  throw new ApiProtocolError(405, '只接受 POST');
72
88
  }
@@ -91,9 +107,6 @@ async function parseRequest(req) {
91
107
  const method = typeof body.method === 'string' ? body.method.trim() : '';
92
108
  if (!method)
93
109
  throw new BadJsonError('缺少 method');
94
- if (method !== 'ping' && !trustedRestartRequest(req)) {
95
- throw new ApiProtocolError(403, '拒绝跨源请求');
96
- }
97
110
  return { method, body };
98
111
  }
99
112
  function strArg(body, key) {
@@ -139,6 +152,14 @@ function errorStatus(err) {
139
152
  }
140
153
  if (err instanceof ApiProtocolError)
141
154
  return { status: err.status, payload: { ok: false, error: err.message } };
155
+ if (err instanceof ProfileUnsupportedError) {
156
+ // 0.9.0:Desktop/未知 profile 的能力表拒绝——结构化 code + 官方入口指引
157
+ return { status: 409, payload: { ok: false, error: err.message, code: err.code, action: err.action, profile: err.profile, guidance: err.guidance } };
158
+ }
159
+ if (err instanceof DesktopOpsError) {
160
+ // 0.9.0:Desktop adapter 失败(no-manager/install-refused/enable-failed/verify-failed)
161
+ return { status: 409, payload: { ok: false, error: err.message, code: err.code } };
162
+ }
142
163
  if (err instanceof ToggleError) {
143
164
  const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
144
165
  return { status, payload: { ok: false, error: err.message, code: err.code } };
@@ -200,8 +221,14 @@ export function createApiDispatcher(ctx) {
200
221
  resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
201
222
  getService: ctx.deps?.getService,
202
223
  toggle: ctx.deps?.togglePlugin ?? togglePlugin,
224
+ desktopInstall: ctx.deps?.desktopInstall ?? desktopInstallFromRegistry,
225
+ desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
203
226
  };
204
227
  const cfg = () => ctx.controller.config;
228
+ // 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
229
+ // profile 为 host 生命周期内不可变对象(host.ts apply 期解析一次)
230
+ const rejectRequest = ctx.deps?.rejectRequest ?? (() => 403);
231
+ const profile = ctx.profile;
205
232
  // DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
206
233
  const dshVersionP = Promise.resolve()
207
234
  .then(() => d.resolveDshVersion())
@@ -210,7 +237,7 @@ export function createApiDispatcher(ctx) {
210
237
  const abort = new AbortController();
211
238
  res.once('close', () => abort.abort());
212
239
  try {
213
- const { method, body } = await parseRequest(req);
240
+ const { method, body } = await parseRequest(req, rejectRequest);
214
241
  const signal = abort.signal;
215
242
  let payload;
216
243
  switch (method) {
@@ -222,6 +249,8 @@ export function createApiDispatcher(ctx) {
222
249
  boot: BOOT_ID,
223
250
  // DSH 运行版本(头部 chip 数据源);解析失败 → undefined → JSON 序列化时字段缺席
224
251
  dshVersion: (await dshVersionP) ?? undefined,
252
+ // 0.9.0 双 profile:GUI/工具展示当前 profile 与能力边界
253
+ profile: { name: profile.name, kind: profile.kind, source: profile.source },
225
254
  };
226
255
  break;
227
256
  case 'self-check': {
@@ -238,6 +267,8 @@ export function createApiDispatcher(ctx) {
238
267
  break;
239
268
  }
240
269
  case 'self-upgrade': {
270
+ // 0.9.0:Desktop 结构化拒绝(能力表),Web 走既有路径
271
+ assertWriteAllowed(profile, 'self-upgrade');
241
272
  // M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
242
273
  const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
243
274
  payload = {
@@ -327,7 +358,8 @@ export function createApiDispatcher(ctx) {
327
358
  const target = strArg(body, 'pkg');
328
359
  if (!target)
329
360
  throw new ApiProtocolError(400, '缺少 pkg');
330
- const result = await readInstalledPluginReadme(target);
361
+ // 0.9.0 双 profile:README 只读当前 profile 的 node_modules
362
+ const result = await readInstalledPluginReadme(target, profile.dir);
331
363
  payload = { ...result };
332
364
  break;
333
365
  }
@@ -338,6 +370,18 @@ export function createApiDispatcher(ctx) {
338
370
  const id = strArg(body, 'id');
339
371
  if (!id)
340
372
  throw new ApiProtocolError(400, '缺少 id');
373
+ // 0.9.0:desktop → 官方 pluginManager 委派(新包);web → 既有事务
374
+ if (profile.kind !== 'web') {
375
+ assertWriteAllowed(profile, 'install');
376
+ payload = { ...(await d.desktopInstall(id, cfg(), {
377
+ version: typeof body.version === 'string' ? body.version : undefined,
378
+ forceIncompatible: boolArg(body.forceIncompatible),
379
+ namespace: 'host',
380
+ profile: profile.name,
381
+ signal,
382
+ })) };
383
+ break;
384
+ }
341
385
  const version = typeof body.version === 'string' ? body.version : undefined;
342
386
  const result = await d.installFromRegistry(id, cfg(), {
343
387
  version,
@@ -352,6 +396,8 @@ export function createApiDispatcher(ctx) {
352
396
  const target = strArg(body, 'pkg');
353
397
  if (!target)
354
398
  throw new ApiProtocolError(400, '缺少 pkg');
399
+ // 0.9.0:Desktop 结构化拒绝(官方入口指引)
400
+ assertWriteAllowed(profile, 'uninstall');
355
401
  const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
356
402
  payload = { ...result };
357
403
  break;
@@ -362,6 +408,12 @@ export function createApiDispatcher(ctx) {
362
408
  throw new ApiProtocolError(400, '缺少 pkg');
363
409
  if (typeof body.enabled !== 'boolean')
364
410
  throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
411
+ // 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝,禁 fallback 文件写)
412
+ if (profile.kind !== 'web') {
413
+ assertWriteAllowed(profile, 'set-enabled');
414
+ payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir })) };
415
+ break;
416
+ }
365
417
  const result = await d.toggle(target, body.enabled, { getService: d.getService });
366
418
  payload = { ...result };
367
419
  break;
@@ -370,6 +422,8 @@ export function createApiDispatcher(ctx) {
370
422
  const target = strArg(body, 'pkg');
371
423
  if (!target)
372
424
  throw new ApiProtocolError(400, '缺少 pkg');
425
+ // 0.9.0:Desktop 结构化拒绝(无官方 upgrade API,指引 Settings → Plugins)
426
+ assertWriteAllowed(profile, 'upgrade');
373
427
  const result = await d.upgradePlugin(target, cfg(), {
374
428
  forceIncompatible: boolArg(body.forceIncompatible),
375
429
  namespace: 'host',
@@ -379,6 +433,8 @@ export function createApiDispatcher(ctx) {
379
433
  break;
380
434
  }
381
435
  case 'restart': {
436
+ // 0.9.0:Desktop 不触发旧 helper(Electron 生命周期归官方;杀子进程=红线)
437
+ assertWriteAllowed(profile, 'restart');
382
438
  const result = d.scheduleRestart(servingPort(req));
383
439
  payload = { ...result };
384
440
  break;
@@ -447,11 +447,11 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
447
447
  const maxLimit = withLatest ? WITH_LATEST_MAX : METADATA_ONLY_MAX;
448
448
  const signal = opts.signal;
449
449
  const remaining = () => deadlineAt - Date.now();
450
- const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
451
- const installedTask = d.listInstalledPlugins().catch(() => null);
450
+ const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
451
+ const installedTask = d.listInstalledPlugins(opts.profileDir).catch(() => null);
452
452
  // 社区 flight 并发启动(source=primary 零调用;0.7.0 Task 7:primaryOnly 字段已删除);共享 loader 不接收调用者 deadline——
453
453
  // listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
454
- const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
454
+ const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
455
455
  let loaded;
456
456
  try {
457
457
  loaded = await deadlineRace(registryTask, remaining());
@@ -606,10 +606,10 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
606
606
  const namespace = opts.namespace ?? 'host';
607
607
  const signal = opts.signal;
608
608
  const remaining = () => deadlineAt - Date.now();
609
- const installedPromise = d.listInstalledPlugins();
610
- const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
609
+ const installedPromise = d.listInstalledPlugins(opts.profileDir);
610
+ const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
611
611
  // 社区 flight 并发启动 + request-scoped GitHub 预算(本次检查 ≤25 个 wire 请求、宿主滚动 50/h)
612
- const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
612
+ const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
613
613
  const githubBudget = createGithubRequestBudget();
614
614
  const installed = await installedPromise;
615
615
  // enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
@@ -718,8 +718,12 @@ async function probeLatest(items, ctx, d, rt) {
718
718
  * 0.7.1 修复:合并市场展示的社区条目 id 不在主清单(loadRegistry 只装载主清单,
719
719
  * 社区层只合入 listMarket 展示)——主清单 miss 时按收录 id 查社区目录再装,
720
720
  * 与 0.5.1 升级路径(findCommunityUpgradeEntry)同构。 */
721
- export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
722
- const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
721
+ /**
722
+ * 按收录 id 解析可安装条目(0.9.0 Task 5 抽出):主清单优先,社区目录兜底。
723
+ * installFromRegistry 与 Desktop adapter(profile-ops)共用同一解析与错误文案。
724
+ */
725
+ export async function resolveRegistryEntry(id, cfg = {}, opts = {}, deps) {
726
+ const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
723
727
  if (loaded.status === 'unavailable') {
724
728
  throw new Error(`收录清单不可用,无法安装 ${id};请检查 registry 配置或网络后重试`);
725
729
  }
@@ -727,6 +731,10 @@ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
727
731
  (await findCommunityInstallEntry(id, cfg, opts, deps));
728
732
  if (!entry)
729
733
  throw new Error(`registry 中没有该条目: ${id}`);
734
+ return entry;
735
+ }
736
+ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
737
+ const entry = await resolveRegistryEntry(id, cfg, opts, deps);
730
738
  return installEntry(entry, cfg, opts, deps);
731
739
  }
732
740
  // ---------- M2 Task 3:统一 mutation session + 装后守卫 + 补偿接线 ----------
@@ -1025,7 +1033,7 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
1025
1033
  * upgradePlugin 已持有 session,必须直调本函数——二次获取 session 会自死锁(session 非重入)。 */
1026
1034
  async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1027
1035
  const timeoutMs = cfg.timeoutMs ?? 20_000;
1028
- const profileDir = deps?.transaction?.profileDir ?? webProfileDir();
1036
+ const profileDir = deps?.transaction?.profileDir ?? deps?.profileDir ?? webProfileDir();
1029
1037
  const d = {
1030
1038
  npmLatest: deps?.npmLatest ?? defaultNpmLatest,
1031
1039
  npmVersion: deps?.npmVersion ?? npmVersion,
@@ -1267,11 +1275,11 @@ export function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
1267
1275
  return withMutationSession(() => upgradePluginLocked(pkg, cfg, opts, deps));
1268
1276
  }
1269
1277
  async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1270
- const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
1278
+ const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
1271
1279
  if (loaded.status === 'unavailable') {
1272
1280
  throw new Error(`收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
1273
1281
  }
1274
- const { items: installed } = await (deps?.listInstalledPlugins ?? defaultListInstalledPlugins)();
1282
+ const { items: installed } = await (deps?.listInstalledPlugins ?? defaultListInstalledPlugins)(opts.profileDir);
1275
1283
  const target = installed.find((it) => it.pkg === pkg);
1276
1284
  if (!target)
1277
1285
  throw new Error(`web profile 未安装该插件: ${pkg}`);
@@ -1293,7 +1301,7 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1293
1301
  */
1294
1302
  async function findCommunityInstallEntry(id, cfg, opts, deps) {
1295
1303
  try {
1296
- const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
1304
+ const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal, profile: opts.profile });
1297
1305
  if (!loaded.catalog)
1298
1306
  return undefined;
1299
1307
  return adaptCommunityCatalog(loaded.catalog).entries.find((e) => e.id === id);
@@ -1308,7 +1316,7 @@ async function findCommunityInstallEntry(id, cfg, opts, deps) {
1308
1316
  */
1309
1317
  async function findCommunityUpgradeEntry(target, cfg, opts, deps) {
1310
1318
  try {
1311
- const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
1319
+ const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal, profile: opts.profile });
1312
1320
  if (!loaded.catalog)
1313
1321
  return undefined;
1314
1322
  const adapted = adaptCommunityCatalog(loaded.catalog);
@@ -2,7 +2,7 @@
2
2
  * cordis 补丁层 YAML 的三个纯函数(0.4.0 开关功能,DESIGN/plan Task 2):
3
3
  *
4
4
  * 1) readBundlePatchRows —— 包内 `cordis.patch.yml`(dsh.bundle.patch 层)行枚举,
5
- * 供「单行/多行插件」粒度判定(CONTEXT.md:行覆盖 vs Bundle 选择);
5
+ * 供「单行/多行插件」粒度判定(GLOSSARY.md:行覆盖 vs Bundle 选择);
6
6
  * 2) planRowOverride —— 官方 `writePluginEnabled`(dsh-plugin-manager L881-916)的纯函数版:
7
7
  * 在 profile `cordis.patch.yml` 文本上做保注释的 `disabled` 覆盖编辑(findLast 按 id 匹配、
8
8
  * 跳过含 insert 的行、name 可选限定、已是目标态 changed:false、无匹配 append `{id, disabled}`;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ProfileOperations 接缝的 Desktop adapter(0.9.0,ADR-0005)。
3
+ *
4
+ * Desktop profile 的包操作一律委派官方 pluginManager(`ctx.get('pluginManager')`,
5
+ * 鸭子类型投影,无静态依赖):`installBundle(spec, { enabled?, approvedBuilds? })`。
6
+ * dsh-m 保留自己的收录条目解析(resolveRegistryEntry)与 peer 兼容预检
7
+ * (precheckNpmCompat,web 同语义);完整性/锁/构建脚本审批/生效相位归官方管理器。
8
+ *
9
+ * 判定纪律(报告 §5.2,dsh-market #703/#772 实证):
10
+ * - 以 `application`/`stage` 为准,绝不只看 `packageResult.exitCode`——
11
+ * `stage:'enable'` + `application:'failed'` 可跟在 exitCode 0 之后;
12
+ * - `application:'overridden'` 是「成功但被更高层 patch 覆盖」,如实标注非失败;
13
+ * - 构建脚本:`build-blocked` + `pendingBuilds` → 结构化 needsBuildApproval 结果,
14
+ * 重试把 pending 名单原样传回 `approvedBuilds`(键格式由官方管理器自己写),
15
+ * dsh-m 绝不直接编辑 desktop profile 的 pnpm-workspace.yaml,绝不全量放行;
16
+ * - 成功判定 = 官方结果无 error + `listBundles()` 复读目标 bundle 在装,二者同时满足。
17
+ *
18
+ * 禁止事项:desktop 路径零 Web fallback——不做文件级装后守卫(app.asar 探测盲区)、
19
+ * 不写 allowBuilds、不跑 dsh-m 自实现 pnpm 编排、服务缺席一律结构化拒绝。
20
+ */
21
+ import { withMutationSession, resolveRegistryEntry } from './market.js';
22
+ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
23
+ import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
24
+ import { togglePlugin, ToggleError } from './toggle.js';
25
+ export class DesktopOpsError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.name = 'DesktopOpsError';
30
+ this.code = code;
31
+ }
32
+ }
33
+ function changeOf(raw) {
34
+ if (raw && typeof raw === 'object')
35
+ return raw;
36
+ return {};
37
+ }
38
+ /**
39
+ * Desktop 安装(仅新包):收录条目解析(web 同源)→ spec 精确锁定 → compat 预检
40
+ * (npm 源;web 同语义)→ 官方 installBundle → ChangeResult 判定 → listBundles 复读。
41
+ * 全程 within mutation session(与 web 五入口同一 FIFO)。
42
+ */
43
+ export async function desktopInstallFromRegistry(id, cfg = {}, opts = {}, deps = {}) {
44
+ return withMutationSession(() => desktopInstallLocked(id, cfg, opts, deps));
45
+ }
46
+ async function desktopInstallLocked(id, cfg, opts, deps) {
47
+ const service = deps.getService?.();
48
+ if (!service || typeof service.installBundle !== 'function') {
49
+ throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 必须由官方管理器执行包操作);拒绝安装(fail-closed)');
50
+ }
51
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
52
+ const entry = await (deps.resolveEntry ?? resolveRegistryEntry)(id, cfg, opts, { loadRegistry: deps.loadRegistry });
53
+ let spec;
54
+ let pkg;
55
+ let version;
56
+ let sha;
57
+ let tag;
58
+ let compat = null;
59
+ let compatSkipped;
60
+ if (entry.source === 'npm' && entry.npm) {
61
+ pkg = entry.npm;
62
+ if (opts.version) {
63
+ version = opts.version;
64
+ }
65
+ else {
66
+ const latest = await (deps.npmLatest ?? defaultNpmLatest)(pkg, timeoutMs, opts.signal);
67
+ version = latest.version;
68
+ }
69
+ spec = `${pkg}@${version}`;
70
+ // peer 兼容预检(web 同语义):预检自身失败视为未检,不拦安装
71
+ try {
72
+ compat = await (deps.precheck ?? precheckNpmCompat)(pkg, version, { timeoutMs, signal: opts.signal });
73
+ }
74
+ catch {
75
+ compat = null;
76
+ }
77
+ if (compat !== null && opts.forceIncompatible !== true)
78
+ throw new IncompatibleError(compat);
79
+ }
80
+ else if (entry.github) {
81
+ // github 源:锁 HEAD SHA(web 同语义),不做兼容预检(明示 compatSkipped)
82
+ const resolved = await (deps.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
83
+ sha = resolved.sha;
84
+ tag = resolved.tag;
85
+ pkg = entry.github.split('/')[1] ?? entry.github;
86
+ spec = `github:${entry.github}#${sha}`;
87
+ compatSkipped = 'github-source';
88
+ }
89
+ else {
90
+ throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
91
+ }
92
+ const raw = await service.installBundle(spec, {
93
+ enabled: true,
94
+ ...(opts.approvedBuilds ? { approvedBuilds: opts.approvedBuilds } : {}),
95
+ });
96
+ const change = changeOf(raw);
97
+ if (change.application === 'cancelled') {
98
+ throw new DesktopOpsError('install-refused', `安装已被取消(${pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
99
+ }
100
+ if (change.application === 'failed' || change.error) {
101
+ const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
102
+ const buildBlocked = change.packageResult?.kind === 'build-blocked';
103
+ if (buildBlocked && pending.length > 0) {
104
+ return {
105
+ ok: false,
106
+ needsBuildApproval: true,
107
+ id: entry.id,
108
+ pkg,
109
+ spec,
110
+ pendingBuilds: pending,
111
+ message: `该插件需要执行构建脚本(${pending.join('、')})。确认后带同样的包名名单重试(approvedBuilds);dsh-m 不做全量放行(ADR-0002 同语义)`,
112
+ };
113
+ }
114
+ if (change.stage === 'enable') {
115
+ throw new DesktopOpsError('enable-failed', `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):包已写入但未能启用,请在官方 Desktop 插件页查看状态或重试`);
116
+ }
117
+ throw new DesktopOpsError('install-refused', `官方管理器安装失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
118
+ }
119
+ // 复读校验:listBundles 可用必须见目标在装;不可用(老管理器)至少要求官方宣称产出 bundle
120
+ const bundleName = change.bundle ?? pkg;
121
+ if (typeof service.listBundles === 'function') {
122
+ const bundles = await service.listBundles();
123
+ const found = (bundles ?? []).find((b) => b && b.name === bundleName);
124
+ if (!found || found.installed === false) {
125
+ throw new DesktopOpsError('verify-failed', `安装复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
126
+ }
127
+ }
128
+ else if (!change.bundle) {
129
+ throw new DesktopOpsError('verify-failed', `安装复读不可用(${pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
130
+ }
131
+ return {
132
+ id: entry.id,
133
+ pkg,
134
+ spec,
135
+ ...(version !== undefined ? { version } : {}),
136
+ ...(sha !== undefined ? { sha } : {}),
137
+ ...(tag !== undefined ? { tag } : {}),
138
+ buildApprovals: Array.isArray(change.approvedBuilds) ? change.approvedBuilds : [],
139
+ fallbackAllBuilds: false,
140
+ ...(compat !== null ? { compat } : {}),
141
+ ...(compatSkipped ? { compatSkipped } : {}),
142
+ needsRestart: true,
143
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
144
+ via: 'desktop-manager',
145
+ ...(change.application === 'overridden' ? { overridden: true } : {}),
146
+ };
147
+ }
148
+ /**
149
+ * Desktop 开关:服务缺席 → 结构化拒绝(ToggleError/unaddressable,禁 fallback 文件写);
150
+ * 服务在用 → 既有 togglePlugin 委派路径(installed 校验按 profileDir,与 web 委派同代码)。
151
+ */
152
+ export async function desktopToggle(pkg, enabled, deps = {}) {
153
+ const service = deps.getService?.();
154
+ if (!service) {
155
+ throw new ToggleError('unaddressable', '官方 pluginManager 服务不可用:desktop profile 的开关必须走官方管理器(dsh-m 不做文件级 fallback);请在官方 Desktop 插件页操作');
156
+ }
157
+ return togglePlugin(pkg, enabled, { getService: () => service, profileDir: deps.profileDir });
158
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
- import { cacheDir } from './env.js';
11
+ import { WEB_PROFILE, cacheRoot } from './env.js';
12
12
  import { commitActiveSource, loadDefaultRegistry, loadRegistry, loadRegistryCandidate, parseRegistryAddress, } from './registry.js';
13
13
  export class RegistryConfigError extends Error {
14
14
  errors;
@@ -19,10 +19,10 @@ export class RegistryConfigError extends Error {
19
19
  }
20
20
  }
21
21
  const ACCEPTED_SOURCE_FILE = 'active-source.json';
22
- /** 读取 host accepted-source metadata(损坏/缺失返回 null)。 */
23
- export async function readAcceptedSourceMetadata() {
22
+ /** 读取 host accepted-source metadata(损坏/缺失返回 null;profile 决定缓存段,0.9.0 双 profile)。 */
23
+ export async function readAcceptedSourceMetadata(profile = WEB_PROFILE) {
24
24
  try {
25
- const raw = JSON.parse(await readFile(join(cacheDir(), 'host', ACCEPTED_SOURCE_FILE), 'utf8'));
25
+ const raw = JSON.parse(await readFile(join(cacheRoot(profile), 'host', ACCEPTED_SOURCE_FILE), 'utf8'));
26
26
  if (!raw || typeof raw !== 'object')
27
27
  return null;
28
28
  if (raw.version !== 1 || raw.namespace !== 'host')
@@ -57,9 +57,11 @@ function placeholderLoaded(configuredAddress) {
57
57
  function errorMessage(err) {
58
58
  return err instanceof Error ? err.message : String(err);
59
59
  }
60
- export function createRegistryController(initial = {}) {
60
+ export function createRegistryController(initial = {}, opts = {}) {
61
61
  // config 是共享对象:host.ts 把它传给 tools/API,controller 原地更新字段实现 live 生效
62
62
  const config = { ...initial };
63
+ // profile 缓存段(0.9.0 双 profile):默认 web=旧路径,行为零漂移;desktop 落 `<root>/desktop` 段
64
+ const profile = opts.profile ?? WEB_PROFILE;
63
65
  let activeConfigAddress = trimAddress(initial.registryUrl);
64
66
  let loaded = placeholderLoaded(activeConfigAddress);
65
67
  let pendingAddress = null;
@@ -120,7 +122,7 @@ export function createRegistryController(initial = {}) {
120
122
  }
121
123
  }
122
124
  function candidateFor(rawAddress, signal) {
123
- return loadRegistryCandidate({ ...config, registryUrl: rawAddress }, { namespace: 'host', signal });
125
+ return loadRegistryCandidate({ ...config, registryUrl: rawAddress }, { namespace: 'host', profile, signal });
124
126
  }
125
127
  const bootstrap = async () => {
126
128
  await enqueue(async (gen) => {
@@ -130,7 +132,7 @@ export function createRegistryController(initial = {}) {
130
132
  // 社区字段初始值(两代 store 同型:legacy/forms 的 get() 都经 unwrapConfig 透传)
131
133
  syncCommunityFields(source);
132
134
  const raw = trimAddress(source.registryUrl);
133
- const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host' });
135
+ const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host', profile });
134
136
  if (disposed || gen !== generation)
135
137
  return;
136
138
  if (attempt.status !== 'unavailable') {
@@ -139,9 +141,9 @@ export function createRegistryController(initial = {}) {
139
141
  }
140
142
  // 持久化 custom 不可达:先试 accepted address(含其 cache),无 accepted 则默认
141
143
  configErrors = [...attempt.errors];
142
- const accepted = await readAcceptedSourceMetadata();
144
+ const accepted = await readAcceptedSourceMetadata(profile);
143
145
  const recoverAddress = accepted && accepted.configuredAddress !== raw ? accepted.configuredAddress : '';
144
- const recovered = await loadRegistry({ ...config, registryUrl: recoverAddress }, { namespace: 'host', force: true });
146
+ const recovered = await loadRegistry({ ...config, registryUrl: recoverAddress }, { namespace: 'host', force: true, profile });
145
147
  if (disposed || gen !== generation)
146
148
  return;
147
149
  if (recovered.status !== 'unavailable') {
@@ -204,7 +206,7 @@ export function createRegistryController(initial = {}) {
204
206
  config.timeoutMs = nextConfig.timeoutMs;
205
207
  config.cacheTtlMin = nextConfig.cacheTtlMin;
206
208
  try {
207
- const commit = await commitActiveSource(parseRegistryAddress(raw), 'host');
209
+ const commit = await commitActiveSource(parseRegistryAddress(raw), 'host', profile);
208
210
  if (commit.warning)
209
211
  warnings.push(commit.warning);
210
212
  }
@@ -240,7 +242,7 @@ export function createRegistryController(initial = {}) {
240
242
  await startBootstrap();
241
243
  if (opts.force) {
242
244
  await enqueue(async (gen) => {
243
- const attempt = await loadRegistry(config, { namespace: 'host', force: true, signal: opts.signal });
245
+ const attempt = await loadRegistry(config, { namespace: 'host', force: true, signal: opts.signal, profile });
244
246
  if (disposed || gen !== generation)
245
247
  return;
246
248
  loaded = attempt;
@@ -250,7 +252,7 @@ export function createRegistryController(initial = {}) {
250
252
  return snapshotInternal();
251
253
  }
252
254
  function loadDefault(opts = {}) {
253
- return loadDefaultRegistry(config, { namespace: 'host', force: opts?.force, signal: opts?.signal });
255
+ return loadDefaultRegistry(config, { namespace: 'host', force: opts?.force, signal: opts?.signal, profile });
254
256
  }
255
257
  function apply(rawAddress, opts = {}) {
256
258
  return enqueue(async (gen) => {
@@ -300,7 +302,7 @@ export function createRegistryController(initial = {}) {
300
302
  }
301
303
  adopt(gen, trimmed, candidate);
302
304
  try {
303
- const commit = await commitActiveSource(address, 'host');
305
+ const commit = await commitActiveSource(address, 'host', profile);
304
306
  if (commit.warning)
305
307
  warnings.push(commit.warning);
306
308
  }