@goodandready/dsh-clinebot 0.3.7 → 0.3.9

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,245 @@
1
+ /**
2
+ * Host-side one-click updater for @goodandready/dsh-clinebot.
3
+ * Conforms to the DSH authoring reference implementation.
4
+ */
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
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
+ function isLoopback(value) {
24
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
26
+ || address?.startsWith('127.') === true
27
+ || address?.startsWith('::ffff:127.') === true
28
+ }
29
+
30
+ export function isTrustedUpdateRequest(request) {
31
+ if (header(request, UPDATE_HEADER) !== '1') return false
32
+ if (!isLoopback(request?.socket?.remoteAddress)) return false
33
+ const site = header(request, 'sec-fetch-site')
34
+ if (site !== undefined && site !== 'same-origin') return false
35
+ const origin = header(request, 'origin')
36
+ const host = header(request, 'host')
37
+ if (origin === undefined || host === undefined) return false
38
+ try {
39
+ const url = new URL(origin)
40
+ return (url.protocol === 'http:' || url.protocol === 'https:')
41
+ && isLoopback(url.hostname) && url.host === host
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ function validProfileName(value) {
48
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
49
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
50
+ }
51
+
52
+ function profileNameFromArgv(argv) {
53
+ if (!Array.isArray(argv)) return undefined
54
+ for (let index = 2; index < argv.length; index += 1) {
55
+ if (argv[index] === '--profile') return argv[index + 1]
56
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
57
+ }
58
+ return argv[2] === 'web' ? 'web' : undefined
59
+ }
60
+
61
+ function findDshCliEntry() {
62
+ const value = process.argv[1]
63
+ if (value === undefined || value === '') return undefined
64
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
65
+ if (!existsSync(entry)) return undefined
66
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
67
+ const manifestPath = resolve(directory, 'package.json')
68
+ if (existsSync(manifestPath)) {
69
+ try {
70
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
71
+ const bin = typeof manifest.bin === 'string'
72
+ ? manifest.bin
73
+ : typeof manifest.bin === 'object' && manifest.bin !== null
74
+ ? manifest.bin.dsh
75
+ : undefined
76
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
77
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
78
+ } catch {
79
+ /* continue */
80
+ }
81
+ }
82
+ const parent = dirname(directory)
83
+ if (parent === directory) return undefined
84
+ }
85
+ }
86
+
87
+ function detectRuntime() {
88
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
89
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
90
+ const selected = profileNameFromArgv(process.argv)
91
+ const profileName = validProfileName(selected)
92
+ ? selected
93
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
94
+ const cliEntry = findDshCliEntry()
95
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
96
+ }
97
+
98
+ function parseSemver(value) {
99
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ''))
100
+ if (match === null) return undefined
101
+ return {
102
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
103
+ prerelease: match[4]?.split('.') ?? [],
104
+ }
105
+ }
106
+
107
+ export function isNewerVersion(currentValue, candidateValue) {
108
+ const current = parseSemver(currentValue)
109
+ const candidate = parseSemver(candidateValue)
110
+ if (current === undefined || candidate === undefined) return false
111
+ for (let index = 0; index < 3; index += 1) {
112
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
113
+ }
114
+ return false
115
+ }
116
+
117
+ async function fetchLatestVersion(packageName, registry) {
118
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
119
+ return latestCache.version
120
+ }
121
+ try {
122
+ const reg = registry.replace(/\/+$/, '')
123
+ const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
124
+ signal: AbortSignal.timeout(6000),
125
+ })
126
+ if (!response.ok) return undefined
127
+ const value = await response.json()
128
+ if (typeof value?.version !== 'string' || !value.version) return undefined
129
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
130
+ return value.version
131
+ } catch {
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ async function readCurrentVersion(manifestUrl) {
137
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
138
+ if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.')
139
+ return value.version
140
+ }
141
+
142
+ export async function checkUpdateStatus(options, target = detectRuntime()) {
143
+ const current = await readCurrentVersion(options.manifestUrl)
144
+ const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
145
+ return {
146
+ packageName: options.packageName,
147
+ currentVersion: current,
148
+ ...(latest === undefined ? {} : { latestVersion: latest }),
149
+ latestCheckFailed: latest === undefined,
150
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
151
+ profileName: target.profileName,
152
+ canAutoUpdate: target.cliEntry !== undefined,
153
+ }
154
+ }
155
+
156
+ async function installExact(target, packageSpec, options) {
157
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
158
+ await new Promise((resolvePromise, reject) => {
159
+ const child = spawn(process.execPath, [
160
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
161
+ '--config.minimumReleaseAge=0', packageSpec,
162
+ `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
163
+ ], {
164
+ cwd: target.profileDir,
165
+ windowsHide: true,
166
+ stdio: ['ignore', 'pipe', 'pipe'],
167
+ env: { ...process.env, NO_COLOR: '1' },
168
+ })
169
+ let detail = ''
170
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
171
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
172
+ const timer = setTimeout(() => {
173
+ child.kill()
174
+ reject(new Error('Update timed out.'))
175
+ }, UPDATE_TIMEOUT_MS)
176
+ child.once('error', (error) => { clearTimeout(timer); reject(error) })
177
+ child.once('exit', (code) => {
178
+ clearTimeout(timer)
179
+ if (code === 0) resolvePromise()
180
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
181
+ })
182
+ })
183
+ }
184
+
185
+ function writeResponseJson(response, statusCode, value) {
186
+ response.writeHead(statusCode, {
187
+ 'content-type': 'application/json; charset=utf-8',
188
+ 'cache-control': 'no-store',
189
+ })
190
+ response.end(JSON.stringify(value))
191
+ }
192
+
193
+ export function registerPluginUpdater(ctx, options) {
194
+ if (!ctx?.webServer?.register) return () => {}
195
+ let installing = false
196
+ return ctx.webServer.register({
197
+ kind: 'exact',
198
+ path: options.endpoint,
199
+ handler: async (request, response) => {
200
+ try {
201
+ const target = detectRuntime()
202
+ if (request.method === 'GET' || request.method === 'HEAD') {
203
+ const payload = await checkUpdateStatus(options, target)
204
+ writeResponseJson(response, 200, payload)
205
+ return
206
+ }
207
+ if (request.method !== 'POST') {
208
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
209
+ response.end()
210
+ return
211
+ }
212
+ if (!isTrustedUpdateRequest(request)) {
213
+ writeResponseJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' })
214
+ return
215
+ }
216
+ if (installing) {
217
+ writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
218
+ return
219
+ }
220
+ installing = true
221
+ try {
222
+ const before = await checkUpdateStatus(options, target)
223
+ if (before.latestVersion === undefined) {
224
+ writeResponseJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' })
225
+ return
226
+ }
227
+ if (!before.updateAvailable) {
228
+ writeResponseJson(response, 200, before)
229
+ return
230
+ }
231
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
232
+ writeResponseJson(response, 200, {
233
+ ...before,
234
+ updatedVersion: before.latestVersion,
235
+ restartRequired: true,
236
+ })
237
+ } finally {
238
+ installing = false
239
+ }
240
+ } catch (error) {
241
+ writeResponseJson(response, 500, { ok: false, error: String(error?.message || error) })
242
+ }
243
+ },
244
+ })
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",