@goodandready/dsh-clinebot 0.3.8 → 0.3.10

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,
@@ -9,6 +10,7 @@ import {
9
10
  getAllModels,
10
11
  getDefaultModelIds,
11
12
  getActiveModelIds,
13
+ isSupportedModel,
12
14
  parsePlanIncludedModels,
13
15
  saveModelsDiskCache,
14
16
  loadModelsDiskCache,
@@ -21,6 +23,9 @@ import {
21
23
  DEFAULT_SMOKE_TIMEOUT_MS,
22
24
  normalizeBaseUrl,
23
25
  resolveApiKey,
26
+ resolveKeyValue,
27
+ resolveAccountPool,
28
+ rotateToNextAccount,
24
29
  saveCredentialKey,
25
30
  fetchUsageLimits,
26
31
  probeHealth,
@@ -29,6 +34,9 @@ import {
29
34
  sessionStats,
30
35
  recordSessionRequest,
31
36
  resetSessionStats,
37
+ clearUsageCache,
38
+ clearProbeCache,
39
+ usageCache,
32
40
  } from './cline-client.js'
33
41
 
34
42
  export const name = '@goodandready/dsh-clinebot'
@@ -37,7 +45,7 @@ export const inject = ['settings', 'webServer', 'credentials']
37
45
  export const NS = 'dsh-clinebot'
38
46
  export const LLM_PI_AI_NS = 'llm-pi-ai'
39
47
 
40
- export { sessionStats, recordSessionRequest, resetSessionStats }
48
+ export { sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
41
49
 
42
50
  export const Config = z.object({
43
51
  enabled: z.boolean().default(true)
@@ -120,80 +128,6 @@ function resolvePathWithHome(p) {
120
128
  return p
121
129
  }
122
130
 
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
131
  async function checkRegisteredInPiAi(ctx) {
198
132
  const settings = ctx?.get?.('settings')
199
133
  if (!settings?.get) return false
@@ -205,48 +139,18 @@ async function checkRegisteredInPiAi(ctx) {
205
139
  }
206
140
  }
207
141
 
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) {
142
+ async function resolveActiveAccountKey(ctx, cfg) {
212
143
  const pool = await resolveAccountPool(ctx, cfg)
213
144
  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' }
145
+ if (!configured.length) {
146
+ return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
216
147
  }
217
-
218
148
  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,
149
+ if (pub.activeAccount) {
150
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
151
+ if (pinned) return pinned
249
152
  }
153
+ return configured[0]
250
154
  }
251
155
 
252
156
  async function buildStatus(ctx, cfg) {
@@ -278,13 +182,13 @@ async function buildStatus(ctx, cfg) {
278
182
  if (pct >= 95) {
279
183
  quotaWarning = {
280
184
  level: 'exhausted',
281
- message: `5-часовой лимит почти полностью исчерпан (${pct}%). Новые запросы могут отклоняться провайдером до сброса.`,
185
+ message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
282
186
  resetsAt: usage.windows.fiveHour.resetsAt,
283
187
  }
284
188
  } else if (pct >= 80) {
285
189
  quotaWarning = {
286
190
  level: 'warning',
287
- message: `Внимание: израсходовано ${pct}% 5-часового скользящего лимита.`,
191
+ message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
288
192
  resetsAt: usage.windows.fiveHour.resetsAt,
289
193
  }
290
194
  }
@@ -474,6 +378,16 @@ export function apply(ctx, config) {
474
378
 
475
379
  // Web server HTTP route handlers
476
380
  if (ctx.webServer?.register) {
381
+ // 0. GET & POST /dsh-clinebot/update — host one-click updater
382
+ const unregisterUpdater = registerPluginUpdater(ctx, {
383
+ endpoint: '/dsh-clinebot/update',
384
+ packageName: name,
385
+ manifestUrl: new URL('../package.json', import.meta.url),
386
+ })
387
+ if (typeof ctx.effect === 'function') {
388
+ ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
389
+ }
390
+
477
391
  // 1. GET /dsh-clinebot/status
478
392
  ctx.effect(() => ctx.webServer.register({
479
393
  kind: 'exact',
@@ -794,6 +708,8 @@ export function apply(ctx, config) {
794
708
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
795
709
  const account = String(body.account || '').trim()
796
710
 
711
+ clearUsageCache()
712
+ clearProbeCache()
797
713
  if (settingsApi?.replace) {
798
714
  const next = Config({ ...live(), activeAccount: account })
799
715
  await settingsApi.replace(next)
@@ -887,56 +803,64 @@ export function apply(ctx, config) {
887
803
  const pool = await resolveAccountPool(ctx, live())
888
804
  const lines = [
889
805
  '### 🔑 ClinePass Accounts Pool',
890
- `* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
806
+ `* **Active Account**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
891
807
  '',
892
808
  ]
893
809
  for (const acc of pool) {
894
810
  const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
895
- const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
896
- lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
811
+ const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing Key'
812
+ const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
813
+ const cached = usageCache.get(cacheKey)?.data
814
+ const usageInfo = cached?.windows?.fiveHour ? ` · ⏱ ${cached.windows.fiveHour.percentUsed}% used` : ''
815
+ lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}${usageInfo}`)
897
816
  }
898
- lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
817
+ lines.push('', 'Switch active account: `/cline switch <env_variable_name>`')
899
818
  return lines.join('\n')
900
819
  }
901
820
 
902
821
  // 3. Subcommand /cline switch <account>
903
822
  if (subcmd === 'switch') {
904
823
  if (!param) {
905
- return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
824
+ return '⚠️ Please specify account: `/cline switch <CLINEBOT_API_KEY_2>`'
906
825
  }
826
+ clearUsageCache()
827
+ clearProbeCache()
907
828
  if (settingsApi?.replace) {
908
829
  const next = Config({ ...live(), activeAccount: param })
909
830
  await settingsApi.replace(next)
910
831
  await syncProviderState(next)
911
- return `✅ Активный аккаунт переключен на \`${param}\``
832
+ return `✅ Active account switched to \`${param}\``
912
833
  }
913
- return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
834
+ return `⚠️ Could not apply setting (settings service unavailable).`
914
835
  }
915
836
 
916
- // 4. Subcommand /cline rotate (auto-failover next)
837
+ // 4. Subcommand /cline rotate (smart failover next)
917
838
  if (subcmd === 'rotate') {
918
839
  const res = await rotateToNextAccount(ctx, live(), 'slash_command', settingsApi)
919
840
  if (res.rotated) {
920
841
  await syncProviderState(live())
921
- return `🔄 **Ротация аккаунта**: переключено с \`${res.previousAccount}\` на \`${res.activeAccount}\`. Провайдер DSH обновлён!`
842
+ return `🔄 **Account Rotated**: switched from \`${res.previousAccount}\` to \`${res.activeAccount}\`. DSH provider updated!`
922
843
  }
923
- return `⚠️ Ротация не выполнена: ${res.message || 'нет доступных альтернативных аккаунтов в пуле'}.`
844
+ return `⚠️ Rotation skipped: ${res.message || 'no alternative configured accounts in pool'}.`
924
845
  }
925
846
 
926
847
  // 5. Subcommand /cline ping (fresh host reachability probe)
927
848
  if (subcmd === 'ping') {
928
849
  const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
929
850
  if (health.ok) {
930
- return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` доступен (задержка: **${health.latencyMs} мс**, HTTP ${health.status})`
851
+ return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` is reachable (latency: **${health.latencyMs} ms**, HTTP ${health.status})`
931
852
  }
932
- return `❌ **Cline API Ping Failed**: ${health.error || 'Хост недоступен'}`
853
+ return `❌ **Cline API Ping Failed**: ${health.error || 'Host unreachable'}`
933
854
  }
934
855
 
935
- // 6. Subcommand /cline test [model]
856
+ // 6. Subcommand /cline test [model] / smoke
936
857
  if (subcmd === 'test' || subcmd === 'smoke') {
858
+ if (param && !isSupportedModel(param, pub.dynamicModels)) {
859
+ return `⚠️ **ClineBot**: Model \`${param}\` is not recognized. Use \`/cline models\` to see available models.`
860
+ }
937
861
  const activeKey = await resolveActiveAccountKey(ctx, live())
938
862
  if (!activeKey.value) {
939
- return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot**.'
863
+ return '⚠️ **ClineBot**: API key is not configured. Open **Settings → Plugins → ClineBot**.'
940
864
  }
941
865
  const modelToTest = param || pub.defaultModel || DEFAULT_MODEL_ID
942
866
  const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
@@ -956,26 +880,25 @@ export function apply(ctx, config) {
956
880
  const failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
957
881
  if (failover.rotated) {
958
882
  await syncProviderState(live())
959
- failoverNotice = `
960
- 🔄 **Auto-failover**: Обнаружен HTTP 429! Активный аккаунт автоматически переключен на \`${failover.activeAccount}\`.`
883
+ failoverNotice = `\n🔄 **Auto-failover**: HTTP 429 detected! Active account automatically rotated to \`${failover.activeAccount}\`.`
961
884
  }
962
885
  }
963
886
 
964
887
  if (outcome.ok) {
965
888
  return [
966
- `### 🟢 Smoke Test Успешен: \`${outcome.model}\``,
967
- `* **Задержка**: ${outcome.latencyMs} мс`,
968
- `* **Токены**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (всего: ${outcome.totalTokens})`,
969
- `* **Превью**: _"${outcome.preview}"_`,
889
+ `### 🟢 Smoke Test Passed: \`${outcome.model}\``,
890
+ `* **Latency**: ${outcome.latencyMs} ms`,
891
+ `* **Tokens**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (total: ${outcome.totalTokens})`,
892
+ `* **Preview**: _"${outcome.preview}"_`,
970
893
  ].join('\n')
971
894
  }
972
- return `❌ **Smoke Test Ошибка**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
895
+ return `❌ **Smoke Test Failed**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
973
896
  }
974
897
 
975
- // 4. Default /cline quota
898
+ // 7. Subcommand /cline quota or /cline balance
976
899
  const activeKey = await resolveActiveAccountKey(ctx, live())
977
900
  if (!activeKey.value) {
978
- return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
901
+ return '⚠️ **ClineBot**: API key is not configured. Open **Settings → Plugins → ClineBot**.'
979
902
  }
980
903
 
981
904
  const [health, usage] = await Promise.all([
@@ -985,31 +908,31 @@ export function apply(ctx, config) {
985
908
 
986
909
  const fiveHour = usage?.windows?.fiveHour
987
910
  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() : 'н/д'
911
+ const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString() : 'N/A'
912
+ const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString() : 'N/A'
990
913
 
991
914
  const lines = [
992
915
  `### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
993
- `* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
994
- `* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
995
- `* **Модель по умолчанию**: \`${pub.defaultModel}\``,
916
+ `* **Host Ping**: ${health.ok ? `✅ ${health.latencyMs} ms` : '❌ Unreachable'}`,
917
+ `* **Active Key**: \`${activeKey.envName}\` (${activeKey.source})`,
918
+ `* **Default Model**: \`${pub.defaultModel}\``,
996
919
  '',
997
- `**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
998
- `**📅 Недельное окно**: ${formatProgressBar(weekly?.percentUsed)} (сброс: ${resetWk})`,
920
+ `**⏱ 5-Hour Rolling Limit**: ${formatProgressBar(fiveHour?.percentUsed)} (reset: ${reset5h})`,
921
+ `**📅 Weekly Window**: ${formatProgressBar(weekly?.percentUsed)} (reset: ${resetWk})`,
999
922
  ]
1000
923
 
1001
924
  if (fiveHour?.percentUsed >= 95) {
1002
- lines.push('', '🚨 **КРИТИЧЕСКИЙ ЛИМИТ**: 5-часовая квота израсходована на 95%+. Запросы могут быть заблокированы до сброса!')
925
+ lines.push('', '🚨 **CRITICAL LIMIT**: 5-hour quota is 95%+ consumed! New requests may be throttled until reset.')
1003
926
  } else if (fiveHour?.percentUsed >= 80) {
1004
- lines.push('', '⚠️ **Внимание**: 5-часовая квота израсходована на ' + fiveHour.percentUsed + '%.')
927
+ lines.push('', `⚠️ **Warning**: 5-hour quota is ${fiveHour.percentUsed}% consumed.`)
1005
928
  }
1006
929
 
1007
930
  if (sessionStats.totalRequests > 0) {
1008
- lines.push('', `**📊 Сессия DSH**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} успешных запросов, ~${sessionStats.totalTokensEst} токенов`)
931
+ lines.push('', `**📊 Session Telemetry**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} successful requests, ~${sessionStats.totalTokensEst} total tokens`)
1009
932
  }
1010
933
 
1011
934
  if (usage?.user?.email) {
1012
- lines.push(`* **Аккаунт**: \`${usage.user.email}\``)
935
+ lines.push(`* **Account**: \`${usage.user.email}\``)
1013
936
  }
1014
937
 
1015
938
  return lines.join('\n')
package/lib/updater.js ADDED
@@ -0,0 +1,276 @@
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
+ function comparePrerelease(candidateParts, currentParts) {
108
+ // A normal version with no pre-release tag is newer than a pre-release version
109
+ if (candidateParts.length === 0 && currentParts.length > 0) return 1
110
+ if (candidateParts.length > 0 && currentParts.length === 0) return -1
111
+ if (candidateParts.length === 0 && currentParts.length === 0) return 0
112
+
113
+ const len = Math.max(candidateParts.length, currentParts.length)
114
+ for (let i = 0; i < len; i += 1) {
115
+ const a = candidateParts[i]
116
+ const b = currentParts[i]
117
+ if (a === undefined) return -1
118
+ if (b === undefined) return 1
119
+ if (a === b) continue
120
+
121
+ const aNum = /^\d+$/.test(a) ? Number(a) : undefined
122
+ const bNum = /^\d+$/.test(b) ? Number(b) : undefined
123
+
124
+ if (aNum !== undefined && bNum !== undefined) {
125
+ return aNum > bNum ? 1 : -1
126
+ }
127
+ if (aNum !== undefined && bNum === undefined) {
128
+ return -1
129
+ }
130
+ if (aNum === undefined && bNum !== undefined) {
131
+ return 1
132
+ }
133
+ return a.localeCompare(b) > 0 ? 1 : -1
134
+ }
135
+ return 0
136
+ }
137
+
138
+ export function isNewerVersion(currentValue, candidateValue) {
139
+ const current = parseSemver(currentValue)
140
+ const candidate = parseSemver(candidateValue)
141
+ if (current === undefined || candidate === undefined) return false
142
+ for (let index = 0; index < 3; index += 1) {
143
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
144
+ }
145
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
146
+ }
147
+
148
+ async function fetchLatestVersion(packageName, registry) {
149
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
150
+ return latestCache.version
151
+ }
152
+ try {
153
+ const reg = registry.replace(/\/+$/, '')
154
+ const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
155
+ signal: AbortSignal.timeout(6000),
156
+ })
157
+ if (!response.ok) return undefined
158
+ const value = await response.json()
159
+ if (typeof value?.version !== 'string' || !value.version) return undefined
160
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
161
+ return value.version
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ async function readCurrentVersion(manifestUrl) {
168
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
169
+ if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.')
170
+ return value.version
171
+ }
172
+
173
+ export async function checkUpdateStatus(options, target = detectRuntime()) {
174
+ const current = await readCurrentVersion(options.manifestUrl)
175
+ const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
176
+ return {
177
+ packageName: options.packageName,
178
+ currentVersion: current,
179
+ ...(latest === undefined ? {} : { latestVersion: latest }),
180
+ latestCheckFailed: latest === undefined,
181
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
182
+ profileName: target.profileName,
183
+ canAutoUpdate: target.cliEntry !== undefined,
184
+ }
185
+ }
186
+
187
+ async function installExact(target, packageSpec, options) {
188
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
189
+ await new Promise((resolvePromise, reject) => {
190
+ const child = spawn(process.execPath, [
191
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
192
+ '--config.minimumReleaseAge=0', packageSpec,
193
+ `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
194
+ ], {
195
+ cwd: target.profileDir,
196
+ windowsHide: true,
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ env: { ...process.env, NO_COLOR: '1' },
199
+ })
200
+ let detail = ''
201
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
202
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
203
+ const timer = setTimeout(() => {
204
+ child.kill()
205
+ reject(new Error('Update timed out.'))
206
+ }, UPDATE_TIMEOUT_MS)
207
+ child.once('error', (error) => { clearTimeout(timer); reject(error) })
208
+ child.once('exit', (code) => {
209
+ clearTimeout(timer)
210
+ if (code === 0) resolvePromise()
211
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
212
+ })
213
+ })
214
+ }
215
+
216
+ function writeResponseJson(response, statusCode, value) {
217
+ response.writeHead(statusCode, {
218
+ 'content-type': 'application/json; charset=utf-8',
219
+ 'cache-control': 'no-store',
220
+ })
221
+ response.end(JSON.stringify(value))
222
+ }
223
+
224
+ export function registerPluginUpdater(ctx, options) {
225
+ if (!ctx?.webServer?.register) return () => {}
226
+ let installing = false
227
+ return ctx.webServer.register({
228
+ kind: 'exact',
229
+ path: options.endpoint,
230
+ handler: async (request, response) => {
231
+ try {
232
+ const target = detectRuntime()
233
+ if (request.method === 'GET' || request.method === 'HEAD') {
234
+ const payload = await checkUpdateStatus(options, target)
235
+ writeResponseJson(response, 200, payload)
236
+ return
237
+ }
238
+ if (request.method !== 'POST') {
239
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
240
+ response.end()
241
+ return
242
+ }
243
+ if (!isTrustedUpdateRequest(request)) {
244
+ writeResponseJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' })
245
+ return
246
+ }
247
+ if (installing) {
248
+ writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
249
+ return
250
+ }
251
+ installing = true
252
+ try {
253
+ const before = await checkUpdateStatus(options, target)
254
+ if (before.latestVersion === undefined) {
255
+ writeResponseJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' })
256
+ return
257
+ }
258
+ if (!before.updateAvailable) {
259
+ writeResponseJson(response, 200, before)
260
+ return
261
+ }
262
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
263
+ writeResponseJson(response, 200, {
264
+ ...before,
265
+ updatedVersion: before.latestVersion,
266
+ restartRequired: true,
267
+ })
268
+ } finally {
269
+ installing = false
270
+ }
271
+ } catch (error) {
272
+ writeResponseJson(response, 500, { ok: false, error: String(error?.message || error) })
273
+ }
274
+ },
275
+ })
276
+ }
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.10",
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",
@@ -15,7 +15,8 @@
15
15
  "lib/",
16
16
  "cordis.patch.yml",
17
17
  "README.md",
18
- "docs/",
18
+ "README.ru.md",
19
+ "README.zh.md",
19
20
  "CHANGELOG.md",
20
21
  "LICENSE"
21
22
  ],