dsh-plugin-resolver 0.1.0 → 0.1.2

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/ABLATION.md CHANGED
@@ -7,7 +7,7 @@ node test/ablation.mjs # 人类可读报告
7
7
  node test/ablation.mjs --json # 机器可读
8
8
  ```
9
9
 
10
- 运行环境:`runtime=0.2.0-rc.2`(本仓库开发所对的宿主版本);日期 2026-10-18。网络类数字随线路波动,量级结论稳定。
10
+ 运行环境:DSH 0.2.x(本仓库开发所对的宿主版本线);日期 2026-10-18。网络类数字随线路波动,量级结论稳定。
11
11
 
12
12
  ## 汇总
13
13
 
package/BENCHMARK.md CHANGED
@@ -71,6 +71,6 @@ node test/benchmark.mjs
71
71
  ## 4. 插件不能压缩的部分(诚实边界)
72
72
 
73
73
  - **pnpm 下载与编译时间**是物理成本:镜像安装仍要 ~13 s,源码构建仍要 ~2 分钟。插件省的是决策、试错、验证与上下文,不是 CPU/带宽。
74
- - **替换已加载插件**仍要重启宿主(`restart-required`):这是 loader 模块缓存 + 打包 Electron 没有 Node 内部 loader 的硬边界,插件如实报出来,不伪装成功。
74
+ - **替换已加载插件**仍要重启宿主(`restart-required`):这是 loader 模块缓存 + 打包宿主没有 Node 内部 loader 的硬边界,插件如实报出来,不伪装成功。
75
75
  - **同名歧义**仍需要一次人类/上层的选择(`owner=`),插件故意不猜。
76
76
  - 目录是社区维护的第三方列表,插件只保证"按声明来源装对版本",不做安全审计。
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  [![test](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/test.yml/badge.svg)](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/test.yml)
7
7
  [![ablation](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/ablation.yml/badge.svg)](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/ablation.yml)
8
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-resolver?logo=npm)](https://www.npmjs.com/package/dsh-plugin-resolver)
8
9
  [![license](https://img.shields.io/github/license/sakikoTGW/dsh-plugin-resolver)](LICENSE)
9
10
  [![node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](package.json)
10
11
  [![DSH](https://img.shields.io/badge/DSH-0.2.0--rc.2%2B-4d6bfe)](https://github.com/deepseek-ai/deepseek-harness)
@@ -20,13 +21,13 @@
20
21
 
21
22
  ## 为什么要它
22
23
 
23
- **场景**:给一台 DSH `0.2.0-rc.2` 装两个真实插件——`dsh-better-sidebar`(npm 上的 latest 要求更高内核)和 `dsh-browser`(6 个同名 owner、GitHub-only、还要本地构建扩展)。当时在 agent 侧,这是这样一串手工活:
24
+ **场景**:给一台 DSH 装两个真实插件——`dsh-better-sidebar`(npm 上的 latest 不保证兼容当前内核)和 `dsh-browser`(6 个同名 owner、GitHub-only、还要本地构建扩展)。当时在 agent 侧,这是这样一串手工活:
24
25
 
25
26
  | 实际会发生的事 | 代价(真机实测) |
26
27
  |---|---|
27
28
  | 目录在 GitHub Pages 上,拉一次 6.4 MB | **91 s**,模型还得读一堆条目 |
28
29
  | 同一个名字有多个 owner(`dsh-browser` 有 6 个) | 靠猜,猜错就装错来源 |
29
- | `latest` 未必兼容当前内核(如 `0.28.0` 要 `^0.2.1-rc.1`,宿主是 `0.2.0-rc.2`) | 装上后启动预检把它静默禁用 |
30
+ | `latest` 未必兼容当前内核(peer 线或 `engines.dsh` 可能高于宿主) | 装上后启动预检把它静默禁用 |
30
31
  | 直连 npmjs 慢 | 两次 **90 s 超时**,换镜像后 13.4 s |
31
32
  | GitHub-only 仓库不提交构建产物 | `dsh plugin add github:…` 装了个 `main` 指向不存在文件的包 |
32
33
  | 装完不知道有没有生效 | 需要另找手段回读 loader entry / 前端 boot 表 |
@@ -55,12 +56,12 @@ flowchart LR
55
56
  要求 **DSH `>=0.2.0-rc.2 <0.3.0`**(已在 `engines.dsh` 声明)。纯 JS、无构建、无 install 脚本,三种方式都不需要 `allowBuilds` 授权:
56
57
 
57
58
  ```sh
58
- # npm(官方推荐,发布后)
59
- dsh plugin --profile web add dsh-plugin-resolver
59
+ # npm(已发布 0.1.2;profile 名以你的 DSH 安装为准)
60
+ dsh plugin --profile <profile> add dsh-plugin-resolver
60
61
  # GitHub 源码安装(建议锁 commit)
61
- dsh plugin --profile web add github:sakikoTGW/dsh-plugin-resolver#<commit>
62
+ dsh plugin --profile <profile> add github:sakikoTGW/dsh-plugin-resolver#<commit>
62
63
  # tarball
63
- npm pack && dsh plugin --profile web add ./dsh-plugin-resolver-0.1.0.tgz
64
+ npm pack && dsh plugin --profile <profile> add ./dsh-plugin-resolver-0.1.2.tgz
64
65
  ```
65
66
 
66
67
  装完(宿主重启后最稳)直接说:
@@ -68,7 +69,7 @@ npm pack && dsh plugin --profile web add ./dsh-plugin-resolver-0.1.0.tgz
68
69
  > "帮我装 dsh-browser" → 它会先问/列出 6 个同名候选;
69
70
  > "装 dsh-builtin-browser" → 自动选到与你内核兼容的版本并装好。
70
71
 
71
- 官方插件市场索引(需先打开实验性「自定义插件市场」,然后 `插件 → 发现 → 添加索引源`):
72
+ 插件市场(Discover)索引(在插件页打开实验性「自定义插件市场」,然后 `插件 → 发现 → 添加索引源`):
72
73
 
73
74
  ```text
74
75
  https://sakikoTGW.github.io/dsh-plugin-resolver/market/index.json
@@ -78,19 +79,18 @@ https://sakikoTGW.github.io/dsh-plugin-resolver/market/index.json
78
79
 
79
80
  ```text
80
81
  $ plugin_resolve dsh-builtin-browser
81
- target kind: npm running DSH: 0.2.0-rc.2
82
+ target kind: npm running DSH: 0.2.1-rc.2
82
83
  package: dsh-builtin-browser@0.4.13
83
84
  install spec: dsh-builtin-browser@0.4.13
84
85
  registry: https://registry.npmmirror.com/
85
86
  notes:
86
- - newest of 39 published versions whose DSH peer range accepts 0.2.0-rc.2
87
+ - newest published version whose DSH peer range accepts the running host
87
88
  ```
88
89
 
89
90
  ```text
90
91
  $ plugin_resolve dsh-better-sidebar
91
- package: dsh-better-sidebar@0.26.1
92
- skipped versions:
93
- - 0.28.0: @deepseek-ai/dsh-agent needs ^0.2.1-rc.1, host is 0.2.0-rc.2
92
+ package: dsh-better-sidebar@0.28.0
93
+ install spec: dsh-better-sidebar@0.28.0
94
94
  ```
95
95
 
96
96
  ## 四个工具
@@ -115,6 +115,20 @@ skipped versions:
115
115
  - **GitHub-only**:检出到 `$DSH_HOME/plugin-sources/<owner>__<repo>`,缺构建产物且声明了 `build` 时执行 `pnpm install` + `pnpm run build`,随后 `installBundle('link:<pkgDir>')`,已构建的检出直接复用;
116
116
  - **显式 spec**(`github:` / `link:` / `file:` / `https:`):原样交给插件管理器。
117
117
 
118
+ ## 重启判定
119
+
120
+ `plugin_install` 不再无条件要求重启;安装前后会比较:
121
+
122
+ - `pluginManager.listBundles()` 的 bundle 集合
123
+ - 已安装包运行时文件的 hash(排除 README / docs / 版本号)
124
+
125
+ 结果:
126
+
127
+ - 无变化 / 仅版本号变化 / 管理器已就地应用 → `restart: not required`
128
+ - bundle 新增、运行时代码变化、client 半变化 → `restart: required — <原因>`
129
+
130
+ 本插件自身的 `logic/` 改动可以按 mtime 热加载,不需要重启;新增 bundle 或宿主壳变化仍然必须重启。
131
+
118
132
  ## 结构:稳定壳 + 可热加载逻辑
119
133
 
120
134
  ```
@@ -128,7 +142,7 @@ logic/ 目录 / 版本 / 兼容 / 安装编排(每次调用按 mti
128
142
  - **改 `logic/` 任何文件 → 下一次工具调用立即生效,不用重启宿主**
129
143
  - 只有改动工具定义层(`index.js` / `host/register.js`)才需要重启
130
144
 
131
- 这是为打包桌面版设计的:它拿不到真 HMR 所需的 Node 内部模块加载器,但"重新 import 一个带新 query 的模块"完全可行。
145
+ 这是为无 HMR 的打包运行环境设计的:宿主拿不到真 HMR 所需的 Node 内部模块加载器,但"重新 import 一个带新 query 的模块"完全可行。
132
146
 
133
147
  ## 效果
134
148
 
@@ -150,7 +164,7 @@ node test/ablation.mjs # 消融测试(无副作用)
150
164
  ## 测试与 CI
151
165
 
152
166
  ```sh
153
- npm test # = selftest(19) + harness(10)
167
+ npm test # = selftest(25) + harness(10)
154
168
  npm run bench # 基准
155
169
  npm run ablate # 消融
156
170
  ```
@@ -163,10 +177,10 @@ npm run ablate # 消融
163
177
  ## 已知限制
164
178
 
165
179
  - 兼容性以 npm 元数据里的 peer / `engines.dsh` 为准,宿主启动预检另有判断时以宿主为准;
166
- - **替换已加载插件**仍需要重启宿主(loader 模块缓存 + 打包 Electron 无 Node 内部 loader),插件会如实报 `restart-required`;
180
+ - **替换已加载插件**仍需要重启宿主(loader 模块缓存 + 打包宿主无 Node 内部 loader);`plugin_install` 会先比较安装前后的 bundle 集合与运行时文件 hash,只有真正变化时才报 `restart-required`;
167
181
  - 同名歧义不猜,需要调用方给 `owner=` 或完整 spec;
168
182
  - 目录是社区维护的第三方列表,插件只保证"按声明来源装对版本",不做安全审计;
169
- - GitHub 源码构建依赖本机 `pnpm`(优先 `$DSH_HOME/.desktop-bin/pnpm*`)与 `tar`,大仓库首次构建较慢。
183
+ - GitHub 源码构建依赖宿主环境里的 `pnpm`(PATH 或 `DSH_PNPM_PATH`)与 `tar`,大仓库首次构建较慢。
170
184
 
171
185
  ## License
172
186
 
package/logic/api.js CHANGED
@@ -15,7 +15,7 @@
15
15
  const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
16
16
  const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
17
17
  const { baseName, loadCatalog, searchCatalog } = await import(__q('catalog.js'))
18
- const { installGithubSource, installRegistry, verifyInstall } = await import(__q('install.js'))
18
+ const { installGithubSource, installRegistry, verifyInstall, capturePackageRuntime, decideRestart } = await import(__q('install.js'))
19
19
  const { resolveTarget } = await import(__q('resolve.js'))
20
20
  const { oneLine, stars } = await import(__q('util.js'))
21
21
 
@@ -122,6 +122,16 @@ export async function pluginInstall(ctx, manager, options, args) {
122
122
  let packageName = plan.name
123
123
  let steps = []
124
124
  let application = 'unknown'
125
+ let beforeRuntime = null
126
+ let bundlesBefore = []
127
+ if (plan.kind === 'npm' && typeof plan.name === 'string' && plan.name !== '') {
128
+ beforeRuntime = await capturePackageRuntime(plan.name).catch(() => null)
129
+ }
130
+ try {
131
+ bundlesBefore = (await manager.listBundles()).map((bundle) => bundle.name)
132
+ } catch {
133
+ bundlesBefore = []
134
+ }
125
135
 
126
136
  if (plan.kind === 'github') {
127
137
  const result = await installGithubSource(manager, plan, {
@@ -140,10 +150,11 @@ export async function pluginInstall(ctx, manager, options, args) {
140
150
  if (steps.length > 0) lines.push(`steps: ${steps.join(' → ')}`)
141
151
  } else {
142
152
  const { summary } = await installRegistry(manager, plan)
143
- application = summary.application
153
+ const alreadyInstalled = /already-installed/i.test(summary.error ?? '')
154
+ application = alreadyInstalled ? 'applied' : summary.application
144
155
  if (packageName === null && summary.bundle) packageName = summary.bundle
145
- lines.push(`install: ${summary.application} (${summary.stage})`)
146
- if (summary.error) lines.push(`error: ${summary.error}`)
156
+ lines.push(alreadyInstalled ? 'install: already installed (no change)' : `install: ${summary.application} (${summary.stage})`)
157
+ if (summary.error && !alreadyInstalled) lines.push(`error: ${summary.error}`)
147
158
  if (summary.pendingBuilds.length > 0) {
148
159
  lines.push(`pending build scripts (needs approval): ${summary.pendingBuilds.join(', ')}`)
149
160
  }
@@ -163,14 +174,26 @@ export async function pluginInstall(ctx, manager, options, args) {
163
174
  lines.push(`- bundle: ${health.bundle.name}@${health.bundle.version ?? '?'} enabled=${health.bundle.enabled}`)
164
175
  }
165
176
  lines.push(`- package: ${packageName}${health.hasClient ? ` (client half via ${health.clientSource})` : ' (host-only)'}`)
166
- const next = []
167
- if (application === 'restart-required') {
168
- next.push('restart DSH fully: replacing an already-mounted plugin cannot be hot-swapped safely')
177
+ let afterRuntime = null
178
+ let bundlesAfter = []
179
+ try {
180
+ afterRuntime = await capturePackageRuntime(packageName)
181
+ bundlesAfter = (await manager.listBundles()).map((bundle) => bundle.name)
182
+ } catch {
183
+ // keep the manager's own restart decision
169
184
  }
185
+ const restart = decideRestart({ before: beforeRuntime, after: afterRuntime, bundlesBefore, bundlesAfter, managerApplication: application })
186
+ lines.push('', `restart: ${restart.required ? 'required' : 'not required'} — ${restart.reason}`)
187
+ if (application === 'restart-required' && !restart.required) {
188
+ lines.push('- note: manager requested a restart, but runtime files are unchanged')
189
+ }
190
+ if (!restart.required && restart.versionChanged) lines.push('- version label updates after the next restart')
191
+ const next = []
192
+ if (restart.required) next.push(`restart DSH fully: ${restart.reason}`)
170
193
  if (health.entry?.fiberPhase === 'failed') next.push('host half failed to load; inspect the harness log for the error')
171
- if (health.hasClient && application !== 'restart-required') next.push('client half exists: refresh the GUI (Ctrl+R) to load it')
172
- if (health.entry?.fiberPhase === 'active' && !health.hasClient && application !== 'restart-required') {
173
- next.push('host-only: no GUI refresh required')
194
+ if (restart.clientChanged) next.push('client half changed: refresh the GUI (Ctrl+R) to load it')
195
+ if (health.entry?.fiberPhase === 'active' && !health.hasClient && !restart.required && !restart.codeChanged) {
196
+ next.push('host-only: no restart or GUI refresh required')
174
197
  }
175
198
  if (next.length > 0) lines.push('', `next: ${next.join('; ')}`)
176
199
  }
package/logic/install.js CHANGED
@@ -1,8 +1,8 @@
1
- // Reloadable module: local dependencies carry this module's `?v=` query.
2
- const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
3
- const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
4
- const { dshHome, dirExists, runProcess } = await import(__q('util.js'))
5
- const { registryBase } = await import(__q('resolve.js'))
1
+ // Reloadable module: local dependencies carry this module's `?v=` query.
2
+ const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
3
+ const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
4
+ const { dshHome, dirExists, runProcess } = await import(__q('util.js'))
5
+ const { registryBase } = await import(__q('resolve.js'))
6
6
  /**
7
7
  * Installation: drive the profile's plugin manager, build GitHub-only sources,
8
8
  * and verify what actually landed.
@@ -17,6 +17,7 @@ const { registryBase } = await import(__q('resolve.js'))
17
17
 
18
18
  import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
19
19
  import { existsSync } from 'node:fs'
20
+ import { createHash } from 'node:crypto'
20
21
  import path from 'node:path'
21
22
 
22
23
  /** Longest a source install may run before the tool reports a timeout. */
@@ -95,21 +96,138 @@ export async function installRegistry(manager, plan) {
95
96
  * @returns {Promise<any | undefined>}
96
97
  */
97
98
  export async function installedManifest(packageName) {
98
- const candidates = []
99
- if (process.env.DSH_PROFILE_DIR) candidates.push(process.env.DSH_PROFILE_DIR)
100
- const home = process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : null
99
+ const packageDir = await installedPackageDir(packageName)
100
+ if (packageDir === undefined) return undefined
101
+ try {
102
+ return JSON.parse(await readFile(path.join(packageDir, 'package.json'), 'utf8'))
103
+ } catch {
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ /** Candidate profile directories, most specific first. */
109
+ function profileDirs() {
110
+ const dirs = []
111
+ if (process.env.DSH_PROFILE_DIR) dirs.push(process.env.DSH_PROFILE_DIR)
112
+ const home = process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : dshHome()
101
113
  if (home) {
102
- const profile = process.env.DSH_PROFILE && process.env.DSH_PROFILE !== '' ? process.env.DSH_PROFILE : 'web'
103
- candidates.push(path.join(home, 'profiles', profile))
114
+ const profile = process.env.DSH_PROFILE && process.env.DSH_PROFILE !== '' ? process.env.DSH_PROFILE : 'desktop'
115
+ for (const name of [profile, 'desktop', 'web']) dirs.push(path.join(home, 'profiles', name))
116
+ }
117
+ return [...new Set(dirs)]
118
+ }
119
+
120
+ /**
121
+ * Locate an installed package directory without assuming one profile layout.
122
+ * @param {string} packageName
123
+ * @returns {Promise<string | undefined>}
124
+ */
125
+ export async function installedPackageDir(packageName) {
126
+ for (const profileDir of profileDirs()) {
127
+ const candidate = path.join(profileDir, 'node_modules', packageName)
128
+ if (existsSync(path.join(candidate, 'package.json'))) return candidate
104
129
  }
105
- for (const profileDir of candidates) {
130
+ return undefined
131
+ }
132
+
133
+ function sha256(value) {
134
+ return createHash('sha256').update(value).digest('hex')
135
+ }
136
+
137
+ /** Runtime files that decide whether the loaded code actually changed. */
138
+ async function hashRuntimeFiles(dir) {
139
+ const files = []
140
+ async function walk(current) {
141
+ let entries
106
142
  try {
107
- return JSON.parse(await readFile(path.join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'))
143
+ entries = await readdir(current, { withFileTypes: true })
108
144
  } catch {
109
- // try the next candidate
145
+ return
146
+ }
147
+ for (const entry of entries) {
148
+ if (entry.name === 'node_modules' || entry.name === 'test' || entry.name === 'tests' || entry.name === 'fixtures' || entry.name === 'examples' || entry.name === '.git' || entry.name === '.github') continue
149
+ if (/^(license|notice|readme)/i.test(entry.name)) continue
150
+ const abs = path.join(current, entry.name)
151
+ if (entry.isDirectory()) {
152
+ await walk(abs)
153
+ continue
154
+ }
155
+ if (!entry.isFile()) continue
156
+ const ext = path.extname(entry.name).toLowerCase()
157
+ if (ext === '.md' || ext === '.markdown' || ext === '.map' || ext === '.d.ts' || ext === '.pdb') continue
158
+ if (entry.name === 'package.json') continue
159
+ try {
160
+ files.push(`${path.relative(dir, abs).split(path.sep).join('/')}:${sha256(await readFile(abs))}`)
161
+ } catch {
162
+ // unreadable runtime file: treating it as absent is safer than crashing
163
+ }
110
164
  }
111
165
  }
112
- return undefined
166
+ await walk(dir)
167
+ files.sort()
168
+ return files
169
+ }
170
+
171
+ /**
172
+ * Snapshot the installed package's runtime identity. README/docs and the
173
+ * version field are excluded: those changes do not alter loaded code.
174
+ * @param {string} packageName
175
+ * @returns {Promise<{version: string, hash: string, clientHash: string, hasClient: boolean, dir: string} | null>}
176
+ */
177
+ export async function capturePackageRuntime(packageName) {
178
+ const dir = await installedPackageDir(packageName)
179
+ if (dir === undefined) return null
180
+ try {
181
+ const manifest = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8'))
182
+ const files = await hashRuntimeFiles(dir)
183
+ const meta = {
184
+ main: manifest.main ?? null,
185
+ exports: manifest.exports ?? null,
186
+ dsh: manifest.dsh ?? null,
187
+ dependencies: manifest.dependencies ?? {},
188
+ peerDependencies: manifest.peerDependencies ?? {},
189
+ }
190
+ const clientFiles = files.filter((line) => /(^|[/\\])[^/\\]*client[^/\\]*\.(?:js|mjs|cjs|ts):/i.test(line))
191
+ return {
192
+ version: String(manifest.version ?? ''),
193
+ hash: sha256(`${JSON.stringify(meta)}\n${files.join('\n')}`),
194
+ clientHash: sha256(clientFiles.join('\n')),
195
+ hasClient: Boolean(manifest.dsh?.client),
196
+ dir,
197
+ }
198
+ } catch {
199
+ return null
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Decide whether install results really require a restart. Bundle additions and
205
+ * changed runtime/client files do; no-op reinstall and metadata-only version
206
+ * bumps do not. A manager-applied in-place change needs no restart either.
207
+ * @param {{before: any, after: any, bundlesBefore: string[], bundlesAfter: string[], managerApplication: string}} input
208
+ */
209
+ export function decideRestart({ before, after, bundlesBefore, bundlesAfter, managerApplication }) {
210
+ const beforeNames = new Set(bundlesBefore ?? [])
211
+ const afterNames = new Set(bundlesAfter ?? [])
212
+ const added = [...afterNames].filter((name) => !beforeNames.has(name))
213
+ const removed = [...beforeNames].filter((name) => !afterNames.has(name))
214
+ if (added.length > 0 || removed.length > 0) {
215
+ return { required: true, reason: `bundle set changed (${[...added, ...removed].join(', ')})`, bundleChanged: true, codeChanged: true, clientChanged: false, versionChanged: false }
216
+ }
217
+ if (!before || !after) {
218
+ const required = managerApplication === 'restart-required'
219
+ return { required, reason: required ? 'manager requested a restart (no runtime snapshot)' : 'manager applied it in place', bundleChanged: false, codeChanged: false, clientChanged: false, versionChanged: false }
220
+ }
221
+ const versionChanged = before.version !== after.version
222
+ const codeChanged = before.hash !== after.hash
223
+ const clientChanged = Boolean(before.hasClient && after.hasClient && before.clientHash !== after.clientHash)
224
+ if (!codeChanged) {
225
+ return { required: false, reason: versionChanged ? 'runtime files are unchanged (version label only)' : 'runtime files are unchanged', bundleChanged: false, codeChanged: false, clientChanged: false, versionChanged }
226
+ }
227
+ if (managerApplication === 'applied') {
228
+ return { required: false, reason: 'manager applied the change in place', bundleChanged: false, codeChanged: true, clientChanged, versionChanged }
229
+ }
230
+ return { required: true, reason: clientChanged ? 'client half changed' : 'runtime code changed', bundleChanged: false, codeChanged: true, clientChanged, versionChanged }
113
231
  }
114
232
 
115
233
  /**
@@ -162,14 +280,8 @@ export async function verifyInstall(ctx, manager, packageName) {
162
280
 
163
281
  /** Locate a pnpm executable usable for a source build. */
164
282
  export function resolvePnpm() {
165
- const home = dshHome()
166
- const candidates =
167
- process.platform === 'win32'
168
- ? [path.join(home, '.desktop-bin', 'pnpm.cmd'), path.join(home, '..', '..', '.desktop-bin', 'pnpm.cmd')]
169
- : [path.join(home, '.desktop-bin', 'pnpm')]
170
- for (const candidate of candidates) {
171
- if (existsSync(candidate)) return candidate
172
- }
283
+ const explicit = process.env.DSH_PNPM_PATH
284
+ if (typeof explicit === 'string' && explicit !== '' && existsSync(explicit)) return explicit
173
285
  return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
174
286
  }
175
287
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-resolver",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Agent-facing DSH plugin resolver: search the catalog, pick a version the running DSH accepts, install (registry or GitHub-source build), and verify - no trial and error.",
5
5
  "type": "module",
6
6
  "main": "index.js",