@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 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
- const payload = volatileConfig(parsed)
78
- if (typeof svc.replace === 'function') {
79
- await svc.replace(NS, payload, getRevision(svc))
80
- } else if (typeof svc.update === 'function') {
81
- await svc.update(NS, payload, getRevision(svc))
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
- const payload = volatileConfig(merged)
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(() => {
@@ -127,17 +127,50 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
127
127
  body = {}
128
128
  }
129
129
  }
130
- const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || '').trim()
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
 
@@ -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.3'
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
  }
@@ -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, fallback to clean stats
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.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",