@goodandready/dsh-model-sync 0.3.14 → 0.4.0

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
@@ -117,6 +117,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
117
117
 
118
118
  ---
119
119
 
120
+ ## 🚀 Enhancements in v0.4.0
121
+
122
+ - **One-Click In-App Updater**: Check npm registry for updates and trigger one-click upgrades directly within the DSH Settings Card, complete with version diff badges and server restart alerts.
123
+ - **Strict Loopback Security**: All mutating HTTP endpoints (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) now require verified loopback origin and remote socket IP to prevent CSRF / unauthorized network access.
124
+ - **100% Theme Token Compliance**: Eliminated all hardcoded colors and `rgba()` values in client UI in favor of official DSH theme tokens (`--dsw-alias-*`), ensuring perfect dark/light mode rendering.
125
+ - **Modular Architecture**: Decomposed core synchronization logic into specialized modules (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`), significantly enhancing code readability and maintainability.
126
+ - **Clean Packaging**: Excluded development and internal planning artifacts from git and npm distribution, maintaining an ultra-lightweight release bundle (< 70 KiB).
127
+
120
128
  ## 🚀 Enhancements in v0.3.14
121
129
 
122
130
  - **Language Standard**: Canonical plugin in `en` and `zh`. Runtime Russian translations decoupled into `goodandready/dsh-russian-lang` (#191).
package/README.ru.md CHANGED
@@ -94,6 +94,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
94
94
 
95
95
  ---
96
96
 
97
+ ## 🚀 Улучшения в версии 0.4.0
98
+
99
+ - **One-Click обновление плагина из настроек**: В карточку настроек плагина добавлен блок обновления с проверкой актуальной версии в npm, цветовым бейджем статуса и кнопкой обновления в один клик.
100
+ - **Защита write-эндпоинтов по Loopback**: Все мутирующие HTTP-маршруты (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) строго проверяют адрес источника (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) и заголовок Origin/Host для предотвращения несанкционированного доступа.
101
+ - **Полная поддержка темы DSH (0 rgba / 0 hex)**: Все стили интерфейса переведены на официальные токены дизайн-системы DSH (`--dsw-alias-*`), обеспечивая нативную интеграцию со светлой и тёмной темами оформления.
102
+ - **Модульная архитектура синхронизатора**: Монолитная логика синхронизации разделена на специализированные модули (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`), что упрощает поддержку и снижает размер основного файла ниже 600 строк.
103
+ - **Чистый дистрибутив пакета**: Исключены внутренние файлы планирования и отладки; размер сжатого npm-пакета составляет менее 70 KiB, все файлы укладываются в лимит 256 KiB.
104
+
97
105
  ## 🚀 Улучшения в v0.3.13
98
106
 
99
107
  * ⚡ **ETag / 304 Not Modified для фонового опроса UI**:
package/README.zh.md CHANGED
@@ -95,6 +95,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
95
95
 
96
96
  ---
97
97
 
98
+ ## 🚀 v0.4.0 新特性与增强
99
+
100
+ - **设置卡片一键更新**: 在 DSH 设置卡片中集成自动版本检查,展示当前与最新版本对比徽标,支持一键安全升级并在更新后提示重启。
101
+ - **严格本地环回 (Loopback) 安全保护**: 所有修改状态的 HTTP 路由 (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) 均强制校验本地环回 IP (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) 及请求来源,杜绝外部恶意调用。
102
+ - **100% 原生主题变量适配**: 客户端 UI 全面移除硬编码颜色与 `rgba()` 样式,完全切换至 DSH 官方 CSS 设计令牌 (`--dsw-alias-*`),无缝适配深色与浅色主题。
103
+ - **同步引擎模块化拆分**: 核心同步逻辑已解耦拆分为专项子模块 (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`),大幅提升代码可读性与维护性。
104
+ - **轻量分发包清理**: 彻底排除内部计划与调试文件,精简 npm 发布体积至 70 KiB 以下,所有文件均完全符合 256 KiB 上限规范。
105
+
98
106
  ## 🚀 v0.3.14 新特性与增强
99
107
 
100
108
  - **语言标准**: 插件原生支持 `en` 与 `zh` 双语,俄语本地化已独立解耦至 `goodandready/dsh-russian-lang` 翻译包 (#191)。
package/lib/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  const CSS = '.dms-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;margin:0;padding:0;overflow:hidden;box-sizing:border-box}' +
9
9
  '.dms-card-open{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-border-l2)}' +
10
10
  '.dms-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}' +
11
- '.dms-card-header:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}' +
11
+ '.dms-card-header:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))}' +
12
12
  '.dms-card-header:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
13
13
  '.dms-card-head-text{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
14
14
  '.dms-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
@@ -17,16 +17,16 @@ window.__ModuleLoader__.load({
17
17
  '.dms-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px;display:flex;flex-direction:column;gap:10px;box-sizing:border-box}' +
18
18
  '.dms-wrap{display:flex;flex-direction:column;gap:14px;max-width:760px;box-sizing:border-box;color:var(--dsw-alias-label-primary,inherit)}' +
19
19
  '.dms-wrap>p{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}' +
20
- '.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary,rgba(255,255,255,.14)));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
20
+ '.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
21
21
  '.dms-toolbar>select{flex:1 1 190px;min-width:190px}' +
22
22
  '.dms-toolbar>.dms-actions{flex-basis:100%}' +
23
23
  '.dms-toolbar>label{display:inline-flex;align-items:flex-start;gap:7px;font-size:12px;line-height:1.35;color:var(--dsw-alias-label-secondary)}' +
24
24
  '.dms-toolbar>select,.dms-editor input,.dms-editor textarea,.dms-input{box-sizing:border-box;min-width:0;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-2,transparent));border:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-l2));border-radius:6px;color:var(--dsw-alias-label-primary);font:inherit;padding:7px 9px}' +
25
25
  '.dms-toolbar>label input{accent-color:var(--dsw-alias-brand-primary,var(--dsw-alias-button-info-fill))}' +
26
26
  '.dms-actions{display:flex;align-items:center;flex-wrap:wrap;gap:7px}' +
27
- '.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary,rgba(255,255,255,.14)));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
27
+ '.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
28
28
  '.dms-btn{margin:0!important;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.35;padding:6px 10px;cursor:pointer;white-space:normal}' +
29
- '.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.05))}' +
29
+ '.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2)}' +
30
30
  '.dms-btn:disabled{opacity:.45;cursor:default}' +
31
31
  '.dms-section>strong{display:block;font-size:13px;font-weight:650}' +
32
32
  '.dms-provider-wrap{margin:0 0 7px}' +
@@ -44,18 +44,18 @@ window.__ModuleLoader__.load({
44
44
  '.dms-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}' +
45
45
  '.dms-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}' +
46
46
  '.dms-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
47
- '.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
48
- '.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
49
- '.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
47
+ '.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2))}' +
48
+ '.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warning-secondary,var(--dsw-alias-bg-layer-2))}' +
49
+ '.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2))}' +
50
50
  '.dms-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
51
51
  '.dms-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
52
52
  '.dms-btn-primary{background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;border-color:transparent!important;font-weight:600}' +
53
53
  '.dms-btn-primary:hover:not(:disabled){opacity:0.88}' +
54
- '.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:rgba(239,68,68,0.3)!important}' +
55
- '.dms-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12)!important;border-color:rgba(239,68,68,0.5)!important}' +
54
+ '.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:var(--dsw-alias-state-error-primary)!important}' +
55
+ '.dms-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2))!important;border-color:var(--dsw-alias-state-error-primary)!important}' +
56
56
  '.dms-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
57
- '.dms-alert-ok{background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary)}' +
58
- '.dms-alert-bad{background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary)}' +
57
+ '.dms-alert-ok{background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-success-primary)}' +
58
+ '.dms-alert-bad{background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary)}' +
59
59
  '.dms-probe-result{padding:2px 6px;border-radius:4px;font-family:monospace;font-size:11px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px}' +
60
60
  '@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-section-card,.dms-editor{padding:11px 12px}}'
61
61
 
@@ -227,6 +227,17 @@ window.__ModuleLoader__.load({
227
227
  addAlias: 'Save alias',
228
228
  removeAlias: 'Delete alias',
229
229
  noAliases: 'No model aliases defined.',
230
+ updaterTitle: 'Plugin Updates',
231
+ checkForUpdates: 'Check for updates',
232
+ checkingUpdates: 'Checking…',
233
+ updateAvailable: 'Update available: ',
234
+ upToDate: 'Up to date',
235
+ updateNow: 'Update now',
236
+ updating: 'Updating…',
237
+ updateSuccess: 'Updated to ',
238
+ restartRequired: ' (restart required)',
239
+ updateFailed: 'Update failed: ',
240
+ checkUpdateFailed: 'Failed to check for updates',
230
241
  targetProvider: 'Provider',
231
242
  }
232
243
 
@@ -347,6 +358,17 @@ window.__ModuleLoader__.load({
347
358
  addAlias: '保存别名',
348
359
  removeAlias: '删除别名',
349
360
  noAliases: '未定义任何模型别名。',
361
+ updaterTitle: '插件更新',
362
+ checkForUpdates: '检查更新',
363
+ checkingUpdates: '正在检查…',
364
+ updateAvailable: '发现新版本: ',
365
+ upToDate: '已是最新版本',
366
+ updateNow: '立即更新',
367
+ updating: '正在更新…',
368
+ updateSuccess: '已成功更新至 ',
369
+ restartRequired: ' (需重启 DSH)',
370
+ updateFailed: '更新失败: ',
371
+ checkUpdateFailed: '检查更新失败',
350
372
  targetProvider: '服务商',
351
373
  }
352
374
 
@@ -372,6 +394,84 @@ window.__ModuleLoader__.load({
372
394
  )
373
395
  }
374
396
 
397
+
398
+ function UpdaterSection({ t, api }) {
399
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
400
+ const [updaterChecking, setUpdaterChecking] = React.useState(false)
401
+ const [updaterUpdating, setUpdaterUpdating] = React.useState(false)
402
+ const [updaterNotice, setUpdaterNotice] = React.useState('')
403
+ const [updaterError, setUpdaterError] = React.useState('')
404
+
405
+ const checkUpdates = () => {
406
+ setUpdaterChecking(true)
407
+ setUpdaterError('')
408
+ setUpdaterNotice('')
409
+ fetch(`${api}/update`)
410
+ .then((res) => {
411
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
412
+ return res.json()
413
+ })
414
+ .then((data) => {
415
+ setUpdaterStatus(data)
416
+ setUpdaterChecking(false)
417
+ })
418
+ .catch(() => {
419
+ setUpdaterChecking(false)
420
+ setUpdaterError(t.checkUpdateFailed)
421
+ })
422
+ }
423
+
424
+ const runUpdate = () => {
425
+ if (updaterUpdating) return
426
+ setUpdaterUpdating(true)
427
+ setUpdaterError('')
428
+ setUpdaterNotice('')
429
+ fetch(`${api}/update`, {
430
+ method: 'POST',
431
+ headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
432
+ })
433
+ .then((res) => res.json())
434
+ .then((resData) => {
435
+ if (resData.error) throw new Error(resData.error.message || resData.error)
436
+ const cur = resData.updatedVersion || (updaterStatus && updaterStatus.latestVersion) || ''
437
+ const nextData = Object.assign({}, updaterStatus, { currentVersion: cur, updateAvailable: false })
438
+ setUpdaterUpdating(false)
439
+ setUpdaterStatus(nextData)
440
+ setUpdaterNotice(t.updateSuccess + cur + (resData.restartRequired ? t.restartRequired : ''))
441
+ })
442
+ .catch((err) => {
443
+ setUpdaterUpdating(false)
444
+ setUpdaterError(t.updateFailed + (err.message || String(err)))
445
+ })
446
+ }
447
+
448
+ return React.createElement('div', { style: { marginTop: 8 } },
449
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
450
+ React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)' } },
451
+ updaterStatus ? `v${updaterStatus.currentVersion}` : '',
452
+ updaterStatus && updaterStatus.updateAvailable ? ` → v${updaterStatus.latestVersion}` : ''
453
+ ),
454
+ React.createElement('div', null,
455
+ updaterStatus && updaterStatus.updateAvailable
456
+ ? React.createElement('button', {
457
+ type: 'button',
458
+ className: 'dms-btn dms-btn-primary',
459
+ disabled: updaterUpdating,
460
+ onClick: runUpdate,
461
+ }, updaterUpdating ? t.updating : t.updateNow)
462
+ : React.createElement('button', {
463
+ type: 'button',
464
+ className: 'dms-btn',
465
+ disabled: updaterChecking,
466
+ onClick: checkUpdates,
467
+ }, updaterChecking ? t.checkingUpdates : (updaterStatus ? t.upToDate : t.checkForUpdates))
468
+ )
469
+ ),
470
+ updaterNotice ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-success)', marginTop: 8, fontSize: 12 } }, updaterNotice) : null,
471
+ updaterError ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', marginTop: 8, fontSize: 12 } }, updaterError) : null
472
+ )
473
+ }
474
+
375
475
  function ModelSyncSection({ ctx, includeIntro = true }) {
376
476
  const locale = useLocale(ctx)
377
477
  const t = locale === 'zh' ? zh : en
@@ -1119,6 +1219,10 @@ window.__ModuleLoader__.load({
1119
1219
  React.createElement('div', { className: 'dms-section-card dms-section' },
1120
1220
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.history)),
1121
1221
  renderHistory()
1222
+ ),
1223
+ React.createElement('div', { className: 'dms-section-card dms-section' },
1224
+ React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.updaterTitle)),
1225
+ React.createElement(UpdaterSection, { t, api: API })
1122
1226
  )
1123
1227
  )
1124
1228
  }
@@ -1157,12 +1261,16 @@ window.__ModuleLoader__.load({
1157
1261
 
1158
1262
  function apply(ctx) {
1159
1263
  ctx.effect(() => {
1160
- try { ctx.locale?.register?.(NS, { en, zh }) } catch {}
1264
+ try { ctx.locale?.register?.(NS, { en, zh }) } catch (err) {
1265
+ try { console.debug('[dsh-model-sync] locale register best-effort:', err) } catch { /* console unavailable */ }
1266
+ }
1161
1267
  return () => {}
1162
1268
  }, 'dsh-model-sync: dictionaries')
1163
1269
  try {
1164
1270
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
1165
- } catch {}
1271
+ } catch (err) {
1272
+ try { console.debug('[dsh-model-sync] slots inject best-effort:', err) } catch { /* console unavailable */ }
1273
+ }
1166
1274
  }
1167
1275
 
1168
1276
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], filterModelsByCapabilities }
package/lib/http.js CHANGED
@@ -8,24 +8,50 @@ function json(res, status, body) {
8
8
  res.end(JSON.stringify(body))
9
9
  }
10
10
 
11
- function trusted(req) {
11
+ export function isLoopbackAddress(ip) {
12
+ if (!ip || typeof ip !== 'string') return false
13
+ const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
14
+ if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
15
+ if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
16
+ return false
17
+ }
18
+
19
+ export function trusted(req, { requireLoopback = false } = {}) {
20
+ const remoteAddress = req?.socket?.remoteAddress
21
+ if (requireLoopback || req?.method === 'POST') {
22
+ if (!isLoopbackAddress(remoteAddress)) return false
23
+ }
12
24
  const fetchSite = req.headers?.['sec-fetch-site']
13
25
  if (fetchSite === 'cross-site') return false
14
26
  const host = req.headers?.host
15
27
  const origin = req.headers?.origin
16
28
  if (origin) {
17
29
  if (!host) return false
18
- try { return new URL(origin).host === host } catch { return false }
30
+ try {
31
+ const url = new URL(origin)
32
+ if (url.host !== host) return false
33
+ if (requireLoopback || req?.method === 'POST') {
34
+ if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
35
+ }
36
+ } catch { return false }
19
37
  }
20
38
  const referer = req.headers?.referer
21
39
  if (referer) {
22
40
  if (!host) return false
23
- try { return new URL(referer).host === host } catch { return false }
41
+ try {
42
+ const url = new URL(referer)
43
+ if (url.host !== host) return false
44
+ if (requireLoopback || req?.method === 'POST') {
45
+ if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
46
+ }
47
+ } catch { return false }
24
48
  }
25
49
  // If sec-fetch-site is same-origin or same-site, allow
26
50
  if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
27
51
  // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
28
52
  if (req.headers?.['sec-fetch-mode'] && !host) return false
53
+ // For POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
54
+ if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
29
55
  return true
30
56
  }
31
57
 
@@ -188,7 +214,7 @@ export function normalizeSelectionRequest(body) {
188
214
 
189
215
  export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
190
216
  const makeHandler = (route) => async (req, res) => {
191
- if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
217
+ if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
192
218
  if (route === 'status') {
193
219
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
194
220
  const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { createProviderService } from './provider-service.js'
3
3
  import { createModelSynchronizer } from './synchronizer.js'
4
4
  import { createSyncScheduler } from './scheduler.js'
5
5
  import { registerHttpApi } from './http.js'
6
+ import { registerPluginUpdater } from './updater.js'
6
7
 
7
8
  export const name = '@goodandready/dsh-model-sync'
8
9
  export const inject = ['llm', 'settings', 'webServer', 'credentials']
@@ -143,7 +144,9 @@ export function apply(ctx, config) {
143
144
  }
144
145
 
145
146
  let settings
146
- try { settings = ctx.get('settings') } catch {}
147
+ try { settings = ctx.get('settings') } catch (e) {
148
+ if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('[dsh-model-sync] settings service not immediately available:', e)
149
+ }
147
150
  if (!bindSettings(settings, ctx)) {
148
151
  ctx.inject(['settings'], (sctx) => {
149
152
  bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
@@ -163,6 +166,11 @@ export function apply(ctx, config) {
163
166
  for (const dispose of registrations) if (typeof dispose === 'function') dispose()
164
167
  }
165
168
  }, 'dsh-model-sync: http api')
169
+ ctx.effect(() => registerPluginUpdater(ctx, {
170
+ packageName: name,
171
+ endpoint: '/api/dsh-model-sync/update',
172
+ manifestUrl: new URL('../package.json', import.meta.url),
173
+ }), 'dsh-model-sync: plugin updater')
166
174
  ctx.effect(() => {
167
175
  if (!liveConfig.enabled) return () => {}
168
176
  ctx.provide('modelSync', service)
@@ -0,0 +1,113 @@
1
+ import { statusOfError } from './reliability.js'
2
+
3
+ export const PIAI_NS = 'llm-pi-ai'
4
+ export const DEFAULT_RELIABILITY = Object.freeze({
5
+ timeoutMs: 15000,
6
+ retryAttempts: 3,
7
+ retryBaseDelayMs: 250,
8
+ retryMaxDelayMs: 4000,
9
+ concurrency: 4,
10
+ circuitBreakerFailures: 3,
11
+ circuitBreakerCooldownMs: 300000,
12
+ })
13
+
14
+ export function boundedInteger(value, fallback, minimum, maximum) {
15
+ const number = Number(value)
16
+ if (!Number.isFinite(number)) return fallback
17
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)))
18
+ }
19
+
20
+ export function reliabilityOptions(config, options) {
21
+ const source = { ...config, ...options }
22
+ const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
23
+ const cooldownMs = source.circuitBreakerCooldownMs !== undefined
24
+ ? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
25
+ : boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
26
+ return {
27
+ timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
28
+ retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
29
+ retryBaseDelayMs: baseDelay,
30
+ retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
31
+ concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
32
+ circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
33
+ circuitBreakerCooldownMs: cooldownMs,
34
+ }
35
+ }
36
+
37
+ export function settingsOf(ctx) {
38
+ try { return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings) } catch { return ctx.settings }
39
+ }
40
+
41
+ export function llmOf(ctx) {
42
+ try {
43
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
44
+ return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
45
+ } catch {
46
+ return ctx.llm
47
+ }
48
+ }
49
+
50
+ export function descriptorOf(settings, ns) {
51
+ try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
52
+ catch { return undefined }
53
+ }
54
+
55
+ export function profileOf(settings, provider) {
56
+ try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
57
+ catch { return {} }
58
+ }
59
+
60
+ export function errorMessage(error) {
61
+ return error instanceof Error ? error.message : String(error)
62
+ }
63
+
64
+ export function diagnosticMessage(error) {
65
+ return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
66
+ }
67
+
68
+ export function adapterErrorType(error) {
69
+ if (error?.adapterCode === 'auth') return 'auth'
70
+ if (error?.adapterCode === 'rate-limit') return 'rate-limit'
71
+ if (error?.adapterCode === 'schema') return 'schema'
72
+ if (error?.adapterCode === 'endpoint') return 'endpoint'
73
+ if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
74
+ const status = statusOfError(error)
75
+ if (status === 401 || status === 403) return 'auth'
76
+ if (status === 429) return 'rate-limit'
77
+ if (status) return 'http'
78
+ return 'endpoint'
79
+ }
80
+
81
+ export async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
82
+ let lastError
83
+ for (let i = 0; i < attempts; i++) {
84
+ const section = settings?.get?.(ns)
85
+ const descriptor = descriptorOf(settings, ns)
86
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
87
+ throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
88
+ }
89
+ const next = patchFn(structuredClone(section))
90
+ try {
91
+ await settings.replace(ns, next, descriptor.revision)
92
+ return next
93
+ } catch (err) {
94
+ lastError = err
95
+ const isConflict = err?.code === 'SETTINGS_CONFLICT' || err?.status === 409 || /conflict|revision/i.test(err?.message || '')
96
+ if (isConflict && i < attempts - 1) {
97
+ continue
98
+ }
99
+ throw err
100
+ }
101
+ }
102
+ throw lastError
103
+ }
104
+
105
+ export function rotationRefs(settings, provider) {
106
+ try {
107
+ const rows = settings?.get?.('dsh-key-rotation')?.providers
108
+ if (!Array.isArray(rows)) return []
109
+ return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
110
+ } catch {
111
+ return []
112
+ }
113
+ }
@@ -0,0 +1,50 @@
1
+ export function createModelProber({ health }) {
2
+ async function tryModel({ provider, model } = {}) {
3
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
4
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
5
+ const start = Date.now()
6
+ const result = await health({ provider })
7
+ const entry = result?.results?.find((r) => r.provider === provider)
8
+ return {
9
+ provider,
10
+ model,
11
+ ok: entry?.status === 'ok',
12
+ latencyMs: Date.now() - start,
13
+ status: entry?.status ?? 'unknown',
14
+ ...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
15
+ }
16
+ }
17
+
18
+ async function batchTryModels({ provider, models } = {}) {
19
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
20
+ if (!Array.isArray(models)) throw new Error('models must be an array')
21
+ const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
22
+ const results = []
23
+ const concurrency = 3
24
+ for (let i = 0; i < list.length; i += concurrency) {
25
+ const chunk = list.slice(i, i + concurrency)
26
+ const chunkResults = await Promise.all(
27
+ chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
28
+ provider,
29
+ model,
30
+ ok: false,
31
+ latencyMs: 0,
32
+ status: 'error',
33
+ error: err instanceof Error ? err.message : String(err)
34
+ })))
35
+ )
36
+ results.push(...chunkResults)
37
+ }
38
+ const reachable = results.filter((r) => r.ok).length
39
+ return {
40
+ provider,
41
+ results,
42
+ summary: { total: results.length, reachable, unreachable: results.length - reachable }
43
+ }
44
+ }
45
+
46
+ return {
47
+ tryModel,
48
+ batchTryModels,
49
+ }
50
+ }
@@ -0,0 +1,118 @@
1
+ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = async () => {}, listProviders = () => [], validatePolicy } = {}) {
2
+ function exportConfig() {
3
+ const config = getConfig() ?? {}
4
+ const pmap = {}
5
+ for (const p of listProviders()) {
6
+ if (p.configured) {
7
+ pmap[p.provider] = {
8
+ policy: config.modelPolicies?.[p.provider] ?? {},
9
+ selectedModels: config.modelSelections?.[p.provider] ?? []
10
+ }
11
+ }
12
+ }
13
+ return {
14
+ version: '1.0',
15
+ exportedAt: new Date().toISOString(),
16
+ plugin: '@goodandready/dsh-model-sync',
17
+ providers: pmap,
18
+ aliases: config.aliases ?? {},
19
+ scheduler: {
20
+ scheduleEnabled: config.scheduleEnabled ?? false,
21
+ intervalMinutes: config.intervalMinutes ?? 60,
22
+ autoApply: config.autoApply ?? false
23
+ }
24
+ }
25
+ }
26
+
27
+ async function importConfig(data) {
28
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
29
+ throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
30
+ }
31
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
32
+ const config = getConfig() ?? {}
33
+ const importedProviders = []
34
+ const importedAliases = []
35
+ const patch = {}
36
+
37
+ if (data.providers && typeof data.providers === 'object') {
38
+ const currentPolicies = structuredClone(config.modelPolicies ?? {})
39
+ const currentSelections = structuredClone(config.modelSelections ?? {})
40
+ for (const [pname, pdata] of Object.entries(data.providers)) {
41
+ if (pdata && typeof pdata === 'object') {
42
+ if (pdata.policy) {
43
+ currentPolicies[pname] = typeof validatePolicy === 'function' ? validatePolicy(pdata.policy) : pdata.policy
44
+ importedProviders.push(pname)
45
+ }
46
+ if (Array.isArray(pdata.selectedModels)) {
47
+ currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
48
+ importedProviders.push(pname)
49
+ }
50
+ }
51
+ }
52
+ patch.modelPolicies = currentPolicies
53
+ patch.modelSelections = currentSelections
54
+ }
55
+
56
+ if (data.aliases && typeof data.aliases === 'object') {
57
+ const currentAliases = structuredClone(config.aliases ?? {})
58
+ for (const [k, v] of Object.entries(data.aliases)) {
59
+ if (typeof k === 'string' && /^[a-zA-Z0-9_\-.]{1,64}$/.test(k.trim()) && v && typeof v === 'object' && typeof v.provider === 'string' && typeof v.model === 'string') {
60
+ currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
61
+ importedAliases.push(k.trim())
62
+ }
63
+ }
64
+ patch.aliases = currentAliases
65
+ }
66
+
67
+ if (data.scheduler && typeof data.scheduler === 'object') {
68
+ if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
69
+ if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
70
+ if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
71
+ }
72
+
73
+ await saveConfig(patch)
74
+ return {
75
+ success: true,
76
+ importedProviders: [...new Set(importedProviders)],
77
+ importedAliases: [...new Set(importedAliases)]
78
+ }
79
+ }
80
+
81
+ function getAliases() {
82
+ const config = getConfig() ?? {}
83
+ return config.aliases ?? {}
84
+ }
85
+
86
+ async function setAlias({ alias, provider, model } = {}) {
87
+ if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
88
+ throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
89
+ }
90
+ const trimmedAlias = alias.trim()
91
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
92
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
93
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
94
+ const config = getConfig() ?? {}
95
+ const currentAliases = structuredClone(config.aliases ?? {})
96
+ currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
97
+ await saveConfig({ aliases: currentAliases })
98
+ return { alias: trimmedAlias, provider, model, aliases: currentAliases }
99
+ }
100
+
101
+ async function deleteAlias({ alias } = {}) {
102
+ if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
103
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
104
+ const config = getConfig() ?? {}
105
+ const currentAliases = structuredClone(config.aliases ?? {})
106
+ delete currentAliases[alias.trim()]
107
+ await saveConfig({ aliases: currentAliases })
108
+ return { alias: alias.trim(), deleted: true, aliases: currentAliases }
109
+ }
110
+
111
+ return {
112
+ exportConfig,
113
+ importConfig,
114
+ getAliases,
115
+ setAlias,
116
+ deleteAlias,
117
+ }
118
+ }
@@ -9,117 +9,23 @@ import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './
9
9
  import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
10
10
  import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
11
11
 
12
- const PIAI_NS = 'llm-pi-ai'
13
- const DEFAULT_RELIABILITY = Object.freeze({
14
- timeoutMs: 15000,
15
- retryAttempts: 3,
16
- retryBaseDelayMs: 250,
17
- retryMaxDelayMs: 4000,
18
- concurrency: 4,
19
- circuitBreakerFailures: 3,
20
- circuitBreakerCooldownMs: 300000,
21
- })
22
-
23
- function boundedInteger(value, fallback, minimum, maximum) {
24
- const number = Number(value)
25
- if (!Number.isFinite(number)) return fallback
26
- return Math.min(maximum, Math.max(minimum, Math.floor(number)))
27
- }
28
-
29
- function reliabilityOptions(config, options) {
30
- const source = { ...config, ...options }
31
- const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
32
- const cooldownMs = source.circuitBreakerCooldownMs !== undefined
33
- ? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
34
- : boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
35
- return {
36
- timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
37
- retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
38
- retryBaseDelayMs: baseDelay,
39
- retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
40
- concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
41
- circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
42
- circuitBreakerCooldownMs: cooldownMs,
43
- }
44
- }
45
-
46
- function settingsOf(ctx) {
47
- try { return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings) } catch { return ctx.settings }
48
- }
49
-
50
- function llmOf(ctx) {
51
- try {
52
- const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
53
- return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
54
- } catch {
55
- return ctx.llm
56
- }
57
- }
58
-
59
- function descriptorOf(settings, ns) {
60
- try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
61
- catch { return undefined }
62
- }
63
-
64
- function profileOf(settings, provider) {
65
- try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
66
- catch { return {} }
67
- }
68
-
69
- function errorMessage(error) {
70
- return error instanceof Error ? error.message : String(error)
71
- }
72
-
73
- function diagnosticMessage(error) {
74
- return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
75
- }
76
-
77
- function adapterErrorType(error) {
78
- if (error?.adapterCode === 'auth') return 'auth'
79
- if (error?.adapterCode === 'rate-limit') return 'rate-limit'
80
- if (error?.adapterCode === 'schema') return 'schema'
81
- if (error?.adapterCode === 'endpoint') return 'endpoint'
82
- if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
83
- const status = statusOfError(error)
84
- if (status === 401 || status === 403) return 'auth'
85
- if (status === 429) return 'rate-limit'
86
- if (status) return 'http'
87
- return 'endpoint'
88
- }
89
-
90
- async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
91
- let lastError
92
- for (let i = 0; i < attempts; i++) {
93
- const section = settings?.get?.(ns)
94
- const descriptor = descriptorOf(settings, ns)
95
- if (!section || !descriptor || typeof settings.replace !== 'function') {
96
- throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
97
- }
98
- const next = patchFn(structuredClone(section))
99
- try {
100
- await settings.replace(ns, next, descriptor.revision)
101
- return next
102
- } catch (err) {
103
- lastError = err
104
- const isConflict = err?.code === 'SETTINGS_CONFLICT' || err?.status === 409 || /conflict|revision/i.test(err?.message || '')
105
- if (isConflict && i < attempts - 1) {
106
- continue
107
- }
108
- throw err
109
- }
110
- }
111
- throw lastError
112
- }
113
-
114
- function rotationRefs(settings, provider) {
115
- try {
116
- const rows = settings?.get?.('dsh-key-rotation')?.providers
117
- if (!Array.isArray(rows)) return []
118
- return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
119
- } catch {
120
- return []
121
- }
122
- }
12
+ import {
13
+ PIAI_NS,
14
+ DEFAULT_RELIABILITY,
15
+ boundedInteger,
16
+ reliabilityOptions,
17
+ settingsOf,
18
+ llmOf,
19
+ descriptorOf,
20
+ profileOf,
21
+ errorMessage,
22
+ diagnosticMessage,
23
+ adapterErrorType,
24
+ replaceWithRetry,
25
+ rotationRefs,
26
+ } from './synchronizer-helpers.js'
27
+ import { createConfigTransfer } from './synchronizer-transfer.js'
28
+ import { createModelProber } from './synchronizer-prober.js'
123
29
 
124
30
  export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
125
31
  const resolveCredential = createCredentialResolver(ctx)
@@ -531,50 +437,9 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
531
437
  return transientNotifications.find((row) => row.id === id)
532
438
  }
533
439
 
534
- async function tryModel({ provider, model } = {}) {
535
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
536
- if (typeof model !== 'string' || !model) throw new Error('model is required')
537
- const start = Date.now()
538
- // Use health as lightweight probe; latency is the signal
539
- const result = await health({ provider })
540
- const entry = result?.results?.find((r) => r.provider === provider)
541
- return {
542
- provider,
543
- model,
544
- ok: entry?.status === 'ok',
545
- latencyMs: Date.now() - start,
546
- status: entry?.status ?? 'unknown',
547
- ...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
548
- }
549
- }
550
- async function batchTryModels({ provider, models } = {}) {
551
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
552
- if (!Array.isArray(models)) throw new Error('models must be an array')
553
- const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
554
- const results = []
555
- const concurrency = 3
556
- for (let i = 0; i < list.length; i += concurrency) {
557
- const chunk = list.slice(i, i + concurrency)
558
- const chunkResults = await Promise.all(
559
- chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
560
- provider,
561
- model,
562
- ok: false,
563
- latencyMs: 0,
564
- status: 'error',
565
- error: err instanceof Error ? err.message : String(err)
566
- })))
567
- )
568
- results.push(...chunkResults)
569
- }
570
- const reachable = results.filter((r) => r.ok).length
571
- return {
572
- provider,
573
- results,
574
- summary: { total: results.length, reachable, unreachable: results.length - reachable }
575
- }
576
- }
577
-
440
+ const prober = createModelProber({ health })
441
+ const tryModel = prober.tryModel
442
+ const batchTryModels = prober.batchTryModels
578
443
 
579
444
  async function rollbackHistory({ historyId, provider } = {}) {
580
445
  if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
@@ -660,115 +525,17 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
660
525
  }
661
526
  return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
662
527
  }
663
- function exportConfig() {
664
- const config = getConfig() ?? {}
665
- const pmap = {}
666
- for (const p of providers()) {
667
- if (p.configured) {
668
- pmap[p.provider] = {
669
- policy: config.modelPolicies?.[p.provider] ?? {},
670
- selectedModels: config.modelSelections?.[p.provider] ?? []
671
- }
672
- }
673
- }
674
- return {
675
- version: '1.0',
676
- exportedAt: new Date().toISOString(),
677
- plugin: '@goodandready/dsh-model-sync',
678
- providers: pmap,
679
- aliases: config.aliases ?? {},
680
- scheduler: {
681
- scheduleEnabled: config.scheduleEnabled ?? false,
682
- intervalMinutes: config.intervalMinutes ?? 60,
683
- autoApply: config.autoApply ?? false
684
- }
685
- }
686
- }
687
-
688
- async function importConfig(data) {
689
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
690
- throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
691
- }
692
- if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
693
- const config = getConfig() ?? {}
694
- const importedProviders = []
695
- const importedAliases = []
696
- const patch = {}
697
-
698
- if (data.providers && typeof data.providers === 'object') {
699
- const currentPolicies = structuredClone(config.modelPolicies ?? {})
700
- const currentSelections = structuredClone(config.modelSelections ?? {})
701
- for (const [pname, pdata] of Object.entries(data.providers)) {
702
- if (pdata && typeof pdata === 'object') {
703
- if (pdata.policy) {
704
- currentPolicies[pname] = validatePolicy(pdata.policy)
705
- importedProviders.push(pname)
706
- }
707
- if (Array.isArray(pdata.selectedModels)) {
708
- currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
709
- importedProviders.push(pname)
710
- }
711
- }
712
- }
713
- patch.modelPolicies = currentPolicies
714
- patch.modelSelections = currentSelections
715
- }
716
-
717
- if (data.aliases && typeof data.aliases === 'object') {
718
- const currentAliases = structuredClone(config.aliases ?? {})
719
- for (const [k, v] of Object.entries(data.aliases)) {
720
- if (typeof k === 'string' && /^[a-zA-Z0-9_\-.]{1,64}$/.test(k.trim()) && v && typeof v === 'object' && typeof v.provider === 'string' && typeof v.model === 'string') {
721
- currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
722
- importedAliases.push(k.trim())
723
- }
724
- }
725
- patch.aliases = currentAliases
726
- }
727
-
728
- if (data.scheduler && typeof data.scheduler === 'object') {
729
- if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
730
- if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
731
- if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
732
- }
733
-
734
- await saveConfig(patch)
735
- return {
736
- success: true,
737
- importedProviders: [...new Set(importedProviders)],
738
- importedAliases: [...new Set(importedAliases)]
739
- }
740
- }
741
-
742
- function getAliases() {
743
- const config = getConfig() ?? {}
744
- return config.aliases ?? {}
745
- }
746
-
747
- async function setAlias({ alias, provider, model } = {}) {
748
- if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
749
- throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
750
- }
751
- const trimmedAlias = alias.trim()
752
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
753
- if (typeof model !== 'string' || !model) throw new Error('model is required')
754
- if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
755
- const config = getConfig() ?? {}
756
- const currentAliases = structuredClone(config.aliases ?? {})
757
- currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
758
- await saveConfig({ aliases: currentAliases })
759
- return { alias: trimmedAlias, provider, model, aliases: currentAliases }
760
- }
761
-
762
- async function deleteAlias({ alias } = {}) {
763
- if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
764
- if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
765
- const config = getConfig() ?? {}
766
- const currentAliases = structuredClone(config.aliases ?? {})
767
- delete currentAliases[alias.trim()]
768
- await saveConfig({ aliases: currentAliases })
769
- return { alias: alias.trim(), deleted: true, aliases: currentAliases }
770
- }
771
-
528
+ const transfer = createConfigTransfer({
529
+ getConfig,
530
+ saveConfig,
531
+ listProviders: providers,
532
+ validatePolicy,
533
+ })
534
+ const exportConfig = transfer.exportConfig
535
+ const importConfig = transfer.importConfig
536
+ const getAliases = transfer.getAliases
537
+ const setAlias = transfer.setAlias
538
+ const deleteAlias = transfer.deleteAlias
772
539
 
773
540
  return Object.freeze({
774
541
  listProviders: providers,
package/lib/updater.js ADDED
@@ -0,0 +1,362 @@
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 @goodandready/dsh-model-sync.
10
+ * Implements the unified DSH plugin updater specification with loopback verification.
11
+ */
12
+
13
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
14
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
15
+ const VERSION_CACHE_MS = 5 * 60_000
16
+ let latestCache = undefined
17
+
18
+ function header(request, name) {
19
+ const value = request?.headers?.[name]
20
+ return Array.isArray(value) ? value[0] : value
21
+ }
22
+
23
+ export function isLoopback(value) {
24
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
+ return (
26
+ address === 'localhost' ||
27
+ address === 'localhost.' ||
28
+ address === '::1' ||
29
+ address?.startsWith('127.') === true ||
30
+ address?.startsWith('::ffff:127.') === true
31
+ )
32
+ }
33
+
34
+ export function isTrustedUpdateRequest(request) {
35
+ if (header(request, UPDATE_HEADER) !== '1') return false
36
+ const remote = request.socket?.remoteAddress
37
+ if (!isLoopback(remote)) return false
38
+ const site = header(request, 'sec-fetch-site')
39
+ if (site !== undefined && site !== 'same-origin') return false
40
+ const origin = header(request, 'origin')
41
+ const host = header(request, 'host')
42
+ if (origin === undefined || host === undefined) return false
43
+ try {
44
+ const url = new URL(origin)
45
+ return (
46
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
47
+ isLoopback(url.hostname) &&
48
+ url.host === host
49
+ )
50
+ } catch (err) {
51
+ // bestEffort URL parse
52
+ return false
53
+ }
54
+ }
55
+
56
+ function validProfileName(value) {
57
+ return (
58
+ typeof value === 'string' &&
59
+ value !== '' &&
60
+ value !== '.' &&
61
+ value !== '..' &&
62
+ !value.includes('/') &&
63
+ !value.includes('\\') &&
64
+ !/[\0-\x1f\x7f]/.test(value)
65
+ )
66
+ }
67
+
68
+ function profileNameFromArgv(argv) {
69
+ for (let index = 0; index < argv.length; index += 1) {
70
+ const arg = argv[index]
71
+ if (arg === '--profile' || arg === '-p') {
72
+ const next = argv[index + 1]
73
+ if (validProfileName(next)) return next
74
+ }
75
+ if (typeof arg === 'string' && arg.startsWith('--profile=')) {
76
+ const value = arg.slice('--profile='.length)
77
+ if (validProfileName(value)) return value
78
+ }
79
+ }
80
+ return undefined
81
+ }
82
+
83
+ function findDshCliEntry() {
84
+ const entry = process.argv[1]
85
+ if (typeof entry !== 'string' || entry === '') return undefined
86
+ const resolved = entry.startsWith('file:') ? fileURLToPath(entry) : resolve(process.cwd(), entry)
87
+ if (!existsSync(resolved)) return undefined
88
+ for (let directory = dirname(resolved); ; directory = dirname(directory)) {
89
+ const manifestPath = resolve(directory, 'package.json')
90
+ if (existsSync(manifestPath)) {
91
+ try {
92
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
93
+ const bin =
94
+ typeof manifest.bin === 'string'
95
+ ? manifest.bin
96
+ : typeof manifest.bin === 'object' && manifest.bin !== null
97
+ ? manifest.bin.dsh
98
+ : undefined
99
+ if (
100
+ manifest.name === '@deepseek-ai/dsh' &&
101
+ typeof bin === 'string' &&
102
+ !isAbsolute(bin) &&
103
+ resolve(directory, bin) === resolve(resolved)
104
+ ) {
105
+ return resolved
106
+ }
107
+ } catch (err) {
108
+ // bestEffort manifest parse
109
+ }
110
+ }
111
+ const parent = dirname(directory)
112
+ if (parent === directory) return undefined
113
+ }
114
+ }
115
+
116
+ export function runtime() {
117
+ const profileDir = resolve(
118
+ process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
119
+ )
120
+ const selected = profileNameFromArgv(process.argv)
121
+ const profileName = validProfileName(selected)
122
+ ? selected
123
+ : validProfileName(basename(profileDir))
124
+ ? basename(profileDir)
125
+ : 'web'
126
+ const cliEntry = findDshCliEntry()
127
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
128
+ }
129
+
130
+ export function parseSemver(value) {
131
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? '').trim())
132
+ if (match === null) return undefined
133
+ return {
134
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
135
+ prerelease: match[4] ? match[4].split('.') : [],
136
+ }
137
+ }
138
+
139
+ export function comparePrerelease(left, right) {
140
+ if (left.length === 0 && right.length === 0) return 0
141
+ if (left.length === 0) return 1
142
+ if (right.length === 0) return -1
143
+ const length = Math.max(left.length, right.length)
144
+ for (let index = 0; index < length; index += 1) {
145
+ const a = left[index]
146
+ const b = right[index]
147
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
148
+ if (a === b) continue
149
+ const aNumeric = /^\d+$/.test(a)
150
+ const bNumeric = /^\d+$/.test(b)
151
+ if (aNumeric && bNumeric) {
152
+ const aNumber = BigInt(a)
153
+ const bNumber = BigInt(b)
154
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
155
+ continue
156
+ }
157
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
158
+ return a > b ? 1 : -1
159
+ }
160
+ return 0
161
+ }
162
+
163
+ export function isNewerVersion(currentValue, candidateValue) {
164
+ const current = parseSemver(currentValue)
165
+ const candidate = parseSemver(candidateValue)
166
+ if (current === undefined || candidate === undefined) return false
167
+ for (let index = 0; index < 3; index += 1) {
168
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
169
+ }
170
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
171
+ }
172
+
173
+ export function resetLatestCache() {
174
+ latestCache = undefined
175
+ }
176
+
177
+ export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
178
+ if (
179
+ latestCache?.packageName === packageName &&
180
+ latestCache.registry === registry &&
181
+ Date.now() < latestCache.expiresAt
182
+ ) {
183
+ return latestCache.version
184
+ }
185
+ try {
186
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
187
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
188
+ })
189
+ if (!response.ok) return undefined
190
+ const value = await response.json()
191
+ if (typeof value?.version !== 'string' || value.version === '') return undefined
192
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
193
+ return value.version
194
+ } catch (err) {
195
+ // bestEffort network check
196
+ return undefined
197
+ }
198
+ }
199
+
200
+ export async function currentVersion(manifestUrl) {
201
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
202
+ if (typeof value?.version !== 'string' || value.version === '') {
203
+ throw new Error('Cannot read current plugin version.')
204
+ }
205
+ return value.version
206
+ }
207
+
208
+ export async function status(options, target) {
209
+ const current = await currentVersion(options.manifestUrl)
210
+ let latest = undefined
211
+ if (typeof options.getLatestVersion === 'function') {
212
+ latest = await options.getLatestVersion()
213
+ } else if (options.latestVersion !== undefined) {
214
+ latest = options.latestVersion
215
+ } else {
216
+ latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
217
+ }
218
+ return {
219
+ packageName: options.packageName,
220
+ currentVersion: current,
221
+ ...(latest === undefined ? {} : { latestVersion: latest }),
222
+ latestCheckFailed: latest === undefined,
223
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
224
+ profileName: target.profileName,
225
+ canAutoUpdate: target.cliEntry !== undefined,
226
+ }
227
+ }
228
+
229
+ export async function installExact(target, packageSpec, options = {}) {
230
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
231
+ await new Promise((resolvePromise, reject) => {
232
+ const child = spawn(
233
+ process.execPath,
234
+ [
235
+ target.cliEntry,
236
+ 'plugin',
237
+ '--profile',
238
+ target.profileName,
239
+ 'add',
240
+ '--config.minimumReleaseAge=0',
241
+ packageSpec,
242
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
243
+ ],
244
+ {
245
+ cwd: target.profileDir,
246
+ windowsHide: true,
247
+ stdio: ['ignore', 'pipe', 'pipe'],
248
+ env: { ...process.env, NO_COLOR: '1' },
249
+ }
250
+ )
251
+ let detail = ''
252
+ child.stdout?.on('data', (chunk) => {
253
+ detail = (detail + String(chunk)).slice(-4000)
254
+ })
255
+ child.stderr?.on('data', (chunk) => {
256
+ detail = (detail + String(chunk)).slice(-4000)
257
+ })
258
+ const timer = setTimeout(() => {
259
+ child.kill()
260
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
261
+ }, UPDATE_TIMEOUT_MS)
262
+ child.once('error', (error) => {
263
+ clearTimeout(timer)
264
+ reject(error)
265
+ })
266
+ child.once('exit', (code) => {
267
+ clearTimeout(timer)
268
+ if (code === 0) resolvePromise()
269
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
270
+ })
271
+ })
272
+ }
273
+
274
+ function json(response, statusCode, value) {
275
+ response.writeHead(statusCode, {
276
+ 'content-type': 'application/json; charset=utf-8',
277
+ 'cache-control': 'no-store',
278
+ })
279
+ response.end(JSON.stringify(value))
280
+ }
281
+
282
+ export function registerPluginUpdater(ctx, options = {}) {
283
+ let installing = false
284
+ if (typeof ctx?.webServer?.register !== 'function') {
285
+ if (typeof ctx?.logger?.warn === 'function') {
286
+ ctx.logger.warn('[dsh-model-sync] webServer service unavailable, updater skipped')
287
+ }
288
+ return () => {}
289
+ }
290
+
291
+ const packageName = options.packageName ?? '@goodandready/dsh-model-sync'
292
+ const endpoints = Array.isArray(options.endpoints)
293
+ ? options.endpoints
294
+ : [options.endpoint ?? '/api/dsh-model-sync/update', '/dsh-model-sync/update']
295
+ const uniqueEndpoints = [...new Set(endpoints)]
296
+
297
+ const handler = async (request, response) => {
298
+ try {
299
+ const target = runtime()
300
+ if (request.method === 'GET' || request.method === 'HEAD') {
301
+ const payload = await status({ ...options, packageName }, target)
302
+ response.writeHead(200, {
303
+ 'content-type': 'application/json; charset=utf-8',
304
+ 'cache-control': 'no-store',
305
+ })
306
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
307
+ return
308
+ }
309
+ if (request.method !== 'POST') {
310
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
311
+ response.end()
312
+ return
313
+ }
314
+ if (!isTrustedUpdateRequest(request)) {
315
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
316
+ return
317
+ }
318
+ if (installing) {
319
+ json(response, 409, { error: 'This plugin is already updating.' })
320
+ return
321
+ }
322
+ installing = true
323
+ try {
324
+ const before = await status({ ...options, packageName }, target)
325
+ if (before.latestVersion === undefined) {
326
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
327
+ return
328
+ }
329
+ if (!before.updateAvailable) {
330
+ json(response, 200, before)
331
+ return
332
+ }
333
+ const doInstall = options.installExact ?? installExact
334
+ await doInstall(target, `${packageName}@${before.latestVersion}`, options)
335
+ json(response, 200, {
336
+ ...before,
337
+ updatedVersion: before.latestVersion,
338
+ restartRequired: true,
339
+ })
340
+ } finally {
341
+ installing = false
342
+ }
343
+ } catch (error) {
344
+ if (typeof ctx?.logger?.warn === 'function') {
345
+ ctx.logger.warn(`[dsh-model-sync] plugin updater failed: ${String(error)}`)
346
+ }
347
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
348
+ }
349
+ }
350
+
351
+ const disposers = uniqueEndpoints.map((path) =>
352
+ ctx.webServer.register({ kind: 'exact', path, handler })
353
+ )
354
+
355
+ return () => {
356
+ for (const dispose of disposers) {
357
+ if (typeof dispose === 'function') {
358
+ try { dispose() } catch (err) { /* bestEffort cleanup */ }
359
+ }
360
+ }
361
+ }
362
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.3.14",
3
+ "version": "0.4.0",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -38,7 +38,11 @@
38
38
  },
39
39
  "client": {
40
40
  "platform": "web",
41
- "inject": []
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-locale",
43
+ "@deepseek-ai/dsh-client-ui-slots",
44
+ "@deepseek-ai/dsh-client-ui-settings"
45
+ ]
42
46
  }
43
47
  },
44
48
  "scripts": {