@goodandready/dsh-key-rotation 0.8.10 → 0.8.11

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/README.md CHANGED
@@ -38,6 +38,13 @@
38
38
 
39
39
  ## ⚡ Overview & The Problem
40
40
 
41
+ ### 🚀 What's New in v0.8.11 (One-click Updater & Quality Gate)
42
+
43
+ - **Plugin updater in Settings**: see current/latest version and update from the card without leaving DSH (`#307`).
44
+ - **Safer best-effort side effects**: intentional non-critical failures log at debug instead of silent empty `catch` (`#315`).
45
+ - **Theme-only client colors** and production-path test cleanup (`#311`, `#314`).
46
+ - **Leaner publication set**: agent-only files no longer ship in git/npm (`#308`).
47
+
41
48
  ### 🚀 What's New in v0.8.10 (Stream Concurrency Hardening & Auto-Pruning)
42
49
  - **Zero Concurrency Leaks**: Guaranteed release of stream concurrency slots via deterministic `try ... finally` block, preventing key starvation during clean finishes or client stream aborts.
43
50
  - **Robust Probe Retry**: Added transient network socket error retry (`PROBE_RETRY_DELAY_MS`) in `SandboxRunner.probeModels` before marking keys as broken.
@@ -164,6 +171,17 @@ graph LR
164
171
 
165
172
  ---
166
173
 
174
+
175
+ ### 🔁 8. One-click Plugin Updater
176
+
177
+ The settings card includes an **Updater** section:
178
+
179
+ 1. **Check for updates** — `GET /api/dsh-key-rotation/update` returns `currentVersion`, `latestVersion`, `updateAvailable`, `canAutoUpdate` (version metadata only; no secrets).
180
+ 2. **Update now** — `POST` with the same path installs the exact latest npm version through the standard `dsh plugin add` flow. The request is accepted only from loopback with a matching same-origin `Origin`/`Host` (and the dedicated header). Cross-origin or missing-origin POST is rejected with `403`.
181
+ 3. After a successful install the UI tells you to **restart DSH** so the new host code loads.
182
+
183
+ No `--force`, no raw shell, no install from worktree/DEV paths. Update runs only after an explicit click.
184
+
167
185
  ## 🖥️ Rich Web GUI & Dashboard
168
186
 
169
187
  Access full visual management under **Settings → Key Rotation** or via the Header quick-widget.
package/README.ru.md CHANGED
@@ -36,6 +36,13 @@
36
36
 
37
37
  ---
38
38
 
39
+ ### 🚀 Что нового в v0.8.11 (обновление в один клик и quality gate)
40
+
41
+ - **Обновление плагина в карточке настроек**: текущая/последняя версия и кнопка установки без выхода из DSH (#307).
42
+ - **bestEffort вместо пустых catch**: осознанные побочные эффекты логируются на debug, а не глотаются (#315).
43
+ - **Цвета клиента только из токенов темы** и чистка production-path тестов (#311, #314).
44
+ - **Публикационный набор без служебных файлов агентов** (#308).
45
+
39
46
  ## ⚡ Обзор и решаемая проблема
40
47
 
41
48
  ### 🚀 Что нового в версии 0.8.10 (Надёжность стриминга и очистка памяти)
@@ -190,6 +197,17 @@ graph LR
190
197
 
191
198
  ---
192
199
 
200
+
201
+ ### 🔁 8. Обновление плагина в один клик
202
+
203
+ В карточке настроек есть раздел **Updater**:
204
+
205
+ 1. **Проверить обновления** — `GET /api/dsh-key-rotation/update` возвращает `currentVersion`, `latestVersion`, `updateAvailable`, `canAutoUpdate` (только метаданные версии).
206
+ 2. **Обновить сейчас** — `POST` ставит точную последнюю npm-версию штатным `dsh plugin add`. Запрос принимается только с loopback и совпадающим same-origin `Origin`/`Host`; иначе `403`.
207
+ 3. После установки UI предлагает **перезапустить DSH**.
208
+
209
+ Без `--force`, без raw shell и установки из worktree/DEV. Обновление — только по явному нажатию.
210
+
193
211
  ## 📦 Установка
194
212
 
195
213
  ```bash
package/README.zh.md CHANGED
@@ -38,6 +38,13 @@
38
38
 
39
39
  ## ⚡ 概述与核心痛点
40
40
 
41
+ ### 🚀 v0.8.11 新特性(一键更新与质量门禁)
42
+
43
+ - **设置卡片中的插件更新器**:查看当前/最新版本并一键安装(#307)。
44
+ - **bestEffort 替代空 catch**:非关键副作用在 debug 级别记录,不再被静默吞掉(#315).
45
+ - **客户端颜色仅使用主题变量**,并清理 production-path 测试(#311、#314)。
46
+ - **发布集不再包含代理专用文件**(#308)。
47
+
41
48
  ### 🚀 v0.8.10 版本新特性(流并发加固与状态自动修剪)
42
49
  - **杜绝并发计数泄漏**:在 `try ... finally` 中强制释放流并发占用,防止在正常完成或客户端中断时密钥被永久锁定。
43
50
  - **探测抗网络抖动重试**:在 `probeModels` 中遇到套接字网络临时错误时自动重试一次,避免密钥被误判损坏。
@@ -0,0 +1,26 @@
1
+ /** Run a non-critical side effect; never throw. Logs at debug when a logger is provided. */
2
+ export function bestEffort(label, fn, logger) {
3
+ const log = (err) => {
4
+ try {
5
+ logger?.debug?.(`dsh-key-rotation: best-effort ${label}`, err);
6
+ } catch {
7
+ /* logger itself must not throw */
8
+ }
9
+ };
10
+ try {
11
+ const result = fn();
12
+ if (result && typeof result.then === 'function') {
13
+ return result.then(
14
+ (value) => value,
15
+ (err) => {
16
+ log(err);
17
+ return undefined;
18
+ },
19
+ );
20
+ }
21
+ return result;
22
+ } catch (err) {
23
+ log(err);
24
+ return undefined;
25
+ }
26
+ }
package/lib/bucket.js CHANGED
@@ -1,65 +1,6 @@
1
- // lib/bucket.js - per-key RPM token bucket (#192) + O(1) accumulator and adaptive tuning.
1
+ // lib/bucket.js - per-key RPM token bucket (#192).
2
2
  const WINDOW_MS = 60000;
3
3
 
4
- /**
5
- * O(1) Mathematical Token Bucket Accumulator.
6
- * tokens = min(capacity, tokens + (now - lastRefill) * refillRate)
7
- */
8
- export class TokenBucketAccumulator {
9
- constructor(capacity, windowMs = WINDOW_MS, now = Date.now()) {
10
- this.capacity = Math.max(1, capacity);
11
- this.tokens = this.capacity;
12
- this.windowMs = windowMs;
13
- this.refillRate = this.capacity / this.windowMs; // tokens per ms
14
- this.lastRefill = now;
15
- }
16
-
17
- refill(now = Date.now()) {
18
- const elapsed = Math.max(0, now - this.lastRefill);
19
- if (elapsed > 0) {
20
- this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
21
- this.lastRefill = now;
22
- }
23
- }
24
-
25
- allow(cost = 1, now = Date.now()) {
26
- this.refill(now);
27
- if (this.tokens >= cost) {
28
- this.tokens -= cost;
29
- return true;
30
- }
31
- return false;
32
- }
33
-
34
- retryMs(cost = 1, now = Date.now()) {
35
- this.refill(now);
36
- if (this.tokens >= cost) return 0;
37
- const needed = cost - this.tokens;
38
- return Math.ceil(needed / this.refillRate);
39
- }
40
-
41
- updateCapacity(newCapacity, now = Date.now()) {
42
- this.refill(now);
43
- const prevCap = this.capacity;
44
- this.capacity = Math.max(1, newCapacity);
45
- this.refillRate = this.capacity / this.windowMs;
46
- // Scale current tokens proportionally or clamp
47
- this.tokens = Math.min(this.capacity, Math.max(0, this.tokens + (this.capacity - prevCap)));
48
- }
49
-
50
- info(now = Date.now()) {
51
- this.refill(now);
52
- const used = Math.max(0, Math.round(this.capacity - this.tokens));
53
- const remaining = Math.max(0, Math.floor(this.tokens));
54
- return {
55
- used,
56
- remaining,
57
- resetMs: this.retryMs(1, now),
58
- capacity: this.capacity,
59
- };
60
- }
61
- }
62
-
63
4
  /** Sliding-window check: true if `ref` is under `limit` requests/min. */
64
5
  export function bucketAllow(windows, ref, limit, now = Date.now()) {
65
6
  if (!limit || limit <= 0) return true;
@@ -74,14 +15,6 @@ export function bucketAllow(windows, ref, limit, now = Date.now()) {
74
15
  return true;
75
16
  }
76
17
 
77
- /** Record a hit without checking (use after a successful resolve). */
78
- export function bucketHit(windows, ref, now = Date.now()) {
79
- const cut = now - WINDOW_MS;
80
- const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
81
- hits.push(now);
82
- windows.set(ref, hits);
83
- }
84
-
85
18
  /** ms until `ref` may retry again (0 = now). */
86
19
  export function bucketRetryMs(windows, ref, limit, now = Date.now()) {
87
20
  if (!limit || limit <= 0) return 0;
@@ -108,22 +41,3 @@ export function bucketInfo(windows, ref, limit, now = Date.now()) {
108
41
  };
109
42
  }
110
43
 
111
- /**
112
- * Adaptive limit computation from HTTP headers with manual limit precedence.
113
- * If manualLimit is given (> 0), it acts as the upper ceiling.
114
- * If adaptive is enabled and upstream reports a lower limit/remaining, adapt downwards.
115
- */
116
- export function computeEffectiveLimit(headerRateLimit, manualLimit, adaptiveEnabled = true) {
117
- const manual = (Number.isFinite(manualLimit) && manualLimit > 0) ? manualLimit : null;
118
- if (!adaptiveEnabled || !headerRateLimit) {
119
- return manual;
120
- }
121
- const headerLimit = headerRateLimit.limit;
122
- if (Number.isFinite(headerLimit) && headerLimit > 0) {
123
- if (manual) {
124
- return Math.min(manual, headerLimit); // manual acts as upper ceiling
125
- }
126
- return headerLimit;
127
- }
128
- return manual;
129
- }
package/lib/cascade.js CHANGED
@@ -1,39 +1,27 @@
1
- // cascade.js — cross-provider failover cascade (issue #194).
2
- // ponytail: minimal — pick fallback provider from a RegionMap-like config.
3
-
4
- export const CASCADE_MAX_DEPTH = 1;
5
-
6
- export function pickCascadeFallback(provider, cfg, pools) {
7
- const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
- for (const entry of list) {
9
- const fb = typeof entry === 'string' ? { provider: entry } : entry;
10
- if (!fb || !fb.provider || fb.provider === provider) continue;
11
- const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
12
- if (!pool) continue;
13
- const now = Date.now();
14
- let healthy = 0;
15
- for (const ref of pool.refs) {
16
- const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
- if (failedUntil > now) continue;
18
- const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
19
- if (exp !== undefined && now >= exp) continue;
20
- healthy += 1;
21
- }
22
- if (healthy === 0) continue;
23
- return { provider: fb.provider, pool, model: fb.model || null };
24
- }
25
- return null;
26
- }
27
-
28
- export function hasHealthyKey(pool, now) {
29
- now = now || Date.now();
30
- if (!pool || !Array.isArray(pool.refs)) return false;
31
- for (const ref of pool.refs) {
32
- const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
33
- if (failedUntil > now) continue;
34
- const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
35
- if (exp !== undefined && now >= exp) continue;
36
- return true;
37
- }
38
- return false;
39
- }
1
+ // cascade.js — cross-provider failover cascade (issue #194).
2
+ // ponytail: minimal — pick fallback provider from a RegionMap-like config.
3
+
4
+ export const CASCADE_MAX_DEPTH = 1;
5
+
6
+ export function pickCascadeFallback(provider, cfg, pools) {
7
+ const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
+ for (const entry of list) {
9
+ const fb = typeof entry === 'string' ? { provider: entry } : entry;
10
+ if (!fb || !fb.provider || fb.provider === provider) continue;
11
+ const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
12
+ if (!pool) continue;
13
+ const now = Date.now();
14
+ let healthy = 0;
15
+ for (const ref of pool.refs) {
16
+ const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
+ if (failedUntil > now) continue;
18
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
19
+ if (exp !== undefined && now >= exp) continue;
20
+ healthy += 1;
21
+ }
22
+ if (healthy === 0) continue;
23
+ return { provider: fb.provider, pool, model: fb.model || null };
24
+ }
25
+ return null;
26
+ }
27
+
package/lib/client.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // manual route typing is ever needed. The plugin derives the fallback chain and
10
10
  // auto-creates clone routes from the key count.
11
11
  //
12
- // Localization: source strings are English only (ctx.locale.register(NS, { en })).
12
+ // Localization: source strings are English; Chinese is a first-class locale (en + zh).
13
13
  // Other languages come from the DSH core locale service / translation plugins
14
14
  // via props.t (slot locale: NS). Active locale prefers ctx.locale.getSnapshot().active,
15
15
  // else first navigator.languages entry, else 'en' — same fallback chain as DSH core.
@@ -301,12 +301,18 @@ window.__ModuleLoader__.load({
301
301
  selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
302
302
  };
303
303
 
304
- // Коды, на которых имеет смысл переключать ключ. Список из хоста
305
- // (DEFAULT_SWITCH_CODES); конфиг может содержать и свои они показываются
306
- // отдельными отмеченными галочками, чтобы правило нельзя было потерять.
304
+ // Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
305
+ // add extras those render as separate checked boxes so a custom rule is
306
+ // not dropped on first save.
307
307
  const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
308
308
 
309
309
  // #273: keep the settings list item mounted if the section throws.
310
+ const bestEffort = (label, fn) => {
311
+ try { return fn(); } catch (err) {
312
+ try { console.debug('[dsh-key-rotation] best-effort ' + label, err); } catch { /* console unavailable */ }
313
+ return undefined;
314
+ }
315
+ };
310
316
  class KeyRotationErrorBoundary extends React.Component {
311
317
  constructor(props) {
312
318
  super(props);
@@ -316,7 +322,7 @@ window.__ModuleLoader__.load({
316
322
  return { error };
317
323
  }
318
324
  componentDidCatch(error, info) {
319
- try { console.error('[dsh-key-rotation] settings card crashed', error, info); } catch (_) {}
325
+ bestEffort('error-boundary.log', () => { console.error('[dsh-key-rotation] settings card crashed', error, info); });
320
326
  }
321
327
  render() {
322
328
  if (this.state.error) {
@@ -333,7 +339,7 @@ window.__ModuleLoader__.load({
333
339
  }
334
340
  }
335
341
 
336
- /** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
342
+ /** Poll rotation status while the settings section is open (smart polling). */
337
343
  function useRotationStatus() {
338
344
  const [byProvider, setByProvider] = React.useState({});
339
345
  React.useEffect(() => {
@@ -348,7 +354,7 @@ window.__ModuleLoader__.load({
348
354
  for (const entry of data.providers) map[entry.provider] = entry;
349
355
  setByProvider(map);
350
356
  })
351
- .catch(() => { /* статус необязателен: карточка остаётся редактором */ });
357
+ .catch(() => { /* status is optional: card stays a working editor */ });
352
358
  };
353
359
  pull();
354
360
  const id = setInterval(pull, 4000);
@@ -363,7 +369,7 @@ window.__ModuleLoader__.load({
363
369
  return byProvider;
364
370
  }
365
371
 
366
- /** Последний probe-результат по каждому ключу (#219): /sandbox-cache (smart polling). */
372
+ /** Latest probe result per key (#219): /sandbox-cache (smart polling). */
367
373
  function useProbeCache() {
368
374
  const [cache, setCache] = React.useState({});
369
375
  React.useEffect(() => {
@@ -373,7 +379,7 @@ window.__ModuleLoader__.load({
373
379
  fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
374
380
  .then((r) => (r.ok ? r.json() : null))
375
381
  .then((data) => { if (alive && data) setCache(data); })
376
- .catch(() => { /* кэш не критичен: карточка работает и без него */ });
382
+ .catch(() => { /* cache is non-critical: card works without it */ });
377
383
  };
378
384
  pull();
379
385
  const id = setInterval(pull, 4000);
@@ -397,10 +403,9 @@ window.__ModuleLoader__.load({
397
403
  return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
398
404
  }
399
405
 
400
- // Разметка карточки: сетка, а не набор inline-стилей. Фиксированные ширины
401
- // здесь уже приводили к тому, что имя ключа обрезалось, а кнопки наезжали
402
- // на поле значения, поэтому имя занимает свою строку, а служебная строка
403
- // под ним ужимается сама.
406
+ // Card layout uses a grid, not ad-hoc inline widths. Fixed widths used to
407
+ // clip key names and collide actions with the value field, so the name owns
408
+ // its own row and the meta row shrinks on its own.
404
409
  const CARD_CSS = [
405
410
  '.krot{display:flex;flex-direction:column;gap:20px;max-width:960px;padding:6px 0 24px;box-sizing:border-box}',
406
411
  '.krot-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
@@ -408,7 +413,7 @@ window.__ModuleLoader__.load({
408
413
  '.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
409
414
  '.krot p{margin:0}',
410
415
  '.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
411
- '.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);display:flex;align-items:center;gap:8px}',
416
+ '.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);display:flex;align-items:center;gap:8px}',
412
417
  '.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
413
418
  '.krot-field{display:flex;flex-direction:column;gap:6px}',
414
419
  '.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
@@ -431,15 +436,15 @@ window.__ModuleLoader__.load({
431
436
  '.krot-load-legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:11px}',
432
437
  '.krot-load-item{display:inline-flex;align-items:center;gap:4px}',
433
438
  '.krot-load-dot{width:7px;height:7px;border-radius:50%}',
434
- '.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
435
- '.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px rgba(0,0,0,0.28);padding:20px;display:flex;flex-direction:column;gap:12px}',
439
+ '.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 55%, transparent));z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
440
+ '.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 28%, transparent);padding:20px;display:flex;flex-direction:column;gap:12px}',
436
441
  '.krot-modal-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
437
442
  '.krot-modal-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.45}',
438
443
  '.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
439
444
  '.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
440
- '.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}',
441
- '.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}',
442
- '.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}',
445
+ '.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent)}',
446
+ '.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 8%, transparent)}',
447
+ '.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent)}',
443
448
  '.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
444
449
  '.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
445
450
  '.krot-prov-head select{flex:1;min-width:180px}',
@@ -457,21 +462,22 @@ window.__ModuleLoader__.load({
457
462
  '.krot-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed,var(--dsw-alias-border-l2))}',
458
463
  '.krot-btn-primary,.krot-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent;font-weight:600}',
459
464
  '.krot-btn-primary:hover:not(:disabled),.krot-save:hover:not(:disabled){background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;opacity:0.88}',
460
- '.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}',
461
- '.krot-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12)!important;border-color:rgba(239,68,68,0.5)}',
465
+ '.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent)}',
466
+ '.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 12%, transparent)!important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 50%, transparent)}',
462
467
  '.krot-btn:disabled{opacity:0.45;cursor:not-allowed}',
463
468
  '.krot-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
464
469
  '.krot-filter-bar{display:flex;gap:8px;margin:4px 0 10px;flex-wrap:wrap}',
465
470
  '.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
466
471
  '.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
467
472
  '.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
468
- '.krot-pill-warn{border-color:rgba(245,158,11,0.3);color:var(--dsw-alias-state-warning-primary)}',
469
- '.krot-pill-err{border-color:rgba(239,68,68,0.3);color:var(--dsw-alias-state-error-primary)}',
470
- '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
471
- '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
473
+ '.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 30%, transparent);color:var(--dsw-alias-state-warning-primary)}',
474
+ '.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
475
+ '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
476
+ '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
472
477
  '.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
473
478
  '.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
474
479
  '.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
480
+ ':root{--krot-chart-4:var(--dsw-alias-state-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
475
481
  '.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
476
482
  '.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
477
483
  '.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
@@ -481,7 +487,7 @@ window.__ModuleLoader__.load({
481
487
  '.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:16px 0 8px}',
482
488
  '.krot-header-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;position:relative;user-select:none;transition:all .15s ease}',
483
489
  '.krot-header-chip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-3));border-color:var(--dsw-alias-border-l1);transform:translateY(-0.5px)}',
484
- '.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px rgba(0,0,0,.45),0 2px 8px rgba(0,0,0,.15);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
490
+ '.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 45%, transparent),0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 15%, transparent);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
485
491
  '.krot-pop-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
486
492
  '.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}',
487
493
  '.krot-pop-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}',
@@ -499,13 +505,13 @@ window.__ModuleLoader__.load({
499
505
  }
500
506
 
501
507
  /**
502
- * Имя переменной под новый ключ.
508
+ * Env-var name for a newly added key.
503
509
  *
504
- * Пользователь его больше не печатает: первый ключ провайдера получает имя
505
- * вида <PROVIDER>_API_KEY, следующие тот же корень с суффиксом _2, _3…
506
- * Корень берётся у уже существующих ключей, чтобы вручную заведённые имена
507
- * не ломались, и проверяется на занятость по ВСЕМ провайдерам иначе два
508
- * провайдера незаметно делили бы одну учётную запись.
510
+ * Users no longer type it: the first key of a provider becomes
511
+ * <PROVIDER>_API_KEY, later keys reuse that root with _2, _3… suffixes.
512
+ * The root is taken from existing keys so hand-made names keep working,
513
+ * and uniqueness is checked across ALL providers otherwise two providers
514
+ * would silently share one credential.
509
515
  */
510
516
  // nextKeyRef also in lib/client-helpers.js
511
517
  function nextKeyRef(providerId, existingKeys, allRefs) {
@@ -650,7 +656,7 @@ window.__ModuleLoader__.load({
650
656
  cancelAnimationFrame(id);
651
657
  const prev = modalReturnFocusRef.current;
652
658
  if (prev && typeof prev.focus === 'function') {
653
- try { prev.focus(); } catch (_) {}
659
+ bestEffort('modal.returnFocus', () => { prev.focus(); });
654
660
  }
655
661
  };
656
662
  }, [confirmModal]);
@@ -885,8 +891,7 @@ window.__ModuleLoader__.load({
885
891
  stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
886
892
  return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
887
893
  });
888
- // Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
889
- // а не перепечатыванием имён.
894
+ // Key order is attempt order move it with buttons, do not retype names.
890
895
  const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
891
896
  const providers = [...(cur.providers ?? [])];
892
897
  const entry = { ...(providers[pIndex] ?? {}) };
@@ -921,8 +926,8 @@ window.__ModuleLoader__.load({
921
926
  return { ...cur, providers };
922
927
  });
923
928
 
924
- // Коды из конфига, которых нет в известном списке, показываем тоже:
925
- // иначе галочки молча выбросили бы чужое правило при первом сохранении.
929
+ // Config codes missing from the known list still render as checked items;
930
+ // otherwise the checkboxes would silently drop a custom rule on first save.
926
931
  const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
927
932
  const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
928
933
  const toggleCode = (code, on) => setField((cur) => {
@@ -992,8 +997,8 @@ window.__ModuleLoader__.load({
992
997
  title: (opts && opts.title) || undefined,
993
998
  }, labelText);
994
999
 
995
- // Точка состояния ключа: цвет и подпись читаются с одного взгляда,
996
- // а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
1000
+ // Key state dot: color and label read at a glance; "key not found"
1001
+ // catches an env-name typo that would otherwise stay silent.
997
1002
  const keyStatus = (providerId, ref) => {
998
1003
  const hit = keyInfo(providerId, ref);
999
1004
  if (!hit) return null;
@@ -1062,8 +1067,7 @@ window.__ModuleLoader__.load({
1062
1067
  const typed = secretDraft[rowKey];
1063
1068
  const fromEnv = Boolean(info && info.source === 'env');
1064
1069
 
1065
- // Имя ключа занимает свою строку целиком: раньше оно обрезалось и
1066
- // соседние ключи выглядели одинаково.
1070
+ // Key name owns a full row so neighbouring keys stay distinguishable.
1067
1071
  const nameRow = [
1068
1072
  h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
1069
1073
  h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
@@ -1204,13 +1208,13 @@ window.__ModuleLoader__.load({
1204
1208
  });
1205
1209
  const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
1206
1210
  const palette = [
1207
- 'var(--dsw-alias-state-brand-primary, #6366f1)',
1208
- 'var(--dsw-alias-state-success-primary, #10b981)',
1209
- 'var(--dsw-alias-state-warning-primary, #f59e0b)',
1210
- 'var(--dsw-alias-state-info-primary, #3b82f6)',
1211
- '#8b5cf6',
1212
- '#ec4899',
1213
- '#14b8a6',
1211
+ 'var(--dsw-alias-state-brand-primary)',
1212
+ 'var(--dsw-alias-state-success-primary)',
1213
+ 'var(--dsw-alias-state-warning-primary)',
1214
+ 'var(--dsw-alias-state-info-primary)',
1215
+ 'var(--krot-chart-4)',
1216
+ 'var(--krot-chart-5)',
1217
+ 'var(--krot-chart-6)',
1214
1218
  ];
1215
1219
  return h('div', { className: 'krot-load-chart' },
1216
1220
  h('div', { className: 'krot-load-header' },
@@ -1311,7 +1315,8 @@ window.__ModuleLoader__.load({
1311
1315
  filterBar,
1312
1316
  loadChart,
1313
1317
  h('div', { className: 'krot-keys' }, keyRows),
1314
- h('div', { className: 'krot-foot' },
1318
+ h(UpdaterSection, { t }),
1319
+ h('div', { className: 'krot-foot' },
1315
1320
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
1316
1321
  switchesLine,
1317
1322
  budgetLine,
@@ -1710,7 +1715,72 @@ window.__ModuleLoader__.load({
1710
1715
  // Collapsible card in Settings -> Plugins -> Plugin settings
1711
1716
  // (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
1712
1717
  // key MUST equal the settings namespace (NS), else the tab silently skips it.
1713
- function KeyRotationCard(props) {
1718
+
1719
+ function UpdaterSection({ t }) {
1720
+ const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
1721
+ const load = React.useCallback(async () => {
1722
+ setState((s) => ({ ...s, phase: 'loading', message: '' }));
1723
+ try {
1724
+ const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
1725
+ const data = await res.json();
1726
+ if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
1727
+ setState({
1728
+ phase: data.updateAvailable ? 'available' : 'current',
1729
+ current: data.currentVersion || '',
1730
+ latest: data.latestVersion || '',
1731
+ message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
1732
+ });
1733
+ } catch (e) {
1734
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
1735
+ }
1736
+ }, [t]);
1737
+ React.useEffect(() => { load(); }, [load]);
1738
+ const run = async () => {
1739
+ setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
1740
+ try {
1741
+ const res = await fetch('/api/dsh-key-rotation/update', {
1742
+ method: 'POST',
1743
+ headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
1744
+ });
1745
+ const data = await res.json();
1746
+ if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
1747
+ setState({
1748
+ phase: 'done',
1749
+ current: data.currentVersion || '',
1750
+ latest: data.installedVersion || data.latestVersion || '',
1751
+ message: t('updateRestart'),
1752
+ });
1753
+ } catch (e) {
1754
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
1755
+ }
1756
+ };
1757
+ return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1758
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1759
+ h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
1760
+ t('currentVersion') + ': ' + (state.current || '—')),
1761
+ state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
1762
+ t('latestVersion') + ': ' + state.latest) : null,
1763
+ h('button', {
1764
+ className: 'krot-btn',
1765
+ type: 'button',
1766
+ disabled: state.phase === 'running' || state.phase === 'loading',
1767
+ onClick: load,
1768
+ }, t('updateCheck')),
1769
+ state.phase === 'available' ? h('button', {
1770
+ className: 'krot-btn krot-btn-primary',
1771
+ type: 'button',
1772
+ disabled: state.phase === 'running',
1773
+ onClick: run,
1774
+ }, t('updateRun')) : null,
1775
+ ),
1776
+ state.message ? h('div', {
1777
+ className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
1778
+ style: { marginTop: 8, fontSize: 12 },
1779
+ }, state.message) : null,
1780
+ );
1781
+ }
1782
+
1783
+ function KeyRotationCard(props) {
1714
1784
  const locale = useLocale();
1715
1785
  const t = resolveT(props);
1716
1786
  const [open, setOpen] = React.useState(false);
@@ -1733,11 +1803,31 @@ window.__ModuleLoader__.load({
1733
1803
  KeyRotationCard,
1734
1804
  ));
1735
1805
  } catch (e) {
1736
- try { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); } catch (_) {}
1806
+ bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); });
1737
1807
  }
1738
1808
  }
1739
1809
 
1740
1810
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] };
1741
1811
  return module.exports;
1742
1812
  },
1813
+ updateCheck: 'Check for updates',
1814
+ updateAvailable: 'Update available',
1815
+ updateNone: 'Plugin is up to date',
1816
+ updateRun: 'Update now',
1817
+ updateRestart: 'Update installed. Restart DSH to load the new version.',
1818
+ updateRunning: 'Updating…',
1819
+ updateFailed: 'Update failed',
1820
+ currentVersion: 'Current version',
1821
+ latestVersion: 'Latest version',
1822
+
1823
+ updateCheck: '检查更新',
1824
+ updateAvailable: '有可用更新',
1825
+ updateNone: '插件已是最新版本',
1826
+ updateRun: '立即更新',
1827
+ updateRestart: '更新已安装。请重启 DSH 以加载新版本。',
1828
+ updateRunning: '更新中…',
1829
+ updateFailed: '更新失败',
1830
+ currentVersion: '当前版本',
1831
+ latestVersion: '最新版本',
1832
+
1743
1833
  });
package/lib/clock.js CHANGED
@@ -20,5 +20,3 @@ export function nowMono() {
20
20
  return performance.timeOrigin + performance.now();
21
21
  }
22
22
 
23
- /** Default clock injectable into pure helpers. */
24
- export const defaultClock = { nowWall, nowMono };
@@ -61,6 +61,3 @@ export function classifyFailure(failureOrPayload) {
61
61
  }
62
62
 
63
63
  /** True when classification says switch to next key. */
64
- export function shouldSwitch(failureOrPayload) {
65
- return classifyFailure(failureOrPayload).action === 'switch';
66
- }
@@ -78,13 +78,6 @@ export function providerCatalog(ctx, cloneIds) {
78
78
  return out;
79
79
  }
80
80
 
81
- export function guardLocal(req, res, label) {
82
- if (!isTrustedBridgeRequest(req)) {
83
- json(res, 403, { error: { code: 'forbidden', message: `dsh-key-rotation: ${label} is local-only` } });
84
- return false;
85
- }
86
- return true;
87
- }
88
81
 
89
82
  export function scanForLiveSecrets(section) {
90
83
  const masked = structuredClone(section);
package/lib/index.js CHANGED
@@ -52,6 +52,7 @@ import { safeParseJson } from './atomic-io.js';
52
52
  import { registerOpsRoutes } from './routes-ops.js';
53
53
  import { StatePersistence, resolveStatePath } from './persistence.js';
54
54
  import path from 'node:path';
55
+ import { registerPluginUpdater } from './plugin-updater.js'
55
56
 
56
57
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
57
58
  const PIAI_NS = 'llm-pi-ai';
@@ -362,14 +363,17 @@ export function apply(ctx, config = {}) {
362
363
  if (!val) return { ok: false };
363
364
  return runner.probeModels(ref, val);
364
365
  });
365
- } catch (_) {}
366
+ } catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
366
367
  }, intervalMs);
367
368
  if (typeof timer.unref === 'function') timer.unref();
368
369
  return () => clearInterval(timer);
369
370
  }, 'dsh-key-rotation: auto-unbreak');
370
371
 
371
372
  ctx.effect(() => {
372
- const timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
373
+ const timer = setInterval(() => {
374
+ try { schedulePersist(); }
375
+ catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
376
+ }, 15000);
373
377
  if (typeof timer.unref === 'function') timer.unref();
374
378
  return () => {
375
379
  clearInterval(timer);
@@ -383,7 +387,9 @@ export function apply(ctx, config = {}) {
383
387
  }
384
388
  statePersistence.dispose();
385
389
  }
386
- } catch (_) {}
390
+ } catch (e) {
391
+ ctx.logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
392
+ }
387
393
  };
388
394
  }, 'dsh-key-rotation: state persistence');
389
395
  // Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
@@ -857,6 +863,7 @@ export function apply(ctx, config = {}) {
857
863
  // the resolve patch hands out the next key on each dispatch.
858
864
  // Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
859
865
  registerOpsRoutes(ctx, {
866
+
860
867
  buildRuntime,
861
868
  latencyHistogram,
862
869
  lastTestCache,
@@ -868,6 +875,16 @@ export function apply(ctx, config = {}) {
868
875
  quotaStore,
869
876
  });
870
877
 
878
+ // One-click plugin updater (#307). GET status / POST install exact registry version.
879
+ ctx.effect(() => {
880
+ if (typeof ctx.webServer?.register !== 'function') return;
881
+ registerPluginUpdater(ctx, {
882
+ endpoint: '/api/dsh-key-rotation/update',
883
+ packageName: '@goodandready/dsh-key-rotation',
884
+ manifestUrl: new URL('../package.json', import.meta.url),
885
+ });
886
+ }, 'dsh-key-rotation: plugin updater');
887
+
871
888
  ctx.effect(() => ctx.on('llm/stream', (options, next) => {
872
889
  if (options[MARKER]) return next();
873
890
  if (rotationDisabled) return next(); // #199: disabled via webhook action
@@ -0,0 +1,277 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * Host-side one-click updater for a DSH Web plugin.
10
+ *
11
+ * Mount from apply():
12
+ *
13
+ * registerPluginUpdater(ctx, {
14
+ * endpoint: '/dsh-key-rotation/update',
15
+ * packageName: '@goodandready/dsh-key-rotation',
16
+ * manifestUrl: new URL('../package.json', import.meta.url),
17
+ * })
18
+ *
19
+ * GET returns status. POST is accepted only from the local same-origin UI and
20
+ * installs the exact registry version through the normal DSH CLI.
21
+ */
22
+
23
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
24
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
25
+ const VERSION_CACHE_MS = 5 * 60_000
26
+ let latestCache
27
+
28
+ function header(request, name) {
29
+ const value = request.headers?.[name]
30
+ return Array.isArray(value) ? value[0] : value
31
+ }
32
+
33
+ function isLoopback(value) {
34
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
35
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
36
+ || address?.startsWith('127.') === true
37
+ || address?.startsWith('::ffff:127.') === true
38
+ }
39
+
40
+ export function isTrustedUpdateRequest(request) {
41
+ if (header(request, UPDATE_HEADER) !== '1') return false
42
+ if (!isLoopback(request.socket?.remoteAddress)) return false
43
+ const site = header(request, 'sec-fetch-site')
44
+ if (site !== undefined && site !== 'same-origin') return false
45
+ const origin = header(request, 'origin')
46
+ const host = header(request, 'host')
47
+ if (origin === undefined || host === undefined) return false
48
+ try {
49
+ const url = new URL(origin)
50
+ return (url.protocol === 'http:' || url.protocol === 'https:')
51
+ && isLoopback(url.hostname) && url.host === host
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ function validProfileName(value) {
58
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
59
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
60
+ }
61
+
62
+ function profileNameFromArgv(argv) {
63
+ for (let index = 2; index < argv.length; index += 1) {
64
+ if (argv[index] === '--profile') return argv[index + 1]
65
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
66
+ }
67
+ return argv[2] === 'web' ? 'web' : undefined
68
+ }
69
+
70
+ function findDshCliEntry() {
71
+ const value = process.argv[1]
72
+ if (value === undefined || value === '') return undefined
73
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
74
+ if (!existsSync(entry)) return undefined
75
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
76
+ const manifestPath = resolve(directory, 'package.json')
77
+ if (existsSync(manifestPath)) {
78
+ try {
79
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
80
+ const bin = typeof manifest.bin === 'string'
81
+ ? manifest.bin
82
+ : typeof manifest.bin === 'object' && manifest.bin !== null
83
+ ? manifest.bin.dsh
84
+ : undefined
85
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
86
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
87
+ } catch {
88
+ // Continue searching parent package directories.
89
+ }
90
+ }
91
+ const parent = dirname(directory)
92
+ if (parent === directory) return undefined
93
+ }
94
+ }
95
+
96
+ function runtime() {
97
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
98
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
99
+ const selected = profileNameFromArgv(process.argv)
100
+ const profileName = validProfileName(selected)
101
+ ? selected
102
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
103
+ const cliEntry = findDshCliEntry()
104
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
105
+ }
106
+
107
+ function parseSemver(value) {
108
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
109
+ if (match === null) return undefined
110
+ return {
111
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
112
+ prerelease: match[4]?.split('.') ?? [],
113
+ }
114
+ }
115
+
116
+ function comparePrerelease(left, right) {
117
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
118
+ const length = Math.max(left.length, right.length)
119
+ for (let index = 0; index < length; index += 1) {
120
+ const a = left[index]
121
+ const b = right[index]
122
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
123
+ if (a === b) continue
124
+ const aNumeric = /^\d+$/.test(a)
125
+ const bNumeric = /^\d+$/.test(b)
126
+ if (aNumeric && bNumeric) {
127
+ const aNumber = BigInt(a)
128
+ const bNumber = BigInt(b)
129
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
130
+ continue
131
+ }
132
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
133
+ return a > b ? 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 latestVersion(packageName, registry) {
149
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version
150
+ try {
151
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
152
+ signal: AbortSignal.timeout(8_000),
153
+ })
154
+ if (!response.ok) return undefined
155
+ const value = await response.json()
156
+ if (typeof value.version !== 'string' || value.version === '') return undefined
157
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
158
+ return value.version
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ async function currentVersion(manifestUrl) {
165
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
166
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.')
167
+ return value.version
168
+ }
169
+
170
+ async function status(options, target) {
171
+ const current = await currentVersion(options.manifestUrl)
172
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
173
+ return {
174
+ packageName: options.packageName,
175
+ currentVersion: current,
176
+ ...(latest === undefined ? {} : { latestVersion: latest }),
177
+ latestCheckFailed: latest === undefined,
178
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
179
+ profileName: target.profileName,
180
+ canAutoUpdate: target.cliEntry !== undefined,
181
+ }
182
+ }
183
+
184
+ async function installExact(target, packageSpec, options) {
185
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
186
+ await new Promise((resolvePromise, reject) => {
187
+ const child = spawn(process.execPath, [
188
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
189
+ '--config.minimumReleaseAge=0', packageSpec,
190
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
191
+ ], {
192
+ cwd: target.profileDir,
193
+ windowsHide: true,
194
+ stdio: ['ignore', 'pipe', 'pipe'],
195
+ env: { ...process.env, NO_COLOR: '1' },
196
+ })
197
+ let detail = ''
198
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
199
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
200
+ const timer = setTimeout(() => {
201
+ child.kill()
202
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
203
+ }, UPDATE_TIMEOUT_MS)
204
+ child.once('error', error => { clearTimeout(timer); reject(error) })
205
+ child.once('exit', code => {
206
+ clearTimeout(timer)
207
+ if (code === 0) resolvePromise()
208
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
209
+ })
210
+ })
211
+ }
212
+
213
+ function json(response, statusCode, value) {
214
+ response.writeHead(statusCode, {
215
+ 'content-type': 'application/json; charset=utf-8',
216
+ 'cache-control': 'no-store',
217
+ })
218
+ response.end(JSON.stringify(value))
219
+ }
220
+
221
+ export function registerPluginUpdater(ctx, options) {
222
+ let installing = false
223
+ return ctx.webServer.register({
224
+ kind: 'exact',
225
+ path: options.endpoint,
226
+ handler: async (request, response) => {
227
+ try {
228
+ const target = runtime()
229
+ if (request.method === 'GET' || request.method === 'HEAD') {
230
+ const payload = await status(options, target)
231
+ response.writeHead(200, {
232
+ 'content-type': 'application/json; charset=utf-8',
233
+ 'cache-control': 'no-store',
234
+ })
235
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(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
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
245
+ return
246
+ }
247
+ if (installing) {
248
+ json(response, 409, { error: 'This plugin is already updating.' })
249
+ return
250
+ }
251
+ installing = true
252
+ try {
253
+ const before = await status(options, target)
254
+ if (before.latestVersion === undefined) {
255
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
256
+ return
257
+ }
258
+ if (!before.updateAvailable) {
259
+ json(response, 200, before)
260
+ return
261
+ }
262
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
263
+ json(response, 200, {
264
+ ...before,
265
+ updatedVersion: before.latestVersion,
266
+ restartRequired: true,
267
+ })
268
+ } finally {
269
+ installing = false
270
+ }
271
+ } catch (error) {
272
+ ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
273
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
274
+ }
275
+ },
276
+ })
277
+ }
package/lib/pool.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
2
3
  // Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
3
4
 
@@ -71,11 +72,6 @@ export const SOFT_FAILURE_CODES = new Set([
71
72
  ]);
72
73
 
73
74
  /** True if failure code or message represents a soft/transient drop. */
74
- export function isSoftFailure(code, message = '') {
75
- if (code && SOFT_FAILURE_CODES.has(String(code).toUpperCase())) return true;
76
- if (/502|503|504|timeout|econnreset|econnrefused|socket hang up/i.test(message)) return true;
77
- return false;
78
- }
79
75
 
80
76
  /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
81
77
  const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -469,7 +465,7 @@ export function budgetVerdict(spend, budget) {
469
465
  /** Safely reset a provider's circuit breaker to closed state. */
470
466
  export function resetCircuitForProvider(circuitBreaker, provider) {
471
467
  if (!circuitBreaker || !provider) return false;
472
- try {
468
+ const ok = bestEffort('resetCircuitForProvider', () => {
473
469
  if (typeof circuitBreaker.reset === 'function') {
474
470
  circuitBreaker.reset(provider);
475
471
  return true;
@@ -478,6 +474,7 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
478
474
  circuitBreaker.onSuccess(provider);
479
475
  return true;
480
476
  }
481
- } catch (_) {}
482
- return false;
477
+ return false;
478
+ });
479
+ return ok === true;
483
480
  }
package/lib/routes-ops.js CHANGED
@@ -24,6 +24,7 @@ import { findSecrets, looksLikeApiSecret } from './keycheck.js';
24
24
  import { nextQuotaReset } from './quota-window.js';
25
25
  import { classifyFailure } from './error-taxonomy.js';
26
26
  import { sanitizeSnapshot } from './sanitize-snapshot.js';
27
+ import { bestEffort } from './best-effort.js';
27
28
 
28
29
  const STATUS_PATH = '/dsh-key-rotation/status';
29
30
  const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
@@ -316,7 +317,7 @@ export function registerOpsRoutes(ctx, deps) {
316
317
  st.brokenUntil?.delete(ref);
317
318
  if (st.lastUsed === ref) st.lastUsed = undefined;
318
319
  }
319
- try { lastTestCache?.delete?.(ref); } catch (_) {}
320
+ bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
320
321
  json(res, 200, { ok: true, ref });
321
322
  return;
322
323
  }
@@ -482,7 +483,7 @@ export function registerOpsRoutes(ctx, deps) {
482
483
  const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
483
484
  const tail = valid ? keyTail(effectiveValue) : '';
484
485
  let source = null;
485
- try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
486
+ await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
486
487
  if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
487
488
  if (!present && testValue) { source = 'pre-save'; }
488
489
  else if (!present) {
package/lib/webhook.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
2
3
  export const WEBHOOK_TIMEOUT_MS = 5000;
3
4
  export const WEBHOOK_MIN_INTERVAL_MS = 1000;
@@ -187,7 +188,7 @@ export class AlertDebouncer {
187
188
 
188
189
  const res = await this._sender.send(url, payload);
189
190
  for (const cb of entry.callbacks) {
190
- try { cb(res); } catch (_) {}
191
+ bestEffort('webhook.callback', () => { cb(res); }, this._logger);
191
192
  }
192
193
  return res;
193
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.10",
3
+ "version": "0.8.11",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [
@@ -41,7 +41,10 @@
41
41
  },
42
42
  "client": {
43
43
  "platform": "web",
44
- "inject": []
44
+ "inject": [
45
+ "@deepseek-ai/dsh-client-locale",
46
+ "@deepseek-ai/dsh-client-ui-settings"
47
+ ]
45
48
  }
46
49
  },
47
50
  "license": "MIT",