@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/CHANGELOG.md +32 -0
- package/README.md +15 -2
- package/{docs/README.ru.md → README.ru.md} +14 -1
- package/{docs/README.zh.md → README.zh.md} +14 -1
- package/lib/client.js +135 -99
- package/lib/cline-client.js +207 -117
- package/lib/http.js +62 -2
- package/lib/index.js +71 -148
- package/lib/updater.js +276 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -82
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
|
|
215
|
-
return {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
`*
|
|
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
|
-
|
|
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('', '
|
|
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 '⚠️
|
|
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 `✅
|
|
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 (
|
|
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 `🔄
|
|
842
|
+
return `🔄 **Account Rotated**: switched from \`${res.previousAccount}\` to \`${res.activeAccount}\`. DSH provider updated!`
|
|
922
843
|
}
|
|
923
|
-
return `⚠️
|
|
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}\`
|
|
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
|
|
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
|
|
967
|
-
`*
|
|
968
|
-
`*
|
|
969
|
-
`*
|
|
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
|
|
895
|
+
return `❌ **Smoke Test Failed**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
|
|
973
896
|
}
|
|
974
897
|
|
|
975
|
-
//
|
|
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
|
|
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
|
-
`*
|
|
994
|
-
`*
|
|
995
|
-
`*
|
|
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
|
|
998
|
-
`**📅
|
|
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('', '🚨
|
|
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('',
|
|
927
|
+
lines.push('', `⚠️ **Warning**: 5-hour quota is ${fiveHour.percentUsed}% consumed.`)
|
|
1005
928
|
}
|
|
1006
929
|
|
|
1007
930
|
if (sessionStats.totalRequests > 0) {
|
|
1008
|
-
lines.push('', `**📊
|
|
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(`*
|
|
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.
|
|
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
|
-
"
|
|
18
|
+
"README.ru.md",
|
|
19
|
+
"README.zh.md",
|
|
19
20
|
"CHANGELOG.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|