dsh-all-usage 1.1.17 → 1.1.18

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/lib/http.js CHANGED
@@ -328,6 +328,21 @@ export function registerRoutes(host) {
328
328
  sendJson(res, 200, snapshot())
329
329
  },
330
330
  }))
331
+ ctx.effect(() => webServer.register({
332
+ kind: 'exact',
333
+ path: '/api/all-usage/version',
334
+ handler: async (req, res) => {
335
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
336
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
337
+ let force = false
338
+ try {
339
+ force = new URL(req.url ?? '/', 'http://x').searchParams.get('force') === '1'
340
+ } catch (err) { /* default */ }
341
+ // The host caches the answer, so a dashboard left open does not hammer the
342
+ // registry; force=1 only comes from the refresh button.
343
+ sendJson(res, 200, await host.versionCheck.read(force))
344
+ },
345
+ }))
331
346
  ctx.effect(() => webServer.register({
332
347
  kind: 'exact',
333
348
  path: '/api/all-usage/balance',
package/lib/plugin.js CHANGED
@@ -7,6 +7,7 @@ import { RETIRED_WORKSPACE_ID, createLedger } from './ledger.js'
7
7
  import { createPricingRuntime } from './pricing-runtime.js'
8
8
  import { createSessionSync } from './session-sync.js'
9
9
  import { createCostAccumulator, createEmptyPricingState } from './pricing.js'
10
+ import { createVersionCheck } from './version-check.js'
10
11
 
11
12
  const name = 'dsh-all-usage'
12
13
  // The page is served from this package, so a version mismatch between the
@@ -221,6 +222,9 @@ function apply(ctx) {
221
222
  host.pricing = createPricingRuntime(host)
222
223
  host.balance = createBalance(host)
223
224
  host.sessionSync = createSessionSync(host)
225
+ // The header asks the host for this, so the page never talks to the registry
226
+ // itself (the desktop shell would not allow it) and the answer is cached here.
227
+ host.versionCheck = createVersionCheck({ currentVersion: pluginVersion })
224
228
 
225
229
  async function loadAliases() {
226
230
  const storage = services.storage
@@ -0,0 +1,133 @@
1
+ // Version check for the dashboard header. The running version is known locally;
2
+ // this only asks the npm registry for the newest published one, at most once per
3
+ // TTL and only while a dashboard is open (the page asks for it, the host never
4
+ // polls on its own). A plain GET of a small JSON document, no body, no cookies.
5
+ const VERSION_CHECK_URL = 'https://registry.npmjs.org/dsh-all-usage/latest'
6
+ const VERSION_CHECK_TTL_MS = 6 * 60 * 60 * 1000
7
+ const VERSION_CHECK_TIMEOUT_MS = 8000
8
+ // The route is a plain local read (like /api/all-usage/status it needs no
9
+ // capability), so this floor is what keeps one page - or a hostile local page -
10
+ // from turning a burst of requests into a burst of registry requests.
11
+ const VERSION_CHECK_MIN_INTERVAL_MS = 30 * 1000
12
+ const MAX_VERSION_BYTES = 64 * 1024
13
+
14
+ function parseVersion(text) {
15
+ const value = String(text === undefined || text === null ? '' : text).trim().replace(/^v/, '')
16
+ const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
17
+ if (match === null) return null
18
+ return {
19
+ major: Number(match[1]),
20
+ minor: Number(match[2]),
21
+ patch: Number(match[3]),
22
+ prerelease: match[4] === undefined ? null : match[4].split('.'),
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Compare two dotted versions the way semver orders them: numeric parts first,
28
+ * then a prerelease ranks below its release and numeric identifiers rank below
29
+ * alphanumeric ones ('1.1.18-rc.1' < '1.1.18' < '1.1.19'). Returns -1, 0 or 1,
30
+ * and 0 for anything unparseable so a malformed tag never claims an update.
31
+ */
32
+ export function compareVersions(left, right) {
33
+ const a = parseVersion(left)
34
+ const b = parseVersion(right)
35
+ if (a === null || b === null) return 0
36
+ for (const key of ['major', 'minor', 'patch']) {
37
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
38
+ }
39
+ if (a.prerelease === null && b.prerelease === null) return 0
40
+ if (a.prerelease === null) return 1
41
+ if (b.prerelease === null) return -1
42
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
43
+ for (let index = 0; index < length; index += 1) {
44
+ const leftPart = a.prerelease[index]
45
+ const rightPart = b.prerelease[index]
46
+ if (leftPart === undefined) return -1
47
+ if (rightPart === undefined) return 1
48
+ if (leftPart === rightPart) continue
49
+ const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null
50
+ const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null
51
+ if (leftNumber !== null && rightNumber !== null) return leftNumber < rightNumber ? -1 : 1
52
+ if (leftNumber !== null) return -1
53
+ if (rightNumber !== null) return 1
54
+ return leftPart < rightPart ? -1 : 1
55
+ }
56
+ return 0
57
+ }
58
+
59
+ /** 'latest' when the running version is at least the published one, else 'outdated'. */
60
+ export function versionStatus(current, latest) {
61
+ if (typeof current !== 'string' || current === '') return 'unknown'
62
+ if (typeof latest !== 'string' || latest === '') return 'unknown'
63
+ if (parseVersion(current) === null || parseVersion(latest) === null) return 'unknown'
64
+ return compareVersions(current, latest) < 0 ? 'outdated' : 'latest'
65
+ }
66
+
67
+ export async function fetchLatestVersion(fetchImpl = globalThis.fetch, options = {}) {
68
+ if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch-unavailable' }
69
+ const url = typeof options.url === 'string' && options.url !== '' ? options.url : VERSION_CHECK_URL
70
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : VERSION_CHECK_TIMEOUT_MS
71
+ let controller = null
72
+ let timer = null
73
+ try {
74
+ if (typeof AbortController === 'function') {
75
+ controller = new AbortController()
76
+ timer = setTimeout(() => controller.abort(), timeoutMs)
77
+ }
78
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: controller ? controller.signal : undefined })
79
+ if (!response || response.ok !== true) return { ok: false, error: 'http-' + String((response && response.status) || 0) }
80
+ const text = await response.text()
81
+ if (new TextEncoder().encode(text).length > MAX_VERSION_BYTES) return { ok: false, error: 'version-too-large' }
82
+ let parsed = null
83
+ try { parsed = JSON.parse(text.replace(/^\uFEFF/, '')) } catch (err) { return { ok: false, error: 'version-invalid-json' } }
84
+ const version = parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string' ? parsed.version.trim() : ''
85
+ if (parseVersion(version) === null) return { ok: false, error: 'version-invalid' }
86
+ return { ok: true, version, sourceUrl: url, fetchedAt: Date.now() }
87
+ } catch (err) {
88
+ return { ok: false, error: 'version-fetch-failed' }
89
+ } finally {
90
+ if (timer !== null) clearTimeout(timer)
91
+ }
92
+ }
93
+
94
+ export function createVersionCheck(options = {}) {
95
+ const fetchImpl = options.fetchImpl === undefined ? globalThis.fetch : options.fetchImpl
96
+ const currentVersion = typeof options.currentVersion === 'string' ? options.currentVersion : ''
97
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : VERSION_CHECK_TTL_MS
98
+ const minIntervalMs = Number.isFinite(options.minIntervalMs) && options.minIntervalMs >= 0 ? options.minIntervalMs : VERSION_CHECK_MIN_INTERVAL_MS
99
+ let entry = null
100
+ let failure = null
101
+ let lastAttemptAt = 0
102
+ function snapshot(cached) {
103
+ return {
104
+ current: currentVersion,
105
+ latest: entry === null ? null : entry.version,
106
+ status: entry === null ? 'unknown' : versionStatus(currentVersion, entry.version),
107
+ checkedAt: entry === null ? null : entry.fetchedAt,
108
+ sourceUrl: entry === null ? VERSION_CHECK_URL : entry.sourceUrl,
109
+ error: failure === null ? null : failure.error,
110
+ cached: cached === true,
111
+ }
112
+ }
113
+ /** Read the cached verdict, or ask the registry again when it is stale or forced. */
114
+ async function read(force) {
115
+ const now = Date.now()
116
+ if (!force && entry !== null && now - entry.fetchedAt < ttlMs) return snapshot(true)
117
+ // A forced read still waits out the floor: force comes from the refresh button,
118
+ // and no human needs a second registry round trip inside the same half minute.
119
+ if (lastAttemptAt > 0 && now - lastAttemptAt < minIntervalMs) return snapshot(false)
120
+ lastAttemptAt = now
121
+ const result = await fetchLatestVersion(fetchImpl, options)
122
+ if (result.ok) {
123
+ entry = { version: result.version, fetchedAt: result.fetchedAt, sourceUrl: result.sourceUrl }
124
+ failure = null
125
+ } else {
126
+ failure = { error: result.error, failedAt: Date.now() }
127
+ }
128
+ return snapshot(false)
129
+ }
130
+ return { read, snapshot: () => snapshot(true), currentVersion }
131
+ }
132
+
133
+ export { VERSION_CHECK_MIN_INTERVAL_MS, VERSION_CHECK_TTL_MS, VERSION_CHECK_URL }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.17",
3
+ "version": "1.1.18",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",
@@ -58,6 +58,7 @@
58
58
  "lib/client.js",
59
59
  "lib/pricing.js",
60
60
  "lib/usage-core.js",
61
+ "lib/version-check.js",
61
62
  "CHANGELOG.md",
62
63
  "cordis.patch.yml",
63
64
  "screenshots.json",