@goodandready/dsh-clinebot 0.5.11 → 0.5.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,14 @@ 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.5.12] - 2026-10-02
9
+
10
+ ### Fixed
11
+ - **Credential Ownership Enforcement on Deletion Retry (Issue #155)**: Reject deletion of unowned or non-existent credentials with HTTP 404 and zero calls to the credentials store, preventing unconfirmed secrets deletion. Retries of previously failed secret cleanups are explicitly tracked in-memory to safely allow recovery without exposing arbitrary secret deletion.
12
+ - **Catalog Lifecycle Across All Account Switch Paths (Issue #168)**: Reset dynamic model catalog (`dynamicModels: []`) and plan synchronization state (`planSyncedAt: 0`) uniformly across slash-command switches (`/cline switch`), automatic account pool rotations, and manual configuration updates, preventing stale or cross-account models from being considered verified.
13
+ - **Rollback Concurrency & Active Account Selection Preservation (Issue #187)**: Ensure transactional account deletion rollback does not overwrite concurrent changes to `activeAccount`. Only restore the deleted account as active if no concurrent change has occurred, preserving external selection alongside other settings.
14
+ - **Test Hardening & Multi-Environment Portability (Issue #173)**: Hardened regression tests to detect surviving descendants across parent exits under genuine failure conditions, updated account deletion tests to assert honest partial state (`partial: true`), and replaced hardcoded path references with dynamic DSH adapter discovery and graceful skips across diverse environments.
15
+
8
16
  ## [0.5.11] - 2026-10-01
9
17
 
10
18
  ### Fixed
@@ -168,10 +168,19 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
168
168
  // Fallback if all candidates are either exhausted or uncached: pick next in round-robin
169
169
  const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
170
170
 
171
+ const prevActive = String(cfg.activeAccount || cfg.apiKeyEnv || "").trim()
172
+ const nextActive = String(nextAcc.apiKeyEnv || "").trim()
173
+ const isDifferent = nextActive !== prevActive
174
+
171
175
  let updated = false
172
176
  if (settingsApi?.replace) {
173
177
  try {
174
- const next = { ...toPlainSnapshot(cfg), activeAccount: nextAcc.apiKeyEnv }
178
+ const next = {
179
+ ...toPlainSnapshot(cfg),
180
+ activeAccount: nextAcc.apiKeyEnv,
181
+ dynamicModels: isDifferent ? [] : (cfg.dynamicModels || []),
182
+ planSyncedAt: isDifferent ? 0 : Number(cfg.planSyncedAt || 0),
183
+ }
175
184
  await settingsApi.replace(next)
176
185
  updated = true
177
186
  } catch (err) {
@@ -181,9 +190,14 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
181
190
  const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
182
191
  if (settings?.mutate) {
183
192
  try {
184
- await settings.mutate('dsh-clinebot', [
193
+ const ops = [
185
194
  { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
186
- ])
195
+ ]
196
+ if (isDifferent) {
197
+ ops.push({ op: 'set', path: ['dynamicModels'], value: [] })
198
+ ops.push({ op: 'set', path: ['planSyncedAt'], value: 0 })
199
+ }
200
+ await settings.mutate('dsh-clinebot', ops)
187
201
  updated = true
188
202
  } catch (err) {
189
203
  ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
@@ -11,6 +11,8 @@ import {
11
11
  DEFAULT_API_KEY_ENV,
12
12
  } from '../cline-client.js'
13
13
 
14
+ const pendingSecretCleanupAccounts = new Set()
15
+
14
16
  export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
15
17
  // POST /dsh-clinebot/accounts — add or update account in pool
16
18
  ctx.effect(() => ctx.webServer.register({
@@ -155,7 +157,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
155
157
  const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
156
158
  const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
157
159
  if (!accountToDelete) {
158
- if (body.deleteSecret && ctx?.get?.('credentials')) {
160
+ if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
159
161
  let retryDelRes = null
160
162
  try {
161
163
  retryDelRes = await deleteCredentialKey(ctx, targetEnv)
@@ -163,6 +165,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
163
165
  retryDelRes = null
164
166
  }
165
167
  if (retryDelRes && retryDelRes.ok) {
168
+ pendingSecretCleanupAccounts.delete(targetEnv)
166
169
  clearUsageCache()
167
170
  clearProbeCache()
168
171
  return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
@@ -192,8 +195,8 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
192
195
  await settingsApi.replace(next)
193
196
  await syncProviderState(next)
194
197
 
198
+ let delRes = null
195
199
  if (body.deleteSecret) {
196
- let delRes = null
197
200
  try {
198
201
  delRes = await deleteCredentialKey(ctx, targetEnv)
199
202
  } catch {
@@ -207,10 +210,13 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
207
210
  if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
208
211
  freshAccounts.push(accountToDelete)
209
212
  }
213
+ const shouldRestoreActive = (curr.activeAccount === targetEnv && (!fresh.activeAccount || fresh.activeAccount === targetEnv))
214
+ ? targetEnv
215
+ : fresh.activeAccount
210
216
  const restoredConfig = plainConfig(Config({
211
217
  ...fresh,
212
218
  accounts: freshAccounts,
213
- activeAccount: curr.activeAccount === targetEnv ? targetEnv : fresh.activeAccount,
219
+ activeAccount: shouldRestoreActive,
214
220
  }))
215
221
  await settingsApi.replace(restoredConfig)
216
222
  await syncProviderState(restoredConfig)
@@ -229,6 +235,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
229
235
  })
230
236
  }
231
237
 
238
+ pendingSecretCleanupAccounts.add(targetEnv)
232
239
  return writeJson(res, 500, {
233
240
  ok: false,
234
241
  partial: true,
@@ -239,10 +246,11 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
239
246
  }
240
247
  }
241
248
 
249
+ pendingSecretCleanupAccounts.delete(targetEnv)
242
250
  clearUsageCache()
243
251
  clearProbeCache()
244
252
 
245
- writeJson(res, 200, { ok: true, removed: targetEnv })
253
+ writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
246
254
  } catch (err) {
247
255
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
248
256
  }
@@ -93,6 +93,10 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
93
93
  const base = plainConfig(live()) || {}
94
94
  delete base.enabledModels
95
95
  const merged = { ...base, ...rawPayload }
96
+ if (rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()) {
97
+ if (!("dynamicModels" in rawPayload)) merged.dynamicModels = []
98
+ if (!("planSyncedAt" in rawPayload)) merged.planSyncedAt = 0
99
+ }
96
100
  const parsed = plainConfig(Config(merged))
97
101
  await settingsApi.replace(parsed)
98
102
  await syncProviderState(parsed)
@@ -100,7 +100,14 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
100
100
  clearProbeCache()
101
101
  const settingsApi = getSettingsApi()
102
102
  if (settingsApi?.replace) {
103
- const next = Config({ ...plainConfig(live()), activeAccount: param })
103
+ const curr = plainConfig(live())
104
+ const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
105
+ const next = Config({
106
+ ...curr,
107
+ activeAccount: param,
108
+ dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
109
+ planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
110
+ })
104
111
  await settingsApi.replace(next)
105
112
  await syncProviderState(next)
106
113
  return `Active account switched to ${param}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
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",