@goodandready/dsh-clinebot 0.3.8 → 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/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import z from '@deepseek-ai/schemastery'
2
2
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
3
  import { writeJson, readBody, isTrustedSettingsRequest } from './http.js'
4
+ import { registerPluginUpdater } from './updater.js'
4
5
  import {
5
6
  CLINE_MODELS,
6
7
  DEFAULT_MODEL_ID,
@@ -21,6 +22,9 @@ import {
21
22
  DEFAULT_SMOKE_TIMEOUT_MS,
22
23
  normalizeBaseUrl,
23
24
  resolveApiKey,
25
+ resolveKeyValue,
26
+ resolveAccountPool,
27
+ rotateToNextAccount,
24
28
  saveCredentialKey,
25
29
  fetchUsageLimits,
26
30
  probeHealth,
@@ -29,6 +33,7 @@ import {
29
33
  sessionStats,
30
34
  recordSessionRequest,
31
35
  resetSessionStats,
36
+ usageCache,
32
37
  } from './cline-client.js'
33
38
 
34
39
  export const name = '@goodandready/dsh-clinebot'
@@ -37,7 +42,7 @@ export const inject = ['settings', 'webServer', 'credentials']
37
42
  export const NS = 'dsh-clinebot'
38
43
  export const LLM_PI_AI_NS = 'llm-pi-ai'
39
44
 
40
- export { sessionStats, recordSessionRequest, resetSessionStats }
45
+ export { sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
41
46
 
42
47
  export const Config = z.object({
43
48
  enabled: z.boolean().default(true)
@@ -120,80 +125,6 @@ function resolvePathWithHome(p) {
120
125
  return p
121
126
  }
122
127
 
123
- async function resolveKeyValue(ctx, apiKeyEnv) {
124
- const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
125
- const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
126
- if (creds && typeof creds.resolve === 'function') {
127
- try {
128
- const hit = await creds.resolve(credentialRef(refName))
129
- if (hit?.value) {
130
- return { envName: refName, value: hit.value, source: 'credentials' }
131
- }
132
- } catch {
133
- /* credentials service miss, fall through */
134
- }
135
- }
136
-
137
- const fromEnv = resolveApiKey(refName)
138
- if (fromEnv.value) {
139
- return { ...fromEnv, source: 'env' }
140
- }
141
-
142
- return { envName: refName, value: '', source: 'none' }
143
- }
144
-
145
- /**
146
- * Resolve all accounts in pool with their status and quota.
147
- */
148
- async function resolveAccountPool(ctx, cfg) {
149
- const pub = publicConfig(cfg)
150
- const defaultSlot = {
151
- id: 'default',
152
- label: 'Default',
153
- apiKeyEnv: pub.apiKeyEnv,
154
- }
155
- const allSlots = [defaultSlot, ...(Array.isArray(pub.accounts) ? pub.accounts : [])]
156
- const resolved = []
157
-
158
- for (let i = 0; i < allSlots.length; i++) {
159
- const slot = allSlots[i]
160
- const envName = slot.apiKeyEnv || (i === 0 ? pub.apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
161
- const keyInfo = await resolveKeyValue(ctx, envName)
162
- resolved.push({
163
- id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
164
- label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
165
- apiKeyEnv: envName,
166
- present: Boolean(keyInfo.value),
167
- source: keyInfo.source,
168
- value: keyInfo.value,
169
- isPinned: pub.activeAccount ? pub.activeAccount === envName : i === 0,
170
- })
171
- }
172
-
173
- return resolved
174
- }
175
-
176
- /**
177
- * Resolve active key with failover support.
178
- */
179
- async function resolveActiveAccountKey(ctx, cfg) {
180
- const pool = await resolveAccountPool(ctx, cfg)
181
- const configured = pool.filter((acc) => acc.present && acc.value)
182
- if (!configured.length) {
183
- return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
184
- }
185
-
186
- // If user pinned a specific account and it has a key, prefer it
187
- const pub = publicConfig(cfg)
188
- if (pub.activeAccount) {
189
- const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
190
- if (pinned) return pinned
191
- }
192
-
193
- // Default: first available configured account
194
- return configured[0]
195
- }
196
-
197
128
  async function checkRegisteredInPiAi(ctx) {
198
129
  const settings = ctx?.get?.('settings')
199
130
  if (!settings?.get) return false
@@ -205,48 +136,18 @@ async function checkRegisteredInPiAi(ctx) {
205
136
  }
206
137
  }
207
138
 
208
- /**
209
- * Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
210
- */
211
- export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
139
+ async function resolveActiveAccountKey(ctx, cfg) {
212
140
  const pool = await resolveAccountPool(ctx, cfg)
213
141
  const configured = pool.filter((acc) => acc.present && acc.value)
214
- if (configured.length <= 1) {
215
- return { rotated: false, reason, message: 'Pool has only 1 configured account' }
142
+ if (!configured.length) {
143
+ return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
216
144
  }
217
-
218
145
  const pub = publicConfig(cfg)
219
- const currentEnv = pub.activeAccount || configured[0].apiKeyEnv
220
- const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === currentEnv)
221
- const nextIndex = (currentIndex + 1) % configured.length
222
- const nextAcc = configured[nextIndex]
223
-
224
- let updated = false
225
- if (settingsApi?.replace) {
226
- try {
227
- const next = Config({ ...cfg, activeAccount: nextAcc.apiKeyEnv })
228
- await settingsApi.replace(next)
229
- updated = true
230
- } catch {}
231
- } else {
232
- const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
233
- if (settings?.mutate) {
234
- try {
235
- await settings.mutate(NS, [
236
- { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
237
- ])
238
- updated = true
239
- } catch {}
240
- }
241
- }
242
-
243
- return {
244
- rotated: true,
245
- previousAccount: currentEnv,
246
- activeAccount: nextAcc.apiKeyEnv,
247
- reason,
248
- updatedSettings: updated,
146
+ if (pub.activeAccount) {
147
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
148
+ if (pinned) return pinned
249
149
  }
150
+ return configured[0]
250
151
  }
251
152
 
252
153
  async function buildStatus(ctx, cfg) {
@@ -278,13 +179,13 @@ async function buildStatus(ctx, cfg) {
278
179
  if (pct >= 95) {
279
180
  quotaWarning = {
280
181
  level: 'exhausted',
281
- message: `5-часовой лимит почти полностью исчерпан (${pct}%). Новые запросы могут отклоняться провайдером до сброса.`,
182
+ message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
282
183
  resetsAt: usage.windows.fiveHour.resetsAt,
283
184
  }
284
185
  } else if (pct >= 80) {
285
186
  quotaWarning = {
286
187
  level: 'warning',
287
- message: `Внимание: израсходовано ${pct}% 5-часового скользящего лимита.`,
188
+ message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
288
189
  resetsAt: usage.windows.fiveHour.resetsAt,
289
190
  }
290
191
  }
@@ -474,6 +375,16 @@ export function apply(ctx, config) {
474
375
 
475
376
  // Web server HTTP route handlers
476
377
  if (ctx.webServer?.register) {
378
+ // 0. GET & POST /dsh-clinebot/update — host one-click updater
379
+ const unregisterUpdater = registerPluginUpdater(ctx, {
380
+ endpoint: '/dsh-clinebot/update',
381
+ packageName: name,
382
+ manifestUrl: new URL('../package.json', import.meta.url),
383
+ })
384
+ if (typeof ctx.effect === 'function') {
385
+ ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
386
+ }
387
+
477
388
  // 1. GET /dsh-clinebot/status
478
389
  ctx.effect(() => ctx.webServer.register({
479
390
  kind: 'exact',
@@ -887,56 +798,59 @@ export function apply(ctx, config) {
887
798
  const pool = await resolveAccountPool(ctx, live())
888
799
  const lines = [
889
800
  '### 🔑 ClinePass Accounts Pool',
890
- `* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
801
+ `* **Active Account**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
891
802
  '',
892
803
  ]
893
804
  for (const acc of pool) {
894
805
  const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
895
- const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
896
- lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
806
+ const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing Key'
807
+ const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
808
+ const cached = usageCache.get(cacheKey)?.data
809
+ const usageInfo = cached?.windows?.fiveHour ? ` · ⏱ ${cached.windows.fiveHour.percentUsed}% used` : ''
810
+ lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}${usageInfo}`)
897
811
  }
898
- lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
812
+ lines.push('', 'Switch active account: `/cline switch <env_variable_name>`')
899
813
  return lines.join('\n')
900
814
  }
901
815
 
902
816
  // 3. Subcommand /cline switch <account>
903
817
  if (subcmd === 'switch') {
904
818
  if (!param) {
905
- return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
819
+ return '⚠️ Please specify account: `/cline switch <CLINEBOT_API_KEY_2>`'
906
820
  }
907
821
  if (settingsApi?.replace) {
908
822
  const next = Config({ ...live(), activeAccount: param })
909
823
  await settingsApi.replace(next)
910
824
  await syncProviderState(next)
911
- return `✅ Активный аккаунт переключен на \`${param}\``
825
+ return `✅ Active account switched to \`${param}\``
912
826
  }
913
- return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
827
+ return `⚠️ Could not apply setting (settings service unavailable).`
914
828
  }
915
829
 
916
- // 4. Subcommand /cline rotate (auto-failover next)
830
+ // 4. Subcommand /cline rotate (smart failover next)
917
831
  if (subcmd === 'rotate') {
918
832
  const res = await rotateToNextAccount(ctx, live(), 'slash_command', settingsApi)
919
833
  if (res.rotated) {
920
834
  await syncProviderState(live())
921
- return `🔄 **Ротация аккаунта**: переключено с \`${res.previousAccount}\` на \`${res.activeAccount}\`. Провайдер DSH обновлён!`
835
+ return `🔄 **Account Rotated**: switched from \`${res.previousAccount}\` to \`${res.activeAccount}\`. DSH provider updated!`
922
836
  }
923
- return `⚠️ Ротация не выполнена: ${res.message || 'нет доступных альтернативных аккаунтов в пуле'}.`
837
+ return `⚠️ Rotation skipped: ${res.message || 'no alternative configured accounts in pool'}.`
924
838
  }
925
839
 
926
840
  // 5. Subcommand /cline ping (fresh host reachability probe)
927
841
  if (subcmd === 'ping') {
928
842
  const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
929
843
  if (health.ok) {
930
- return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` доступен (задержка: **${health.latencyMs} мс**, HTTP ${health.status})`
844
+ return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` is reachable (latency: **${health.latencyMs} ms**, HTTP ${health.status})`
931
845
  }
932
- return `❌ **Cline API Ping Failed**: ${health.error || 'Хост недоступен'}`
846
+ return `❌ **Cline API Ping Failed**: ${health.error || 'Host unreachable'}`
933
847
  }
934
848
 
935
- // 6. Subcommand /cline test [model]
849
+ // 6. Subcommand /cline test [model] / smoke
936
850
  if (subcmd === 'test' || subcmd === 'smoke') {
937
851
  const activeKey = await resolveActiveAccountKey(ctx, live())
938
852
  if (!activeKey.value) {
939
- return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot**.'
853
+ return '⚠️ **ClineBot**: API key is not configured. Open **Settings → Plugins → ClineBot**.'
940
854
  }
941
855
  const modelToTest = param || pub.defaultModel || DEFAULT_MODEL_ID
942
856
  const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
@@ -956,26 +870,25 @@ export function apply(ctx, config) {
956
870
  const failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
957
871
  if (failover.rotated) {
958
872
  await syncProviderState(live())
959
- failoverNotice = `
960
- 🔄 **Auto-failover**: Обнаружен HTTP 429! Активный аккаунт автоматически переключен на \`${failover.activeAccount}\`.`
873
+ failoverNotice = `\n🔄 **Auto-failover**: HTTP 429 detected! Active account automatically rotated to \`${failover.activeAccount}\`.`
961
874
  }
962
875
  }
963
876
 
964
877
  if (outcome.ok) {
965
878
  return [
966
- `### 🟢 Smoke Test Успешен: \`${outcome.model}\``,
967
- `* **Задержка**: ${outcome.latencyMs} мс`,
968
- `* **Токены**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (всего: ${outcome.totalTokens})`,
969
- `* **Превью**: _"${outcome.preview}"_`,
879
+ `### 🟢 Smoke Test Passed: \`${outcome.model}\``,
880
+ `* **Latency**: ${outcome.latencyMs} ms`,
881
+ `* **Tokens**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (total: ${outcome.totalTokens})`,
882
+ `* **Preview**: _"${outcome.preview}"_`,
970
883
  ].join('\n')
971
884
  }
972
- return `❌ **Smoke Test Ошибка**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
885
+ return `❌ **Smoke Test Failed**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
973
886
  }
974
887
 
975
- // 4. Default /cline quota
888
+ // 7. Subcommand /cline quota or /cline balance
976
889
  const activeKey = await resolveActiveAccountKey(ctx, live())
977
890
  if (!activeKey.value) {
978
- return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
891
+ return '⚠️ **ClineBot**: API key is not configured. Open **Settings → Plugins → ClineBot**.'
979
892
  }
980
893
 
981
894
  const [health, usage] = await Promise.all([
@@ -985,31 +898,31 @@ export function apply(ctx, config) {
985
898
 
986
899
  const fiveHour = usage?.windows?.fiveHour
987
900
  const weekly = usage?.windows?.weekly
988
- const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString() : 'н/д'
989
- const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString() : 'н/д'
901
+ const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString() : 'N/A'
902
+ const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString() : 'N/A'
990
903
 
991
904
  const lines = [
992
905
  `### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
993
- `* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
994
- `* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
995
- `* **Модель по умолчанию**: \`${pub.defaultModel}\``,
906
+ `* **Host Ping**: ${health.ok ? `✅ ${health.latencyMs} ms` : '❌ Unreachable'}`,
907
+ `* **Active Key**: \`${activeKey.envName}\` (${activeKey.source})`,
908
+ `* **Default Model**: \`${pub.defaultModel}\``,
996
909
  '',
997
- `**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
998
- `**📅 Недельное окно**: ${formatProgressBar(weekly?.percentUsed)} (сброс: ${resetWk})`,
910
+ `**⏱ 5-Hour Rolling Limit**: ${formatProgressBar(fiveHour?.percentUsed)} (reset: ${reset5h})`,
911
+ `**📅 Weekly Window**: ${formatProgressBar(weekly?.percentUsed)} (reset: ${resetWk})`,
999
912
  ]
1000
913
 
1001
914
  if (fiveHour?.percentUsed >= 95) {
1002
- lines.push('', '🚨 **КРИТИЧЕСКИЙ ЛИМИТ**: 5-часовая квота израсходована на 95%+. Запросы могут быть заблокированы до сброса!')
915
+ lines.push('', '🚨 **CRITICAL LIMIT**: 5-hour quota is 95%+ consumed! New requests may be throttled until reset.')
1003
916
  } else if (fiveHour?.percentUsed >= 80) {
1004
- lines.push('', '⚠️ **Внимание**: 5-часовая квота израсходована на ' + fiveHour.percentUsed + '%.')
917
+ lines.push('', `⚠️ **Warning**: 5-hour quota is ${fiveHour.percentUsed}% consumed.`)
1005
918
  }
1006
919
 
1007
920
  if (sessionStats.totalRequests > 0) {
1008
- lines.push('', `**📊 Сессия DSH**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} успешных запросов, ~${sessionStats.totalTokensEst} токенов`)
921
+ lines.push('', `**📊 Session Telemetry**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} successful requests, ~${sessionStats.totalTokensEst} total tokens`)
1009
922
  }
1010
923
 
1011
924
  if (usage?.user?.email) {
1012
- lines.push(`* **Аккаунт**: \`${usage.user.email}\``)
925
+ lines.push(`* **Account**: \`${usage.user.email}\``)
1013
926
  }
1014
927
 
1015
928
  return lines.join('\n')
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.8",
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",