dsh-tokenlens 1.2.2 → 1.3.1
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/CHANGELOG.md +35 -73
- package/PROTOCOL.md +45 -7
- package/README.en.md +6 -5
- package/README.md +7 -6
- package/client.js +496 -10
- package/index.js +90 -3
- package/lib/provider-store.mjs +5 -0
- package/lib/update-check.mjs +87 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './lib/provider-store.mjs'
|
|
38
38
|
import { makeCustomAdapter, sanitizeUsageBaseURL, validateCustomProvider } from './lib/custom-provider.mjs'
|
|
39
39
|
import { redactEndpoint, redactPaths, redactTextPaths } from './lib/redact.mjs'
|
|
40
|
+
import { UPDATE_CHECK_URL, UPDATE_PACKAGE_URL, buildUpdateInfo, parseLatestVersion } from './lib/update-check.mjs'
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* The one browser-facing route this plugin owns.
|
|
@@ -60,10 +61,11 @@ export const ACTIONS = [
|
|
|
60
61
|
'settingsUpdate',
|
|
61
62
|
'customProviderSave',
|
|
62
63
|
'customProviderDelete',
|
|
64
|
+
'updateCheck',
|
|
63
65
|
]
|
|
64
66
|
|
|
65
67
|
/** The tool-facing subset (a model has no reason to announce a browser). */
|
|
66
|
-
export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status']
|
|
68
|
+
export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status', 'updateCheck']
|
|
67
69
|
|
|
68
70
|
/** Optional services; every one is probed with `ctx.get` at use time. */
|
|
69
71
|
export const inject = []
|
|
@@ -72,7 +74,7 @@ export const inject = []
|
|
|
72
74
|
export const PLUGIN_NAME = 'dsh-tokenlens'
|
|
73
75
|
|
|
74
76
|
/** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
|
|
75
|
-
export const PLUGIN_VERSION = '1.
|
|
77
|
+
export const PLUGIN_VERSION = '1.3.1'
|
|
76
78
|
|
|
77
79
|
/** Documented defaults; every field of the raw row config is optional. */
|
|
78
80
|
const DEFAULTS = {
|
|
@@ -282,6 +284,11 @@ export function apply(ctx, rawConfig) {
|
|
|
282
284
|
requestCounts: new Map(), // providerId 鈫?successful reads
|
|
283
285
|
subscribers: new Set(),
|
|
284
286
|
client: null, // last browser `hello`
|
|
287
|
+
// Optional startup update check (settings.updateCheck): the last result
|
|
288
|
+
// `{current, latest, available, checkedAt, error}` or null before the first
|
|
289
|
+
// check. Never fetched unless the user enables the setting.
|
|
290
|
+
update: null,
|
|
291
|
+
updateChecking: false,
|
|
285
292
|
bridge: { found: false, registered: false, path: BRIDGE, error: null },
|
|
286
293
|
poll: { timer: null },
|
|
287
294
|
status: { timer: null },
|
|
@@ -729,6 +736,11 @@ export function apply(ctx, rawConfig) {
|
|
|
729
736
|
providers,
|
|
730
737
|
dshProviders,
|
|
731
738
|
settings: sanitizeSettings(providerStore.settings),
|
|
739
|
+
// Optional startup update check (settings.updateCheck): null until the
|
|
740
|
+
// first check; never fetched unless the setting is enabled or the user
|
|
741
|
+
// presses "check now".
|
|
742
|
+
update: state.update,
|
|
743
|
+
updatePackageUrl: UPDATE_PACKAGE_URL,
|
|
732
744
|
storeFile: redactPaths(providerStoreFile),
|
|
733
745
|
storeFileDisplay: redactPaths(providerStoreFile),
|
|
734
746
|
}
|
|
@@ -876,6 +888,53 @@ export function apply(ctx, rawConfig) {
|
|
|
876
888
|
return requestJson(url, { bearer: keyValue })
|
|
877
889
|
}
|
|
878
890
|
|
|
891
|
+
// Single-flight guard for the optional update check (see runUpdateCheck).
|
|
892
|
+
let updateFlight = null
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Ask the npm registry for the published `latest` version and remember the
|
|
896
|
+
* comparison result. This is the plugin's only non-provider outbound request
|
|
897
|
+
* and it only ever happens when the user enabled `settings.updateCheck`
|
|
898
|
+
* (startup / toggling it on) or presses "check now" on the management page.
|
|
899
|
+
*
|
|
900
|
+
* Single-flight: a concurrent call awaits the same in-flight check instead of
|
|
901
|
+
* answering `null`, and the function never throws — an offline machine or an
|
|
902
|
+
* upstream failure is stored as an error state so the UI can explain it.
|
|
903
|
+
*/
|
|
904
|
+
async function runUpdateCheck() {
|
|
905
|
+
if (updateFlight !== null) return updateFlight
|
|
906
|
+
updateFlight = (async () => {
|
|
907
|
+
state.updateChecking = true
|
|
908
|
+
let latest = null
|
|
909
|
+
let failure = null
|
|
910
|
+
try {
|
|
911
|
+
const payload = await requestJson(UPDATE_CHECK_URL)
|
|
912
|
+
latest = parseLatestVersion(payload)
|
|
913
|
+
if (latest === null) {
|
|
914
|
+
throw new UsageError('parse', 'the npm registry response has no usable version', { retryable: false })
|
|
915
|
+
}
|
|
916
|
+
} catch (error) {
|
|
917
|
+
failure = errorToWire(error)
|
|
918
|
+
} finally {
|
|
919
|
+
state.updateChecking = false
|
|
920
|
+
}
|
|
921
|
+
state.update = buildUpdateInfo(PLUGIN_VERSION, latest, new Date().toISOString(), failure)
|
|
922
|
+
noteStatus('update-check')
|
|
923
|
+
announce()
|
|
924
|
+
if (failure !== null) {
|
|
925
|
+
warn(`update check failed: ${failure.code}: ${failure.message}`)
|
|
926
|
+
} else if (state.update.available === true) {
|
|
927
|
+
log('info', `update available: ${state.update.latest} (running ${PLUGIN_VERSION})`)
|
|
928
|
+
}
|
|
929
|
+
return state.update
|
|
930
|
+
})()
|
|
931
|
+
try {
|
|
932
|
+
return await updateFlight
|
|
933
|
+
} finally {
|
|
934
|
+
updateFlight = null
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
879
938
|
/** Read one provider's usage: resolve its key (when it has one), call its adapter. */
|
|
880
939
|
async function readProvider(providerId, now) {
|
|
881
940
|
const id = canonicalProviderId(providerId)
|
|
@@ -1391,6 +1450,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1391
1450
|
activeProvider: activeProviderId(),
|
|
1392
1451
|
client: state.client,
|
|
1393
1452
|
bridge: { ...state.bridge },
|
|
1453
|
+
update: state.update,
|
|
1394
1454
|
providers,
|
|
1395
1455
|
stats: statsService?.diagnostics?.() ?? null,
|
|
1396
1456
|
subscribers: state.subscribers.size,
|
|
@@ -1586,7 +1646,15 @@ export function apply(ctx, rawConfig) {
|
|
|
1586
1646
|
log('info', `browser half connected from ${String(state.client.origin ?? 'unknown origin')} (${providerId ?? 'default provider'})`)
|
|
1587
1647
|
// Answer with a snapshot too, so the first paint needs one round trip.
|
|
1588
1648
|
const first = await get(requestProvider(body))
|
|
1589
|
-
return json({
|
|
1649
|
+
return json({
|
|
1650
|
+
ok: true,
|
|
1651
|
+
plugin: PLUGIN_NAME,
|
|
1652
|
+
version: PLUGIN_VERSION,
|
|
1653
|
+
bridge: BRIDGE,
|
|
1654
|
+
pid: process.pid,
|
|
1655
|
+
update: state.update,
|
|
1656
|
+
...first,
|
|
1657
|
+
})
|
|
1590
1658
|
}
|
|
1591
1659
|
if (action === 'snapshot' || action === 'status') {
|
|
1592
1660
|
rememberRequestedProvider(body)
|
|
@@ -1598,6 +1666,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1598
1666
|
}
|
|
1599
1667
|
if (action === 'stats') return json(await getStats({ fresh: body?.fresh === true }))
|
|
1600
1668
|
if (action === 'providers') return json(await providersPayload())
|
|
1669
|
+
if (action === 'updateCheck') return json({ ok: true, update: await runUpdateCheck() })
|
|
1601
1670
|
if (action === 'customProviderSave') {
|
|
1602
1671
|
const def = validateCustomProvider(body?.provider ?? body)
|
|
1603
1672
|
if (PROVIDER_DEFAULTS[def.providerId] !== undefined || ADAPTERS[def.providerId] !== undefined) {
|
|
@@ -1668,11 +1737,23 @@ export function apply(ctx, rawConfig) {
|
|
|
1668
1737
|
}
|
|
1669
1738
|
next.detailsWindow = incoming.detailsWindow
|
|
1670
1739
|
}
|
|
1740
|
+
if (Object.prototype.hasOwnProperty.call(incoming, 'updateCheck')) {
|
|
1741
|
+
if (typeof incoming.updateCheck !== 'boolean') {
|
|
1742
|
+
throw new UsageError('unsupported-provider', 'settings.updateCheck must be a boolean', {
|
|
1743
|
+
retryable: false,
|
|
1744
|
+
})
|
|
1745
|
+
}
|
|
1746
|
+
next.updateCheck = incoming.updateCheck
|
|
1747
|
+
}
|
|
1671
1748
|
providerStore = saveProviderStore(providerStoreFile, {
|
|
1672
1749
|
...providerStore,
|
|
1673
1750
|
settings: next,
|
|
1674
1751
|
})
|
|
1675
1752
|
noteStatus('settings-update', { providerStoreFile })
|
|
1753
|
+
// Turning the optional update check ON runs one check right away, so the
|
|
1754
|
+
// management page can show a result instead of waiting for a restart. A
|
|
1755
|
+
// full-settings save while it is already on must not re-request npm.
|
|
1756
|
+
if (incoming.updateCheck === true && current.updateCheck !== true) void runUpdateCheck()
|
|
1676
1757
|
return json(await providersPayload())
|
|
1677
1758
|
}
|
|
1678
1759
|
if (action === 'providerUpdate') {
|
|
@@ -1765,6 +1846,12 @@ export function apply(ctx, rawConfig) {
|
|
|
1765
1846
|
}
|
|
1766
1847
|
|
|
1767
1848
|
noteStatus('activate', { pollIntervalMs: config.pollIntervalMs })
|
|
1849
|
+
// Startup update check: one plain npm-registry request unless the user turned
|
|
1850
|
+
// `settings.updateCheck` off (default on since v1.3.1). Failures are recorded
|
|
1851
|
+
// and never break activation.
|
|
1852
|
+
if (sanitizeSettings(providerStore.settings).updateCheck === true) {
|
|
1853
|
+
void runUpdateCheck()
|
|
1854
|
+
}
|
|
1768
1855
|
log(
|
|
1769
1856
|
'info',
|
|
1770
1857
|
`ready (providers ${Object.keys(config.providers).filter((id) => config.providers[id].enabled).join(', ') || 'none'}, cache ${config.minRefreshIntervalMs} ms, poll ${config.pollIntervalMs} ms)`,
|
package/lib/provider-store.mjs
CHANGED
|
@@ -40,6 +40,10 @@ export const DEFAULT_SETTINGS = {
|
|
|
40
40
|
detailsExpanded: 'auto',
|
|
41
41
|
// v1.2.1 图表明细窗口默认值: 总计 (the last choice is persisted there).
|
|
42
42
|
detailsWindow: 'total',
|
|
43
|
+
// Optional startup update check (npm registry); ON by default (v1.3.1) so users
|
|
44
|
+
// learn about new releases, and can be switched off to keep the plugin fully
|
|
45
|
+
// local-only. Only the published version number is requested.
|
|
46
|
+
updateCheck: true,
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/** The default override file: `$DSH_HOME/dsh-tokenlens/providers.json`. */
|
|
@@ -126,6 +130,7 @@ export function sanitizeSettings(value) {
|
|
|
126
130
|
detailsWindow: DETAILS_WINDOWS.includes(source.detailsWindow)
|
|
127
131
|
? source.detailsWindow
|
|
128
132
|
: DEFAULT_SETTINGS.detailsWindow,
|
|
133
|
+
updateCheck: typeof source.updateCheck === 'boolean' ? source.updateCheck : DEFAULT_SETTINGS.updateCheck,
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional startup update check.
|
|
3
|
+
*
|
|
4
|
+
* The plugin stays local-only by default: nothing is sent anywhere unless the
|
|
5
|
+
* user enables `settings.updateCheck` in the management page. When enabled, one
|
|
6
|
+
* HTTPS GET asks the npm registry for the published `latest` version and the
|
|
7
|
+
* result is surfaced on the management page (and as a line in the detail dialog
|
|
8
|
+
* footer). No identifiers, credentials or usage data are sent — only a plain
|
|
9
|
+
* version-metadata request with the plugin's user agent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The npm registry endpoint that answers the published `latest` version. */
|
|
13
|
+
export const UPDATE_CHECK_URL = 'https://registry.npmjs.org/dsh-tokenlens/latest'
|
|
14
|
+
|
|
15
|
+
/** The canonical npm page of the package (for the "open on npm" link). */
|
|
16
|
+
export const UPDATE_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse one version string into comparable parts.
|
|
20
|
+
*
|
|
21
|
+
* Accepts `1.2.3`, `v1.2.3` and an optional prerelease (`1.3.0-beta.1`). Junk,
|
|
22
|
+
* empty strings and absurdly long input return null.
|
|
23
|
+
*/
|
|
24
|
+
export function parseVersion(raw) {
|
|
25
|
+
if (typeof raw !== 'string') return null
|
|
26
|
+
const text = raw.trim()
|
|
27
|
+
if (text === '' || text.length > 64) return null
|
|
28
|
+
const match = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z.-]{1,40}))?$/.exec(text)
|
|
29
|
+
if (match === null) return null
|
|
30
|
+
return {
|
|
31
|
+
major: Number(match[1]),
|
|
32
|
+
minor: Number(match[2]),
|
|
33
|
+
patch: Number(match[3]),
|
|
34
|
+
pre: match[4] ?? '',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compare two versions (`> 0` when `left` is newer, `< 0` when older, `0` when
|
|
40
|
+
* equal). A release always outranks its prereleases; prereleases compare as
|
|
41
|
+
* strings, which is enough for this plugin's plain `x.y.z` history.
|
|
42
|
+
*/
|
|
43
|
+
export function compareVersions(left, right) {
|
|
44
|
+
const a = parseVersion(left)
|
|
45
|
+
const b = parseVersion(right)
|
|
46
|
+
if (a === null || b === null) return null
|
|
47
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
48
|
+
if (a[key] !== b[key]) return a[key] - b[key]
|
|
49
|
+
}
|
|
50
|
+
if (a.pre === b.pre) return 0
|
|
51
|
+
if (a.pre === '') return 1
|
|
52
|
+
if (b.pre === '') return -1
|
|
53
|
+
return a.pre < b.pre ? -1 : 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether `latest` is strictly newer than `current` (false for junk input). */
|
|
57
|
+
export function isNewerVersion(latest, current) {
|
|
58
|
+
const compared = compareVersions(latest, current)
|
|
59
|
+
return compared !== null && compared > 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `version` field of the npm `/latest` document, or null. */
|
|
63
|
+
export function parseLatestVersion(payload) {
|
|
64
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
65
|
+
const version = payload.version
|
|
66
|
+
if (typeof version !== 'string') return null
|
|
67
|
+
const trimmed = version.trim()
|
|
68
|
+
return parseVersion(trimmed) === null ? null : trimmed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The wire shape the client renders:
|
|
73
|
+
* `{ current, latest, available, checkedAt, error }`.
|
|
74
|
+
*
|
|
75
|
+
* `available` is only true for a well-formed, strictly newer non-errored answer;
|
|
76
|
+
* the check never invents a version and an upstream failure keeps `latest: null`.
|
|
77
|
+
*/
|
|
78
|
+
export function buildUpdateInfo(current, latest, checkedAt, error = null) {
|
|
79
|
+
const latestVersion = typeof latest === 'string' && parseVersion(latest) !== null ? latest.trim() : null
|
|
80
|
+
return {
|
|
81
|
+
current: typeof current === 'string' ? current : '',
|
|
82
|
+
latest: latestVersion,
|
|
83
|
+
available: error === null && latestVersion !== null && isNewerVersion(latestVersion, current),
|
|
84
|
+
checkedAt: typeof checkedAt === 'string' && checkedAt !== '' ? checkedAt : new Date().toISOString(),
|
|
85
|
+
error: error === null || error === undefined ? null : error,
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tokenlens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
|
|
6
6
|
"license": "MIT",
|