dsh-token-use 0.2.2 → 0.3.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.
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Version discovery and process control for the settings panel's config tab.
3
+ *
4
+ * Installed versions are read from disk, never guessed: the running dsh bin
5
+ * (`process.argv[1]`) names the service package, and the plugin's own module
6
+ * chain reaches the harness packages. Latest versions come from the npm
7
+ * registry and are cached on disk for hours — the panel is a settings page,
8
+ * not a poller.
9
+ *
10
+ * The restart helper writes a detached shell script instead of killing the
11
+ * process directly: the HTTP response has to reach the browser first, and the
12
+ * relaunch must survive the death of the process that spawned it.
13
+ *
14
+ * @module dsh-token-use/versions
15
+ */
16
+ import { execFileSync, spawn } from 'node:child_process'
17
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
+ import { createRequire } from 'node:module'
19
+ import { arch, platform as platformName, release, type as osType } from 'node:os'
20
+ import { dirname, join } from 'node:path'
21
+
22
+ /** Packages the config tab reports; `id` is what the client labels. */
23
+ export const TRACKED = [
24
+ { id: 'dsh-service', label: 'dsh-service', name: '@deepseek-ai/dsh' },
25
+ { id: 'dsh', label: 'dsh', name: '@deepseek-ai/dsh-web-app' },
26
+ { id: 'dsh-base', label: 'dsh-base', name: '@deepseek-ai/dsh-base' },
27
+ { id: 'dsh-token-use', label: 'dsh-token-use', name: 'dsh-token-use' },
28
+ ]
29
+
30
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
31
+ const TIMEOUT_MS = 8000
32
+ /** How long a registry answer is trusted before the next check. */
33
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000
34
+ const USER_AGENT = 'dsh-token-use (+https://github.com/huangyuheng/dsh-token-use)'
35
+ /** Steps walked up a directory chain while hunting for a package.json. */
36
+ const MAX_WALK = 12
37
+
38
+ const require = createRequire(import.meta.url)
39
+
40
+ function readJson(file) {
41
+ try {
42
+ return JSON.parse(readFileSync(file, 'utf8'))
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ /** package.json paths to try, nearest first, walking up from `start`. */
49
+ function walkUp(start, name) {
50
+ const candidates = []
51
+ let dir = start
52
+ for (let i = 0; i < MAX_WALK; i++) {
53
+ candidates.push(join(dir, 'node_modules', name, 'package.json'))
54
+ const parent = dirname(dir)
55
+ if (parent === dir) break
56
+ dir = parent
57
+ }
58
+ return candidates
59
+ }
60
+
61
+ /**
62
+ * Version of the harness package that owns the running bin. `process.argv[1]`
63
+ * is the entry script, so the first package.json named `@deepseek-ai/dsh` above
64
+ * it is exactly the code being served — the truth even when several copies are
65
+ * installed (pnpm dlx keeps one store link per version).
66
+ */
67
+ function runningServiceVersion() {
68
+ const bin = process.argv[1]
69
+ if (typeof bin !== 'string' || bin.length === 0) return undefined
70
+ let dir = dirname(bin)
71
+ for (let i = 0; i < MAX_WALK; i++) {
72
+ const pkg = readJson(join(dir, 'package.json'))
73
+ if (pkg !== undefined && pkg.name === '@deepseek-ai/dsh' && typeof pkg.version === 'string') return pkg.version
74
+ const parent = dirname(dir)
75
+ if (parent === dir) break
76
+ dir = parent
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** Installed version of one package, or undefined when it cannot be located. */
82
+ export function installedVersion(name) {
83
+ if (name === '@deepseek-ai/dsh') {
84
+ const fromBin = runningServiceVersion()
85
+ if (fromBin !== undefined) return fromBin
86
+ }
87
+ try {
88
+ const pkg = readJson(require.resolve(`${name}/package.json`))
89
+ if (pkg !== undefined && typeof pkg.version === 'string') return pkg.version
90
+ } catch {
91
+ // fall through to the directory walk
92
+ }
93
+ const starts = [dirname(new URL(import.meta.url).pathname), ...(typeof process.argv[1] === 'string' ? [dirname(process.argv[1])] : []), process.cwd()]
94
+ for (const start of starts) {
95
+ for (const candidate of walkUp(start, name)) {
96
+ const pkg = readJson(candidate)
97
+ if (pkg !== undefined && typeof pkg.version === 'string') return pkg.version
98
+ }
99
+ }
100
+ return undefined
101
+ }
102
+
103
+ /**
104
+ * Semver-lite precedence: `-1` older, `0` equal, `1` newer. A prerelease sorts
105
+ * below its release, and prerelease identifiers are compared the semver way
106
+ * (numeric below alphanumeric, numbers numerically) so `0.1.5-rc.2` beats
107
+ * `0.1.5-rc.1`.
108
+ */
109
+ export function compareVersions(a, b) {
110
+ const parse = (value) => {
111
+ const [core, pre = ''] = String(value).split('-')
112
+ return {
113
+ core: core.split('.').map((part) => Number.parseInt(part, 10) || 0),
114
+ pre: pre.length === 0 ? [] : pre.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
115
+ }
116
+ }
117
+ const left = parse(a)
118
+ const right = parse(b)
119
+ for (let i = 0; i < Math.max(left.core.length, right.core.length); i++) {
120
+ const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0)
121
+ if (diff !== 0) return diff > 0 ? 1 : -1
122
+ }
123
+ if (left.pre.length === 0 || right.pre.length === 0) {
124
+ if (left.pre.length === right.pre.length) return 0
125
+ return left.pre.length === 0 ? 1 : -1
126
+ }
127
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
128
+ const one = left.pre[i]
129
+ const two = right.pre[i]
130
+ if (one === undefined) return -1
131
+ if (two === undefined) return 1
132
+ if (one === two) continue
133
+ const oneNumeric = typeof one === 'number'
134
+ if (oneNumeric !== (typeof two === 'number')) return oneNumeric ? -1 : 1
135
+ return one > two ? 1 : -1
136
+ }
137
+ return 0
138
+ }
139
+
140
+ /**
141
+ * Release channel of a version: the `rc` of `0.1.5-rc.2`, or `stable`. The
142
+ * harness publishes every channel to the same package, and `latest` is not
143
+ * always the newest of them — `@deepseek-ai/dsh-web-app` still tags a stale
144
+ * `0.0.1-rc.1` as latest while `next` carries the real build.
145
+ */
146
+ export function channelOf(version, fallback = 'stable') {
147
+ if (typeof version !== 'string' || version.length === 0) return fallback
148
+ const match = /-[a-zA-Z]+/.exec(version)
149
+ return match === null ? 'stable' : match[0].slice(1).toLowerCase()
150
+ }
151
+
152
+ /** Highest version inside one channel. */
153
+ function highestInChannel(versions, channel) {
154
+ let best
155
+ for (const version of versions) {
156
+ if (channelOf(version) !== channel) continue
157
+ if (best === undefined || compareVersions(version, best) > 0) best = version
158
+ }
159
+ return best
160
+ }
161
+
162
+ /** macOS prefers the marketing version (`sw_vers`); elsewhere the kernel tells enough. */
163
+ function osDisplayName() {
164
+ if (platformName() === 'darwin') {
165
+ try {
166
+ const product = execFileSync('sw_vers', ['-productVersion'], { encoding: 'utf8', timeout: 2000 }).trim()
167
+ if (product.length > 0) return `macOS ${product}`
168
+ } catch {
169
+ return `macOS (kernel ${release()})`
170
+ }
171
+ }
172
+ const family = { linux: 'Linux', win32: 'Windows', freebsd: 'FreeBSD' }[platformName()]
173
+ return family === undefined ? `${osType()} ${release()}` : `${family} ${release()}`
174
+ }
175
+
176
+ /** Runtime facts for the config tab; `port` comes from the request that asked. */
177
+ export function describePlatform(port, dshHome) {
178
+ return {
179
+ os: platformName(),
180
+ osName: osDisplayName(),
181
+ arch: arch(),
182
+ node: process.version,
183
+ pid: process.pid,
184
+ port: typeof port === 'string' && port.length > 0 ? port : null,
185
+ uptimeSec: Math.round(process.uptime()),
186
+ cwd: process.cwd(),
187
+ execPath: process.execPath,
188
+ dshHome,
189
+ }
190
+ }
191
+
192
+ /** `'current' | 'outdated' | 'ahead' | 'unknown'`. */
193
+ function statusOf(installed, latest) {
194
+ if (typeof installed !== 'string' || installed.length === 0) return 'unknown'
195
+ if (typeof latest !== 'string' || latest.length === 0) return 'unknown'
196
+ const diff = compareVersions(installed, latest)
197
+ if (diff === 0) return 'current'
198
+ return diff < 0 ? 'outdated' : 'ahead'
199
+ }
200
+
201
+ /**
202
+ * Latest-version lookups with an on-disk cache. A failed lookup keeps the last
203
+ * known answer (marked stale) so the tab still renders offline.
204
+ */
205
+ export class VersionStore {
206
+ /**
207
+ * @param dshHome - `$DSH_HOME`, where the cache lives.
208
+ * @param options - `registry` (defaults to npmjs), `timeoutMs`, `ttlMs`.
209
+ */
210
+ constructor(dshHome, options = {}) {
211
+ this.file = join(dshHome, 'dsh-token-use', 'versions.json')
212
+ this.registry = typeof options.registry === 'string' && options.registry.length > 0 ? options.registry : DEFAULT_REGISTRY
213
+ this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : TIMEOUT_MS
214
+ this.ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : CACHE_TTL_MS
215
+ this.cache = readJson(this.file) ?? {}
216
+ this.pending = null
217
+ }
218
+
219
+ /** Cached record for one package, or undefined. */
220
+ cached(name) {
221
+ return this.cache.packages?.[name]
222
+ }
223
+
224
+ /**
225
+ * The registry's install metadata (versions + dist-tags) — the abbreviated
226
+ * packument, which is a fraction of the full document's size.
227
+ */
228
+ async fetchPackument(name) {
229
+ const url = `${this.registry}/${name.replace('/', '%2f')}`
230
+ const response = await fetch(url, {
231
+ headers: { accept: 'application/vnd.npm.install-v1+json', 'user-agent': USER_AGENT },
232
+ signal: AbortSignal.timeout(this.timeoutMs),
233
+ })
234
+ if (!response.ok) throw new Error(`http ${response.status}`)
235
+ const body = await response.json()
236
+ const versions = Object.keys(body?.versions ?? {})
237
+ if (versions.length === 0) throw new Error('no versions in registry response')
238
+ return { versions, tags: body['dist-tags'] ?? {} }
239
+ }
240
+
241
+ /**
242
+ * Refresh every tracked package at most once per TTL, deduplicated.
243
+ * @param names - packages to look up.
244
+ * @param options - `force: true` skips the TTL, for the panel's re-check button.
245
+ */
246
+ async ensure(names, options = {}) {
247
+ const checkedAt = typeof this.cache.checkedAt === 'number' ? this.cache.checkedAt : 0
248
+ if (options.force !== true && Date.now() - checkedAt < this.ttlMs) return
249
+ if (this.pending !== null) {
250
+ await this.pending
251
+ return
252
+ }
253
+ this.pending = (async () => {
254
+ const packages = { ...this.cache.packages }
255
+ const errors = {}
256
+ await Promise.all(names.map(async (name) => {
257
+ try {
258
+ const { versions, tags } = await this.fetchPackument(name)
259
+ const next = typeof tags.next === 'string' ? tags.next : undefined
260
+ packages[name] = {
261
+ versions: versions.slice(-40),
262
+ tags,
263
+ next: next ?? null,
264
+ latest: highestInChannel(versions, channelOf(next ?? '')) ?? highestInChannel(versions, 'stable') ?? versions[versions.length - 1],
265
+ }
266
+ } catch (error) {
267
+ errors[name] = String(error?.message ?? error)
268
+ }
269
+ }))
270
+ this.cache = { checkedAt: Date.now(), packages, errors }
271
+ this.persist()
272
+ })()
273
+ try {
274
+ await this.pending
275
+ } finally {
276
+ this.pending = null
277
+ }
278
+ }
279
+
280
+ persist() {
281
+ try {
282
+ mkdirSync(dirname(this.file), { recursive: true })
283
+ const tmp = `${this.file}.tmp`
284
+ writeFileSync(tmp, `${JSON.stringify(this.cache, null, 2)}\n`)
285
+ renameSync(tmp, this.file)
286
+ } catch {
287
+ // a cache that cannot be written is still a usable in-memory cache
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The config tab's payload: installed vs latest for every tracked package,
293
+ * the runtime facts, and whether the cached registry answer is fresh.
294
+ * @param options - `port`, `dshHome`, and `force` to re-query the registry now.
295
+ */
296
+ async describe({ port, dshHome, force }) {
297
+ try {
298
+ await this.ensure(TRACKED.map((entry) => entry.name), { force: force === true })
299
+ } catch {
300
+ // ensure() swallows per-package errors already; a throw here means the cache
301
+ // file itself is unwritable, which the report below still handles
302
+ }
303
+ const errors = this.cache.errors ?? {}
304
+ const packages = TRACKED.map((entry) => {
305
+ const installed = entry.id === 'dsh-token-use'
306
+ ? readJson(new URL('../package.json', import.meta.url))?.version
307
+ : installedVersion(entry.name)
308
+ const record = this.cached(entry.name)
309
+ // Compare inside the channel the user actually runs: an rc install is not
310
+ // out of date just because an alpha exists.
311
+ const channel = channelOf(installed ?? record?.next ?? '')
312
+ const latest = record === undefined
313
+ ? undefined
314
+ : highestInChannel(record.versions, channel) ?? record.latest
315
+ return {
316
+ id: entry.id,
317
+ label: entry.label,
318
+ name: entry.name,
319
+ installed: installed ?? null,
320
+ latest: latest ?? null,
321
+ channel,
322
+ tags: record?.tags ?? {},
323
+ status: statusOf(installed, latest),
324
+ error: errors[entry.name] ?? null,
325
+ }
326
+ })
327
+ return {
328
+ packages,
329
+ registry: this.registry,
330
+ checkedAt: typeof this.cache.checkedAt === 'number' ? this.cache.checkedAt : 0,
331
+ platform: describePlatform(port, dshHome),
332
+ }
333
+ }
334
+ }
335
+
336
+ /** Single-quote a shell word so paths with spaces survive. */
337
+ function shellQuote(value) {
338
+ return `'${String(value).replaceAll("'", `'\\''`)}'`
339
+ }
340
+
341
+ /**
342
+ * The detached restart script: wait for the response to land, stop the process
343
+ * that serves this request, then start the very same command again from the
344
+ * same directory. Relaunching by `argv` (not by a guessed CLI invocation)
345
+ * keeps custom flags, ports and working directories intact.
346
+ *
347
+ * @returns the script text, or undefined when the running command is unusable.
348
+ */
349
+ export function restartScript({ pid, argv, cwd: workingDir, logFile }) {
350
+ const entry = argv[1]
351
+ const rest = argv.slice(2)
352
+ if (typeof entry !== 'string' || entry.length === 0 || typeof pid !== 'number') return undefined
353
+ const command = [process.execPath, entry, ...rest].map(shellQuote).join(' ')
354
+ return [
355
+ '#!/bin/sh',
356
+ '# Written by dsh-token-use: restart the dsh web service on demand.',
357
+ 'sleep 2',
358
+ `kill -TERM ${pid} 2>/dev/null`,
359
+ 'i=0',
360
+ `while kill -0 ${pid} 2>/dev/null && [ "$i" -lt 30 ]; do sleep 1; i=$((i+1)); done`,
361
+ `kill -9 ${pid} 2>/dev/null`,
362
+ 'sleep 1',
363
+ `cd ${shellQuote(workingDir)} || exit 1`,
364
+ `nohup ${command} >>${shellQuote(logFile)} 2>&1 </dev/null &`,
365
+ '',
366
+ ].join('\n')
367
+ }
368
+
369
+ /**
370
+ * Write and fire the restart script, fully detached so it outlives the server
371
+ * it is about to stop.
372
+ *
373
+ * @returns `{ script, logFile }` for the response, or undefined when the
374
+ * current command line cannot be relaunched safely.
375
+ */
376
+ export function scheduleRestart(dshHome) {
377
+ const dir = join(dshHome, 'dsh-token-use')
378
+ const logFile = join(dir, 'restart.log')
379
+ const scriptFile = join(dir, 'restart.sh')
380
+ const script = restartScript({
381
+ pid: process.pid,
382
+ argv: process.argv,
383
+ cwd: process.cwd(),
384
+ logFile,
385
+ })
386
+ if (script === undefined) return undefined
387
+ try {
388
+ mkdirSync(dir, { recursive: true })
389
+ writeFileSync(scriptFile, script, { mode: 0o700 })
390
+ } catch (error) {
391
+ return { error: String(error?.message ?? error) }
392
+ }
393
+ const child = spawn('sh', [scriptFile], { detached: true, stdio: 'ignore' })
394
+ child.unref()
395
+ return { script: scriptFile, logFile }
396
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-use",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Real-time token usage and cost dashboard for DeepSeek Harness — live totals (input/output/cache/reasoning), estimated spend per model/day/project from DeepSeek's official prices (refreshed daily, peak/off-peak aware), model/day/month/project filters and a smooth trend chart. · 实时 Token 用量与消费金额仪表盘:按模型/按天/按月/按项目查看用量与估算金额(取 DeepSeek 官网定价,每日自动更新,区分高峰/空闲),附趋势曲线。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -32,7 +32,8 @@
32
32
  "files": [
33
33
  "lib",
34
34
  "client/client.js",
35
- "cordis.patch.yml"
35
+ "cordis.patch.yml",
36
+ "assets"
36
37
  ],
37
38
  "engines": {
38
39
  "node": ">=22.19"