@zhangferry-dev/tokendash 1.6.0 → 1.6.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.
@@ -12,11 +12,22 @@ const CLI_USAGE = [
12
12
  ' tokendash --port <number> [--no-open]',
13
13
  ' tokendash --tray [--port <number>]',
14
14
  ].join('\n');
15
+ const PACKAGE_NAME = '@zhangferry-dev/tokendash';
15
16
  function getPackageVersion() {
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = dirname(__filename);
18
- const packageJson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
19
- return packageJson.version ?? 'unknown';
19
+ const packageJsonPaths = [
20
+ join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
21
+ join(__dirname, '..', 'package.json'), // dist/electron-server.cjs
22
+ ];
23
+ for (const packageJsonPath of packageJsonPaths) {
24
+ if (!existsSync(packageJsonPath))
25
+ continue;
26
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
27
+ if (packageJson.version)
28
+ return packageJson.version;
29
+ }
30
+ return 'unknown';
20
31
  }
21
32
  function exitWithCliError(message) {
22
33
  console.error(message);
@@ -137,7 +148,11 @@ export function createApp(_port, baseDir) {
137
148
  const app = express();
138
149
  const router = express.Router();
139
150
  // Register API routes
140
- registerApiRoutes(router);
151
+ registerApiRoutes(router, {
152
+ packageName: PACKAGE_NAME,
153
+ version: getPackageVersion(),
154
+ dashboardUrl: `http://localhost:${resolvePort(_port)}`,
155
+ });
141
156
  app.use('/api', router);
142
157
  const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
143
158
  const popoverPath = isProduction
@@ -1,2 +1,7 @@
1
1
  import { type Router } from 'express';
2
- export declare function registerApiRoutes(router: Router): void;
2
+ export interface AppInfo {
3
+ packageName: string;
4
+ version: string;
5
+ dashboardUrl?: string;
6
+ }
7
+ export declare function registerApiRoutes(router: Router, appInfo: AppInfo): void;
@@ -26,7 +26,17 @@ function getAgents(_req, res) {
26
26
  res.status(500).json({ error: 'Failed to detect agents', hint: message });
27
27
  }
28
28
  }
29
- export function registerApiRoutes(router) {
29
+ function getAppInfo(info) {
30
+ return (req, res) => {
31
+ const host = req.get('host');
32
+ res.json({
33
+ ...info,
34
+ dashboardUrl: host ? `${req.protocol}://${host}` : info.dashboardUrl,
35
+ });
36
+ };
37
+ }
38
+ export function registerApiRoutes(router, appInfo) {
39
+ router.get('/app-info', getAppInfo(appInfo));
30
40
  router.get('/agents', getAgents);
31
41
  router.get('/daily', getDaily);
32
42
  router.get('/monthly', getMonthly);
package/electron/main.cjs CHANGED
@@ -19,6 +19,8 @@ try {
19
19
 
20
20
  const { formatTokens } = require('./trayBadge.cjs');
21
21
  const { checkForUpdates, downloadUpdateAsset } = require('./updateService.cjs');
22
+ const { syncNpmPackageVersion } = require('./npmSync.cjs');
23
+ const { findCompatibleServer, getDashboardUrl } = require('./serverReuse.cjs');
22
24
 
23
25
  // Resolve trayHelper binary: extract from asar if needed
24
26
  function resolveTrayHelperPath() {
@@ -52,6 +54,7 @@ let server = null;
52
54
  let trayProcess = null;
53
55
  let selectedAgents = null; // null = use all available agents
54
56
  let serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
57
+ let dashboardUrl = getDashboardUrl(serverPort);
55
58
  let lastUpdateInfo = null;
56
59
  let isDownloadingUpdate = false;
57
60
  const POPOVER_WIDTH = 380;
@@ -99,6 +102,10 @@ function fetchJson(url) {
99
102
  });
100
103
  }
101
104
 
105
+ function getServerBaseUrl() {
106
+ return dashboardUrl || getDashboardUrl(serverPort);
107
+ }
108
+
102
109
  function getAppInfo() {
103
110
  // app.getVersion() returns Electron's version in dev mode (e.g. 41.5).
104
111
  // Always read from package.json to get the app's own version.
@@ -245,7 +252,8 @@ function updateTrayBadge() {
245
252
  const d = new Date(); const today = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
246
253
 
247
254
  // Fetch agents list, then fetch daily data for each agent in parallel
248
- fetchJson(`http://localhost:${serverPort}/api/agents`)
255
+ const serverBaseUrl = getServerBaseUrl();
256
+ fetchJson(`${serverBaseUrl}/api/agents`)
249
257
  .then((agentData) => {
250
258
  let agents = (agentData && Array.isArray(agentData.available)) ? agentData.available : ['claude'];
251
259
  if (agents.length === 0) {
@@ -262,7 +270,7 @@ function updateTrayBadge() {
262
270
  const agentKey = getTrayAgentKey(agents);
263
271
  return Promise.all(
264
272
  agents.map(agent =>
265
- fetchJson(`http://localhost:${serverPort}/api/daily?agent=${agent}`)
273
+ fetchJson(`${serverBaseUrl}/api/daily?agent=${agent}`)
266
274
  .catch(() => null)
267
275
  )
268
276
  ).then(results => ({ agentKey, results }));
@@ -350,7 +358,7 @@ function createPopoverWindow() {
350
358
  },
351
359
  });
352
360
 
353
- popover.loadURL(`http://localhost:${serverPort}/popover.html`);
361
+ popover.loadURL(`${getServerBaseUrl()}/popover.html`);
354
362
 
355
363
  popover.on('blur', () => {
356
364
  popover.hide();
@@ -366,8 +374,7 @@ function createPopoverWindow() {
366
374
 
367
375
  function registerIpcHandlers() {
368
376
  ipcMain.handle('tokendash:open-dashboard', (_event, url) => {
369
- const target = typeof url === 'string' && url.length > 0 ? url : `http://localhost:${serverPort}`;
370
- return shell.openExternal(target);
377
+ return shell.openExternal(getServerBaseUrl());
371
378
  });
372
379
 
373
380
  ipcMain.handle('tokendash:get-app-info', () => {
@@ -462,15 +469,34 @@ app.whenReady().then(async () => {
462
469
  if (server) server.close();
463
470
  });
464
471
 
465
- // Create and bind Express server
466
- // Pass dist/ directory so createApp resolves client assets correctly
472
+ const currentVersion = getAppInfo().version;
473
+ syncNpmPackageVersion(PACKAGE_NAME, currentVersion).then((result) => {
474
+ if (!result || result.ok) return;
475
+ console.warn('Could not sync npm package version:', result.error || 'unknown error');
476
+ }).catch((error) => {
477
+ console.warn('Could not sync npm package version:', error instanceof Error ? error.message : String(error));
478
+ });
479
+
480
+ const existingServer = await findCompatibleServer(serverPort, currentVersion, PACKAGE_NAME);
481
+ if (existingServer) {
482
+ serverPort = existingServer.port;
483
+ dashboardUrl = existingServer.dashboardUrl;
484
+ console.log(`tokendash reusing CLI server on ${dashboardUrl}`);
485
+ startTrayHelper();
486
+ createPopoverWindow();
487
+ return;
488
+ }
489
+
490
+ // Create and bind Express server.
491
+ // Pass dist/ directory so createApp resolves client assets correctly.
467
492
  const distDir = path.join(__dirname, '..', 'dist');
468
493
  const expressApp = createApp(serverPort, distDir);
469
494
  try {
470
495
  const result = await listenWithFallback(expressApp, serverPort);
471
496
  server = result.server;
472
497
  serverPort = result.port;
473
- console.log(`tokendash running on http://localhost:${result.port}`);
498
+ dashboardUrl = getDashboardUrl(result.port);
499
+ console.log(`tokendash running on ${dashboardUrl}`);
474
500
  } catch (err) {
475
501
  console.error('Failed to start server:', err);
476
502
  app.quit();
@@ -0,0 +1,62 @@
1
+ const { spawn } = require('node:child_process');
2
+
3
+ function normalizeVersion(version) {
4
+ return String(version || '').trim().replace(/^v/, '');
5
+ }
6
+
7
+ function shouldInstallPackage(installedVersion, targetVersion) {
8
+ const installed = normalizeVersion(installedVersion);
9
+ const target = normalizeVersion(targetVersion);
10
+ return Boolean(target) && installed !== target;
11
+ }
12
+
13
+ function buildNpmInstallArgs(packageName, version) {
14
+ return ['install', '-g', `${packageName}@${version}`];
15
+ }
16
+
17
+ function runCommand(command, args) {
18
+ return new Promise((resolve) => {
19
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
20
+ let stdout = '';
21
+ let stderr = '';
22
+
23
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
24
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
25
+ child.on('error', (error) => resolve({ ok: false, stdout, stderr, error }));
26
+ child.on('close', (code) => resolve({ ok: code === 0, code, stdout, stderr }));
27
+ });
28
+ }
29
+
30
+ async function getInstalledPackageVersion(packageName) {
31
+ const result = await runCommand('npm', ['list', '-g', packageName, '--depth=0', '--json']);
32
+ if (!result.ok) return null;
33
+ try {
34
+ const data = JSON.parse(result.stdout);
35
+ return normalizeVersion(data && data.dependencies && data.dependencies[packageName] && data.dependencies[packageName].version);
36
+ } catch (_) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function syncNpmPackageVersion(packageName, version) {
42
+ const installedVersion = await getInstalledPackageVersion(packageName);
43
+ if (!shouldInstallPackage(installedVersion, version)) {
44
+ return { ok: true, installedVersion, targetVersion: version, changed: false };
45
+ }
46
+
47
+ const result = await runCommand('npm', buildNpmInstallArgs(packageName, version));
48
+ return {
49
+ ok: result.ok,
50
+ installedVersion,
51
+ targetVersion: version,
52
+ changed: result.ok,
53
+ error: result.ok ? null : (result.error ? result.error.message : result.stderr || `npm exited with ${result.code}`),
54
+ };
55
+ }
56
+
57
+ module.exports = {
58
+ buildNpmInstallArgs,
59
+ getInstalledPackageVersion,
60
+ shouldInstallPackage,
61
+ syncNpmPackageVersion,
62
+ };
@@ -0,0 +1,59 @@
1
+ const http = require('node:http');
2
+
3
+ function normalizePort(port) {
4
+ const value = parseInt(String(port || ''), 10);
5
+ return Number.isInteger(value) && value > 0 ? value : 3456;
6
+ }
7
+
8
+ function getDashboardUrl(port) {
9
+ return `http://localhost:${normalizePort(port)}`;
10
+ }
11
+
12
+ function isCompatibleServerInfo(info, expectedVersion, expectedPackageName) {
13
+ return Boolean(
14
+ info &&
15
+ info.packageName === expectedPackageName &&
16
+ String(info.version || '').replace(/^v/, '') === String(expectedVersion || '').replace(/^v/, '')
17
+ );
18
+ }
19
+
20
+ function fetchJson(url, timeoutMs = 1000) {
21
+ return new Promise((resolve, reject) => {
22
+ const req = http.get(url, (res) => {
23
+ let data = '';
24
+ res.on('data', (chunk) => { data += chunk; });
25
+ res.on('end', () => {
26
+ if (res.statusCode && res.statusCode >= 400) {
27
+ reject(new Error(`HTTP ${res.statusCode}`));
28
+ return;
29
+ }
30
+ try { resolve(JSON.parse(data)); }
31
+ catch (error) { reject(error); }
32
+ });
33
+ });
34
+
35
+ req.setTimeout(timeoutMs, () => {
36
+ req.destroy(new Error('Request timed out'));
37
+ });
38
+ req.on('error', reject);
39
+ });
40
+ }
41
+
42
+ async function findCompatibleServer(preferredPort, expectedVersion, expectedPackageName) {
43
+ const port = normalizePort(preferredPort);
44
+ try {
45
+ const info = await fetchJson(`${getDashboardUrl(port)}/api/app-info`);
46
+ if (isCompatibleServerInfo(info, expectedVersion, expectedPackageName)) {
47
+ return { port, dashboardUrl: info.dashboardUrl || getDashboardUrl(port), info };
48
+ }
49
+ } catch (_) {}
50
+ return null;
51
+ }
52
+
53
+ module.exports = {
54
+ fetchJson,
55
+ findCompatibleServer,
56
+ getDashboardUrl,
57
+ isCompatibleServerInfo,
58
+ normalizePort,
59
+ };
@@ -30,6 +30,35 @@ function fetchHttpsJson(url) {
30
30
  });
31
31
  }
32
32
 
33
+ function fetchLatestReleaseUrl(repo) {
34
+ const url = `https://github.com/${repo}/releases/latest`;
35
+ return new Promise((resolve, reject) => {
36
+ const opts = new URL(url);
37
+ const reqOpts = {
38
+ hostname: opts.hostname,
39
+ path: opts.pathname + opts.search,
40
+ method: 'HEAD',
41
+ headers: {
42
+ 'User-Agent': 'TokenDash',
43
+ },
44
+ };
45
+
46
+ https.request(reqOpts, (res) => {
47
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
48
+ resolve(new URL(res.headers.location, url).toString());
49
+ return;
50
+ }
51
+
52
+ if (res.statusCode && res.statusCode >= 400) {
53
+ reject(new Error(`HTTP ${res.statusCode}`));
54
+ return;
55
+ }
56
+
57
+ resolve(url);
58
+ }).on('error', reject).end();
59
+ });
60
+ }
61
+
33
62
  function compareVersions(a, b) {
34
63
  const aParts = String(a).replace(/^v/, '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
35
64
  const bParts = String(b).replace(/^v/, '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
@@ -78,9 +107,50 @@ function getReleaseUpdateInfo(release, currentVersion, arch = process.arch) {
78
107
  };
79
108
  }
80
109
 
81
- async function checkForUpdates({ repo, currentVersion, arch = process.arch }) {
82
- const release = await fetchHttpsJson(`https://api.github.com/repos/${repo}/releases/latest`);
83
- return getReleaseUpdateInfo(release, currentVersion, arch);
110
+ function buildDmgAssetFromVersion(repo, tagName, arch = process.arch) {
111
+ const version = String(tagName || '').replace(/^v/, '');
112
+ const archSuffix = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'x64' : 'universal';
113
+ const name = `TokenDash-${version}-${archSuffix}.dmg`;
114
+
115
+ return {
116
+ name,
117
+ size: 0,
118
+ url: `https://github.com/${repo}/releases/download/${tagName}/${name}`,
119
+ };
120
+ }
121
+
122
+ function getRedirectReleaseUpdateInfo(repo, releaseUrl, currentVersion, arch = process.arch) {
123
+ const parsedUrl = new URL(releaseUrl);
124
+ const tagMatch = parsedUrl.pathname.match(/\/releases\/tag\/([^/]+)\/?$/);
125
+ if (!tagMatch) throw new Error('Unable to determine the latest release tag.');
126
+
127
+ const tagName = decodeURIComponent(tagMatch[1]);
128
+ const latestVersion = tagName.replace(/^v/, '');
129
+ const upToDate = compareVersions(currentVersion, latestVersion) >= 0;
130
+
131
+ return {
132
+ currentVersion,
133
+ latestVersion,
134
+ upToDate,
135
+ releaseUrl: parsedUrl.toString(),
136
+ asset: upToDate ? null : buildDmgAssetFromVersion(repo, tagName, arch),
137
+ };
138
+ }
139
+
140
+ async function checkForUpdates({
141
+ repo,
142
+ currentVersion,
143
+ arch = process.arch,
144
+ fetchReleaseJson = fetchHttpsJson,
145
+ fetchLatestReleaseUrl: fetchLatestReleaseUrlOverride = fetchLatestReleaseUrl,
146
+ }) {
147
+ try {
148
+ const release = await fetchReleaseJson(`https://api.github.com/repos/${repo}/releases/latest`);
149
+ return getReleaseUpdateInfo(release, currentVersion, arch);
150
+ } catch (error) {
151
+ const releaseUrl = await fetchLatestReleaseUrlOverride(repo);
152
+ return getRedirectReleaseUpdateInfo(repo, releaseUrl, currentVersion, arch);
153
+ }
84
154
  }
85
155
 
86
156
  function safeDownloadName(name) {
@@ -143,6 +213,8 @@ module.exports = {
143
213
  checkForUpdates,
144
214
  compareVersions,
145
215
  downloadUpdateAsset,
216
+ fetchLatestReleaseUrl,
217
+ getRedirectReleaseUpdateInfo,
146
218
  getReleaseUpdateInfo,
147
219
  selectMacDmgAsset,
148
220
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {
@@ -1,7 +0,0 @@
1
- export declare function runCcusage(args: string[], timeout?: number): Promise<string>;
2
- export declare function ensureUsageToolsReady(): Promise<void>;
3
- export declare function isClaudeCodeAvailable(): Promise<boolean>;
4
- export declare function detectAvailableAgents(): Promise<{
5
- claude: boolean;
6
- codex: boolean;
7
- }>;
@@ -1,69 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { isSessionsDirAccessible } from './codexParser.js';
4
- const execFileAsync = promisify(execFile);
5
- function withJsonFlag(args, asJson) {
6
- if (!asJson || args.includes('--json')) {
7
- return args;
8
- }
9
- return [...args, '--json'];
10
- }
11
- async function runCommand(spec, timeout) {
12
- const { stdout } = await execFileAsync(spec.command, spec.args, {
13
- timeout,
14
- maxBuffer: 10 * 1024 * 1024,
15
- });
16
- return stdout;
17
- }
18
- function isMissingCommand(error) {
19
- return typeof error === 'object'
20
- && error !== null
21
- && 'code' in error
22
- && error.code === 'ENOENT';
23
- }
24
- async function runCcusageCommand(args, timeout, asJson) {
25
- const primary = {
26
- command: 'ccusage',
27
- args: withJsonFlag(args, asJson),
28
- };
29
- const fallback = {
30
- command: 'npx',
31
- args: ['--yes', 'ccusage@latest', ...withJsonFlag(args, asJson)],
32
- };
33
- try {
34
- return await runCommand(primary, timeout);
35
- }
36
- catch (error) {
37
- if (isMissingCommand(error)) {
38
- return runCommand(fallback, timeout);
39
- }
40
- throw error;
41
- }
42
- }
43
- export async function runCcusage(args, timeout = 30_000) {
44
- return runCcusageCommand(args, timeout, true);
45
- }
46
- export async function ensureUsageToolsReady() {
47
- // Claude Code: check ccusage CLI
48
- await runCcusageCommand(['--version'], 120_000, false);
49
- // Codex: check local sessions directory (instant, no npm subprocess)
50
- if (!isSessionsDirAccessible()) {
51
- throw new Error('Codex sessions directory not found at ~/.codex/sessions/');
52
- }
53
- }
54
- export async function isClaudeCodeAvailable() {
55
- try {
56
- await runCcusageCommand(['--version'], 120_000, false);
57
- return true;
58
- }
59
- catch {
60
- return false;
61
- }
62
- }
63
- export async function detectAvailableAgents() {
64
- const [claude, codex] = await Promise.all([
65
- isClaudeCodeAvailable(),
66
- Promise.resolve(isSessionsDirAccessible()),
67
- ]);
68
- return { claude, codex };
69
- }