dsh-plugin-update-checker 0.1.4 → 0.1.6

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 CHANGED
@@ -30,7 +30,19 @@ The check is on demand: click the button, the host scans, and the table fills in
30
30
 
31
31
  ## 📦 Install
32
32
 
33
- ### Option A — from this GitHub repository (recommended)
33
+ ### Option A — from npm (recommended)
34
+
35
+ ```bash
36
+ dsh install-plugin dsh-plugin-update-checker
37
+ ```
38
+
39
+ Installing from the registry matters for this plugin in particular: a `github:`
40
+ install is pinned to a commit in the profile lockfile, so re-running the same
41
+ spec never picks up a newer release, and the panel reports it as *Unsupported*
42
+ (it can only compare registry versions). From `0.1.4` on, plain version numbers
43
+ resolve normally and the panel's own **Update** button works.
44
+
45
+ ### Option B — from this GitHub repository
34
46
 
35
47
  1. Open the sidebar **Plugins** page → **Add plugin**.
36
48
  2. In the source field, enter one of these install specs (the launcher resolves them through `installBundle`, the same primitive the GUI uses):
package/README.zh-CN.md CHANGED
@@ -30,7 +30,17 @@
30
30
 
31
31
  ## 📦 安装
32
32
 
33
- ### 方式 A —— 从本 GitHub 仓库直接安装(推荐)
33
+ ### 方式 A —— 从 npm 安装(推荐)
34
+
35
+ ```bash
36
+ dsh install-plugin dsh-plugin-update-checker
37
+ ```
38
+
39
+ 从注册表安装对这个插件**尤其值得**:用 `github:` 安装会被 profile 锁文件钉在某个
40
+ commit 上,重跑同样的 spec 不会拿到新版本,面板也只能把它报成 *不受支持*
41
+ (它只会比对注册表版本)。`0.1.4` 之后按普通版本号解析,面板自带的「更新」按钮就能用。
42
+
43
+ ### 方式 B —— 从本 GitHub 仓库直接安装
34
44
 
35
45
  1. 打开右侧栏 **插件(Plugins)** 页面 → **添加插件**。
36
46
  2. 在来源框里填入下面任意一种安装描述符(启动器通过 `installBundle` 解析,和 GUI 用的是同一个原语):
package/lib/client.js CHANGED
@@ -59,6 +59,7 @@ window.__ModuleLoader__.load({
59
59
  "updating": "更新中…",
60
60
  "updated": "已更新",
61
61
  "updateFailed": "更新失败",
62
+ "updateBlocked": "profile 未变更:很可能是发布年龄策略尚未放行该版本。请执行",
62
63
  "linked": "源码链接(请从源码更新)",
63
64
  "linkedHint": "该插件以源码软链方式安装,无法一键更新;请到源码目录拉取最新代码后重启 DSH。",
64
65
  "sourceLink": "前往源码仓库",
@@ -102,6 +103,7 @@ window.__ModuleLoader__.load({
102
103
  "updating": "Updating…",
103
104
  "updated": "Updated",
104
105
  "updateFailed": "Update failed",
106
+ "updateBlocked": "the profile was not modified. Most likely the release-age policy has not admitted that version yet — install it explicitly with",
105
107
  "linked": "Source-linked (update from source)",
106
108
  "linkedHint": "This plugin is installed as a source symlink and cannot be updated in one click. Pull the latest from its source repo, then restart DSH.",
107
109
  "sourceLink": "Open source repository",
@@ -245,7 +247,17 @@ window.__ModuleLoader__.load({
245
247
  // still report ok without changing anything (e.g. a bare spec
246
248
  // against an already-pinned version), so surface that.
247
249
  const changed = value.changed === true;
248
- if (!changed) throw new Error("registry returned no newer version; profile unchanged");
250
+ if (!changed) {
251
+ // ok:false would mean the install failed; ok:true with changed:false
252
+ // means pnpm accepted the command but the profile already satisfied it.
253
+ // The common cause is the profile release-age policy: a version
254
+ // published minutes ago is not admitted until it is old enough, and
255
+ // installBundle does not register the exclusion the App CLI adds.
256
+ // "registry returned no newer version" was misleading: the registry
257
+ // did have it, the local policy refused it.
258
+ const target = typeof latest === "string" && latest !== "" ? `${name}@${latest}` : name;
259
+ throw new Error(`${t("updateBlocked")} dsh install-plugin ${target}`);
260
+ }
249
261
  setOutcome((s) => ({ ...s, [name]: { ok: true, restartRequired: application === "restart-required", application } }));
250
262
  // When the change is live immediately, re-scan so the row flips
251
263
  // to "up to date" instead of still showing "update available".
@@ -95,41 +95,94 @@ function readRegistryFromNpmrc(file) {
95
95
  * @param {AbortSignal} signal - request deadline.
96
96
  * @returns {Promise<{ result: object, registry: string }>} the winning answer.
97
97
  */
98
- export async function probeAnyRegistry(name, registries, signal) {
98
+ export async function probeAnyRegistry(name, registries, signal, installedVersion) {
99
99
  // Start every candidate at once and settle on the first *successful* answer,
100
100
  // in completion order rather than list order. Awaiting in list order would let
101
101
  // a slow first registry delay an already-finished mirror — exactly the case
102
102
  // this exists to fix.
103
103
  if (registries.length === 0) return { result: { error: 'no registry configured' }, registry: undefined }
104
- return await new Promise((resolve) => {
104
+
105
+ const probes = registries.map((registry) =>
106
+ probeRegistry(name, registry, signal).then(
107
+ (result) => ({ result, registry }),
108
+ (err) => ({ result: { error: `registry unreachable: ${String(err?.message ?? err)}` }, registry }),
109
+ ),
110
+ )
111
+
112
+ // First success wins, so the common case stays as fast as the fastest mirror.
113
+ const first = await new Promise((resolve) => {
114
+ let done = false
105
115
  let failures = 0
106
- let lastFailure = null
107
- let settled = false
108
- for (const registry of registries) {
109
- probeRegistry(name, registry, signal).then((result) => {
110
- if (settled) return
111
- if (result.error === undefined) {
112
- settled = true
113
- resolve({ result, registry })
116
+ for (const probe of probes) {
117
+ probe.then((answer) => {
118
+ if (done) return
119
+ if (answer.result.error === undefined) {
120
+ done = true
121
+ resolve(answer)
114
122
  return
115
123
  }
116
- lastFailure = { result, registry }
117
124
  failures += 1
118
- if (failures === registries.length) {
119
- settled = true
120
- resolve(lastFailure)
121
- }
122
- }, (err) => {
123
- if (settled) return
124
- lastFailure = { result: { error: `registry unreachable: ${String(err?.message ?? err)}` }, registry }
125
- failures += 1
126
- if (failures === registries.length) {
127
- settled = true
128
- resolve(lastFailure)
125
+ if (failures === probes.length) {
126
+ done = true
127
+ resolve(undefined)
129
128
  }
130
129
  })
131
130
  }
132
131
  })
132
+
133
+ if (first === undefined) {
134
+ const all = await Promise.all(probes)
135
+ return all[all.length - 1]
136
+ }
137
+
138
+ // A fast mirror can be stale: it may still carry the placeholder version npm
139
+ // creates for a brand-new package, or lag behind the version we already have
140
+ // installed. Taking that at face value reports "up to date" for something a
141
+ // second registry knows better, which is the one verdict this plugin must not
142
+ // get wrong. When the first answer looks like that, wait for the rest (they
143
+ // are already in flight) and keep the highest real version.
144
+ if (!isSuspect(first.result, installedVersion)) return first
145
+ const all = await Promise.all(probes)
146
+ const best = bestAnswer(all.filter((answer) => answer.result.error === undefined))
147
+ return best ?? first
148
+ }
149
+
150
+ /** A placeholder version npm creates while a package has no real release yet. */
151
+ function isPlaceholder(version, result) {
152
+ return result?.stubLatest === true && version !== undefined
153
+ }
154
+
155
+ /**
156
+ * Whether an answer should not be trusted as the final word.
157
+ * @param result - one registry's probe result.
158
+ * @param installedVersion - the version on disk, when known.
159
+ */
160
+ function isSuspect(result, installedVersion) {
161
+ const latest = result?.latest
162
+ if (latest === undefined) return false
163
+ if (isPlaceholder(latest, result)) return true
164
+ // A registry claiming something older than what is installed is stale by
165
+ // definition; the installed version came from a registry in the first place.
166
+ return installedVersion !== undefined
167
+ && semver.valid(installedVersion) !== null
168
+ && semver.valid(latest) !== null
169
+ && semver.lt(latest, installedVersion)
170
+ }
171
+
172
+ /** Pick the highest real release among several answers, ignoring placeholders. */
173
+ function bestAnswer(answers) {
174
+ const real = answers.filter((answer) => !isPlaceholder(answer.result.latest, answer.result))
175
+ const pool = real.length > 0 ? real : answers
176
+ let best
177
+ for (const answer of pool) {
178
+ const latest = answer.result.latest
179
+ if (latest === undefined) continue
180
+ if (best === undefined) { best = answer; continue }
181
+ if (semver.valid(latest) !== null && semver.valid(best.result.latest) !== null) {
182
+ if (semver.gt(latest, best.result.latest)) best = answer
183
+ }
184
+ }
185
+ return best
133
186
  }
134
187
 
135
188
  /** Probe one npm registry for a package's published versions. */
@@ -143,7 +196,10 @@ async function probeRegistry(name, registry, signal) {
143
196
  const distTags = data?.['dist-tags']
144
197
  const versions = Array.isArray(data?.versions) ? data.versions : Object.keys(data?.versions ?? {})
145
198
  if (distTags?.latest === undefined && versions.length === 0) return { error: 'package not found on registry' }
146
- return { latest: distTags?.latest, versions }
199
+ // npm writes a stub version (0.0.0-stage) for a package whose first real
200
+ // release has not landed yet; it is a placeholder, not a release.
201
+ const stubLatest = distTags?.latest !== undefined && data?.versions?.[distTags.latest]?.stub === true
202
+ return { latest: distTags?.latest, versions, stubLatest }
147
203
  } catch (err) {
148
204
  if (err?.name === 'AbortError') return { error: 'request aborted' }
149
205
  return { error: `registry unreachable: ${String(err?.message ?? err)}` }
package/lib/host/scan.js CHANGED
@@ -109,7 +109,10 @@ export async function scanBundle(bundle, profileDir, dshVersion, exemptions, sig
109
109
  // race the configured registries so a slow public registry cannot dominate.
110
110
  const declared = typeof bundle.registry === 'string' && bundle.registry !== '' ? bundle.registry : ''
111
111
  const candidates = declared !== '' ? [declared] : registries
112
- const { result: remote, registry: usedRegistry } = await probeAnyRegistry(name, candidates, signal)
112
+ // The installed version lets the race spot a stale mirror instead of
113
+ // reporting "up to date" from an answer older than what is on disk.
114
+ const { result: remote, registry: usedRegistry } =
115
+ await probeAnyRegistry(name, candidates, signal, installedVersion ?? manifest?.version)
113
116
  row.update = classifyUpdate(installedVersion ?? manifest?.version, remote)
114
117
  // Surface the registry the answer came from so the one-click update installs
115
118
  // from the same place the version was read (the client passes it through).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-update-checker",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
5
  "keywords": [
6
6
  "deepseek",
@@ -59,6 +59,6 @@
59
59
  }
60
60
  },
61
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"
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/registry-staleness.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
63
  }
64
64
  }