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/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.2.2'
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({ ok: true, plugin: PLUGIN_NAME, version: PLUGIN_VERSION, bridge: BRIDGE, pid: process.pid, ...first })
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)`,
@@ -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.2.2",
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",