@goodandready/dsh-messenger-gateway 0.3.21 → 0.3.22

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,304 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { spawn } from 'node:child_process'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { isTrustedSettingsRequest } from './http.js'
8
+
9
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
10
+ const UPDATE_TIMEOUT_MS = 180_000
11
+ const VERSION_CACHE_MS = 300_000
12
+
13
+ let latestCache = null
14
+
15
+ function header(request, name) {
16
+ const value = request?.headers?.[name.toLowerCase()]
17
+ if (Array.isArray(value)) return value[0]
18
+ return typeof value === 'string' ? value : undefined
19
+ }
20
+
21
+ function isLoopback(value) {
22
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
23
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
24
+ || address?.startsWith('127.') === true
25
+ || address?.startsWith('::ffff:127.') === true
26
+ }
27
+
28
+ export function isTrustedUpdateRequest(request) {
29
+ if (header(request, UPDATE_HEADER) !== '1') return false
30
+ if (!isLoopback(request?.socket?.remoteAddress)) return false
31
+ const site = header(request, 'sec-fetch-site')
32
+ if (site !== undefined && site !== 'same-origin') return false
33
+ const origin = header(request, 'origin')
34
+ const host = header(request, 'host')
35
+ if (origin === undefined || host === undefined) return false
36
+ try {
37
+ const url = new URL(origin)
38
+ return (url.protocol === 'http:' || url.protocol === 'https:')
39
+ && isLoopback(url.hostname) && url.host === host
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ function validProfileName(value) {
46
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
47
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
48
+ }
49
+
50
+ function profileNameFromArgv(argv) {
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const item = argv[i]
53
+ if (item === '--profile' || item === '-p') {
54
+ const next = argv[i + 1]
55
+ return validProfileName(next) ? next : undefined
56
+ }
57
+ if (typeof item === 'string' && item.startsWith('--profile=')) {
58
+ const value = item.slice('--profile='.length)
59
+ return validProfileName(value) ? value : undefined
60
+ }
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ export function findDshCliEntry() {
66
+ const value = process.argv[1]
67
+ if (value === undefined || value === '') return undefined
68
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
69
+ if (!existsSync(entry)) return undefined
70
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
71
+ const manifestPath = resolve(directory, 'package.json')
72
+ if (existsSync(manifestPath)) {
73
+ try {
74
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
75
+ const bin = typeof manifest.bin === 'string'
76
+ ? manifest.bin
77
+ : typeof manifest.bin === 'object' && manifest.bin !== null
78
+ ? manifest.bin.dsh
79
+ : undefined
80
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
81
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
82
+ } catch {
83
+ // Continue searching parent directories
84
+ }
85
+ }
86
+ const parent = dirname(directory)
87
+ if (parent === directory) return undefined
88
+ }
89
+ }
90
+
91
+ export function runtime() {
92
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
93
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
94
+ const selected = profileNameFromArgv(process.argv)
95
+ const profileName = validProfileName(selected)
96
+ ? selected
97
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
98
+ const cliEntry = findDshCliEntry()
99
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
100
+ }
101
+
102
+ export function parseSemver(value) {
103
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim())
104
+ if (match === null) return undefined
105
+ return {
106
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
107
+ prerelease: match[4]?.split('.') ?? [],
108
+ }
109
+ }
110
+
111
+ function comparePrerelease(left, right) {
112
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
113
+ const length = Math.max(left.length, right.length)
114
+ for (let index = 0; index < length; index += 1) {
115
+ const a = left[index]
116
+ const b = right[index]
117
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
118
+ if (a === b) continue
119
+ const aNumeric = /^\d+$/.test(a)
120
+ const bNumeric = /^\d+$/.test(b)
121
+ if (aNumeric && bNumeric) {
122
+ const aNumber = BigInt(a)
123
+ const bNumber = BigInt(b)
124
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
125
+ continue
126
+ }
127
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
128
+ return a > b ? 1 : -1
129
+ }
130
+ return 0
131
+ }
132
+
133
+ export function isNewerVersion(currentValue, candidateValue) {
134
+ const current = parseSemver(currentValue)
135
+ const candidate = parseSemver(candidateValue)
136
+ if (current === undefined || candidate === undefined) return false
137
+ for (let index = 0; index < 3; index += 1) {
138
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
139
+ }
140
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
141
+ }
142
+
143
+ export async function fetchLatestVersion(packageName, registry = 'https://registry.npmjs.org') {
144
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
145
+ return latestCache.version
146
+ }
147
+ try {
148
+ const cleanReg = registry.replace(/\/$/, '')
149
+ const response = await fetch(`${cleanReg}/${encodeURIComponent(packageName)}/latest`, {
150
+ headers: { accept: 'application/json' },
151
+ signal: AbortSignal.timeout(8000),
152
+ })
153
+ if (!response.ok) return undefined
154
+ const value = await response.json()
155
+ if (typeof value.version !== 'string' || value.version === '') return undefined
156
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
157
+ return value.version
158
+ } catch {
159
+ return undefined
160
+ }
161
+ }
162
+
163
+ export async function currentVersion(manifestPath) {
164
+ const raw = await readFile(manifestPath, 'utf8')
165
+ const value = JSON.parse(raw)
166
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.')
167
+ return value.version
168
+ }
169
+
170
+ export async function getUpdateStatus(options, target = runtime()) {
171
+ const current = await currentVersion(options.manifestPath)
172
+ const latest = await fetchLatestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
173
+ return {
174
+ packageName: options.packageName,
175
+ currentVersion: current,
176
+ ...(latest === undefined ? {} : { latestVersion: latest }),
177
+ latestCheckFailed: latest === undefined,
178
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
179
+ profileName: target.profileName,
180
+ canAutoUpdate: target.cliEntry !== undefined,
181
+ }
182
+ }
183
+
184
+ export async function installExact(target, packageSpec, options = {}) {
185
+ if (target.cliEntry === undefined) throw new Error('Automatic update CLI entry is unavailable in this runtime.')
186
+ return new Promise((resolvePromise, reject) => {
187
+ const args = [
188
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
189
+ '--config.minimumReleaseAge=0', packageSpec,
190
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
191
+ ]
192
+ const child = spawn(process.execPath, args, {
193
+ cwd: target.profileDir,
194
+ windowsHide: true,
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ env: { ...process.env, NO_COLOR: '1' },
197
+ })
198
+ let detail = ''
199
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
200
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
201
+ const timer = setTimeout(() => {
202
+ child.kill()
203
+ reject(new Error('Update timed out; use the standard DSH update flow.'))
204
+ }, UPDATE_TIMEOUT_MS)
205
+ child.once('error', (error) => { clearTimeout(timer); reject(error) })
206
+ child.once('exit', (code) => {
207
+ clearTimeout(timer)
208
+ if (code === 0) resolvePromise({ ok: true })
209
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
210
+ })
211
+ })
212
+ }
213
+
214
+ let isGlobalUpdating = false
215
+
216
+ export async function runDirectUpdate(options, target = runtime()) {
217
+ if (isGlobalUpdating) {
218
+ throw new Error('Update is already in progress.')
219
+ }
220
+ isGlobalUpdating = true
221
+ try {
222
+ const status = await getUpdateStatus(options, target)
223
+ if (status.latestVersion === undefined) {
224
+ throw new Error('Latest version information is temporarily unavailable.')
225
+ }
226
+ if (!status.updateAvailable) {
227
+ return { ...status, updated: false, message: 'Already up to date.' }
228
+ }
229
+ await installExact(target, `${options.packageName}@${status.latestVersion}`, options)
230
+ return {
231
+ ...status,
232
+ updated: true,
233
+ updatedVersion: status.latestVersion,
234
+ restartRequired: true,
235
+ }
236
+ } finally {
237
+ isGlobalUpdating = false
238
+ }
239
+ }
240
+
241
+ export function registerPluginUpdater(ctx, options) {
242
+ const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
243
+ return getWebServer().register({
244
+ kind: 'exact',
245
+ path: options.endpoint || '/dsh-messenger-gateway/update',
246
+ handler: async (request, response) => {
247
+ try {
248
+ const target = runtime()
249
+ if (request.method === 'GET' || request.method === 'HEAD') {
250
+ const payload = await getUpdateStatus(options, target)
251
+ response.writeHead(200, {
252
+ 'content-type': 'application/json; charset=utf-8',
253
+ 'cache-control': 'no-store',
254
+ })
255
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
256
+ return
257
+ }
258
+ if (request.method !== 'POST') {
259
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
260
+ response.end()
261
+ return
262
+ }
263
+ if (!isTrustedUpdateRequest(request)) {
264
+ response.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
265
+ response.end(JSON.stringify({ ok: false, error: 'Rejected untrusted or cross-origin update request.' }))
266
+ return
267
+ }
268
+ if (isGlobalUpdating) {
269
+ response.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
270
+ response.end(JSON.stringify({ ok: false, error: 'This plugin is already updating.' }))
271
+ return
272
+ }
273
+ isGlobalUpdating = true
274
+ try {
275
+ const before = await getUpdateStatus(options, target)
276
+ if (before.latestVersion === undefined) {
277
+ response.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
278
+ response.end(JSON.stringify({ ok: false, error: 'The latest version is temporarily unavailable.' }))
279
+ return
280
+ }
281
+ if (!before.updateAvailable) {
282
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
283
+ response.end(JSON.stringify({ ...before, updated: false }))
284
+ return
285
+ }
286
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
287
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
288
+ response.end(JSON.stringify({
289
+ ...before,
290
+ updated: true,
291
+ updatedVersion: before.latestVersion,
292
+ restartRequired: true,
293
+ }))
294
+ } finally {
295
+ isGlobalUpdating = false
296
+ }
297
+ } catch (error) {
298
+ ctx.logger?.warn?.(`plugin updater error: ${error?.message || String(error)}`)
299
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
300
+ response.end(JSON.stringify({ ok: false, error: error?.message || 'Plugin update failed.' }))
301
+ }
302
+ },
303
+ })
304
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
5
5
  "license": "MIT",
6
6
  "type": "module",