@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/CHANGELOG.md +13 -0
- package/README.md +15 -2
- package/README.ru.md +199 -0
- package/README.zh.md +201 -0
- package/docs/README.ru.md +14 -1
- package/docs/README.zh.md +14 -1
- package/docs/design/DESIGN.md +7 -1
- package/lib/client.js +91 -81
- package/lib/cline-client.js +177 -114
- package/lib/index.js +61 -148
- package/lib/updater.js +245 -0
- package/package.json +1 -1
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
|
|
215
|
-
return {
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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:
|
|
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
|
-
`*
|
|
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
|
-
|
|
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('', '
|
|
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 '⚠️
|
|
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 `✅
|
|
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 (
|
|
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 `🔄
|
|
835
|
+
return `🔄 **Account Rotated**: switched from \`${res.previousAccount}\` to \`${res.activeAccount}\`. DSH provider updated!`
|
|
922
836
|
}
|
|
923
|
-
return `⚠️
|
|
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}\`
|
|
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
|
|
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
|
|
967
|
-
`*
|
|
968
|
-
`*
|
|
969
|
-
`*
|
|
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
|
|
885
|
+
return `❌ **Smoke Test Failed**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
|
|
973
886
|
}
|
|
974
887
|
|
|
975
|
-
//
|
|
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
|
|
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
|
-
`*
|
|
994
|
-
`*
|
|
995
|
-
`*
|
|
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
|
|
998
|
-
`**📅
|
|
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('', '🚨
|
|
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('',
|
|
917
|
+
lines.push('', `⚠️ **Warning**: 5-hour quota is ${fiveHour.percentUsed}% consumed.`)
|
|
1005
918
|
}
|
|
1006
919
|
|
|
1007
920
|
if (sessionStats.totalRequests > 0) {
|
|
1008
|
-
lines.push('', `**📊
|
|
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(`*
|
|
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.
|
|
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",
|