dsh-dbhub-live 5.2.0-dev.3 → 5.2.0

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/lib/update.mjs CHANGED
@@ -1,244 +1,244 @@
1
- // dsh-dbhub-live: the plugin's own update check (npm registry, GitHub fallback).
2
- //
3
- // This is a small, self-contained network probe used by the settings card: the
4
- // Host asks "is a newer version of dsh-dbhub-live published?" and publishes the
5
- // answer as a JSON view field. It is deliberately isolated from the dbhub
6
- // runtime: no binary, no store, no DSN, no credentials — the ONLY secrets this
7
- // module could ever see are none (both endpoints are public reads).
8
- //
9
- // Contract: `check()` NEVER rejects. Every failure becomes a short `error`
10
- // string in the snapshot, and a previously known result is kept (stale) instead
11
- // of being wiped, so a flaky network never turns the UI's version row blank.
12
- //
13
- // Dependency direction: a LEAF that imports only ./meta.mjs (semver helpers).
14
- // lib/index.mjs imports it; nothing imports back.
15
-
16
- import { PLUGIN_NAME, isNewer, parseSemver } from './meta.mjs'
17
-
18
- /** Public npm dist-tag endpoint: `{ version }` for the `latest` tag. */
19
- export const NPM_LATEST_URL = 'https://registry.npmjs.org/' + PLUGIN_NAME + '/latest'
20
-
21
- /** Public GitHub releases API: `{ tag_name: 'v5.2.0', ... }`. */
22
- export const GITHUB_LATEST_URL = 'https://api.github.com/repos/mr-mihu/dsh-dbhub-live/releases/latest'
23
-
24
- /** Per-request budget. Two sources => at most ~2x this for one full check. */
25
- export const DEFAULT_TIMEOUT_MS = 5000
26
-
27
- /** Without `force`, a result younger than this is reused without any request. */
28
- export const DEFAULT_THROTTLE_MS = 30 * 60 * 1000
29
-
30
- const REQUEST_HEADERS = {
31
- accept: 'application/json',
32
- // GitHub's API rejects requests without a User-Agent (undici sets one, but an
33
- // explicit value names us in their logs).
34
- 'user-agent': PLUGIN_NAME + '-host',
35
- }
36
-
37
- /** Parse a body into an object, tolerating a raw JSON string (fetch stubs). */
38
- function bodyObject(body) {
39
- if (body && typeof body === 'object') return body
40
- if (typeof body !== 'string') return undefined
41
- try {
42
- const parsed = JSON.parse(body)
43
- return parsed && typeof parsed === 'object' ? parsed : undefined
44
- } catch (e) {
45
- return undefined
46
- }
47
- }
48
-
49
- /**
50
- * Extract a version from the npm registry body (`{ version: '5.2.0' }`).
51
- * Pure; '' for any body without a semver-valid version.
52
- */
53
- export function versionFromNpmBody(body) {
54
- const obj = bodyObject(body)
55
- const version = obj && typeof obj.version === 'string' ? obj.version.trim() : ''
56
- return parseSemver(version) ? version : ''
57
- }
58
-
59
- /**
60
- * Extract a version from the GitHub release body (`{ tag_name: 'v5.2.0' }`).
61
- * The release tag convention in this repo is `v<x.y.z>`, so one leading `v` is
62
- * stripped before validation. Pure; '' for anything else.
63
- */
64
- export function versionFromGithubBody(body) {
65
- const obj = bodyObject(body)
66
- const raw = obj && typeof obj.tag_name === 'string' ? obj.tag_name.trim() : ''
67
- const version = raw.replace(/^[vV]/, '')
68
- return parseSemver(version) ? version : ''
69
- }
70
-
71
- /**
72
- * Turn any thrown value into a SHORT, sanitized message suitable for the view
73
- * (the card renders it inline). Never returns a URL: an error message from a
74
- * fetch stack can embed the request URL, and the view is browser-facing.
75
- */
76
- function shortError(error) {
77
- if (error && (error.name === 'AbortError' || error.name === 'TimeoutError' || error.code === 'ABORT_ERR')) {
78
- return 'timeout'
79
- }
80
- let text = String((error && error.message) || error || 'unknown error')
81
- // Keep the low-level cause code (ENOTFOUND/ECONNREFUSED/...): it is the only
82
- // actionable part of a Node fetch error ("fetch failed").
83
- const cause = error && error.cause
84
- const code = cause && typeof cause.code === 'string' ? cause.code : ''
85
- text = text.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim()
86
- if (code && !text.includes(code)) text = text ? text + ' (' + code + ')' : code
87
- if (text.length > 80) text = text.slice(0, 77) + '...'
88
- return text || 'request failed'
89
- }
90
-
91
- /**
92
- * Create a lazy update checker.
93
- *
94
- * @param {object} [options]
95
- * @param {Function} [options.fetchImpl] fetch-compatible implementation
96
- * @param {Function} [options.now] clock (ms); injectable for tests
97
- * @param {string} [options.npmUrl] npm dist-tag endpoint
98
- * @param {string} [options.githubUrl] GitHub latest-release endpoint
99
- * @param {number} [options.timeoutMs] per-request budget
100
- * @param {number} [options.throttleMs] reuse window for non-forced checks
101
- * @param {string} [options.currentVersion] installed plugin version to compare against
102
- * @returns {{ check: (force?: boolean) => Promise<object>, snapshot: () => object }}
103
- */
104
- export function createUpdateChecker(options = {}) {
105
- const fetchImpl = typeof options.fetchImpl === 'function'
106
- ? options.fetchImpl
107
- : (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined)
108
- const now = typeof options.now === 'function' ? options.now : Date.now
109
- const npmUrl = typeof options.npmUrl === 'string' && options.npmUrl ? options.npmUrl : NPM_LATEST_URL
110
- const githubUrl = typeof options.githubUrl === 'string' && options.githubUrl
111
- ? options.githubUrl
112
- : GITHUB_LATEST_URL
113
- const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
114
- ? options.timeoutMs
115
- : DEFAULT_TIMEOUT_MS
116
- const throttleMs = Number.isFinite(options.throttleMs) && options.throttleMs >= 0
117
- ? options.throttleMs
118
- : DEFAULT_THROTTLE_MS
119
- const currentVersion = typeof options.currentVersion === 'string' ? options.currentVersion : ''
120
-
121
- // Last known result. `latest`/`source` survive a failed re-check on purpose
122
- // (see the module contract above).
123
- let state = { latest: '', latestSpec: '', hasUpdate: false, checkedAt: 0, source: '', error: '' }
124
- let inFlight
125
-
126
- const snapshot = () => ({ ...state })
127
-
128
- const safeNow = () => {
129
- try {
130
- const value = Number(now())
131
- return Number.isFinite(value) ? value : Date.now()
132
- } catch (e) {
133
- return Date.now()
134
- }
135
- }
136
-
137
- const throttled = () => state.checkedAt > 0 && safeNow() - state.checkedAt < throttleMs
138
-
139
- /**
140
- * One request, resolved to a parsed JSON body. A per-request AbortController
141
- * enforces the budget; the timer stays referenced until the request settles
142
- * (an unref'd timer would let the process drain before it fires) and is
143
- * always cleared.
144
- */
145
- const requestJson = async (url) => {
146
- if (typeof fetchImpl !== 'function') throw new Error('fetch unavailable')
147
- const controller = typeof AbortController === 'function' ? new AbortController() : undefined
148
- const timer = controller
149
- ? setTimeout(() => {
150
- try {
151
- controller.abort()
152
- } catch (e) {
153
- /* already aborted */
154
- }
155
- }, timeoutMs)
156
- : undefined
157
- try {
158
- const response = await fetchImpl(url, {
159
- headers: REQUEST_HEADERS,
160
- signal: controller ? controller.signal : undefined,
161
- })
162
- if (!response || typeof response !== 'object') throw new Error('empty response')
163
- const status = typeof response.status === 'number' ? response.status : 0
164
- const ok = typeof response.ok === 'boolean'
165
- ? response.ok
166
- : status === 0 || (status >= 200 && status < 300)
167
- if (!ok) throw new Error('HTTP ' + (status || 'error'))
168
- if (typeof response.json === 'function') return await response.json()
169
- if (typeof response.text === 'function') {
170
- const text = await response.text()
171
- return text ? JSON.parse(text) : undefined
172
- }
173
- return undefined
174
- } finally {
175
- if (timer) clearTimeout(timer)
176
- }
177
- }
178
-
179
- /** Try one source; return the version or '' after recording its error. */
180
- const attempt = async (label, url, extract, errors) => {
181
- try {
182
- const version = extract(await requestJson(url))
183
- if (!version) throw new Error('invalid version in response')
184
- return version
185
- } catch (e) {
186
- errors.push(label + ': ' + shortError(e))
187
- return ''
188
- }
189
- }
190
-
191
- const runCheck = async () => {
192
- const errors = []
193
- let latest = ''
194
- let source = ''
195
- try {
196
- latest = await attempt('npm', npmUrl, versionFromNpmBody, errors)
197
- if (latest) source = 'npm'
198
- if (!latest) {
199
- latest = await attempt('github', githubUrl, versionFromGithubBody, errors)
200
- if (latest) source = 'github'
201
- }
202
- } catch (e) {
203
- // Belt and braces: `attempt` already absorbs its own failures.
204
- errors.push(shortError(e))
205
- }
206
- const checkedAt = safeNow()
207
- if (latest) {
208
- state = {
209
- latest,
210
- latestSpec: PLUGIN_NAME + '@' + latest,
211
- hasUpdate: isNewer(latest, currentVersion),
212
- checkedAt,
213
- source,
214
- error: '',
215
- }
216
- } else {
217
- state = {
218
- // Keep whatever we knew: a transient failure is not "no release".
219
- latest: state.latest,
220
- latestSpec: state.latestSpec,
221
- hasUpdate: state.hasUpdate,
222
- checkedAt,
223
- source: state.source,
224
- error: errors.join('; ') || 'no version found',
225
- }
226
- }
227
- return snapshot()
228
- }
229
-
230
- /**
231
- * Run (or reuse) the check. Concurrent calls share one in-flight promise, so
232
- * a double click or a page reload cannot double the request burst.
233
- */
234
- const check = (force) => {
235
- if (inFlight) return inFlight
236
- if (!force && throttled()) return Promise.resolve(snapshot())
237
- inFlight = runCheck().finally(() => {
238
- inFlight = undefined
239
- })
240
- return inFlight
241
- }
242
-
243
- return { check, snapshot }
244
- }
1
+ // dsh-dbhub-live: the plugin's own update check (npm registry, GitHub fallback).
2
+ //
3
+ // This is a small, self-contained network probe used by the settings card: the
4
+ // Host asks "is a newer version of dsh-dbhub-live published?" and publishes the
5
+ // answer as a JSON view field. It is deliberately isolated from the dbhub
6
+ // runtime: no binary, no store, no DSN, no credentials — the ONLY secrets this
7
+ // module could ever see are none (both endpoints are public reads).
8
+ //
9
+ // Contract: `check()` NEVER rejects. Every failure becomes a short `error`
10
+ // string in the snapshot, and a previously known result is kept (stale) instead
11
+ // of being wiped, so a flaky network never turns the UI's version row blank.
12
+ //
13
+ // Dependency direction: a LEAF that imports only ./meta.mjs (semver helpers).
14
+ // lib/index.mjs imports it; nothing imports back.
15
+
16
+ import { PLUGIN_NAME, isNewer, parseSemver } from './meta.mjs'
17
+
18
+ /** Public npm dist-tag endpoint: `{ version }` for the `latest` tag. */
19
+ export const NPM_LATEST_URL = 'https://registry.npmjs.org/' + PLUGIN_NAME + '/latest'
20
+
21
+ /** Public GitHub releases API: `{ tag_name: 'v5.2.0', ... }`. */
22
+ export const GITHUB_LATEST_URL = 'https://api.github.com/repos/mr-mihu/dsh-dbhub-live/releases/latest'
23
+
24
+ /** Per-request budget. Two sources => at most ~2x this for one full check. */
25
+ export const DEFAULT_TIMEOUT_MS = 5000
26
+
27
+ /** Without `force`, a result younger than this is reused without any request. */
28
+ export const DEFAULT_THROTTLE_MS = 30 * 60 * 1000
29
+
30
+ const REQUEST_HEADERS = {
31
+ accept: 'application/json',
32
+ // GitHub's API rejects requests without a User-Agent (undici sets one, but an
33
+ // explicit value names us in their logs).
34
+ 'user-agent': PLUGIN_NAME + '-host',
35
+ }
36
+
37
+ /** Parse a body into an object, tolerating a raw JSON string (fetch stubs). */
38
+ function bodyObject(body) {
39
+ if (body && typeof body === 'object') return body
40
+ if (typeof body !== 'string') return undefined
41
+ try {
42
+ const parsed = JSON.parse(body)
43
+ return parsed && typeof parsed === 'object' ? parsed : undefined
44
+ } catch (e) {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Extract a version from the npm registry body (`{ version: '5.2.0' }`).
51
+ * Pure; '' for any body without a semver-valid version.
52
+ */
53
+ export function versionFromNpmBody(body) {
54
+ const obj = bodyObject(body)
55
+ const version = obj && typeof obj.version === 'string' ? obj.version.trim() : ''
56
+ return parseSemver(version) ? version : ''
57
+ }
58
+
59
+ /**
60
+ * Extract a version from the GitHub release body (`{ tag_name: 'v5.2.0' }`).
61
+ * The release tag convention in this repo is `v<x.y.z>`, so one leading `v` is
62
+ * stripped before validation. Pure; '' for anything else.
63
+ */
64
+ export function versionFromGithubBody(body) {
65
+ const obj = bodyObject(body)
66
+ const raw = obj && typeof obj.tag_name === 'string' ? obj.tag_name.trim() : ''
67
+ const version = raw.replace(/^[vV]/, '')
68
+ return parseSemver(version) ? version : ''
69
+ }
70
+
71
+ /**
72
+ * Turn any thrown value into a SHORT, sanitized message suitable for the view
73
+ * (the card renders it inline). Never returns a URL: an error message from a
74
+ * fetch stack can embed the request URL, and the view is browser-facing.
75
+ */
76
+ function shortError(error) {
77
+ if (error && (error.name === 'AbortError' || error.name === 'TimeoutError' || error.code === 'ABORT_ERR')) {
78
+ return 'timeout'
79
+ }
80
+ let text = String((error && error.message) || error || 'unknown error')
81
+ // Keep the low-level cause code (ENOTFOUND/ECONNREFUSED/...): it is the only
82
+ // actionable part of a Node fetch error ("fetch failed").
83
+ const cause = error && error.cause
84
+ const code = cause && typeof cause.code === 'string' ? cause.code : ''
85
+ text = text.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim()
86
+ if (code && !text.includes(code)) text = text ? text + ' (' + code + ')' : code
87
+ if (text.length > 80) text = text.slice(0, 77) + '...'
88
+ return text || 'request failed'
89
+ }
90
+
91
+ /**
92
+ * Create a lazy update checker.
93
+ *
94
+ * @param {object} [options]
95
+ * @param {Function} [options.fetchImpl] fetch-compatible implementation
96
+ * @param {Function} [options.now] clock (ms); injectable for tests
97
+ * @param {string} [options.npmUrl] npm dist-tag endpoint
98
+ * @param {string} [options.githubUrl] GitHub latest-release endpoint
99
+ * @param {number} [options.timeoutMs] per-request budget
100
+ * @param {number} [options.throttleMs] reuse window for non-forced checks
101
+ * @param {string} [options.currentVersion] installed plugin version to compare against
102
+ * @returns {{ check: (force?: boolean) => Promise<object>, snapshot: () => object }}
103
+ */
104
+ export function createUpdateChecker(options = {}) {
105
+ const fetchImpl = typeof options.fetchImpl === 'function'
106
+ ? options.fetchImpl
107
+ : (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined)
108
+ const now = typeof options.now === 'function' ? options.now : Date.now
109
+ const npmUrl = typeof options.npmUrl === 'string' && options.npmUrl ? options.npmUrl : NPM_LATEST_URL
110
+ const githubUrl = typeof options.githubUrl === 'string' && options.githubUrl
111
+ ? options.githubUrl
112
+ : GITHUB_LATEST_URL
113
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
114
+ ? options.timeoutMs
115
+ : DEFAULT_TIMEOUT_MS
116
+ const throttleMs = Number.isFinite(options.throttleMs) && options.throttleMs >= 0
117
+ ? options.throttleMs
118
+ : DEFAULT_THROTTLE_MS
119
+ const currentVersion = typeof options.currentVersion === 'string' ? options.currentVersion : ''
120
+
121
+ // Last known result. `latest`/`source` survive a failed re-check on purpose
122
+ // (see the module contract above).
123
+ let state = { latest: '', latestSpec: '', hasUpdate: false, checkedAt: 0, source: '', error: '' }
124
+ let inFlight
125
+
126
+ const snapshot = () => ({ ...state })
127
+
128
+ const safeNow = () => {
129
+ try {
130
+ const value = Number(now())
131
+ return Number.isFinite(value) ? value : Date.now()
132
+ } catch (e) {
133
+ return Date.now()
134
+ }
135
+ }
136
+
137
+ const throttled = () => state.checkedAt > 0 && safeNow() - state.checkedAt < throttleMs
138
+
139
+ /**
140
+ * One request, resolved to a parsed JSON body. A per-request AbortController
141
+ * enforces the budget; the timer stays referenced until the request settles
142
+ * (an unref'd timer would let the process drain before it fires) and is
143
+ * always cleared.
144
+ */
145
+ const requestJson = async (url) => {
146
+ if (typeof fetchImpl !== 'function') throw new Error('fetch unavailable')
147
+ const controller = typeof AbortController === 'function' ? new AbortController() : undefined
148
+ const timer = controller
149
+ ? setTimeout(() => {
150
+ try {
151
+ controller.abort()
152
+ } catch (e) {
153
+ /* already aborted */
154
+ }
155
+ }, timeoutMs)
156
+ : undefined
157
+ try {
158
+ const response = await fetchImpl(url, {
159
+ headers: REQUEST_HEADERS,
160
+ signal: controller ? controller.signal : undefined,
161
+ })
162
+ if (!response || typeof response !== 'object') throw new Error('empty response')
163
+ const status = typeof response.status === 'number' ? response.status : 0
164
+ const ok = typeof response.ok === 'boolean'
165
+ ? response.ok
166
+ : status === 0 || (status >= 200 && status < 300)
167
+ if (!ok) throw new Error('HTTP ' + (status || 'error'))
168
+ if (typeof response.json === 'function') return await response.json()
169
+ if (typeof response.text === 'function') {
170
+ const text = await response.text()
171
+ return text ? JSON.parse(text) : undefined
172
+ }
173
+ return undefined
174
+ } finally {
175
+ if (timer) clearTimeout(timer)
176
+ }
177
+ }
178
+
179
+ /** Try one source; return the version or '' after recording its error. */
180
+ const attempt = async (label, url, extract, errors) => {
181
+ try {
182
+ const version = extract(await requestJson(url))
183
+ if (!version) throw new Error('invalid version in response')
184
+ return version
185
+ } catch (e) {
186
+ errors.push(label + ': ' + shortError(e))
187
+ return ''
188
+ }
189
+ }
190
+
191
+ const runCheck = async () => {
192
+ const errors = []
193
+ let latest = ''
194
+ let source = ''
195
+ try {
196
+ latest = await attempt('npm', npmUrl, versionFromNpmBody, errors)
197
+ if (latest) source = 'npm'
198
+ if (!latest) {
199
+ latest = await attempt('github', githubUrl, versionFromGithubBody, errors)
200
+ if (latest) source = 'github'
201
+ }
202
+ } catch (e) {
203
+ // Belt and braces: `attempt` already absorbs its own failures.
204
+ errors.push(shortError(e))
205
+ }
206
+ const checkedAt = safeNow()
207
+ if (latest) {
208
+ state = {
209
+ latest,
210
+ latestSpec: PLUGIN_NAME + '@' + latest,
211
+ hasUpdate: isNewer(latest, currentVersion),
212
+ checkedAt,
213
+ source,
214
+ error: '',
215
+ }
216
+ } else {
217
+ state = {
218
+ // Keep whatever we knew: a transient failure is not "no release".
219
+ latest: state.latest,
220
+ latestSpec: state.latestSpec,
221
+ hasUpdate: state.hasUpdate,
222
+ checkedAt,
223
+ source: state.source,
224
+ error: errors.join('; ') || 'no version found',
225
+ }
226
+ }
227
+ return snapshot()
228
+ }
229
+
230
+ /**
231
+ * Run (or reuse) the check. Concurrent calls share one in-flight promise, so
232
+ * a double click or a page reload cannot double the request burst.
233
+ */
234
+ const check = (force) => {
235
+ if (inFlight) return inFlight
236
+ if (!force && throttled()) return Promise.resolve(snapshot())
237
+ inFlight = runCheck().finally(() => {
238
+ inFlight = undefined
239
+ })
240
+ return inFlight
241
+ }
242
+
243
+ return { check, snapshot }
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dbhub-live",
3
- "version": "5.2.0-dev.3",
3
+ "version": "5.2.0",
4
4
  "description": "DSH 数据库 MCP 插件:把 DBHub 接入 DeepSeek Harness,让 AI 在对话里直接查询 MySQL / PostgreSQL / MariaDB / SQLite / SQL Server。",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",