@goodandready/dsh-model-sync 0.3.13 → 0.4.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/updater.js ADDED
@@ -0,0 +1,362 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * Host-side one-click updater for @goodandready/dsh-model-sync.
10
+ * Implements the unified DSH plugin updater specification with loopback verification.
11
+ */
12
+
13
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
14
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
15
+ const VERSION_CACHE_MS = 5 * 60_000
16
+ let latestCache = undefined
17
+
18
+ function header(request, name) {
19
+ const value = request?.headers?.[name]
20
+ return Array.isArray(value) ? value[0] : value
21
+ }
22
+
23
+ export function isLoopback(value) {
24
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
+ return (
26
+ address === 'localhost' ||
27
+ address === 'localhost.' ||
28
+ address === '::1' ||
29
+ address?.startsWith('127.') === true ||
30
+ address?.startsWith('::ffff:127.') === true
31
+ )
32
+ }
33
+
34
+ export function isTrustedUpdateRequest(request) {
35
+ if (header(request, UPDATE_HEADER) !== '1') return false
36
+ const remote = request.socket?.remoteAddress
37
+ if (!isLoopback(remote)) return false
38
+ const site = header(request, 'sec-fetch-site')
39
+ if (site !== undefined && site !== 'same-origin') return false
40
+ const origin = header(request, 'origin')
41
+ const host = header(request, 'host')
42
+ if (origin === undefined || host === undefined) return false
43
+ try {
44
+ const url = new URL(origin)
45
+ return (
46
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
47
+ isLoopback(url.hostname) &&
48
+ url.host === host
49
+ )
50
+ } catch (err) {
51
+ // bestEffort URL parse
52
+ return false
53
+ }
54
+ }
55
+
56
+ function validProfileName(value) {
57
+ return (
58
+ typeof value === 'string' &&
59
+ value !== '' &&
60
+ value !== '.' &&
61
+ value !== '..' &&
62
+ !value.includes('/') &&
63
+ !value.includes('\\') &&
64
+ !/[\0-\x1f\x7f]/.test(value)
65
+ )
66
+ }
67
+
68
+ function profileNameFromArgv(argv) {
69
+ for (let index = 0; index < argv.length; index += 1) {
70
+ const arg = argv[index]
71
+ if (arg === '--profile' || arg === '-p') {
72
+ const next = argv[index + 1]
73
+ if (validProfileName(next)) return next
74
+ }
75
+ if (typeof arg === 'string' && arg.startsWith('--profile=')) {
76
+ const value = arg.slice('--profile='.length)
77
+ if (validProfileName(value)) return value
78
+ }
79
+ }
80
+ return undefined
81
+ }
82
+
83
+ function findDshCliEntry() {
84
+ const entry = process.argv[1]
85
+ if (typeof entry !== 'string' || entry === '') return undefined
86
+ const resolved = entry.startsWith('file:') ? fileURLToPath(entry) : resolve(process.cwd(), entry)
87
+ if (!existsSync(resolved)) return undefined
88
+ for (let directory = dirname(resolved); ; directory = dirname(directory)) {
89
+ const manifestPath = resolve(directory, 'package.json')
90
+ if (existsSync(manifestPath)) {
91
+ try {
92
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
93
+ const bin =
94
+ typeof manifest.bin === 'string'
95
+ ? manifest.bin
96
+ : typeof manifest.bin === 'object' && manifest.bin !== null
97
+ ? manifest.bin.dsh
98
+ : undefined
99
+ if (
100
+ manifest.name === '@deepseek-ai/dsh' &&
101
+ typeof bin === 'string' &&
102
+ !isAbsolute(bin) &&
103
+ resolve(directory, bin) === resolve(resolved)
104
+ ) {
105
+ return resolved
106
+ }
107
+ } catch (err) {
108
+ // bestEffort manifest parse
109
+ }
110
+ }
111
+ const parent = dirname(directory)
112
+ if (parent === directory) return undefined
113
+ }
114
+ }
115
+
116
+ export function runtime() {
117
+ const profileDir = resolve(
118
+ process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
119
+ )
120
+ const selected = profileNameFromArgv(process.argv)
121
+ const profileName = validProfileName(selected)
122
+ ? selected
123
+ : validProfileName(basename(profileDir))
124
+ ? basename(profileDir)
125
+ : 'web'
126
+ const cliEntry = findDshCliEntry()
127
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
128
+ }
129
+
130
+ export function parseSemver(value) {
131
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? '').trim())
132
+ if (match === null) return undefined
133
+ return {
134
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
135
+ prerelease: match[4] ? match[4].split('.') : [],
136
+ }
137
+ }
138
+
139
+ export function comparePrerelease(left, right) {
140
+ if (left.length === 0 && right.length === 0) return 0
141
+ if (left.length === 0) return 1
142
+ if (right.length === 0) return -1
143
+ const length = Math.max(left.length, right.length)
144
+ for (let index = 0; index < length; index += 1) {
145
+ const a = left[index]
146
+ const b = right[index]
147
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
148
+ if (a === b) continue
149
+ const aNumeric = /^\d+$/.test(a)
150
+ const bNumeric = /^\d+$/.test(b)
151
+ if (aNumeric && bNumeric) {
152
+ const aNumber = BigInt(a)
153
+ const bNumber = BigInt(b)
154
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
155
+ continue
156
+ }
157
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
158
+ return a > b ? 1 : -1
159
+ }
160
+ return 0
161
+ }
162
+
163
+ export function isNewerVersion(currentValue, candidateValue) {
164
+ const current = parseSemver(currentValue)
165
+ const candidate = parseSemver(candidateValue)
166
+ if (current === undefined || candidate === undefined) return false
167
+ for (let index = 0; index < 3; index += 1) {
168
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
169
+ }
170
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
171
+ }
172
+
173
+ export function resetLatestCache() {
174
+ latestCache = undefined
175
+ }
176
+
177
+ export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
178
+ if (
179
+ latestCache?.packageName === packageName &&
180
+ latestCache.registry === registry &&
181
+ Date.now() < latestCache.expiresAt
182
+ ) {
183
+ return latestCache.version
184
+ }
185
+ try {
186
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
187
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
188
+ })
189
+ if (!response.ok) return undefined
190
+ const value = await response.json()
191
+ if (typeof value?.version !== 'string' || value.version === '') return undefined
192
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
193
+ return value.version
194
+ } catch (err) {
195
+ // bestEffort network check
196
+ return undefined
197
+ }
198
+ }
199
+
200
+ export async function currentVersion(manifestUrl) {
201
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
202
+ if (typeof value?.version !== 'string' || value.version === '') {
203
+ throw new Error('Cannot read current plugin version.')
204
+ }
205
+ return value.version
206
+ }
207
+
208
+ export async function status(options, target) {
209
+ const current = await currentVersion(options.manifestUrl)
210
+ let latest = undefined
211
+ if (typeof options.getLatestVersion === 'function') {
212
+ latest = await options.getLatestVersion()
213
+ } else if (options.latestVersion !== undefined) {
214
+ latest = options.latestVersion
215
+ } else {
216
+ latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
217
+ }
218
+ return {
219
+ packageName: options.packageName,
220
+ currentVersion: current,
221
+ ...(latest === undefined ? {} : { latestVersion: latest }),
222
+ latestCheckFailed: latest === undefined,
223
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
224
+ profileName: target.profileName,
225
+ canAutoUpdate: target.cliEntry !== undefined,
226
+ }
227
+ }
228
+
229
+ export async function installExact(target, packageSpec, options = {}) {
230
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
231
+ await new Promise((resolvePromise, reject) => {
232
+ const child = spawn(
233
+ process.execPath,
234
+ [
235
+ target.cliEntry,
236
+ 'plugin',
237
+ '--profile',
238
+ target.profileName,
239
+ 'add',
240
+ '--config.minimumReleaseAge=0',
241
+ packageSpec,
242
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
243
+ ],
244
+ {
245
+ cwd: target.profileDir,
246
+ windowsHide: true,
247
+ stdio: ['ignore', 'pipe', 'pipe'],
248
+ env: { ...process.env, NO_COLOR: '1' },
249
+ }
250
+ )
251
+ let detail = ''
252
+ child.stdout?.on('data', (chunk) => {
253
+ detail = (detail + String(chunk)).slice(-4000)
254
+ })
255
+ child.stderr?.on('data', (chunk) => {
256
+ detail = (detail + String(chunk)).slice(-4000)
257
+ })
258
+ const timer = setTimeout(() => {
259
+ child.kill()
260
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
261
+ }, UPDATE_TIMEOUT_MS)
262
+ child.once('error', (error) => {
263
+ clearTimeout(timer)
264
+ reject(error)
265
+ })
266
+ child.once('exit', (code) => {
267
+ clearTimeout(timer)
268
+ if (code === 0) resolvePromise()
269
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
270
+ })
271
+ })
272
+ }
273
+
274
+ function json(response, statusCode, value) {
275
+ response.writeHead(statusCode, {
276
+ 'content-type': 'application/json; charset=utf-8',
277
+ 'cache-control': 'no-store',
278
+ })
279
+ response.end(JSON.stringify(value))
280
+ }
281
+
282
+ export function registerPluginUpdater(ctx, options = {}) {
283
+ let installing = false
284
+ if (typeof ctx?.webServer?.register !== 'function') {
285
+ if (typeof ctx?.logger?.warn === 'function') {
286
+ ctx.logger.warn('[dsh-model-sync] webServer service unavailable, updater skipped')
287
+ }
288
+ return () => {}
289
+ }
290
+
291
+ const packageName = options.packageName ?? '@goodandready/dsh-model-sync'
292
+ const endpoints = Array.isArray(options.endpoints)
293
+ ? options.endpoints
294
+ : [options.endpoint ?? '/api/dsh-model-sync/update', '/dsh-model-sync/update']
295
+ const uniqueEndpoints = [...new Set(endpoints)]
296
+
297
+ const handler = async (request, response) => {
298
+ try {
299
+ const target = runtime()
300
+ if (request.method === 'GET' || request.method === 'HEAD') {
301
+ const payload = await status({ ...options, packageName }, target)
302
+ response.writeHead(200, {
303
+ 'content-type': 'application/json; charset=utf-8',
304
+ 'cache-control': 'no-store',
305
+ })
306
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
307
+ return
308
+ }
309
+ if (request.method !== 'POST') {
310
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
311
+ response.end()
312
+ return
313
+ }
314
+ if (!isTrustedUpdateRequest(request)) {
315
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
316
+ return
317
+ }
318
+ if (installing) {
319
+ json(response, 409, { error: 'This plugin is already updating.' })
320
+ return
321
+ }
322
+ installing = true
323
+ try {
324
+ const before = await status({ ...options, packageName }, target)
325
+ if (before.latestVersion === undefined) {
326
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
327
+ return
328
+ }
329
+ if (!before.updateAvailable) {
330
+ json(response, 200, before)
331
+ return
332
+ }
333
+ const doInstall = options.installExact ?? installExact
334
+ await doInstall(target, `${packageName}@${before.latestVersion}`, options)
335
+ json(response, 200, {
336
+ ...before,
337
+ updatedVersion: before.latestVersion,
338
+ restartRequired: true,
339
+ })
340
+ } finally {
341
+ installing = false
342
+ }
343
+ } catch (error) {
344
+ if (typeof ctx?.logger?.warn === 'function') {
345
+ ctx.logger.warn(`[dsh-model-sync] plugin updater failed: ${String(error)}`)
346
+ }
347
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
348
+ }
349
+ }
350
+
351
+ const disposers = uniqueEndpoints.map((path) =>
352
+ ctx.webServer.register({ kind: 'exact', path, handler })
353
+ )
354
+
355
+ return () => {
356
+ for (const dispose of disposers) {
357
+ if (typeof dispose === 'function') {
358
+ try { dispose() } catch (err) { /* bestEffort cleanup */ }
359
+ }
360
+ }
361
+ }
362
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.3.13",
3
+ "version": "0.4.0",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -38,7 +38,11 @@
38
38
  },
39
39
  "client": {
40
40
  "platform": "web",
41
- "inject": []
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-locale",
43
+ "@deepseek-ai/dsh-client-ui-slots",
44
+ "@deepseek-ai/dsh-client-ui-settings"
45
+ ]
42
46
  }
43
47
  },
44
48
  "scripts": {