dsh-plugin-resolver 0.1.1 → 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/README.md +18 -4
- package/logic/api.js +33 -10
- package/logic/install.js +127 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,12 +56,12 @@ flowchart LR
|
|
|
56
56
|
要求 **DSH `>=0.2.0-rc.2 <0.3.0`**(已在 `engines.dsh` 声明)。纯 JS、无构建、无 install 脚本,三种方式都不需要 `allowBuilds` 授权:
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
|
-
# npm(已发布 0.1.
|
|
59
|
+
# npm(已发布 0.1.2;profile 名以你的 DSH 安装为准)
|
|
60
60
|
dsh plugin --profile <profile> add dsh-plugin-resolver
|
|
61
61
|
# GitHub 源码安装(建议锁 commit)
|
|
62
62
|
dsh plugin --profile <profile> add github:sakikoTGW/dsh-plugin-resolver#<commit>
|
|
63
63
|
# tarball
|
|
64
|
-
npm pack && dsh plugin --profile <profile> add ./dsh-plugin-resolver-0.1.
|
|
64
|
+
npm pack && dsh plugin --profile <profile> add ./dsh-plugin-resolver-0.1.2.tgz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
装完(宿主重启后最稳)直接说:
|
|
@@ -115,6 +115,20 @@ install spec: dsh-better-sidebar@0.28.0
|
|
|
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
|
```
|
|
@@ -150,7 +164,7 @@ node test/ablation.mjs # 消融测试(无副作用)
|
|
|
150
164
|
## 测试与 CI
|
|
151
165
|
|
|
152
166
|
```sh
|
|
153
|
-
npm test # = selftest(
|
|
167
|
+
npm test # = selftest(25) + harness(10)
|
|
154
168
|
npm run bench # 基准
|
|
155
169
|
npm run ablate # 消融
|
|
156
170
|
```
|
|
@@ -163,7 +177,7 @@ npm run ablate # 消融
|
|
|
163
177
|
## 已知限制
|
|
164
178
|
|
|
165
179
|
- 兼容性以 npm 元数据里的 peer / `engines.dsh` 为准,宿主启动预检另有判断时以宿主为准;
|
|
166
|
-
- **替换已加载插件**仍需要重启宿主(loader 模块缓存 + 打包宿主无 Node 内部 loader
|
|
180
|
+
- **替换已加载插件**仍需要重启宿主(loader 模块缓存 + 打包宿主无 Node 内部 loader);`plugin_install` 会先比较安装前后的 bundle 集合与运行时文件 hash,只有真正变化时才报 `restart-required`;
|
|
167
181
|
- 同名歧义不猜,需要调用方给 `owner=` 或完整 spec;
|
|
168
182
|
- 目录是社区维护的第三方列表,插件只保证"按声明来源装对版本",不做安全审计;
|
|
169
183
|
- GitHub 源码构建依赖宿主环境里的 `pnpm`(PATH 或 `DSH_PNPM_PATH`)与 `tar`,大仓库首次构建较慢。
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 (
|
|
172
|
-
if (health.entry?.fiberPhase === 'active' && !health.hasClient &&
|
|
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
|
@@ -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
|
|
99
|
-
if (
|
|
100
|
-
|
|
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 : '
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
entries = await readdir(current, { withFileTypes: true })
|
|
108
144
|
} catch {
|
|
109
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-resolver",
|
|
3
|
-
"version": "0.1.
|
|
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",
|