dsh-plugin-update-checker 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * dsh-plugin-update-checker — host/scan.js
3
+ *
4
+ * 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
5
+ * scanBundle
6
+ */
7
+ import { lstatSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { isRuntimePackage, evaluatePluginCompatibility } from './dsh.js'
10
+ import { readManifest, findBundleDir, KNOWN_REPOS, resolveSourceLink } from './profile.js'
11
+ import { probeAnyRegistry, classifyUpdate } from './registry.js'
12
+
13
+ export async function scanBundle(bundle, profileDir, dshVersion, exemptions, signal, repositories = {}, registries = ['https://registry.npmjs.org']) {
14
+ const name = bundle.name
15
+ const installedVersion = bundle.version ?? null
16
+
17
+ // The @deepseek-ai/dsh* packages are the runtime, symlinked into the shared
18
+ // tree from the runtime install. They are not user plugins and must not be
19
+ // treated as source-links or independently updatable bundles. The runtime is
20
+ // checked once, as a unit, by checkDshRuntime (which the route appends as the
21
+ // 'dsh-runtime' row). Report the runtime packages themselves as a thin,
22
+ // inert row so they neither blank the report nor invite a misleading
23
+ // "update from the repository" action.
24
+ if (isRuntimePackage(name)) {
25
+ return {
26
+ name,
27
+ installedVersion,
28
+ installed: bundle.installed === true,
29
+ linked: false,
30
+ description: bundle.description ?? bundle.meta?.description ?? null,
31
+ compatibility: { status: 'compatible' },
32
+ update: { status: 'part-of-runtime', error: 'part of the DSH runtime; checked as a unit' },
33
+ }
34
+ }
35
+ const row = {
36
+ name,
37
+ installedVersion,
38
+ installed: bundle.installed === true,
39
+ linked: false,
40
+ description: bundle.description ?? bundle.meta?.description ?? null,
41
+ compatibility: { status: 'compatible' },
42
+ update: { status: 'unknown', error: 'not scanned' },
43
+ }
44
+
45
+ // Compatibility (no network needed).
46
+ // describeBundle already resolved both, so reuse them when present: a
47
+ // scan of N bundles used to resolve every directory and parse every
48
+ // package.json twice.
49
+ const dir = bundle.dir !== undefined ? bundle.dir : findBundleDir(name, profileDir)
50
+ // A link:-installed bundle (symlink into a source tree) is not published to a
51
+ // registry, so installBundle would replace the developer's symlink with a
52
+ // published copy and break their live-edit loop. Flag it so the UI disables
53
+ // the one-click update and instead links to the source.
54
+ try { if (dir && lstatSync(dir).isSymbolicLink()) row.linked = true } catch { /* keep false */ }
55
+ const manifest = bundle.manifest !== undefined
56
+ ? bundle.manifest
57
+ : (dir ? await readManifest(join(dir, 'package.json')) : undefined)
58
+ // Always evaluate: the launcher does. Gating on peerDependencies meant a
59
+ // manifest that declares only engines.dsh was never evaluated here, so the
60
+ // row kept its default "compatible" even when the launcher would skip it.
61
+ if (manifest) {
62
+ try {
63
+ const issue = evaluatePluginCompatibility(manifest, exemptions, dshVersion)
64
+ if (issue) {
65
+ row.compatibility = {
66
+ status: issue.exempted ? 'compatible' : 'incompatible',
67
+ peers: issue.peers,
68
+ exempted: issue.exempted === true,
69
+ }
70
+ }
71
+ } catch {
72
+ row.compatibility = { status: 'unknown', error: 'could not evaluate compatibility' }
73
+ }
74
+ }
75
+
76
+ // Update availability.
77
+ if (!bundle.installed && bundle.optional) {
78
+ row.update = { status: 'unsupported', error: 'shipped by the installation' }
79
+ return row
80
+ }
81
+ // Listed in dsh.profile.bundles but absent from disk. Probing the registry
82
+ // would compare the published version against the 0.0.0 fallback and report
83
+ // "update available" for something that is not installed at all, so say so
84
+ // instead of inventing a phantom upgrade.
85
+ if (!bundle.installed) {
86
+ row.update = { status: 'unsupported', error: 'listed in the profile but not installed' }
87
+ row.compatibility = { status: 'unknown', error: 'no manifest to evaluate' }
88
+ return row
89
+ }
90
+ // Link:-installed bundles publish nothing to a registry; probing it only
91
+ // yields a misleading 404. Skip the network and point the user at the source.
92
+ if (row.linked && dir) {
93
+ const src = resolveSourceLink(dir, repositories[name] ?? KNOWN_REPOS[name])
94
+ const link = src.repoUrl
95
+ ? src.repoUrl + '/tree/' + src.ref + (src.subpath ? '/' + src.subpath : '')
96
+ : null
97
+ row.source = {
98
+ dir: src.dir,
99
+ repoUrl: src.repoUrl,
100
+ ref: src.ref,
101
+ subpath: src.subpath,
102
+ link,
103
+ }
104
+ row.update = { status: 'source', error: 'installed from source; update from the repository' }
105
+ return row
106
+ }
107
+ try {
108
+ // A manifest-declared registry is authoritative for that package; otherwise
109
+ // race the configured registries so a slow public registry cannot dominate.
110
+ const declared = typeof bundle.registry === 'string' && bundle.registry !== '' ? bundle.registry : ''
111
+ const candidates = declared !== '' ? [declared] : registries
112
+ const { result: remote, registry: usedRegistry } = await probeAnyRegistry(name, candidates, signal)
113
+ row.update = classifyUpdate(installedVersion ?? manifest?.version, remote)
114
+ // Surface the registry the answer came from so the one-click update installs
115
+ // from the same place the version was read (the client passes it through).
116
+ if (row.update.status !== 'unknown' && usedRegistry) row.update.registry = usedRegistry
117
+ } catch (err) {
118
+ row.update = { status: 'unknown', error: String(err?.message ?? err) }
119
+ }
120
+ return row
121
+ }
122
+
123
+ /** Read one manifest synchronously (used for lightweight bundle enumeration). */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * dsh-plugin-update-checker — host half entry.
3
+ *
4
+ * 这里只做装配:解析运行时版本 → 枚举 profile 的 bundles → 逐包扫描 → 回一行 JSON。
5
+ * 具体职责见同目录:dsh.js(DSH 适配)、profile.js(目录与清单)、registry.js(源与探测)、
6
+ * runtime.js(运行时更新源)、scan.js(单包扫描)。
7
+ */
8
+ import { getDshRuntimeVersion, readProfileVersionExemptions } from './dsh.js'
9
+ import { activeProfileDir, KNOWN_REPOS, listBundles } from './profile.js'
10
+ import { resolveRegistries } from './registry.js'
11
+ import { checkDshRuntime } from './runtime.js'
12
+ import { scanBundle } from './scan.js'
13
+
14
+ // 对外导出面保持不变:evaluatePluginCompatibility 随拆分搬到了 dsh.js,
15
+ // 但测试与外部调用方一直从入口取它,所以在这里再导出一次。
16
+ export { evaluatePluginCompatibility } from './dsh.js'
17
+
18
+ export function apply(ctx, _config = {}) {
19
+ const { webServer } = ctx
20
+ const dshVersion = getDshRuntimeVersion()
21
+ const repositories = { ...KNOWN_REPOS, ...(_config?.repositories ?? {}) }
22
+
23
+ ctx.effect(() => webServer.register({
24
+ kind: 'exact',
25
+ path: '/api/plugin-update-checker/scan',
26
+ handler: async (req, res) => {
27
+ if (req.method !== 'POST') {
28
+ res.statusCode = 405
29
+ res.setHeader('content-type', 'application/json')
30
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
31
+ return
32
+ }
33
+ const profileDir = activeProfileDir()
34
+ const exemptions = (() => { try { return readProfileVersionExemptions(profileDir) } catch { return {} } })()
35
+ const controller = new AbortController()
36
+ const timeout = setTimeout(() => controller.abort(), 20000)
37
+
38
+ try {
39
+ // Enumerate from the profile's dsh.profile.bundles on disk. The host
40
+ // half used to try `ctx.remote.pluginManager.listBundles()` first, but
41
+ // `remote` is a browser-side service: on the host that access throws
42
+ // "cannot get property ... without inject", the catch swallowed it, and
43
+ // the disk scan ran anyway. The throw was dead weight, not a fallback.
44
+ const bundles = await listBundles(profileDir)
45
+
46
+ // Resolve registries once per scan, not once per bundle, and racing them
47
+ // keeps a slow public registry from dominating a mirror-served host.
48
+ // NOTE: reads configuration, never ctx.remote — that is a browser-side
49
+ // service, and touching it here throws "cannot get property ... without
50
+ // inject" (the host half declares only webServer).
51
+ const registries = resolveRegistries(profileDir)
52
+
53
+ const plugins = await Promise.all(
54
+ bundles.map((b) => scanBundle(b, profileDir, dshVersion, exemptions, controller.signal, repositories, registries)
55
+ .catch((err) => ({
56
+ name: b.name,
57
+ installedVersion: b.version ?? null,
58
+ compatibility: { status: 'unknown', error: String(err?.message ?? err) },
59
+ update: { status: 'unknown', error: String(err?.message ?? err) },
60
+ })))
61
+ )
62
+
63
+ // The runtime is not a bundle in the profile, so it is never in
64
+ // `bundles`. Check it explicitly as one unit so the panel can answer
65
+ // "is my DSH itself up to date?" — the question the old panel silently
66
+ // left unanswered. It is appended, never merged into a plugin row.
67
+ const runtimeRow = await checkDshRuntime(dshVersion, registries, controller.signal)
68
+ .catch((err) => ({ name: 'dsh-runtime', installedVersion: dshVersion, compatibility: { status: 'compatible' }, update: { status: 'unknown', error: String(err?.message ?? err) } }))
69
+ plugins.push(runtimeRow)
70
+
71
+ res.statusCode = 200
72
+ res.setHeader('content-type', 'application/json')
73
+ res.end(JSON.stringify({ ok: true, dshVersion, checkedAt: new Date().toISOString(), plugins }))
74
+ } catch (err) {
75
+ res.statusCode = 500
76
+ res.setHeader('content-type', 'application/json')
77
+ res.end(JSON.stringify({ ok: false, error: String(err?.message ?? err) }))
78
+ } finally {
79
+ clearTimeout(timeout)
80
+ }
81
+ },
82
+ }), 'plugin-update-checker: scan route')
83
+
84
+ // NOTE: There is deliberately NO restart route here. DSH's own plugin
85
+ // manager never restarts the app (a "restart-required" install result merely
86
+ // means "takes effect at next start"); restarting on this machine is a
87
+ // manual operator action (stop the :3080 listeners, then `dsh restart` or
88
+ // re-launch). The browser half surfaces that as a manual prompt instead of
89
+ // trying to detach-kill its own host process, which proved unreliable.
90
+ }
package/lib/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * dsh-plugin-update-checker — Host half entry.
3
+ *
4
+ * Composes the scan route. The browser half (./client.js) registers the
5
+ * "Check updates" tab into the Plugins settings section and calls the route.
6
+ *
7
+ * `inject` lists exactly the services the host logic touches. NOTE: `remote`
8
+ * is a *browser*-side service (injected by dsh-client-ui-plugin-manager into
9
+ * the browser half) — it does NOT exist on the host, so the host half must
10
+ * never declare it. Updating is performed from the browser half via
11
+ * ctx.remote.pluginManager.installBundle, exactly like the GUI "Add plugin"
12
+ * dialog; the host half only serves the read-only scan route here.
13
+ */
14
+
15
+ import { apply as applyUpdateChecker } from './host/update-checker.js'
16
+
17
+ export const name = 'dsh-plugin-update-checker'
18
+
19
+ export const inject = ['webServer']
20
+
21
+ export function apply(ctx, config = {}) {
22
+ applyUpdateChecker(ctx, config)
23
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "dsh-plugin-update-checker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "description": "A DeepSeek Harness plugin that adds a \"Check updates\" button to the Plugins settings section: it scans every installed plugin bundle for published updates and evaluates its peer-dependency compatibility against the running DSH runtime.",
5
+ "keywords": [
6
+ "deepseek",
7
+ "deepseek-harness",
8
+ "dsh-plugin",
9
+ "dsh",
10
+ "plugin-manager",
11
+ "update-checker"
12
+ ],
13
+ "homepage": "https://github.com/windrover/dsh-plugin-update-checker#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/windrover/dsh-plugin-update-checker/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/windrover/dsh-plugin-update-checker.git"
20
+ },
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "main": "lib/index.js",
24
+ "exports": {
25
+ ".": "./lib/index.js",
26
+ "./client": "./lib/client.js",
27
+ "./cordis.patch.yml": "./cordis.patch.yml",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "lib/index.js",
32
+ "lib/client.js",
33
+ "lib/host/*.js",
34
+ "cordis.patch.yml",
35
+ "README.md",
36
+ "LICENSE",
37
+ "README.zh-CN.md"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "semver": "^7.6.0"
44
+ },
45
+ "engines": {
46
+ "dsh": ">=0.2.0-0 <0.3.0-0"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-locale",
55
+ "@deepseek-ai/dsh-client-ui-settings",
56
+ "@deepseek-ai/dsh-client-modules"
57
+ ],
58
+ "platform": "web"
59
+ }
60
+ },
61
+ "scripts": {
62
+ "test": "node test/layering.test.mjs && node test/client-contract.test.mjs && node test/profile-argv.test.mjs && node test/registry-race.test.mjs && node test/runtime-check.test.mjs && node test/compat-parity.test.mjs && node test/scan-route.test.mjs && node test/host-load.test.mjs"
63
+ }
64
+ }