@goodandready/dsh-clinebot 0.5.5 → 0.5.6
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 +14 -5
- package/lib/config.js +1 -1
- package/lib/index.js +56 -20
- package/lib/routes/accounts.js +34 -5
- package/lib/routes/proxy.js +1 -1
- package/lib/stats-storage.js +26 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.4
|
|
4
|
-
|
|
5
|
-
### Fixed
|
|
6
|
-
- **Peer gate on DSH 0.2.0-rc.1** (#58): DSH skips a profile bundle whose `peerDependencies` exclude the running version, so this plugin was absent from the profile with no error in the UI. Every `@deepseek-ai/dsh-*` peer now names both the 0.1.7-rc.2 and 0.2.0-rc.1 lines, because semver does not admit a prerelease of the next minor into a range that does not name it.
|
|
7
|
-
|
|
8
3
|
All notable changes to `@goodandready/dsh-clinebot` will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
7
|
|
|
8
|
+
## [0.5.6] - 2026-09-30
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **Credential Deletion Scope Enforcement (Issue #155)**: Restricted credential deletion on `DELETE /dsh-clinebot/accounts` and `POST /dsh-clinebot/accounts/delete` strictly to configured secondary accounts matching `^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`. Prohibited deletion of the primary account. Unconfigured or invalid keys receive HTTP 400/404 without calling credentials store. Credential deletion errors return HTTP 500.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Atomic Settings Persistence & Volatile Lifecycle (Issue #157)**: Re-architected `settingsApi.replace` and `settingsApi.update` to persist changes via DSH `SettingsForms` *before* mutating live state. If settings service is unavailable or non-writable, explicit errors are thrown. Mutating in-memory configuration now preserves dynamic Volatile references and getters across updates without severance.
|
|
15
|
+
- **Cold Start Persistent Stats Loading & Flush on Dispose (Issue #158)**: Connected `loadStats(statsPath)` to plugin startup lifecycle in `apply()`, preventing cold starts from overwriting historical token metrics with empty counters. Added `flushStats()` effect hook on plugin disposal and automatic corrupted file preservation (`*.corrupt.<timestamp>`) for diagnostics.
|
|
16
|
+
|
|
17
|
+
## [0.5.5] - 2026-09-30
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Volatile Boxes Unwrapping in Config Validation (Issue #153)**: Corrected deep unwrapping of Schemastery Volatile boxes and getters when passing dynamic configuration objects through `Config()` validation, preventing schema validation failures.
|
|
21
|
+
|
|
13
22
|
## [0.5.3] - 2026-09-28
|
|
14
23
|
|
|
15
24
|
### Fixed
|
package/lib/config.js
CHANGED
|
@@ -87,7 +87,7 @@ export const Config = z.object({
|
|
|
87
87
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
function isVolatileRef(value) {
|
|
90
|
+
export function isVolatileRef(value) {
|
|
91
91
|
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
92
92
|
}
|
|
93
93
|
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Config, publicConfig, plainConfig, volatileConfig, NS, LLM_PI_AI_NS } from './config.js'
|
|
1
|
+
import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
|
|
2
2
|
import { registerPluginUpdater } from './updater.js'
|
|
3
3
|
import {
|
|
4
4
|
sessionStats,
|
|
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
|
|
|
22
22
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
23
23
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
24
24
|
import { registerSlashCommand } from './slash-command.js'
|
|
25
|
-
import { recordUsage } from './stats-storage.js'
|
|
25
|
+
import { recordUsage, loadStats, flushStats } from './stats-storage.js'
|
|
26
26
|
|
|
27
27
|
export const name = '@goodandready/dsh-clinebot'
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
@@ -67,35 +67,62 @@ export function apply(ctx, config) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const applyConfigMutations = (target, source) => {
|
|
71
|
+
const targetConfig = typeof target === 'function' ? target() : target
|
|
72
|
+
if (targetConfig && typeof targetConfig === 'object' && !Object.isFrozen(targetConfig)) {
|
|
73
|
+
for (const [key, value] of Object.entries(source)) {
|
|
74
|
+
try {
|
|
75
|
+
if (isVolatileRef(targetConfig[key]) && typeof targetConfig[key].set === 'function') {
|
|
76
|
+
targetConfig[key].set(value)
|
|
77
|
+
} else if (Object.getOwnPropertyDescriptor(targetConfig, key)?.set) {
|
|
78
|
+
targetConfig[key] = value
|
|
79
|
+
} else if (!Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
|
|
80
|
+
targetConfig[key] = value
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore read-only */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
currentConfig = source
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
settingsApi = {
|
|
71
92
|
get: () => live(),
|
|
72
93
|
replace: async (next) => {
|
|
73
|
-
const parsed = Config(structuredClone(plainConfig(next)))
|
|
74
|
-
currentConfig = parsed
|
|
75
94
|
const svc = ctx?.get?.('settings')
|
|
76
|
-
if (svc) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
|
|
96
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
97
|
+
}
|
|
98
|
+
const parsed = Config(structuredClone(plainConfig(next)))
|
|
99
|
+
const payload = volatileConfig(parsed)
|
|
100
|
+
const rev = getRevision(svc)
|
|
101
|
+
if (typeof svc.replace === 'function') {
|
|
102
|
+
await svc.replace(NS, payload, rev)
|
|
103
|
+
} else {
|
|
104
|
+
await svc.update(NS, payload, rev)
|
|
83
105
|
}
|
|
106
|
+
applyConfigMutations(currentConfig, parsed)
|
|
107
|
+
loadStats(publicConfig(live()).statsPath)
|
|
84
108
|
await syncProviderState(live())
|
|
85
109
|
return parsed
|
|
86
110
|
},
|
|
87
111
|
update: async (patch) => {
|
|
88
|
-
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
89
|
-
currentConfig = merged
|
|
90
112
|
const svc = ctx?.get?.('settings')
|
|
91
|
-
if (svc) {
|
|
92
|
-
|
|
93
|
-
if (typeof svc.update === 'function') {
|
|
94
|
-
await svc.update(NS, payload, getRevision(svc))
|
|
95
|
-
} else if (typeof svc.replace === 'function') {
|
|
96
|
-
await svc.replace(NS, payload, getRevision(svc))
|
|
97
|
-
}
|
|
113
|
+
if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
|
|
114
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
98
115
|
}
|
|
116
|
+
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
117
|
+
const payload = volatileConfig(merged)
|
|
118
|
+
const rev = getRevision(svc)
|
|
119
|
+
if (typeof svc.update === 'function') {
|
|
120
|
+
await svc.update(NS, payload, rev)
|
|
121
|
+
} else {
|
|
122
|
+
await svc.replace(NS, payload, rev)
|
|
123
|
+
}
|
|
124
|
+
applyConfigMutations(currentConfig, merged)
|
|
125
|
+
loadStats(publicConfig(live()).statsPath)
|
|
99
126
|
await syncProviderState(live())
|
|
100
127
|
return merged
|
|
101
128
|
},
|
|
@@ -106,6 +133,15 @@ export function apply(ctx, config) {
|
|
|
106
133
|
},
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
loadStats(publicConfig(live()).statsPath)
|
|
137
|
+
if (typeof ctx.effect === 'function') {
|
|
138
|
+
ctx.effect(() => {
|
|
139
|
+
return () => {
|
|
140
|
+
flushStats(publicConfig(live()).statsPath)
|
|
141
|
+
}
|
|
142
|
+
}, 'dsh-clinebot: stats-storage')
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
syncProviderState(live())
|
|
110
146
|
if (typeof ctx.effect === 'function') {
|
|
111
147
|
ctx.effect(() => {
|
package/lib/routes/accounts.js
CHANGED
|
@@ -127,17 +127,50 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
127
127
|
body = {}
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
let queryApiKeyEnv = ''
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
133
|
+
queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
|
|
134
|
+
} catch {
|
|
135
|
+
queryApiKeyEnv = ''
|
|
136
|
+
}
|
|
137
|
+
const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
|
|
131
138
|
if (!targetEnv) {
|
|
132
139
|
return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
|
|
143
|
+
if (!isDefaultPattern) {
|
|
144
|
+
return writeJson(res, 400, {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
135
150
|
const curr = plainConfig(live())
|
|
136
151
|
if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
|
|
137
152
|
return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
156
|
+
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
157
|
+
if (!accountToDelete) {
|
|
158
|
+
return writeJson(res, 404, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (body.deleteSecret) {
|
|
165
|
+
const delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
166
|
+
if (!delRes || !delRes.ok) {
|
|
167
|
+
return writeJson(res, 500, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
141
174
|
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
142
175
|
let activeAccount = curr.activeAccount
|
|
143
176
|
if (activeAccount === targetEnv) {
|
|
@@ -148,10 +181,6 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
148
181
|
await settingsApi.replace(next)
|
|
149
182
|
await syncProviderState(next)
|
|
150
183
|
|
|
151
|
-
if (body.deleteSecret) {
|
|
152
|
-
await deleteCredentialKey(ctx, targetEnv)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
184
|
clearUsageCache()
|
|
156
185
|
clearProbeCache()
|
|
157
186
|
|
package/lib/routes/proxy.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getLocalProxyToken } from '../proxy-token.js'
|
|
|
26
26
|
const require = createRequire(import.meta.url)
|
|
27
27
|
let packageVersion = '0.5.3'
|
|
28
28
|
try {
|
|
29
|
-
packageVersion = require('../../package.json').version || '0.5.
|
|
29
|
+
packageVersion = require('../../package.json').version || '0.5.6'
|
|
30
30
|
} catch (err) {
|
|
31
31
|
/* fallback to default version if manifest is unreadable */
|
|
32
32
|
}
|
package/lib/stats-storage.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
@@ -84,9 +84,9 @@ export function resolvePath(targetPath) {
|
|
|
84
84
|
* @returns {Object} Loaded stats
|
|
85
85
|
*/
|
|
86
86
|
export function loadStats(customPath) {
|
|
87
|
+
const filePath = resolvePath(customPath)
|
|
88
|
+
activeStatsPath = filePath
|
|
87
89
|
try {
|
|
88
|
-
const filePath = resolvePath(customPath)
|
|
89
|
-
activeStatsPath = filePath
|
|
90
90
|
if (existsSync(filePath)) {
|
|
91
91
|
const raw = readFileSync(filePath, 'utf8')
|
|
92
92
|
const parsed = JSON.parse(raw)
|
|
@@ -103,14 +103,35 @@ export function loadStats(customPath) {
|
|
|
103
103
|
return inMemoryStats
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
// If reading fails or file is corrupted,
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// If reading fails or file is corrupted, preserve corrupted file for diagnostics
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(filePath)) {
|
|
110
|
+
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
111
|
+
copyFileSync(filePath, corruptBackup)
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore backup failure */
|
|
115
|
+
}
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
inMemoryStats = createEmptyStats()
|
|
111
119
|
return inMemoryStats
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Flush any debounced pending writes immediately to disk.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} [customPath]
|
|
126
|
+
*/
|
|
127
|
+
export function flushStats(customPath) {
|
|
128
|
+
if (saveTimer) {
|
|
129
|
+
clearTimeout(saveTimer)
|
|
130
|
+
saveTimer = null
|
|
131
|
+
}
|
|
132
|
+
saveStatsSync(customPath)
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
/**
|
|
115
136
|
* Synchronously write stats to disk using atomic rename.
|
|
116
137
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|