@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 +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/accounts.js +0 -17
- package/lib/client.js +84 -8
- package/lib/http.js +7 -0
- package/lib/routes/status.js +17 -12
- package/lib/routes.js +9 -2
- package/lib/updater.js +260 -0
- package/package.json +6 -6
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}
|
package/lib/routes/status.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|