@goodandready/dsh-clinebot 0.3.10 → 0.3.12

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 CHANGED
@@ -5,6 +5,22 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.12] - 2026-09-16
9
+
10
+ ### Changed
11
+ - **Full Source Code Decomposition (< 600 Lines Limit)** (Gitea Issues #41, #29):
12
+ - Split server-side `lib/index.js` (formerly 976 lines) into focused domain modules: `lib/access.js` (CSRF / origin security), `lib/config.js` (Schemastery configuration), `lib/provider-sync.js` (PiAi provider lifecycle & model discovery), `lib/slash-command.js` (`/cline` chat command), and `lib/routes/*` (`settings.js`, `accounts.js`, `models.js`, `auth.js`). Main entry point `lib/index.js` reduced to 148 lines.
13
+ - Extracted `lib/account-pool.js` from `lib/cline-client.js`, reducing it from 684 to 557 lines.
14
+ - Modularized client codebase into 16 clean source files in `src/client/` (< 480 lines each), separating locales, theme styles, error boundaries, and dedicated UI components.
15
+ - Added zero-dependency `scripts/build-client.js` maintaining single-bundle DSH Store contract (< 256 KiB limit, actual size 57.7 KiB).
16
+ - **Test Suite Expansion**: Added `test/decomposition.test.js` validating line limits and exported domain interfaces (45 passing tests).
17
+
18
+ ## [0.3.11] - 2026-09-16
19
+
20
+ ### Added
21
+ - **One-Click In-App Update UI & Live Status Banner** (Gitea Issue #39): Rendered interactive update status bar in `PluginCard` / `SettingsPage`. Displays current installed version, live npm registry check indicator, and up-to-date status badge. Automatically reveals warning badge and 'Update Now' button (`update.btn`) when a newer version is released, triggering safe POST `/dsh-clinebot/update` with `x-dsh-plugin-update: 1` header and completion guidance.
22
+ - **Client Test Coverage Expansion**: Added component render assertions for one-click update UI elements and updated test state table to 41 passing unit tests.
23
+
8
24
  ## [0.3.10] - 2026-09-16
9
25
 
10
26
  ### Changed
package/lib/access.js ADDED
@@ -0,0 +1,10 @@
1
+ import { isTrustedSettingsRequest } from './http.js'
2
+
3
+ export { isTrustedSettingsRequest }
4
+
5
+ export function assertTrustedSettingsRequest(req, res) {
6
+ if (!isTrustedSettingsRequest(req)) {
7
+ return false
8
+ }
9
+ return true
10
+ }
@@ -0,0 +1,131 @@
1
+ import { resolveKeyValue, usageCache, clearUsageCache, clearProbeCache, DEFAULT_API_KEY_ENV } from './cline-client.js'
2
+
3
+ /**
4
+ * Resolve all accounts in pool with their status and keys.
5
+ */
6
+ export async function resolveAccountPool(ctx, cfg) {
7
+ const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
8
+ const defaultSlot = {
9
+ id: 'default',
10
+ label: 'Default',
11
+ apiKeyEnv,
12
+ }
13
+ const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
14
+ const allSlots = [defaultSlot, ...accounts]
15
+ const activeAccount = String(cfg?.activeAccount || '')
16
+ const resolved = []
17
+
18
+ for (let i = 0; i < allSlots.length; i++) {
19
+ const slot = allSlots[i]
20
+ const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
21
+ const keyInfo = await resolveKeyValue(ctx, envName)
22
+ resolved.push({
23
+ id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
24
+ label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
25
+ apiKeyEnv: envName,
26
+ present: Boolean(keyInfo.value),
27
+ source: keyInfo.source,
28
+ value: keyInfo.value,
29
+ isPinned: activeAccount ? activeAccount === envName : i === 0,
30
+ })
31
+ }
32
+
33
+ return resolved
34
+ }
35
+
36
+ /**
37
+ * Check if a cached quota entry is currently exhausted.
38
+ * Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
39
+ */
40
+ export function isAccountQuotaExhausted(usage) {
41
+ if (!usage?.windows?.fiveHour) return false
42
+ const fiveHour = usage.windows.fiveHour
43
+ if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
44
+ return false
45
+ }
46
+ // Auto-recovery: if resetsAt is present and in the past, the account is recovered
47
+ if (fiveHour.resetsAt) {
48
+ const resetTime = new Date(fiveHour.resetsAt).getTime()
49
+ if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
50
+ return false
51
+ }
52
+ }
53
+ return true
54
+ }
55
+
56
+ /**
57
+ * Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
58
+ * Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
59
+ */
60
+ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
61
+ const pool = await resolveAccountPool(ctx, cfg)
62
+ const configured = pool.filter((acc) => acc.present && acc.value)
63
+ if (configured.length <= 1) {
64
+ return { rotated: false, reason, message: 'Pool has only 1 configured account' }
65
+ }
66
+
67
+ const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
68
+ const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
69
+
70
+ // Candidate pool excluding current account if possible
71
+ const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
72
+ if (!candidates.length) {
73
+ return { rotated: false, reason, message: 'No alternative accounts configured' }
74
+ }
75
+
76
+ // Assess quota for candidates if cached in memory
77
+ let bestCandidate = null
78
+ let lowestUsagePct = Infinity
79
+
80
+ for (const cand of candidates) {
81
+ const cacheKey = `cline:usage:${cand.value.slice(-8)}`
82
+ const cached = usageCache.get(cacheKey)?.data
83
+ const isExhausted = isAccountQuotaExhausted(cached)
84
+
85
+ if (!isExhausted) {
86
+ const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
87
+ if (pct < lowestUsagePct) {
88
+ lowestUsagePct = pct
89
+ bestCandidate = cand
90
+ }
91
+ }
92
+ }
93
+
94
+ // Fallback if all candidates are either exhausted or uncached: pick next in round-robin
95
+ const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
96
+
97
+ let updated = false
98
+ if (settingsApi?.replace) {
99
+ try {
100
+ const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
101
+ await settingsApi.replace(next)
102
+ updated = true
103
+ } catch (err) {
104
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settingsApi: ' + (err?.message || err))
105
+ }
106
+ } else {
107
+ const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
108
+ if (settings?.mutate) {
109
+ try {
110
+ await settings.mutate('dsh-clinebot', [
111
+ { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
112
+ ])
113
+ updated = true
114
+ } catch (err) {
115
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
116
+ }
117
+ }
118
+ }
119
+
120
+ // Reset cached quota and host probes so new account immediately revalidates
121
+ clearUsageCache()
122
+ clearProbeCache()
123
+
124
+ return {
125
+ rotated: true,
126
+ previousAccount: active,
127
+ activeAccount: nextAcc.apiKeyEnv,
128
+ reason,
129
+ updatedSettings: updated,
130
+ }
131
+ }