@zhangferry-dev/tokendash 1.6.2 → 1.7.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.
@@ -146,6 +146,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
146
146
  export function createApp(_port, baseDir) {
147
147
  const app = express();
148
148
  const router = express.Router();
149
+ app.use(express.json({ limit: '16kb' }));
149
150
  // Register API routes
150
151
  registerApiRoutes(router, {
151
152
  packageName: PACKAGE_NAME,
@@ -1,4 +1,4 @@
1
- import type { QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
1
+ import type { QuotaCredentialInput, QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
2
2
  /**
3
3
  * Structured quota error. Adapters throw this (not generic Error) so the
4
4
  * service can classify the status without inspecting message strings.
@@ -28,7 +28,9 @@ export interface QuotaAdapter {
28
28
  * Fetch a fresh normalized snapshot. Throws QuotaError on any failure.
29
29
  * Must NOT include secrets in any field of the returned snapshot.
30
30
  */
31
- fetch(): Promise<QuotaSnapshot>;
31
+ fetch(options?: {
32
+ credential?: QuotaCredentialInput;
33
+ }): Promise<QuotaSnapshot>;
32
34
  }
33
35
  /**
34
36
  * Registry of all known adapters, keyed by provider id.
@@ -1,2 +1,4 @@
1
1
  import type { QuotaAdapter } from '../adapter.js';
2
2
  export declare const claudeAdapter: QuotaAdapter;
3
+ export declare function claudeKeychainServiceNames(configDir?: string): string[];
4
+ export declare function extractClaudeAccessToken(value: unknown): string | null;
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { homedir } from 'node:os';
3
+ import { homedir, userInfo } from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
5
6
  import { QuotaError, baseSnapshot } from '../adapter.js';
6
7
  import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent } from '../helpers.js';
7
8
  export const claudeAdapter = {
@@ -77,7 +78,7 @@ function readClaudeToken() {
77
78
  if (existsSync(credPath)) {
78
79
  try {
79
80
  const parsed = JSON.parse(readFileSync(credPath, 'utf8'));
80
- return parsed?.claudeAiOauth?.accessToken ?? null;
81
+ return extractClaudeAccessToken(parsed);
81
82
  }
82
83
  catch {
83
84
  return null;
@@ -86,39 +87,66 @@ function readClaudeToken() {
86
87
  return null;
87
88
  }
88
89
  function readFromKeychain() {
89
- // Keychain entries may carry a per-installation GUID suffix; try the base
90
- // name first, then any matching suffix.
91
- const candidates = ['Claude Code-credentials'];
90
+ const candidates = claudeKeychainServiceNames(process.env.CLAUDE_CONFIG_DIR);
92
91
  try {
93
92
  const list = execFileSync('security', ['dump-keychain'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
94
- for (const m of list.matchAll(/"srvname"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
93
+ for (const m of list.matchAll(/"svce"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
95
94
  if (m[1] && !candidates.includes(m[1]))
96
95
  candidates.push(m[1]);
97
96
  }
98
97
  }
99
98
  catch {
100
- // dump-keychain may prompt or fail; base name is the common case.
99
+ // dump-keychain may fail; deterministic service names are still tried.
101
100
  }
101
+ const accounts = [safeUsername(), undefined];
102
102
  for (const name of candidates) {
103
- try {
104
- const raw = execFileSync('security', ['find-generic-password', '-s', name, '-w'], {
105
- stdio: ['ignore', 'pipe', 'ignore'],
106
- encoding: 'utf8',
107
- }).trim();
108
- if (!raw)
109
- continue;
110
- // The stored value is itself a JSON blob holding the OAuth tokens.
103
+ for (const account of accounts) {
111
104
  try {
112
- const parsed = JSON.parse(raw);
113
- return parsed?.claudeAiOauth?.accessToken ?? null;
105
+ const args = ['find-generic-password', '-s', name];
106
+ if (account)
107
+ args.push('-a', account);
108
+ args.push('-w');
109
+ const raw = execFileSync('/usr/bin/security', args, {
110
+ stdio: ['ignore', 'pipe', 'ignore'],
111
+ encoding: 'utf8',
112
+ timeout: 2_000,
113
+ }).trim();
114
+ if (!raw)
115
+ continue;
116
+ const token = extractClaudeAccessToken(JSON.parse(raw));
117
+ if (token)
118
+ return token;
114
119
  }
115
120
  catch {
116
- return raw; // already a bare token in some setups
121
+ continue;
117
122
  }
118
123
  }
119
- catch {
120
- continue;
121
- }
122
124
  }
123
125
  return null;
124
126
  }
127
+ export function claudeKeychainServiceNames(configDir) {
128
+ if (!configDir)
129
+ return ['Claude Code-credentials'];
130
+ const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8);
131
+ return [`Claude Code-credentials-${hash}`, 'Claude Code-credentials'];
132
+ }
133
+ export function extractClaudeAccessToken(value) {
134
+ if (!value || typeof value !== 'object')
135
+ return null;
136
+ const root = value;
137
+ const nested = root.claudeAiOauth;
138
+ const credentials = nested && typeof nested === 'object'
139
+ ? nested
140
+ : root;
141
+ return typeof credentials.accessToken === 'string' && credentials.accessToken
142
+ ? credentials.accessToken
143
+ : null;
144
+ }
145
+ function safeUsername() {
146
+ try {
147
+ return userInfo().username?.trim() || undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
@@ -1,2 +1,16 @@
1
1
  import type { QuotaAdapter } from '../adapter.js';
2
2
  export declare const codexAdapter: QuotaAdapter;
3
+ interface CodexBinaryResolutionOptions {
4
+ home?: string;
5
+ path?: string;
6
+ explicitBinary?: string;
7
+ isExecutable?: (candidate: string) => boolean;
8
+ nvmVersions?: string[];
9
+ }
10
+ /**
11
+ * Resolve Codex independently of the launch environment. Finder-launched apps
12
+ * normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
13
+ * installed in Codex.app, Homebrew, Volta, or an NVM version directory.
14
+ */
15
+ export declare function resolveCodexBinary(options?: CodexBinaryResolutionOptions): string | null;
16
+ export {};
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readdirSync, accessSync, constants } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { QuotaError, baseSnapshot } from '../adapter.js';
@@ -11,12 +11,10 @@ export const codexAdapter = {
11
11
  provider: 'codex',
12
12
  displayName: 'OpenAI Codex',
13
13
  async isConfigured() {
14
- // auth.json present signals the user has logged in (file credential store).
15
- // Keyring-stored creds won't show a file, but the app-server itself is the
16
- // authority there so also treat the codex binary being runnable as configured.
17
- if (existsSync(join(codexHome(), 'auth.json')))
18
- return true;
19
- return await codexBinaryAvailable();
14
+ // Only surface Codex when the official login file and a runnable official
15
+ // CLI are both present. Finder-launched apps have a minimal PATH, so binary
16
+ // discovery must not rely on `which codex`.
17
+ return existsSync(join(codexHome(), 'auth.json')) && resolveCodexBinary() !== null;
20
18
  },
21
19
  async fetch() {
22
20
  const result = await queryRateLimits();
@@ -64,10 +62,16 @@ function capitalize(s) {
64
62
  }
65
63
  // --- JSON-RPC over the codex app-server ---
66
64
  async function queryRateLimits() {
67
- if (!(await codexBinaryAvailable())) {
68
- throw new QuotaError({ state: 'not_configured', message: 'codex CLI not found on PATH' });
65
+ const codexBinary = resolveCodexBinary();
66
+ if (!codexBinary) {
67
+ throw new QuotaError({ state: 'not_configured', message: 'official Codex CLI not found' });
69
68
  }
70
- const proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
69
+ const binaryDir = dirname(codexBinary);
70
+ const childPath = [binaryDir, process.env.PATH].filter(Boolean).join(':');
71
+ const proc = spawn(codexBinary, ['app-server'], {
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ env: { ...process.env, PATH: childPath },
74
+ });
71
75
  const client = new JsonRpcClient(proc);
72
76
  try {
73
77
  await client.request('initialize', {
@@ -169,20 +173,54 @@ class JsonRpcClient {
169
173
  this.pending.clear();
170
174
  }
171
175
  }
172
- let cachedCodexPath;
173
- async function codexBinaryAvailable() {
174
- if (cachedCodexPath !== undefined)
175
- return cachedCodexPath !== null;
176
- return new Promise((resolve) => {
177
- const proc = spawn('which', ['codex'], { stdio: ['ignore', 'pipe', 'ignore'] });
178
- let out = '';
179
- proc.stdout.on('data', (c) => { out += c; });
180
- proc.on('close', () => {
181
- cachedCodexPath = out.trim() || null;
182
- resolve(cachedCodexPath !== null);
183
- });
184
- proc.on('error', () => { cachedCodexPath = null; resolve(false); });
185
- });
176
+ /**
177
+ * Resolve Codex independently of the launch environment. Finder-launched apps
178
+ * normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
179
+ * installed in Codex.app, Homebrew, Volta, or an NVM version directory.
180
+ */
181
+ export function resolveCodexBinary(options = {}) {
182
+ const home = options.home ?? homedir();
183
+ const path = options.path ?? process.env.PATH ?? '';
184
+ const explicitBinary = options.explicitBinary ?? process.env.CODEX_BIN;
185
+ const isExecutable = options.isExecutable ?? defaultIsExecutable;
186
+ const nvmRoot = join(home, '.nvm', 'versions', 'node');
187
+ const nvmVersions = options.nvmVersions ?? readDirectoryNames(nvmRoot);
188
+ const candidates = [
189
+ explicitBinary,
190
+ '/Applications/Codex.app/Contents/Resources/codex',
191
+ join(home, 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
192
+ '/opt/homebrew/bin/codex',
193
+ '/usr/local/bin/codex',
194
+ join(home, '.local', 'bin', 'codex'),
195
+ join(home, '.volta', 'bin', 'codex'),
196
+ join(home, '.bun', 'bin', 'codex'),
197
+ ...nvmVersions
198
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
199
+ .map((version) => join(nvmRoot, version, 'bin', 'codex')),
200
+ ...path.split(':').filter(Boolean).map((directory) => join(directory, 'codex')),
201
+ ];
202
+ for (const candidate of candidates) {
203
+ if (candidate && isExecutable(candidate))
204
+ return candidate;
205
+ }
206
+ return null;
207
+ }
208
+ function defaultIsExecutable(candidate) {
209
+ try {
210
+ accessSync(candidate, constants.X_OK);
211
+ return true;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ function readDirectoryNames(directory) {
218
+ try {
219
+ return readdirSync(directory, { withFileTypes: true })
220
+ .filter((entry) => entry.isDirectory())
221
+ .map((entry) => entry.name);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
186
226
  }
187
- // Keep readFileSync referenced for potential future auth.json inspection without network.
188
- void readFileSync;
@@ -10,8 +10,8 @@ export const glmAdapter = {
10
10
  async isConfigured() {
11
11
  return !!resolveCredential();
12
12
  },
13
- async fetch() {
14
- const cred = resolveCredential();
13
+ async fetch(options) {
14
+ const cred = resolveCredential(options?.credential);
15
15
  if (!cred) {
16
16
  throw new QuotaError({ state: 'not_configured', message: 'set ZAI_API_KEY or ZHIPU_API_KEY' });
17
17
  }
@@ -63,7 +63,13 @@ function classifyFetchError(err) {
63
63
  const msg = err instanceof Error ? err.message : String(err);
64
64
  return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
65
65
  }
66
- function resolveCredential() {
66
+ function resolveCredential(proposed) {
67
+ if (proposed?.apiKey) {
68
+ return {
69
+ key: proposed.apiKey,
70
+ base: proposed.baseUrl || 'https://open.bigmodel.cn',
71
+ };
72
+ }
67
73
  // 0. Key entered in-app (via the credential sheet) — highest priority.
68
74
  const stored = readStoredCredential('glm');
69
75
  if (stored)
@@ -26,9 +26,11 @@ export const kimiAdapter = {
26
26
  const cred = readCredentials();
27
27
  return !!cred && !!cred.access_token;
28
28
  },
29
- async fetch() {
29
+ async fetch(options) {
30
30
  const credPath = credentialsPath();
31
- let cred = readCredentials();
31
+ let cred = options?.credential?.apiKey
32
+ ? { access_token: options.credential.apiKey, token_type: 'Bearer' }
33
+ : readCredentials();
32
34
  if (!cred || !cred.access_token) {
33
35
  throw new QuotaError({ state: 'not_configured', message: 'run `kimi` to log in first' });
34
36
  }
@@ -7,8 +7,8 @@ export const minimaxAdapter = {
7
7
  async isConfigured() {
8
8
  return !!resolveCredential();
9
9
  },
10
- async fetch() {
11
- const cred = resolveCredential();
10
+ async fetch(options) {
11
+ const cred = resolveCredential(options?.credential);
12
12
  if (!cred) {
13
13
  throw new QuotaError({ state: 'not_configured', message: 'set MINIMAX_API_KEY (Subscription Key)' });
14
14
  }
@@ -57,7 +57,12 @@ function classifyFetchError(err) {
57
57
  const msg = err instanceof Error ? err.message : String(err);
58
58
  return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
59
59
  }
60
- function resolveCredential() {
60
+ function resolveCredential(proposed) {
61
+ if (proposed?.apiKey) {
62
+ const region = (process.env.MINIMAX_REGION || '').toLowerCase();
63
+ const base = proposed.baseUrl || (region === 'cn' ? 'https://www.minimaxi.com' : 'https://www.minimax.io');
64
+ return { key: proposed.apiKey, base };
65
+ }
61
66
  // 0. Key entered in-app (via the credential sheet) — highest priority.
62
67
  const stored = readStoredCredential('minimax');
63
68
  if (stored) {
@@ -1,5 +1,5 @@
1
1
  import { QuotaCache } from './cache.js';
2
2
  import { QuotaService } from './quotaService.js';
3
- export type { QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId } from './types.js';
3
+ export type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId, } from './types.js';
4
4
  export declare const quotaCache: QuotaCache;
5
5
  export declare const quotaService: QuotaService;
@@ -1,4 +1,4 @@
1
- import type { QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
1
+ import type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
2
2
  import type { QuotaAdapterRegistry } from './adapter.js';
3
3
  import { QuotaCache } from './cache.js';
4
4
  /**
@@ -32,6 +32,12 @@ export declare class QuotaService {
32
32
  fetchAll(): Promise<QuotaResponse>;
33
33
  /** Force a refresh of all configured providers, bypassing the cache. */
34
34
  refreshAll(): Promise<QuotaResponse>;
35
+ /**
36
+ * Validate a credential without caching it or writing it to disk. This keeps
37
+ * the settings form transactional: only credentials accepted upstream are
38
+ * persisted by the native app.
39
+ */
40
+ validateCredential(provider: QuotaProviderId, credential: QuotaCredentialInput): Promise<QuotaCredentialValidation>;
35
41
  private fetchWithTimeout;
36
42
  private handleFailure;
37
43
  }
@@ -74,6 +74,29 @@ export class QuotaService {
74
74
  }
75
75
  return this.fetchAll();
76
76
  }
77
+ /**
78
+ * Validate a credential without caching it or writing it to disk. This keeps
79
+ * the settings form transactional: only credentials accepted upstream are
80
+ * persisted by the native app.
81
+ */
82
+ async validateCredential(provider, credential) {
83
+ const adapter = this.registry.get(provider);
84
+ if (!adapter) {
85
+ return {
86
+ provider,
87
+ valid: false,
88
+ status: { state: 'not_configured', message: 'Unsupported provider' },
89
+ };
90
+ }
91
+ try {
92
+ const snapshot = await withTimeout(adapter.fetch({ credential }), this.fetchTimeoutMs, provider);
93
+ const validated = validateQuotaSnapshot(snapshot);
94
+ return { provider, valid: validated.status.state === 'ok', status: validated.status };
95
+ }
96
+ catch (err) {
97
+ return { provider, valid: false, status: statusForError(err, this.fetchTimeoutMs) };
98
+ }
99
+ }
77
100
  async fetchWithTimeout(adapter) {
78
101
  try {
79
102
  const snapshot = await withTimeout(adapter.fetch(), this.fetchTimeoutMs, adapter.provider);
@@ -86,16 +109,7 @@ export class QuotaService {
86
109
  }
87
110
  }
88
111
  handleFailure(adapter, err) {
89
- let status;
90
- if (err instanceof QuotaError) {
91
- status = err.status;
92
- }
93
- else if (err instanceof TimeoutError) {
94
- status = { state: 'timed_out', message: `upstream did not respond within ${this.fetchTimeoutMs}ms` };
95
- }
96
- else {
97
- status = { state: 'error', message: redact(err), category: 'unexpected' };
98
- }
112
+ const status = statusForError(err, this.fetchTimeoutMs);
99
113
  // Retain last good snapshot as stale.
100
114
  const stale = this.cache.getStale(adapter.provider);
101
115
  if (stale) {
@@ -112,6 +126,14 @@ export class QuotaService {
112
126
  };
113
127
  }
114
128
  }
129
+ function statusForError(err, timeoutMs) {
130
+ if (err instanceof QuotaError)
131
+ return err.status;
132
+ if (err instanceof TimeoutError) {
133
+ return { state: 'timed_out', message: `upstream did not respond within ${timeoutMs}ms` };
134
+ }
135
+ return { state: 'error', message: redact(err), category: 'unexpected' };
136
+ }
115
137
  class TimeoutError extends Error {
116
138
  constructor(provider) {
117
139
  super(`quota fetch timed out: ${provider}`);
@@ -63,3 +63,14 @@ export interface QuotaProviderStatus {
63
63
  export interface QuotaResponse {
64
64
  providers: QuotaSnapshot[];
65
65
  }
66
+ /** A credential proposed by the settings UI but not persisted yet. */
67
+ export interface QuotaCredentialInput {
68
+ apiKey: string;
69
+ baseUrl?: string;
70
+ }
71
+ /** Result of checking a proposed credential against its upstream provider. */
72
+ export interface QuotaCredentialValidation {
73
+ provider: QuotaProviderId;
74
+ valid: boolean;
75
+ status: QuotaProviderStatus;
76
+ }
@@ -21,6 +21,24 @@ async function getQuota(_req, res) {
21
21
  res.status(500).json({ error: 'Failed to fetch quota', hint: message });
22
22
  }
23
23
  }
24
+ const editableQuotaProviders = new Set(['glm', 'kimi', 'minimax']);
25
+ async function validateQuotaCredential(req, res) {
26
+ const provider = req.body?.provider;
27
+ const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey.trim() : '';
28
+ const baseUrl = typeof req.body?.baseUrl === 'string' ? req.body.baseUrl.trim() : undefined;
29
+ if (!editableQuotaProviders.has(provider) || !apiKey) {
30
+ res.status(400).json({
31
+ error: 'Invalid credential request',
32
+ hint: 'A supported provider and non-empty token are required.',
33
+ });
34
+ return;
35
+ }
36
+ const result = await quotaService.validateCredential(provider, {
37
+ apiKey,
38
+ baseUrl: baseUrl || undefined,
39
+ });
40
+ res.status(result.valid ? 200 : 422).json(result);
41
+ }
24
42
  function getAgents(_req, res) {
25
43
  try {
26
44
  const agents = detectAvailableAgents();
@@ -59,4 +77,5 @@ export function registerApiRoutes(router, appInfo) {
59
77
  router.get('/blocks', getBlocks);
60
78
  router.get('/analytics', getAnalytics);
61
79
  router.get('/quota', getQuota);
80
+ router.post('/quota/validate', validateQuotaCredential);
62
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {
@@ -30,7 +30,9 @@
30
30
  "test:watch": "vitest",
31
31
  "test:e2e": "playwright test",
32
32
  "prepack": "npm run build",
33
- "release": "npm publish --access public --registry https://registry.npmjs.org"
33
+ "release:npm": "npm publish --access public --registry https://registry.npmjs.org",
34
+ "deploy:check": "./scripts/deploy.sh --dry-run",
35
+ "deploy": "./scripts/deploy.sh"
34
36
  },
35
37
  "dependencies": {
36
38
  "express": "^5.1.0",