@goodandready/dsh-subscriptions 0.6.8 → 0.6.9

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
@@ -260,6 +260,16 @@ Supports Anthropic's adaptive thinking (`thinking: { type: "adaptive" }`) and re
260
260
 
261
261
  ---
262
262
 
263
+ ## 🚀 One-Click Plugin Updater & Stability Hardening (Added in v0.6.9)
264
+
265
+ - **Host One-Click Updater**: Automatic in-place updater mounted at `/dsh-subscriptions/update`, checking the npm registry for newer releases and executing single-flight installation via the host DSH CLI (`dsh plugin add --config.minimumReleaseAge=0`).
266
+ - **Security & Origin Protection**: Updater endpoints enforce strict loopback address checks (supporting IPv4 `127.0.0.1`, IPv6 `::1`, `localhost`), `x-dsh-plugin-update` header verification, and same-origin validation to prevent unauthorized updates.
267
+ - **Header Badge & UI Action**: The settings header bar displays current plugin version, an update warning badge when a new release is detected, and an instant "Update" button with live restart countdown.
268
+ - **Network Timeout Hardening**: Quota balance and smoke test probes now enforce guaranteed 15-second abort timeouts (`AbortSignal.timeout(15_000)`), preventing hanging sockets during vendor outages.
269
+ - **Parameter Normalization & Deduplication**: Unified support for `provider || vendor` and `index || accountIndex` aliases in check endpoints, and deduplicated threshold notification loops.
270
+
271
+ ---
272
+
263
273
  ## 🌐 Localization
264
274
 
265
275
  The plugin source language is English only. Russian and other translations are provided at runtime by separate language plugins (for example the russification plugin), which translate the registered locale keys - the package itself ships no bundled translations (Changed in 0.6.1).
package/README.ru.md CHANGED
@@ -233,6 +233,16 @@ dsh-subscriptions:
233
233
 
234
234
  ---
235
235
 
236
+ ## 🚀 Обновление в один клик и повышение стабильности (Добавлено в v0.6.9)
237
+
238
+ - **Встроенный апдейтер в один клик**: Фоновая проверка новых версий в npm registry и обновление на месте по маршруту `/dsh-subscriptions/update` через хостовый CLI DSH (`dsh plugin add --config.minimumReleaseAge=0`).
239
+ - **Безопасность и защита эндпоинта**: Строгая верификация loopback-адресов (поддержка IPv4 `127.0.0.1`, IPv6 `::1`, `localhost`), валидация заголовка `x-dsh-plugin-update` и защита от CSRF/cross-origin запросов.
240
+ - **Индикатор в шапке и кнопка обновления**: В строке состояния панели отображается текущая версия, бейдж наличия обновления и кнопка быстрого обновления («Update → vX.Y.Z») с уведомлением о перезапуске службы.
241
+ - **Сетевые таймауты и устойчивость**: Ограничение времени ожидания запросов баланса и smoke-тестов до 15 секунд (`AbortSignal.timeout(15_000)`), предотвращающее зависание сокетов при недоступности внешних API.
242
+ - **Нормализация параметров и дедупликация**: Поддержка алиасов параметров `provider || vendor` и `index || accountIndex` в статусных маршрутах, а также устранение дублирующего цикла нотификаций в `lib/accounts.js`.
243
+
244
+ ---
245
+
236
246
  ## 🌐 Локализация
237
247
 
238
248
  Исходный язык плагина - только английский. Русский и другие переводы во время работы предоставляют отдельные языковые плагины (например, плагин русификации), переводящие зарегистрированные ключи локали, - сам пакет не содержит встроенных переводов (Changed in 0.6.1).
package/README.zh.md CHANGED
@@ -155,6 +155,16 @@ dsh plugin --profile web add @goodandready/dsh-subscriptions
155
155
 
156
156
  ---
157
157
 
158
+ ## 🚀 一键插件自更新与稳定性强化 (v0.6.9 新增)
159
+
160
+ - **宿主端一键更新**: 挂载于 `/dsh-subscriptions/update` 的就地更新机制,自动对比 npm registry 最新版本并通过宿主 DSH CLI 执行单飞安装 (`dsh plugin add --config.minimumReleaseAge=0`)。
161
+ - **安全与来源防护**: 更新接口严格校验环回地址(支持 IPv4 `127.0.0.1`、IPv6 `::1`、`localhost`)、`x-dsh-plugin-update` 请求头及同源策略,阻断未授权跨站调用。
162
+ - **状态栏徽章与更新按钮**: 设置面板顶部状态栏显示当前插件版本,有新版本时提示告警徽章并提供一键更新按钮与重启提示。
163
+ - **网络超时熔断**: 配额检测与冒烟测试增加 15 秒超时信号保护 (`AbortSignal.timeout(15_000)`),防止外部服务商接口故障导致请求挂起。
164
+ - **参数容错与逻辑去重**: 状态接口兼容 `provider || vendor` 与 `index || accountIndex` 参数别名,并消除 `lib/accounts.js` 中的重复配额通知循环。
165
+
166
+ ---
167
+
158
168
  ## 🌐 本地化
159
169
 
160
170
  插件源语言仅为英语。俄语及其他翻译由独立的语言插件(如俄化插件)在运行时对注册的本地化键进行翻译,包本身不内置翻译(Changed in 0.6.1)。
package/lib/accounts.js CHANGED
@@ -268,23 +268,6 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
268
268
  }
269
269
  }
270
270
  }
271
- // Notification thresholds: 70/90/100% — fire once per window until reset.
272
- if (Array.isArray(snap.windows)) {
273
- for (const win of snap.windows) {
274
- if (!win || !Number.isFinite(Number(win.usedPercent))) continue
275
- const pct = Number(win.usedPercent)
276
- const key = slot.ref + ':' + win.id
277
- const prev = notifyThresholds.get(key) || 0
278
- const THRESHOLDS = [70, 90, 100]
279
- for (const th of THRESHOLDS) {
280
- if (pct >= th && prev < th) {
281
- notifyThresholds.set(key, th)
282
- try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch {}
283
- break
284
- }
285
- }
286
- }
287
- }
288
271
  const list = Array.isArray(snap.windows) ? snap.windows : null
289
272
  if (list) {
290
273
  windows.set(slot.ref, list)
package/lib/client.js CHANGED
@@ -481,7 +481,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
481
481
  "instructionsTitle": "How to obtain token",
482
482
  "close": "Close",
483
483
  "reconnectRequired": "Reconnect required",
484
- "manualTitle": "If the browser did not come back"
484
+ "manualTitle": "If the browser did not come back",
485
+ "updateChecking": "Checking updates…",
486
+ "updateUpToDate": "Up to date",
487
+ "updateAvailable": "Update available",
488
+ "updateNow": "Update",
489
+ "updating": "Updating…",
490
+ "updateSuccessRestart": "Update complete. Restarting service…",
491
+ "updateFailed": "Update failed"
485
492
  };
486
493
 
487
494
  const zh = {
@@ -643,7 +650,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
643
650
  "instructionsTitle": "获取令牌指引",
644
651
  "close": "关闭",
645
652
  "reconnectRequired": "需要重新连接",
646
- "manualTitle": "若浏览器未能自动跳转回调"
653
+ "manualTitle": "若浏览器未能自动跳转回调",
654
+ "updateChecking": "检查更新中…",
655
+ "updateUpToDate": "已是最新版本",
656
+ "updateAvailable": "有新版本可用",
657
+ "updateNow": "立即更新",
658
+ "updating": "正在更新…",
659
+ "updateSuccessRestart": "更新成功,服务重启中…",
660
+ "updateFailed": "更新失败"
647
661
  };
648
662
  // #51: live countdown to the quota window reset (account.quota.resetAt).
649
663
  function cleanErrorMessage(raw) {
@@ -831,6 +845,43 @@ const cssId = 'dsh-subscriptions/settings.module.css'
831
845
  const [hostPing, setHostPing] = React.useState({ ok: true, latencyMs: null })
832
846
  const [telemetry, setTelemetry] = React.useState(null)
833
847
  const [smoke, setSmoke] = React.useState({ testing: false, result: null })
848
+ const [updater, setUpdater] = React.useState({ checking: false, updating: false, status: null, message: '' })
849
+
850
+ const checkUpdater = () => {
851
+ setUpdater((u) => Object.assign({}, u, { checking: true, message: '' }))
852
+ return fetch('/dsh-subscriptions/update', { cache: 'no-store' })
853
+ .then((res) => res.json())
854
+ .then((data) => {
855
+ setUpdater((u) => Object.assign({}, u, { checking: false, status: data }))
856
+ return data
857
+ })
858
+ .catch((e) => {
859
+ setUpdater((u) => Object.assign({}, u, { checking: false, message: cleanErrorMessage(e.message || e) }))
860
+ })
861
+ }
862
+
863
+ const performUpdate = async () => {
864
+ setUpdater((u) => Object.assign({}, u, { updating: true, message: '' }))
865
+ try {
866
+ const res = await fetch('/dsh-subscriptions/update', {
867
+ method: 'POST',
868
+ headers: {
869
+ 'Content-Type': 'application/json',
870
+ 'x-dsh-plugin-update': '1',
871
+ },
872
+ })
873
+ const data = await res.json().catch(() => ({}))
874
+ if (!res.ok || !data.ok) {
875
+ throw new Error((data && data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status))
876
+ }
877
+ setUpdater((u) => Object.assign({}, u, { updating: false, message: t('updateSuccessRestart') }))
878
+ setTimeout(() => {
879
+ window.location.reload()
880
+ }, 3500)
881
+ } catch (e) {
882
+ setUpdater((u) => Object.assign({}, u, { updating: false, message: (t('updateFailed') + ': ' + cleanErrorMessage(e.message || e)) }))
883
+ }
884
+ }
834
885
 
835
886
  const reloadTelemetry = () => {
836
887
  fetch('/dsh-subscriptions/telemetry', { cache: 'no-store' })
@@ -886,6 +937,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
886
937
  React.useEffect(() => {
887
938
  let alive = true
888
939
  reload().catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
940
+ checkUpdater().catch(() => {})
889
941
  return () => { alive = false }
890
942
  }, [])
891
943
 
@@ -1107,14 +1159,38 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1107
1159
  React.createElement('span', {
1108
1160
  className: 'dsub-badge dsub-badge-neutral'
1109
1161
  }, 'Pool: ' + accounts.length + ' accounts'),
1162
+ updater.status && updater.status.updateAvailable
1163
+ ? React.createElement('span', { className: 'dsub-badge dsub-badge-warn' },
1164
+ '⬆ ' + t('updateAvailable') + ' (v' + (updater.status.latestVersion || '') + ')'
1165
+ )
1166
+ : (updater.status && updater.status.currentVersion
1167
+ ? React.createElement('span', { className: 'dsub-badge dsub-badge-neutral' },
1168
+ 'v' + updater.status.currentVersion
1169
+ )
1170
+ : null
1171
+ ),
1172
+ ),
1173
+ React.createElement('div', { className: 'dsub-row', style: { marginTop: 0, gap: '8px' } },
1174
+ updater.status && updater.status.updateAvailable && updater.status.canAutoUpdate
1175
+ ? React.createElement('button', {
1176
+ type: 'button',
1177
+ className: 'dsub-mini dsub-btn-primary',
1178
+ disabled: updater.updating,
1179
+ onClick: () => performUpdate(),
1180
+ }, updater.updating ? t('updating') : (t('updateNow') + ' → v' + updater.status.latestVersion))
1181
+ : null,
1182
+ React.createElement('button', {
1183
+ type: 'button',
1184
+ className: 'dsub-mini',
1185
+ disabled: smoke.testing,
1186
+ onClick: () => runSmokeTest(),
1187
+ }, smoke.testing ? t('smokeTesting') : t('runSmoke')),
1110
1188
  ),
1111
- React.createElement('button', {
1112
- type: 'button',
1113
- className: 'dsub-mini dsub-btn-primary',
1114
- disabled: smoke.testing,
1115
- onClick: () => runSmokeTest(),
1116
- }, smoke.testing ? t('smokeTesting') : t('runSmoke')),
1117
1189
  ),
1190
+ updater.message ? React.createElement('div', {
1191
+ className: updater.message.includes(t('updateSuccessRestart')) ? 'dsub-alert-ok' : 'dsub-alert-bad',
1192
+ style: { marginBottom: '12px' }
1193
+ }, updater.message) : null,
1118
1194
  smoke.result ? React.createElement('div', {
1119
1195
  className: smoke.result.ok ? 'dsub-alert-ok' : 'dsub-alert-bad',
1120
1196
  style: { marginBottom: '12px' }
package/lib/http.js CHANGED
@@ -30,6 +30,13 @@ export function readBody(req, maxBytes = 64 * 1024) {
30
30
  })
31
31
  }
32
32
 
33
+ export 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
+
33
40
  /**
34
41
  * @param {import('node:http').IncomingMessage} request
35
42
  * @returns {boolean}
@@ -177,26 +177,27 @@ export function registerStatusRoutes(ctx, state) {
177
177
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
178
178
  return
179
179
  }
180
- const provider = payload.provider
180
+ const provider = payload.provider || payload.vendor
181
+ const index = payload.index || (payload.accountIndex != null ? payload.accountIndex + 1 : 1)
181
182
  if (!isProvider(provider)) {
182
183
  writeJson(res, 200, { ok: false, provider, error: { code: 'provider', message: 'unknown provider' } })
183
184
  return
184
185
  }
185
186
  const t0 = Date.now()
186
- const ref = oauthRef(provider, payload.index)
187
+ const ref = oauthRef(provider, index)
187
188
  const info = await store.describeRef(ref)
188
189
  if (!info.configured) {
189
- writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'not_connected', message: 'not connected' } })
190
+ writeJson(res, 200, { ok: false, provider, index: index, ref, error: { code: 'not_connected', message: 'not connected' } })
190
191
  return
191
192
  }
192
193
  let blob
193
194
  try { blob = await store.loadBlob(ref) } catch (e) {
194
- writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'auth', message: String(e && e.message || e) } })
195
+ writeJson(res, 200, { ok: false, provider, index: index, ref, error: { code: 'auth', message: String(e && e.message || e) } })
195
196
  return
196
197
  }
197
198
  try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
198
199
  writeJson(res, 200, {
199
- ok: false, provider, index: payload.index, ref,
200
+ ok: false, provider, index: index, ref,
200
201
  email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
201
202
  quota: info.quota || null,
202
203
  error: { code: 'refresh', message: String(e && e.message || e) },
@@ -205,12 +206,14 @@ export function registerStatusRoutes(ctx, state) {
205
206
  }
206
207
  const vendor = getVendor(provider)
207
208
  if (typeof vendor.check !== 'function') {
208
- writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
209
+ writeJson(res, 200, { ok: true, provider, index: index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
209
210
  return
210
211
  }
211
212
  let capturedQuota = null
212
- const probeFetch = async (url, init) => {
213
- const res2 = await fetch(url, init)
213
+ const probeFetch = async (url, init = {}) => {
214
+ const timeoutSignal = AbortSignal.timeout(15_000)
215
+ const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
216
+ const res2 = await fetch(url, { ...init, signal })
214
217
  try {
215
218
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
216
219
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -220,10 +223,10 @@ export function registerStatusRoutes(ctx, state) {
220
223
  try {
221
224
  await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
222
225
  const latencyMs = Date.now() - t0;
223
- writeJson(res, 200, { ok: true, provider, index: payload.index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
226
+ writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
224
227
  } catch (e) {
225
228
  writeJson(res, 200, {
226
- ok: false, provider, index: payload.index, ref,
229
+ ok: false, provider, index: index, ref,
227
230
  latencyMs: Date.now() - t0,
228
231
  email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
229
232
  quota: capturedQuota || info.quota || null,
@@ -321,8 +324,10 @@ export function registerStatusRoutes(ctx, state) {
321
324
  const vendor = getVendor(provider)
322
325
  if (typeof vendor.check === 'function') {
323
326
  let capturedQuota = null
324
- const probeFetch = async (url, init) => {
325
- const res2 = await fetch(url, init)
327
+ const probeFetch = async (url, init = {}) => {
328
+ const timeoutSignal = AbortSignal.timeout(15_000)
329
+ const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
330
+ const res2 = await fetch(url, { ...init, signal })
326
331
  try {
327
332
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
328
333
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
package/lib/routes.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { publicConfig } from './config-schema.js'
2
2
  import { PROVIDERS, displayName } from './refs.js'
3
- import { escapeHtml, writeHtml, writeJson } from './http.js'
3
+ import { escapeHtml, isLoopback, writeHtml, writeJson } from './http.js'
4
4
  import { registerStatusRoutes } from './routes/status.js'
5
5
  import { registerOauthRoutes } from './routes/oauth.js'
6
6
  import { registerAccountsRoutes } from './routes/accounts.js'
7
7
  import { registerProxyRoutes } from './routes/proxy.js'
8
+ import { registerPluginUpdater } from './updater.js'
8
9
 
9
10
  export function registerRoutes(ctx, state) {
10
11
  const {
@@ -22,6 +23,11 @@ export function registerRoutes(ctx, state) {
22
23
  }
23
24
  }
24
25
 
26
+ ctx.effect(() => registerPluginUpdater(ctx, {
27
+ endpoint: '/dsh-subscriptions/update',
28
+ packageName: '@goodandready/dsh-subscriptions',
29
+ manifestUrl: new URL('../package.json', import.meta.url),
30
+ }), 'dsh-subscriptions: /update')
25
31
 
26
32
  ctx.effect(() => ctx.webServer.register({
27
33
  kind: 'exact',
@@ -32,7 +38,8 @@ export function registerRoutes(ctx, state) {
32
38
  return
33
39
  }
34
40
  const host = (req.headers.host || '').split(':')[0]
35
- if (host !== 'localhost' && host !== '127.0.0.1') {
41
+ const remote = req.socket?.remoteAddress
42
+ if (!isLoopback(host) || (remote && !isLoopback(remote))) {
36
43
  writeHtml(res, 403, '<!doctype html><meta charset="utf-8"><p>Subscriptions overview is localhost-only.</p>')
37
44
  return
38
45
  }
package/lib/updater.js ADDED
@@ -0,0 +1,260 @@
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
+ import { writeJson } from './http.js'
8
+
9
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
10
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
11
+ const VERSION_CACHE_MS = 5 * 60_000
12
+ let latestCache
13
+
14
+ function header(request, name) {
15
+ const value = request.headers?.[name]
16
+ return Array.isArray(value) ? value[0] : value
17
+ }
18
+
19
+ export function isLoopback(value) {
20
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
21
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
22
+ || address?.startsWith('127.') === true
23
+ || address?.startsWith('::ffff:127.') === true
24
+ }
25
+
26
+ export function isTrustedUpdateRequest(request) {
27
+ const customHeader = header(request, UPDATE_HEADER)
28
+ if (customHeader !== '1') {
29
+ // In browser fetch, allow if sec-fetch-site is same-origin or same-site
30
+ const site = header(request, 'sec-fetch-site')
31
+ if (site === 'cross-site') return false
32
+ }
33
+ const remote = request.socket?.remoteAddress
34
+ if (remote && !isLoopback(remote)) return false
35
+ const origin = header(request, 'origin')
36
+ const host = header(request, 'host')
37
+ if (origin && host) {
38
+ try {
39
+ const url = new URL(origin)
40
+ if (url.host !== host && !isLoopback(url.hostname)) return false
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+ return true
46
+ }
47
+
48
+ function validProfileName(value) {
49
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
50
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
51
+ }
52
+
53
+ function profileNameFromArgv(argv) {
54
+ for (let index = 2; index < argv.length; index += 1) {
55
+ if (argv[index] === '--profile') return argv[index + 1]
56
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
57
+ }
58
+ return argv[2] === 'web' ? 'web' : undefined
59
+ }
60
+
61
+ function findDshCliEntry() {
62
+ const value = process.argv[1]
63
+ if (value === undefined || value === '') return undefined
64
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
65
+ if (!existsSync(entry)) return undefined
66
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
67
+ const manifestPath = resolve(directory, 'package.json')
68
+ if (existsSync(manifestPath)) {
69
+ try {
70
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
71
+ const bin = typeof manifest.bin === 'string'
72
+ ? manifest.bin
73
+ : typeof manifest.bin === 'object' && manifest.bin !== null
74
+ ? manifest.bin.dsh
75
+ : undefined
76
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
77
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
78
+ } catch {
79
+ // Continue searching parent package directories.
80
+ }
81
+ }
82
+ const parent = dirname(directory)
83
+ if (parent === directory) return undefined
84
+ }
85
+ }
86
+
87
+ function getRuntime() {
88
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
89
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
90
+ const selected = profileNameFromArgv(process.argv)
91
+ const profileName = validProfileName(selected)
92
+ ? selected
93
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
94
+ const cliEntry = findDshCliEntry()
95
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
96
+ }
97
+
98
+ export function parseSemver(value) {
99
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
100
+ if (match === null) return undefined
101
+ return {
102
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
103
+ prerelease: match[4]?.split('.') ?? [],
104
+ }
105
+ }
106
+
107
+ function comparePrerelease(left, right) {
108
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
109
+ const length = Math.max(left.length, right.length)
110
+ for (let index = 0; index < length; index += 1) {
111
+ const a = left[index]
112
+ const b = right[index]
113
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
114
+ if (a === b) continue
115
+ const aNumeric = /^\d+$/.test(a)
116
+ const bNumeric = /^\d+$/.test(b)
117
+ if (aNumeric && bNumeric) {
118
+ const aNumber = BigInt(a)
119
+ const bNumber = BigInt(b)
120
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
121
+ continue
122
+ }
123
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
124
+ return a > b ? 1 : -1
125
+ }
126
+ return 0
127
+ }
128
+
129
+ export function isNewerVersion(currentValue, candidateValue) {
130
+ const current = parseSemver(currentValue)
131
+ const candidate = parseSemver(candidateValue)
132
+ if (current === undefined || candidate === undefined) return false
133
+ for (let index = 0; index < 3; index += 1) {
134
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
135
+ }
136
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
137
+ }
138
+
139
+ export async function fetchLatestVersion(packageName, registry = 'https://registry.npmjs.org') {
140
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
141
+ return latestCache.version
142
+ }
143
+ try {
144
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
145
+ signal: AbortSignal.timeout(8_000),
146
+ })
147
+ if (!response.ok) return undefined
148
+ const value = await response.json()
149
+ if (typeof value?.version !== 'string' || value.version === '') return undefined
150
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
151
+ return value.version
152
+ } catch {
153
+ return undefined
154
+ }
155
+ }
156
+
157
+ export async function readCurrentVersion(manifestUrl) {
158
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
159
+ if (typeof value?.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.')
160
+ return value.version
161
+ }
162
+
163
+ export async function getUpdaterStatus(options, target = getRuntime()) {
164
+ const current = await readCurrentVersion(options.manifestUrl)
165
+ const latest = await fetchLatestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
166
+ return {
167
+ ok: true,
168
+ packageName: options.packageName,
169
+ currentVersion: current,
170
+ ...(latest === undefined ? {} : { latestVersion: latest }),
171
+ latestCheckFailed: latest === undefined,
172
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
173
+ profileName: target.profileName,
174
+ canAutoUpdate: target.cliEntry !== undefined,
175
+ }
176
+ }
177
+
178
+ export async function installExactPackage(target, packageSpec, options) {
179
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
180
+ await new Promise((resolvePromise, reject) => {
181
+ const child = spawn(process.execPath, [
182
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
183
+ '--config.minimumReleaseAge=0', packageSpec,
184
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
185
+ ], {
186
+ cwd: target.profileDir,
187
+ windowsHide: true,
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ env: { ...process.env, NO_COLOR: '1' },
190
+ })
191
+ let detail = ''
192
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
193
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
194
+ const timer = setTimeout(() => {
195
+ child.kill()
196
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
197
+ }, UPDATE_TIMEOUT_MS)
198
+ child.once('error', error => { clearTimeout(timer); reject(error) })
199
+ child.once('exit', code => {
200
+ clearTimeout(timer)
201
+ if (code === 0) resolvePromise()
202
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
203
+ })
204
+ })
205
+ }
206
+
207
+ export function registerPluginUpdater(ctx, options) {
208
+ let installing = false
209
+ return ctx.webServer.register({
210
+ kind: 'exact',
211
+ path: options.endpoint,
212
+ handler: async (request, response) => {
213
+ try {
214
+ const target = getRuntime()
215
+ if (request.method === 'GET' || request.method === 'HEAD') {
216
+ const payload = await getUpdaterStatus(options, target)
217
+ writeJson(response, 200, payload)
218
+ return
219
+ }
220
+ if (request.method !== 'POST') {
221
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
222
+ response.end()
223
+ return
224
+ }
225
+ if (!isTrustedUpdateRequest(request)) {
226
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'Rejected non-local or cross-origin update request.' } })
227
+ return
228
+ }
229
+ if (installing) {
230
+ writeJson(response, 409, { ok: false, error: { code: 'busy', message: 'This plugin is already updating.' } })
231
+ return
232
+ }
233
+ installing = true
234
+ try {
235
+ const before = await getUpdaterStatus(options, target)
236
+ if (before.latestVersion === undefined) {
237
+ writeJson(response, 503, { ok: false, error: { code: 'unavailable', message: 'The latest version is temporarily unavailable.' } })
238
+ return
239
+ }
240
+ if (!before.updateAvailable) {
241
+ writeJson(response, 200, { ok: true, ...before })
242
+ return
243
+ }
244
+ await installExactPackage(target, `${options.packageName}@${before.latestVersion}`, options)
245
+ writeJson(response, 200, {
246
+ ok: true,
247
+ ...before,
248
+ updatedVersion: before.latestVersion,
249
+ restartRequired: true,
250
+ })
251
+ } finally {
252
+ installing = false
253
+ }
254
+ } catch (error) {
255
+ ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
256
+ writeJson(response, 503, { ok: false, error: { code: 'update_failed', message: String(error?.message || error) } })
257
+ }
258
+ },
259
+ })
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,10 @@
34
34
  "bugs": {
35
35
  "url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
36
36
  },
37
+ "scripts": {
38
+ "test": "eslint lib/ && node --test test/*.test.mjs",
39
+ "typecheck": "tsc -p tsconfig.checkjs.json"
40
+ },
37
41
  "dsh": {
38
42
  "bundle": {
39
43
  "patch": "./cordis.patch.yml"
@@ -62,9 +66,5 @@
62
66
  "@deepseek-ai/dsh-llm": "0.1.0-rc.8",
63
67
  "@types/node": "^22.20.2",
64
68
  "typescript": "^7.0.2"
65
- },
66
- "scripts": {
67
- "test": "eslint lib/ && node --test test/*.test.mjs",
68
- "typecheck": "tsc -p tsconfig.checkjs.json"
69
69
  }
70
- }
70
+ }