dsh-m 0.9.7 → 0.9.8
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.
- package/README.en.md +7 -0
- package/README.md +7 -0
- package/lib/core/active-profile.js +16 -2
- package/lib/core/host-api.js +43 -5
- package/lib/core/market.js +2 -1
- package/lib/core/profile-ops.js +187 -25
- package/lib/host.js +40 -3
- package/lib/tools.js +18 -5
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -48,6 +48,13 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
|
|
|
48
48
|
|
|
49
49
|
The restart chain below applies to **Web only** (Desktop takes effect via the official app lifecycle: after installing, quit and reopen the Desktop app — see the 0.9.0 section). After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The restart chain has been live-verified on the current DSH Web `0.2.0-rc.2` (2026-10-01): dsh-m probes the unit's `Restart=` policy, hands the stop back via `appExit`, the `status=75/TEMPFAIL` exit is caught by the Restart policy and auto-started, and after recovery the page reconnects in the background with the panel fully functional. Historical scope: the `0.1.5-rc.1` era covered live `/dshm` ping and authenticated `303 → 200` checks; `0.1.2-rc.1` is covered by contract checks plus runtime shape-detection fallback (see the verified array) — with the deployment moved on, no live E2E is chased on retired generations; the transient `systemd-run` fallback only applies to hosts without `appExit` (all supported generations provide it) and stays a documented fallback rather than a release gate; repeated install/uninstall is covered by the profile-transaction test surface (compensation, post-install guard, 813 cases) plus the 0.4.0→0.4.2 consecutive-release evidence. Installs stream live pnpm progress (resolve → download → link → build).
|
|
50
50
|
|
|
51
|
+
### Fixed in 0.9.8 — Desktop package operations fully wired to the official manager (install always failed; upgrade/uninstall/self-upgrade opened)
|
|
52
|
+
|
|
53
|
+
- **Why every install failed**: the Host API's desktop install branch called `desktopInstall(id, cfg, opts)` without the 4th `deps` argument — `getService` never reached the adapter, so desktop installs always failed with "official pluginManager service unavailable (fail-closed)" (100% reproducible, timing irrelevant). The `dshm_install` tool had the same defect.
|
|
54
|
+
- **Service resolution aligned with dsh-market** (borrowed from its official-desktop wiring, proven on this machine by two managed upgrades): the probe now checks both contexts (the hostCtx from the webServer inject callback first) and falls back to a short-timeout cordis `inject` to lazily wake the official service — `pluginManager` is lazy, so a one-shot `get` returns undefined until something used it. Only a genuinely absent service still refuses (the no-file-level-fallback red line is untouched).
|
|
55
|
+
- **Capability table widened (owner decision, following dsh-market strategy)**: desktop upgrade (installBundle over the installed bundle), uninstall (removeBundle) and self-upgrade (installBundle('dsh-m@latest')) are now delegated to the official manager — exactly how dshmarket performed the dsh-m 0.9.3→0.9.4/0.9.5 upgrades on this machine. Judgment discipline unchanged (application/stage first, overridden is not a failure, build-blocked returns structured pendingBuilds, listBundles re-read never fakes success). restart stays refused (Electron lifecycle belongs to the shell).
|
|
56
|
+
- All three entry points (GUI, installed-view actions, and the dshm_install / dshm_uninstall / dshm_upgrade tools) are wired in the same pass.
|
|
57
|
+
|
|
51
58
|
### Fixed in 0.9.7 — Upgrade chip on Desktop showed a red "upgrade failed" banner (capability refusal is guidance, not an error)
|
|
52
59
|
|
|
53
60
|
- **Root cause**: the 0.9.1 upgrade chip always calls `self-upgrade`; the Desktop capability table refuses by design with a structured 409, but the client rendered that 409 like any other failure — a red "upgrade failed" banner. Per the capability table this is not a failure, it's a pointer to the official entry.
|
package/README.md
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
|
|
49
49
|
以下重启链路仅适用于 **Web**(Desktop 的生效走官方应用生命周期:安装完成后退出并重新打开 Desktop 应用即可,见 0.9.0 节)。安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。重启链路已在当前 DSH Web `0.2.0-rc.2` 实机核验(2026-10-01):dsh-m 探测 unit `Restart=` 策略后经 `appExit` 交还 systemd,`status=75/TEMPFAIL` 退出由 Restart 策略接住自动拉起,服务恢复后页面后台重连、面板全功能可用。历史口径:`0.1.5-rc.1` 时代已核验 `/dshm` ping 与带认证 `303 → 200`;`0.1.2-rc.1` 按契约核对 + 形态探测兜底收录(verified 数组),部署面演进后不再追旧代 live E2E;transient `systemd-run` 兜底仅适用于无 `appExit` 的宿主(受支持代际均提供 `appExit`),保持设计兜底而非发布门槛;连续安装/卸载由 profile 事务测试面(补偿事务、装后守卫等 813 用例)与 0.4.0→0.4.2 连续发布实证覆盖。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
|
|
50
50
|
|
|
51
|
+
### 0.9.8 修复:Desktop 包操作全面接通官方管理器(安装恒 no-manager;升级/卸载/自升级开放)
|
|
52
|
+
|
|
53
|
+
- **安装恒失败的根因**:Host API 的 desktop 安装分支调 `desktopInstall(id, cfg, opts)` 漏传第 4 参 deps——`getService` 根本没进适配器,desktop 安装恒报「官方 pluginManager 服务不可用(fail-closed)」(100% 必现,与时机无关)。工具面 `dshm_install` 同病。
|
|
54
|
+
- **服务解析与 dsh-market 同源**(借鉴其 official-desktop 接线,本机两轮覆盖安装实证):探测改双上下文(webServer 注入回调的 hostCtx 优先)+ cordis inject 惰性拉起兜底(短超时)——官方 pluginManager 是惰性服务,未被拉起前一次性 get 恒 undefined;仍缺席才结构化拒绝(绝不文件级回退的红线不动)。
|
|
55
|
+
- **能力表扩充(主人裁决,借鉴 dsh-market 策略)**:desktop 的 upgrade(installBundle 覆盖安装)/ uninstall(removeBundle)/ self-upgrade(installBundle('dsh-m@latest'))全部开放——dshmarket 正是这样完成 dsh-m 0.9.3→0.9.4/0.9.5 两轮升级的;判定纪律沿用(application/stage 为准、overridden 非失败、build-blocked 结构化回传 pendingBuilds、listBundles 复读不冒充成功)。restart 继续拒绝(Electron 生命周期归官方壳)。
|
|
56
|
+
- GUI 与工具面(dshm_install / dshm_uninstall / dshm_upgrade)三入口同批接线。
|
|
57
|
+
|
|
51
58
|
### 0.9.7 修复:Desktop 下点升级角标弹红色「升级失败」(能力表拒绝应为指导而非报错)
|
|
52
59
|
|
|
53
60
|
- **根因**:0.9.1 的升级角标点击后一律调 `self-upgrade`;Desktop 能力表按设计 409 结构化拒绝,但客户端把 409 当普通失败渲染成红色「升级失败」横幅——按能力表这根本不是失败,是「该走官方入口」的指引。
|
|
@@ -8,8 +8,22 @@
|
|
|
8
8
|
* process.cwd() 或文件存在性猜 profile(报告 §4 同业旁证 #655/#744)。
|
|
9
9
|
*/
|
|
10
10
|
import { webProfileDir, WEB_PROFILE } from './env.js';
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Desktop 能力表:
|
|
13
|
+
* - 0.9.0 首发:install / set-enabled(upgrade/uninstall/self-upgrade 结构化拒绝)
|
|
14
|
+
* - 0.9.8 主人裁决(借鉴 dsh-market 官方市场同机实证:0.9.3→0.9.4/0.9.5 两轮覆盖安装、
|
|
15
|
+
* removeBundle 卸载均由官方管理器完成):upgrade(installBundle 覆盖安装)、
|
|
16
|
+
* uninstall(removeBundle)、self-upgrade(installBundle('dsh-m@latest'))一并放开;
|
|
17
|
+
* 全部走官方管理器委派,服务缺席仍结构化拒绝(fail-closed 红线不动:绝不文件级回退)。
|
|
18
|
+
* - restart 继续拒绝:Electron 进程生命周期归官方壳,dsh-m 不重启宿主。
|
|
19
|
+
*/
|
|
20
|
+
const DESKTOP_ALLOWED = new Set([
|
|
21
|
+
'install',
|
|
22
|
+
'set-enabled',
|
|
23
|
+
'upgrade',
|
|
24
|
+
'uninstall',
|
|
25
|
+
'self-upgrade',
|
|
26
|
+
]);
|
|
13
27
|
/** 结构化拒绝的官方入口指引(zh;GUI/工具/HTTP 三入口共用同一文案源)。 */
|
|
14
28
|
export const WRITE_GUIDANCE = {
|
|
15
29
|
install: '',
|
package/lib/core/host-api.js
CHANGED
|
@@ -21,7 +21,7 @@ import { servingPort, scheduleRestart } from './restart.js';
|
|
|
21
21
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
22
22
|
import { IncompatibleError } from './compat-check.js';
|
|
23
23
|
import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
|
|
24
|
-
import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle } from './profile-ops.js';
|
|
24
|
+
import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle, desktopUninstall, desktopUpgradeFromRegistry } from './profile-ops.js';
|
|
25
25
|
export class BadJsonError extends Error {
|
|
26
26
|
}
|
|
27
27
|
export class BodyTooLargeError extends Error {
|
|
@@ -223,6 +223,9 @@ export function createApiDispatcher(ctx) {
|
|
|
223
223
|
toggle: ctx.deps?.togglePlugin ?? togglePlugin,
|
|
224
224
|
desktopInstall: ctx.deps?.desktopInstall ?? desktopInstallFromRegistry,
|
|
225
225
|
desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
|
|
226
|
+
desktopUninstall: ctx.deps?.desktopUninstall ?? desktopUninstall,
|
|
227
|
+
desktopUpgrade: ctx.deps?.desktopUpgrade ?? desktopUpgradeFromRegistry,
|
|
228
|
+
ensureService: ctx.deps?.ensureService,
|
|
226
229
|
};
|
|
227
230
|
const cfg = () => ctx.controller.config;
|
|
228
231
|
// 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
|
|
@@ -267,8 +270,29 @@ export function createApiDispatcher(ctx) {
|
|
|
267
270
|
break;
|
|
268
271
|
}
|
|
269
272
|
case 'self-upgrade': {
|
|
270
|
-
// 0.9.0:Desktop 结构化拒绝(能力表),Web 走既有路径
|
|
271
273
|
assertWriteAllowed(profile, 'self-upgrade');
|
|
274
|
+
// 0.9.8:desktop → 官方管理器覆盖安装 dsh-m@latest(dsh-market 同策略实证;
|
|
275
|
+
// 生效仍需重启 Desktop——banner.desktop 指引由客户端 needsRestart 通道给出)
|
|
276
|
+
if (profile.kind !== 'web') {
|
|
277
|
+
const managed = await d.desktopInstall(ctx.pkg.name, cfg(), {
|
|
278
|
+
namespace: 'host',
|
|
279
|
+
profile: profile.name,
|
|
280
|
+
signal,
|
|
281
|
+
}, { getService: d.getService, ensureService: d.ensureService });
|
|
282
|
+
if ('needsBuildApproval' in managed) {
|
|
283
|
+
// build-blocked(自升级罕见):结构化透出,不冒充成功
|
|
284
|
+
payload = { ...managed };
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
payload = {
|
|
288
|
+
pkg: managed.pkg,
|
|
289
|
+
version: managed.version,
|
|
290
|
+
buildApprovals: managed.buildApprovals ?? [],
|
|
291
|
+
fallbackAllBuilds: false,
|
|
292
|
+
needsRestart: true,
|
|
293
|
+
};
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
272
296
|
// M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
|
|
273
297
|
const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
|
|
274
298
|
payload = {
|
|
@@ -391,7 +415,7 @@ export function createApiDispatcher(ctx) {
|
|
|
391
415
|
namespace: 'host',
|
|
392
416
|
profile: profile.name,
|
|
393
417
|
signal,
|
|
394
|
-
})) };
|
|
418
|
+
}, { getService: d.getService, ensureService: d.ensureService })) };
|
|
395
419
|
break;
|
|
396
420
|
}
|
|
397
421
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
@@ -408,8 +432,12 @@ export function createApiDispatcher(ctx) {
|
|
|
408
432
|
const target = strArg(body, 'pkg');
|
|
409
433
|
if (!target)
|
|
410
434
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
411
|
-
// 0.9.0:Desktop 结构化拒绝(官方入口指引)
|
|
412
435
|
assertWriteAllowed(profile, 'uninstall');
|
|
436
|
+
// 0.9.8:desktop → 官方管理器 removeBundle(dsh-market 同策略;此前结构化拒绝)
|
|
437
|
+
if (profile.kind !== 'web') {
|
|
438
|
+
payload = { ...(await d.desktopUninstall(target, { getService: d.getService, ensureService: d.ensureService, profileDir: profile.dir })) };
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
413
441
|
const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
|
|
414
442
|
payload = { ...result };
|
|
415
443
|
break;
|
|
@@ -434,8 +462,18 @@ export function createApiDispatcher(ctx) {
|
|
|
434
462
|
const target = strArg(body, 'pkg');
|
|
435
463
|
if (!target)
|
|
436
464
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
437
|
-
// 0.9.0:Desktop 结构化拒绝(无官方 upgrade API,指引 Settings → Plugins)
|
|
438
465
|
assertWriteAllowed(profile, 'upgrade');
|
|
466
|
+
// 0.9.8:desktop → 官方管理器覆盖安装(dsh-market 同策略;此前结构化拒绝)
|
|
467
|
+
if (profile.kind !== 'web') {
|
|
468
|
+
payload = { ...(await d.desktopUpgrade(target, cfg(), {
|
|
469
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
470
|
+
namespace: 'host',
|
|
471
|
+
profile: profile.name,
|
|
472
|
+
profileDir: profile.dir,
|
|
473
|
+
signal,
|
|
474
|
+
}, { getService: d.getService, ensureService: d.ensureService })) };
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
439
477
|
const result = await d.upgradePlugin(target, cfg(), {
|
|
440
478
|
forceIncompatible: boolArg(body.forceIncompatible),
|
|
441
479
|
namespace: 'host',
|
package/lib/core/market.js
CHANGED
|
@@ -209,7 +209,8 @@ function classifyLatestError(err) {
|
|
|
209
209
|
return 'rate-limited';
|
|
210
210
|
return 'network-error';
|
|
211
211
|
}
|
|
212
|
-
|
|
212
|
+
/** 0.9.8 导出(原模块私有):desktopUpgrade 的「已装 ↔ 收录条目」匹配与 web upgradePlugin 同语义。 */
|
|
213
|
+
export function matchInstalledByEntry(entry, installed) {
|
|
213
214
|
return installed.find((it) => {
|
|
214
215
|
if (entry.npm && it.pkg === entry.npm)
|
|
215
216
|
return true;
|
package/lib/core/profile-ops.js
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* 禁止事项:desktop 路径零 Web fallback——不做文件级装后守卫(app.asar 探测盲区)、
|
|
19
19
|
* 不写 allowBuilds、不跑 dsh-m 自实现 pnpm 编排、服务缺席一律结构化拒绝。
|
|
20
20
|
*/
|
|
21
|
-
import { withMutationSession, resolveRegistryEntry } from './market.js';
|
|
21
|
+
import { withMutationSession, resolveRegistryEntry, matchInstalledByEntry } from './market.js';
|
|
22
|
+
import { loadRegistry as defaultLoadRegistry } from './registry.js';
|
|
23
|
+
import { fetchCommunityCatalog as defaultFetchCommunityCatalog } from './community.js';
|
|
24
|
+
import { adaptCommunityCatalog } from './community-adapter.js';
|
|
25
|
+
import { listInstalledPlugins as defaultListInstalled } from './installed.js';
|
|
22
26
|
import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
23
27
|
import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
|
|
24
28
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
@@ -30,6 +34,21 @@ export class DesktopOpsError extends Error {
|
|
|
30
34
|
this.code = code;
|
|
31
35
|
}
|
|
32
36
|
}
|
|
37
|
+
/** 结构化信号:build-blocked + pendingBuilds(desktopInstall/Upgrade 共享,调用方映射各自返回形态)。 */
|
|
38
|
+
export class DesktopBuildApprovalNeeded extends Error {
|
|
39
|
+
pendingBuilds;
|
|
40
|
+
constructor(pending) {
|
|
41
|
+
super(`该插件需要执行构建脚本(${pending.join('、')})。确认后带同样的包名名单重试(approvedBuilds);dsh-m 不做全量放行(ADR-0002 同语义)`);
|
|
42
|
+
this.name = 'DesktopBuildApprovalNeeded';
|
|
43
|
+
this.pendingBuilds = pending;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function resolveManager(deps) {
|
|
47
|
+
const direct = deps.getService?.();
|
|
48
|
+
if (direct)
|
|
49
|
+
return direct;
|
|
50
|
+
return deps.ensureService ? await deps.ensureService() : undefined;
|
|
51
|
+
}
|
|
33
52
|
function changeOf(raw) {
|
|
34
53
|
if (raw && typeof raw === 'object')
|
|
35
54
|
return raw;
|
|
@@ -44,7 +63,7 @@ export async function desktopInstallFromRegistry(id, cfg = {}, opts = {}, deps =
|
|
|
44
63
|
return withMutationSession(() => desktopInstallLocked(id, cfg, opts, deps));
|
|
45
64
|
}
|
|
46
65
|
async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
47
|
-
const service = deps
|
|
66
|
+
const service = await resolveManager(deps);
|
|
48
67
|
if (!service || typeof service.installBundle !== 'function') {
|
|
49
68
|
throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 必须由官方管理器执行包操作);拒绝安装(fail-closed)');
|
|
50
69
|
}
|
|
@@ -89,9 +108,55 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
89
108
|
else {
|
|
90
109
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
|
|
91
110
|
}
|
|
111
|
+
let change;
|
|
112
|
+
let bundleName;
|
|
113
|
+
try {
|
|
114
|
+
({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
118
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
id: entry.id,
|
|
124
|
+
pkg,
|
|
125
|
+
spec,
|
|
126
|
+
...(version !== undefined ? { version } : {}),
|
|
127
|
+
...(sha !== undefined ? { sha } : {}),
|
|
128
|
+
...(tag !== undefined ? { tag } : {}),
|
|
129
|
+
buildApprovals: Array.isArray(change.approvedBuilds) ? change.approvedBuilds : [],
|
|
130
|
+
fallbackAllBuilds: false,
|
|
131
|
+
...(compat !== null ? { compat } : {}),
|
|
132
|
+
...(compatSkipped ? { compatSkipped } : {}),
|
|
133
|
+
needsRestart: true,
|
|
134
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
135
|
+
via: 'desktop-manager',
|
|
136
|
+
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Desktop 开关:服务缺席 → 结构化拒绝(ToggleError/unaddressable,禁 fallback 文件写);
|
|
141
|
+
* 服务在用 → 既有 togglePlugin 委派路径(installed 校验按 profileDir,与 web 委派同代码)。
|
|
142
|
+
*/
|
|
143
|
+
export async function desktopToggle(pkg, enabled, deps = {}) {
|
|
144
|
+
const service = await resolveManager(deps);
|
|
145
|
+
if (!service) {
|
|
146
|
+
throw new ToggleError('unaddressable', '官方 pluginManager 服务不可用:desktop profile 的开关必须走官方管理器(dsh-m 不做文件级 fallback);请在官方 Desktop 插件页操作');
|
|
147
|
+
}
|
|
148
|
+
return togglePlugin(pkg, enabled, { getService: () => service, profileDir: deps.profileDir });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 管理器安装共享核心(0.9.8):installBundle + ChangeResult 判定 + listBundles 复读。
|
|
152
|
+
* desktopInstall / desktopUpgrade 共用;build-blocked 以 DesktopBuildApprovalNeeded 抛出,
|
|
153
|
+
* 由调用方映射各自的返回形态。判定纪律沿用文件头「报告 §5.2」(dsh-market #703/#772 实证):
|
|
154
|
+
* 以 application/stage 为准,绝不只看 packageResult.exitCode;overridden 非失败。
|
|
155
|
+
*/
|
|
156
|
+
async function runManagedInstall(service, pkg, spec, approvedBuilds) {
|
|
92
157
|
const raw = await service.installBundle(spec, {
|
|
93
158
|
enabled: true,
|
|
94
|
-
...(
|
|
159
|
+
...(approvedBuilds && approvedBuilds.length > 0 ? { approvedBuilds } : {}),
|
|
95
160
|
});
|
|
96
161
|
const change = changeOf(raw);
|
|
97
162
|
if (change.application === 'cancelled') {
|
|
@@ -99,17 +164,8 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
99
164
|
}
|
|
100
165
|
if (change.application === 'failed' || change.error) {
|
|
101
166
|
const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
};
|
|
167
|
+
if (change.packageResult?.kind === 'build-blocked' && pending.length > 0) {
|
|
168
|
+
throw new DesktopBuildApprovalNeeded(pending);
|
|
113
169
|
}
|
|
114
170
|
if (change.stage === 'enable') {
|
|
115
171
|
throw new DesktopOpsError('enable-failed', `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):包已写入但未能启用,请在官方 Desktop 插件页查看状态或重试`);
|
|
@@ -128,6 +184,122 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
128
184
|
else if (!change.bundle) {
|
|
129
185
|
throw new DesktopOpsError('verify-failed', `安装复读不可用(${pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
|
|
130
186
|
}
|
|
187
|
+
return { change, bundleName };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
|
|
191
|
+
* listBundles 复读(可用时必须不见目标在装)。服务缺席 → 结构化拒绝,零 CLI、零文件级 fallback。
|
|
192
|
+
*/
|
|
193
|
+
export async function desktopUninstall(pkg, deps = {}) {
|
|
194
|
+
const service = await resolveManager(deps);
|
|
195
|
+
if (!service || typeof service.removeBundle !== 'function') {
|
|
196
|
+
throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的卸载必须由官方管理器执行);拒绝卸载(fail-closed)');
|
|
197
|
+
}
|
|
198
|
+
const raw = await service.removeBundle(pkg);
|
|
199
|
+
const change = changeOf(raw);
|
|
200
|
+
if (change.application === 'failed' || change.error) {
|
|
201
|
+
throw new DesktopOpsError('remove-failed', `官方管理器卸载失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
|
|
202
|
+
}
|
|
203
|
+
if (typeof service.listBundles === 'function') {
|
|
204
|
+
const bundles = await service.listBundles();
|
|
205
|
+
const found = (bundles ?? []).find((b) => b && b.name === (change.bundle ?? pkg));
|
|
206
|
+
if (found && found.installed === true) {
|
|
207
|
+
throw new DesktopOpsError('verify-failed', `卸载复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 仍见目标在装——不冒充卸载成功`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
pkg,
|
|
212
|
+
liveDisabled: false,
|
|
213
|
+
needsRestart: change.application === 'restart-required',
|
|
214
|
+
leftovers: [],
|
|
215
|
+
via: 'desktop-manager',
|
|
216
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Desktop 升级(0.9.8,dsh-market 同策略——本机 0.9.3→0.9.4/0.9.5 即此路径实证):
|
|
221
|
+
* 覆盖安装 = installBundle(latest 精确 spec)。收录匹配(主清单 → 社区目录)与
|
|
222
|
+
* upgradePluginLocked 同语义;非 dsh-m 收录的插件结构化拒绝(官方插件页负责)。
|
|
223
|
+
*/
|
|
224
|
+
export async function desktopUpgradeFromRegistry(pkg, cfg = {}, opts = {}, deps = {}) {
|
|
225
|
+
return withMutationSession(() => desktopUpgradeLocked(pkg, cfg, opts, deps));
|
|
226
|
+
}
|
|
227
|
+
async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
228
|
+
const service = await resolveManager(deps);
|
|
229
|
+
if (!service || typeof service.installBundle !== 'function') {
|
|
230
|
+
throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的升级必须由官方管理器执行覆盖安装);拒绝升级(fail-closed)');
|
|
231
|
+
}
|
|
232
|
+
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
233
|
+
const loaded = await (deps.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
|
|
234
|
+
if (loaded.status === 'unavailable') {
|
|
235
|
+
throw new DesktopOpsError('install-refused', `收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
|
|
236
|
+
}
|
|
237
|
+
const { items } = await (deps.listInstalled ?? defaultListInstalled)(opts.profileDir);
|
|
238
|
+
const target = items.find((it) => it.pkg === pkg);
|
|
239
|
+
if (!target)
|
|
240
|
+
throw new DesktopOpsError('install-refused', `desktop profile 未安装该插件: ${pkg}(升级目标必须是已装插件)`);
|
|
241
|
+
let entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
|
|
242
|
+
if (!entry) {
|
|
243
|
+
// 0.5.1 同语义:主清单 miss 查社区目录(合并市场安装的社区条目同样可升级)
|
|
244
|
+
try {
|
|
245
|
+
const community = await (deps.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal, profile: opts.profile });
|
|
246
|
+
entry = community.catalog
|
|
247
|
+
? adaptCommunityCatalog(community.catalog).entries.find((e) => matchInstalledByEntry(e, [target]))
|
|
248
|
+
: undefined;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
entry = undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!entry)
|
|
255
|
+
throw new DesktopOpsError('install-refused', `「${pkg}」不是经 dsh-m 收录的插件;desktop 升级请在官方 Desktop 插件页操作`);
|
|
256
|
+
let spec;
|
|
257
|
+
let version;
|
|
258
|
+
let sha;
|
|
259
|
+
let tag;
|
|
260
|
+
let compat = null;
|
|
261
|
+
let compatSkipped;
|
|
262
|
+
if (entry.source === 'npm' && entry.npm) {
|
|
263
|
+
if (opts.version) {
|
|
264
|
+
version = opts.version;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const latest = await (deps.npmLatest ?? defaultNpmLatest)(entry.npm, timeoutMs, opts.signal);
|
|
268
|
+
version = latest.version;
|
|
269
|
+
}
|
|
270
|
+
spec = `${entry.npm}@${version}`;
|
|
271
|
+
if (version !== undefined) {
|
|
272
|
+
try {
|
|
273
|
+
compat = await (deps.precheck ?? precheckNpmCompat)(entry.npm, version, { timeoutMs, signal: opts.signal });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
compat = null;
|
|
277
|
+
}
|
|
278
|
+
if (compat !== null && opts.forceIncompatible !== true)
|
|
279
|
+
throw new IncompatibleError(compat);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (entry.github) {
|
|
283
|
+
const resolved = await (deps.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
|
|
284
|
+
sha = resolved.sha;
|
|
285
|
+
tag = resolved.tag;
|
|
286
|
+
spec = `github:${entry.github}#${sha}`;
|
|
287
|
+
compatSkipped = 'github-source';
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
|
|
291
|
+
}
|
|
292
|
+
let change;
|
|
293
|
+
let bundleName;
|
|
294
|
+
try {
|
|
295
|
+
({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
299
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
131
303
|
return {
|
|
132
304
|
id: entry.id,
|
|
133
305
|
pkg,
|
|
@@ -143,16 +315,6 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
143
315
|
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
144
316
|
via: 'desktop-manager',
|
|
145
317
|
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
318
|
+
fromVersion: target.version,
|
|
146
319
|
};
|
|
147
320
|
}
|
|
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
|
-
}
|
package/lib/host.js
CHANGED
|
@@ -43,9 +43,44 @@ export function apply(ctx, config) {
|
|
|
43
43
|
// using it avoids guessing the service unit from release-specific cgroups.
|
|
44
44
|
const appExit = appExitFromContext(ctx);
|
|
45
45
|
const restart = (port = null) => scheduleRestart(port, { appExit });
|
|
46
|
-
// 开关委派的官方服务探测(ADR-0001):运行时按服务存在性探测,不判 DSH
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// 开关委派的官方服务探测(ADR-0001):运行时按服务存在性探测,不判 DSH 版本号。
|
|
47
|
+
// 0.9.8 与 dsh-market 同源修正(Windows 实机 2026-10-01):pluginManager 是惰性服务,
|
|
48
|
+
// 外层 apply ctx 的一次性 get 在本机探不到;改为双上下文探测(webServer 注入回调的
|
|
49
|
+
// hostCtx 优先——dshmarket 即此路径,两轮覆盖安装实证)+ inject 惰性拉起兜底。
|
|
50
|
+
let serviceCtx = undefined;
|
|
51
|
+
const getService = () => {
|
|
52
|
+
for (const c of [serviceCtx, ctx]) {
|
|
53
|
+
const svc = c?.get?.('pluginManager');
|
|
54
|
+
if (svc !== undefined && svc !== null)
|
|
55
|
+
return svc;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
// 惰性拉起:get 缺席时经 cordis inject 等官方服务实例化(短超时,仍缺席由调用方 fail-closed)
|
|
60
|
+
const ensureService = (timeoutMs = 5_000) => new Promise((resolve) => {
|
|
61
|
+
const immediate = getService();
|
|
62
|
+
if (immediate !== undefined) {
|
|
63
|
+
resolve(immediate);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let settled = false;
|
|
67
|
+
const finish = () => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve(getService());
|
|
73
|
+
};
|
|
74
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
75
|
+
try {
|
|
76
|
+
;
|
|
77
|
+
ctx.inject?.(['pluginManager'], () => finish());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
finish();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
registerTools(ctx, controller.config, { restart, getService, ensureService }, profile);
|
|
49
84
|
// 设置页接线(双代兼容,详见 core/settings-compat.ts):
|
|
50
85
|
// - ≤0.1.5:settings.register('dshm', …) scope(get/update/watch);
|
|
51
86
|
// - 0.1.7-rc.1 / rc.2:Config `.volatile()` 字段自动生成设置页(autoGenerate 默认开,无需 configure),
|
|
@@ -78,6 +113,7 @@ export function apply(ctx, config) {
|
|
|
78
113
|
// 服务缺席或抛错一律 fail-closed 403(warn 一次)。
|
|
79
114
|
ctx.inject(['webServer'], (c) => {
|
|
80
115
|
const server = c.webServer;
|
|
116
|
+
serviceCtx = c; // 0.9.8:webServer 注入回调的 hostCtx 是 pluginManager 的可见上下文(dsh-market 同款)
|
|
81
117
|
const rejectRequest = createRequestRejection(ctx);
|
|
82
118
|
const handleApi = createApiDispatcher({
|
|
83
119
|
controller,
|
|
@@ -86,6 +122,7 @@ export function apply(ctx, config) {
|
|
|
86
122
|
deps: {
|
|
87
123
|
scheduleRestart: restart,
|
|
88
124
|
getService,
|
|
125
|
+
ensureService,
|
|
89
126
|
rejectRequest,
|
|
90
127
|
},
|
|
91
128
|
});
|
package/lib/tools.js
CHANGED
|
@@ -5,7 +5,7 @@ import { COMMUNITY_CATEGORY_LABELS } from './core/community.js';
|
|
|
5
5
|
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
6
6
|
import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
|
|
7
7
|
import { resolveActiveProfile, assertWriteAllowed } from './core/active-profile.js';
|
|
8
|
-
import { desktopInstallFromRegistry, desktopToggle } from './core/profile-ops.js';
|
|
8
|
+
import { desktopInstallFromRegistry, desktopToggle, desktopUninstall, desktopUpgradeFromRegistry } from './core/profile-ops.js';
|
|
9
9
|
export const CATEGORY_LABELS = {
|
|
10
10
|
market: '市场',
|
|
11
11
|
tools: '工具',
|
|
@@ -67,11 +67,15 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
67
67
|
upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
|
|
68
68
|
desktopInstall: deps.desktopInstall ?? desktopInstallFromRegistry,
|
|
69
69
|
desktopToggle: deps.desktopToggle ?? desktopToggle,
|
|
70
|
+
desktopUninstall: deps.desktopUninstall ?? desktopUninstall,
|
|
71
|
+
desktopUpgrade: deps.desktopUpgrade ?? desktopUpgradeFromRegistry,
|
|
70
72
|
};
|
|
71
73
|
const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
|
|
72
74
|
const toggle = deps.togglePlugin ?? coreTogglePlugin;
|
|
73
|
-
// 开关委派服务探测(ADR-0001
|
|
74
|
-
|
|
75
|
+
// 开关委派服务探测(ADR-0001):运行时按存在性,不判版本号。
|
|
76
|
+
// 0.9.8:host.ts 共用解析器(双上下文 + 惰性拉起)优先;缺省退回外层 ctx 一次性 get(旧行为)。
|
|
77
|
+
const getService = deps.getService ?? (() => ctx.get?.('pluginManager'));
|
|
78
|
+
const ensureService = deps.ensureService;
|
|
75
79
|
ctx.tools.register(defineTool({
|
|
76
80
|
name: 'dshm_search',
|
|
77
81
|
description: 'Search the DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Dual catalog: ~22 hand-curated 精选 entries plus a 4,000+ community catalog — default searches all; pass source="community" or "primary" to zone in. Returns 10 cards by default; use offset (take nextOffset from the result) for more batches. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
|
|
@@ -250,7 +254,8 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
250
254
|
// 0.9.0:desktop → 官方 pluginManager 委派(新包);未知 profile → 结构化拒绝
|
|
251
255
|
if (profile.kind !== 'web') {
|
|
252
256
|
assertWriteAllowed(profile, 'install');
|
|
253
|
-
|
|
257
|
+
// 0.9.8:补传服务解析 deps(此前漏传 → desktop 安装恒 no-manager,实机 2026-10-01)
|
|
258
|
+
return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name }, { getService: getService, ensureService: ensureService }));
|
|
254
259
|
}
|
|
255
260
|
try {
|
|
256
261
|
return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
|
|
@@ -296,7 +301,7 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
296
301
|
// 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝);web → 既有委派/降级
|
|
297
302
|
if (profile.kind !== 'web') {
|
|
298
303
|
assertWriteAllowed(profile, 'set-enabled');
|
|
299
|
-
return cloneJson(await m.desktopToggle(target, args.enabled, { getService: getService, profileDir: profile.dir }));
|
|
304
|
+
return cloneJson(await m.desktopToggle(target, args.enabled, { getService: getService, ensureService: ensureService, profileDir: profile.dir }));
|
|
300
305
|
}
|
|
301
306
|
return cloneJson(await toggle(target, args.enabled, { getService: getService }));
|
|
302
307
|
},
|
|
@@ -324,6 +329,10 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
324
329
|
if (!target)
|
|
325
330
|
throw new Error('缺少 pkg');
|
|
326
331
|
assertWriteAllowed(profile, 'uninstall');
|
|
332
|
+
// 0.9.8:desktop → 官方管理器 removeBundle(dsh-market 同策略;此前该工具在 desktop 走 web 事务必然错位)
|
|
333
|
+
if (profile.kind !== 'web') {
|
|
334
|
+
return cloneJson(await m.desktopUninstall(target, { getService: getService, ensureService: ensureService, profileDir: profile.dir }));
|
|
335
|
+
}
|
|
327
336
|
return cloneJson(await m.uninstallPlugin(target, cfg, { namespace: 'host' }));
|
|
328
337
|
},
|
|
329
338
|
}));
|
|
@@ -407,6 +416,10 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
407
416
|
throw new Error('缺少 pkg');
|
|
408
417
|
const force = args.force === true;
|
|
409
418
|
assertWriteAllowed(profile, 'upgrade');
|
|
419
|
+
// 0.9.8:desktop → 官方管理器覆盖安装(dsh-market 同策略;此前该工具在 desktop 走 web 事务必然错位)
|
|
420
|
+
if (profile.kind !== 'web') {
|
|
421
|
+
return cloneJson(await m.desktopUpgrade(target, cfg, { forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir }, { getService: getService, ensureService: ensureService }));
|
|
422
|
+
}
|
|
410
423
|
try {
|
|
411
424
|
return cloneJson(await m.upgradePlugin(target, cfg, { forceIncompatible: force, namespace: 'host' }));
|
|
412
425
|
}
|