nansen-cli 1.36.2 → 1.38.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.
package/src/doctor.js ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Nansen CLI - Offline diagnostics
3
+ *
4
+ * `nansen auth status` — where credentials come from, without any network call.
5
+ * `nansen doctor` — health checks over ~/.nansen, environment, and wallets.
6
+ *
7
+ * Everything in this module is offline by design: it reads local files, env
8
+ * vars, and (for the wallet password) the OS keychain — never the network.
9
+ * Paths are resolved lazily at call time so tests can point HOME at a temp dir.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { passwordSource } from './keychain.js';
16
+ import { isNewer } from './update-check.js';
17
+ import { isTelemetryDisabled } from './telemetry.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ const DEFAULT_BASE_URL = 'https://api.nansen.ai';
22
+ const COST_MAP_STALE_MS = 24 * 60 * 60 * 1000;
23
+
24
+ // ============= Lazy Paths =============
25
+
26
+ function getHome(env) {
27
+ return env.HOME || env.USERPROFILE || '';
28
+ }
29
+
30
+ function getConfigDir(env) {
31
+ return path.join(getHome(env), '.nansen');
32
+ }
33
+
34
+ function getConfigFilePath(env) {
35
+ return path.join(getConfigDir(env), 'config.json');
36
+ }
37
+
38
+ function getWalletsDir(env) {
39
+ return path.join(getConfigDir(env), 'wallets');
40
+ }
41
+
42
+ function getCredentialsFilePath(env) {
43
+ return path.join(getWalletsDir(env), '.credentials');
44
+ }
45
+
46
+ // ============= Local Readers (never throw) =============
47
+
48
+ /**
49
+ * Read + parse a JSON file, distinguishing "cannot read it" (permissions, IO)
50
+ * from "read it but it is not JSON" — the two need different diagnostics and
51
+ * different fixes.
52
+ */
53
+ function readJsonDetailed(filePath) {
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(filePath, 'utf8');
57
+ } catch {
58
+ return { data: null, error: 'unreadable' };
59
+ }
60
+ try {
61
+ return { data: JSON.parse(raw), error: null };
62
+ } catch {
63
+ return { data: null, error: 'parse' };
64
+ }
65
+ }
66
+
67
+ function readJson(filePath) {
68
+ return readJsonDetailed(filePath).data;
69
+ }
70
+
71
+ /**
72
+ * Mask an API key for display: enough to recognise it, never enough to use it.
73
+ */
74
+ export function maskKey(key) {
75
+ if (typeof key !== 'string' || key.length === 0) return null;
76
+ // Below 12 chars, first4+last4 would disclose most of the key
77
+ if (key.length < 12) return '****';
78
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
79
+ }
80
+
81
+ const DEV_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
82
+
83
+ /**
84
+ * Resolve the API key and base URL the way src/api.js loadConfig() does —
85
+ * ~/.nansen/config.json, then the repo-local dev config.json, then env
86
+ * overrides — but lazily and without secrets leaving this function unmasked.
87
+ */
88
+ function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) {
89
+ const userConfigPath = getConfigFilePath(env);
90
+
91
+ let config = null;
92
+ let configPath = null;
93
+ let configError = null;
94
+
95
+ if (fs.existsSync(userConfigPath)) {
96
+ const result = readJsonDetailed(userConfigPath);
97
+ config = result.data;
98
+ configPath = userConfigPath;
99
+ configError = result.error;
100
+ }
101
+ if (!config && fs.existsSync(devConfigPath)) {
102
+ config = readJson(devConfigPath);
103
+ if (config) configPath = devConfigPath;
104
+ }
105
+
106
+ let apiKey = config?.apiKey || null;
107
+ let apiKeySource = apiKey ? (configPath === devConfigPath ? 'dev-config' : 'config') : null;
108
+ if (env.NANSEN_API_KEY) {
109
+ apiKey = env.NANSEN_API_KEY;
110
+ apiKeySource = 'env';
111
+ }
112
+
113
+ let baseUrl = config?.baseUrl || DEFAULT_BASE_URL;
114
+ let baseUrlSource = config?.baseUrl ? 'config' : 'default';
115
+ if (env.NANSEN_BASE_URL) {
116
+ baseUrl = env.NANSEN_BASE_URL;
117
+ baseUrlSource = 'env';
118
+ }
119
+
120
+ return {
121
+ apiKey,
122
+ apiKeySource,
123
+ baseUrl,
124
+ baseUrlSource,
125
+ configPath,
126
+ configFileExists: fs.existsSync(userConfigPath),
127
+ configError,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Enumerate local wallets without side effects — unlike listWallets(), this
133
+ * never creates the wallets directory.
134
+ */
135
+ function readWallets(env) {
136
+ const dir = getWalletsDir(env);
137
+ const result = {
138
+ dir,
139
+ dirExists: fs.existsSync(dir),
140
+ dirError: null,
141
+ wallets: [],
142
+ defaultWallet: null,
143
+ passwordHashSet: false,
144
+ configError: null,
145
+ };
146
+ if (!result.dirExists) return result;
147
+
148
+ const configFilePath = path.join(dir, 'config.json');
149
+ const walletConfig = fs.existsSync(configFilePath) ? readJsonDetailed(configFilePath) : { data: null, error: null };
150
+ result.configError = walletConfig.error;
151
+ result.defaultWallet = walletConfig.data?.defaultWallet || null;
152
+ result.passwordHashSet = Boolean(walletConfig.data?.passwordHash);
153
+
154
+ let entries = [];
155
+ try { entries = fs.readdirSync(dir); } catch { result.dirError = 'unreadable'; }
156
+ for (const entry of entries) {
157
+ if (!entry.endsWith('.json') || entry === 'config.json') continue;
158
+ const wallet = readJsonDetailed(path.join(dir, entry));
159
+ result.wallets.push({
160
+ name: entry.replace(/\.json$/, ''),
161
+ provider: wallet.data?.provider || (wallet.data ? 'local' : null),
162
+ error: wallet.error,
163
+ });
164
+ }
165
+ return result;
166
+ }
167
+
168
+ function fileMode(filePath) {
169
+ try {
170
+ return fs.statSync(filePath).mode & 0o777;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Whether an OS keychain is available for wallet password storage, mirroring
178
+ * the platform support in src/keychain.js. Checks tool presence only — never
179
+ * reads or writes an entry.
180
+ */
181
+ function isKeychainAvailable(platform, env) {
182
+ if (platform === 'darwin') return fs.existsSync('/usr/bin/security');
183
+ if (platform === 'linux') {
184
+ return (env.PATH || '').split(path.delimiter).some(dir => {
185
+ try { return dir && fs.existsSync(path.join(dir, 'secret-tool')); } catch { return false; }
186
+ });
187
+ }
188
+ return false; // Windows: keychain.js always falls back to the .credentials file
189
+ }
190
+
191
+ function isInsecureMode(mode) {
192
+ return mode !== null && (mode & 0o077) !== 0;
193
+ }
194
+
195
+ // ============= auth status =============
196
+
197
+ /**
198
+ * Offline authentication status. Reads config files, env vars, and the wallet
199
+ * store; makes no network calls. Returns a data object (rendered as JSON by
200
+ * the CLI layer, like `account`).
201
+ */
202
+ export function getAuthStatus(deps = {}) {
203
+ const {
204
+ env = process.env,
205
+ passwordSourceFn = passwordSource,
206
+ devConfigPath = DEV_CONFIG_PATH,
207
+ platform = process.platform,
208
+ } = deps;
209
+
210
+ const auth = resolveAuthConfig(env, devConfigPath);
211
+ const walletInfo = readWallets(env);
212
+ const pwSource = passwordSourceFn();
213
+ const defaultEntry = walletInfo.wallets.find(w => w.name === walletInfo.defaultWallet) || null;
214
+
215
+ return {
216
+ logged_in: Boolean(auth.apiKey),
217
+ api_key: {
218
+ present: Boolean(auth.apiKey),
219
+ source: auth.apiKeySource,
220
+ masked: maskKey(auth.apiKey),
221
+ },
222
+ config_file: {
223
+ path: getConfigFilePath(env),
224
+ exists: auth.configFileExists,
225
+ // 'parse' (corrupt JSON) | 'unreadable' (permissions/IO) | null — without
226
+ // this, a broken config file is indistinguishable from "not logged in"
227
+ error: auth.configError,
228
+ },
229
+ base_url: {
230
+ value: auth.baseUrl,
231
+ source: auth.baseUrlSource,
232
+ },
233
+ x402: {
234
+ configured: walletInfo.wallets.length > 0,
235
+ wallets_dir: walletInfo.dir,
236
+ wallets_dir_error: walletInfo.dirError,
237
+ wallet_count: walletInfo.wallets.length,
238
+ default_wallet: walletInfo.defaultWallet,
239
+ default_wallet_provider: defaultEntry?.provider || null,
240
+ password: {
241
+ available: pwSource !== null,
242
+ source: pwSource,
243
+ keychain_available: isKeychainAvailable(platform, env),
244
+ },
245
+ },
246
+ offline: true,
247
+ };
248
+ }
249
+
250
+ // ============= doctor =============
251
+
252
+ function check(id, status, message, fix = null) {
253
+ const result = { id, status, message };
254
+ if (fix) result.fix = fix;
255
+ return result;
256
+ }
257
+
258
+ function parseEngineMajor(requirement) {
259
+ const match = /(\d+)/.exec(requirement || '');
260
+ return match ? parseInt(match[1], 10) : null;
261
+ }
262
+
263
+ /**
264
+ * Run all offline diagnostics. Returns an array of
265
+ * { id, status: 'ok'|'warn'|'error'|'info', message, fix? } checks.
266
+ */
267
+ export function runDoctorChecks(deps = {}) {
268
+ const {
269
+ env = process.env,
270
+ passwordSourceFn = passwordSource,
271
+ nodeVersion = process.version,
272
+ cliVersion = null,
273
+ engines = null,
274
+ devConfigPath = DEV_CONFIG_PATH,
275
+ platform = process.platform,
276
+ } = deps;
277
+
278
+ const checks = [];
279
+ const auth = resolveAuthConfig(env, devConfigPath);
280
+
281
+ // --- environment ---
282
+ const requiredMajor = parseEngineMajor(engines?.node) ?? 20;
283
+ const currentMajor = parseInt(nodeVersion.replace(/^v/, ''), 10);
284
+ checks.push(currentMajor >= requiredMajor
285
+ ? check('node-version', 'ok', `Node ${nodeVersion} (>= ${requiredMajor} required)`)
286
+ : check('node-version', 'error', `Node ${nodeVersion} is below the required major version ${requiredMajor}`, `Install Node >= ${requiredMajor}: https://nodejs.org`));
287
+
288
+ // Report the URL the CLI will actually use — a config file can set a
289
+ // non-default base URL too, not just the env var
290
+ if (auth.baseUrlSource === 'env') {
291
+ checks.push(check('base-url', 'warn', `NANSEN_BASE_URL override active: ${auth.baseUrl}`, `Unset NANSEN_BASE_URL to use ${DEFAULT_BASE_URL}`));
292
+ } else if (auth.baseUrl !== DEFAULT_BASE_URL) {
293
+ checks.push(check('base-url', 'warn', `Non-default API base URL in ${auth.configPath}: ${auth.baseUrl}`, 'Run: nansen login (re-saves the default)'));
294
+ } else {
295
+ checks.push(check('base-url', 'ok', `API base URL: ${auth.baseUrl}`));
296
+ }
297
+
298
+ // --- auth ---
299
+ if (auth.configError === 'unreadable') {
300
+ checks.push(check('config-file', 'error', `${getConfigFilePath(env)} exists but cannot be read — permission problem?`, `Check ownership and mode: ls -l "${getConfigFilePath(env)}"`));
301
+ } else if (auth.configError === 'parse') {
302
+ checks.push(check('config-file', 'error', `${getConfigFilePath(env)} exists but is not valid JSON`, 'Run: nansen login (re-saves the file)'));
303
+ }
304
+ if (auth.apiKey) {
305
+ const sourceLabel = auth.apiKeySource === 'env'
306
+ ? 'NANSEN_API_KEY env var'
307
+ : `config file ${auth.configPath}`;
308
+ checks.push(check('api-key', 'ok', `API key found (${maskKey(auth.apiKey)}, source: ${sourceLabel})`));
309
+ if (auth.apiKeySource === 'env' && auth.configFileExists && !auth.configError) {
310
+ const fileKey = readJson(getConfigFilePath(env))?.apiKey;
311
+ if (fileKey && fileKey !== auth.apiKey) {
312
+ checks.push(check('api-key-shadow', 'warn', 'NANSEN_API_KEY env var overrides a different key saved in the config file'));
313
+ }
314
+ }
315
+ } else {
316
+ checks.push(check('api-key', auth.configFileExists ? 'error' : 'warn', 'No API key configured', 'Run: nansen login --api-key <key> (or fund an x402 wallet for pay-per-call access)'));
317
+ }
318
+ // POSIX modes are meaningless on Windows — fs.stat reports 0o666 for every
319
+ // file there, which would warn on all of them
320
+ const posixModes = platform !== 'win32';
321
+ if (posixModes) {
322
+ const configMode = fileMode(getConfigFilePath(env));
323
+ if (isInsecureMode(configMode)) {
324
+ checks.push(check('config-perms', 'warn', `${getConfigFilePath(env)} has insecure permissions (${configMode.toString(8)})`, `Run: chmod 600 "${getConfigFilePath(env)}"`));
325
+ }
326
+ }
327
+
328
+ // --- wallets / x402 ---
329
+ const keychainAvailable = isKeychainAvailable(platform, env);
330
+ checks.push(keychainAvailable
331
+ ? check('keychain', 'ok', 'OS keychain available for wallet password storage')
332
+ : check('keychain', 'info', 'No OS keychain on this platform — wallet passwords fall back to the .credentials file'));
333
+
334
+ const walletInfo = readWallets(env);
335
+ if (walletInfo.configError) {
336
+ const problem = walletInfo.configError === 'unreadable' ? 'cannot be read — permission problem?' : 'is not valid JSON';
337
+ checks.push(check('wallet-config', 'error', `${path.join(walletInfo.dir, 'config.json')} exists but ${problem}`));
338
+ }
339
+ if (walletInfo.dirError) {
340
+ checks.push(check('wallets', 'error', `${walletInfo.dir} exists but cannot be read — permission problem?`, `Check ownership and mode: ls -ld "${walletInfo.dir}"`));
341
+ } else if (!walletInfo.dirExists || walletInfo.wallets.length === 0) {
342
+ checks.push(check('wallets', 'info', 'No local wallets (only needed for trading and x402 micropayments)', 'Run: nansen wallet create <name>'));
343
+ } else {
344
+ const defaultNote = walletInfo.defaultWallet ? `default: ${walletInfo.defaultWallet}` : 'no default set';
345
+ checks.push(check('wallets', 'ok', `${walletInfo.wallets.length} wallet${walletInfo.wallets.length === 1 ? '' : 's'} in ${walletInfo.dir} (${defaultNote})`));
346
+ if (walletInfo.defaultWallet && !walletInfo.wallets.some(w => w.name === walletInfo.defaultWallet)) {
347
+ checks.push(check('default-wallet', 'error', `Default wallet "${walletInfo.defaultWallet}" has no wallet file`, 'Run: nansen wallet default <name>'));
348
+ }
349
+ for (const w of walletInfo.wallets.filter(w => w.error)) {
350
+ const problem = w.error === 'unreadable' ? 'cannot be read — permission problem?' : 'is not valid JSON';
351
+ checks.push(check('wallet-file', 'error', `Wallet file ${path.join(walletInfo.dir, `${w.name}.json`)} ${problem}`));
352
+ }
353
+ if (posixModes) {
354
+ const walletsDirMode = fileMode(walletInfo.dir);
355
+ if (isInsecureMode(walletsDirMode)) {
356
+ checks.push(check('wallets-perms', 'warn', `${walletInfo.dir} has insecure permissions (${walletsDirMode.toString(8)})`, `Run: chmod 700 "${walletInfo.dir}"`));
357
+ }
358
+ }
359
+
360
+ // Password storage — only relevant once wallets exist. Metadata-only:
361
+ // the secret itself is never retrieved.
362
+ const pwSource = passwordSourceFn();
363
+ if (pwSource === 'file') {
364
+ checks.push(check('wallet-password', 'warn', 'Wallet password stored in the insecure .credentials file', 'Run: nansen wallet secure (migrates it to the OS keychain)'));
365
+ } else if (pwSource) {
366
+ checks.push(check('wallet-password', 'ok', `Wallet password available (source: ${pwSource})`));
367
+ } else if (walletInfo.passwordHashSet) {
368
+ checks.push(check('wallet-password', 'warn', 'Wallets are password-protected but no password is stored — x402 payments and trading will fail non-interactively', 'Set NANSEN_WALLET_PASSWORD, or store it: nansen wallet secure'));
369
+ }
370
+ if (pwSource !== 'file' && fs.existsSync(getCredentialsFilePath(env))) {
371
+ checks.push(check('stale-credentials', 'warn', `${getCredentialsFilePath(env)} still exists but is not the active password source`, `Delete it: rm "${getCredentialsFilePath(env)}"`));
372
+ }
373
+
374
+ // Privy wallets need API credentials from the environment
375
+ const hasPrivyWallet = walletInfo.wallets.some(w => w.provider === 'privy');
376
+ if (hasPrivyWallet && (!env.PRIVY_APP_ID || !env.PRIVY_APP_SECRET)) {
377
+ checks.push(check('privy-env', 'warn', 'Privy wallet present but PRIVY_APP_ID / PRIVY_APP_SECRET are not set', 'Export both env vars to use Privy wallets'));
378
+ }
379
+ }
380
+
381
+ // --- caches ---
382
+ const cacheDir = path.join(getConfigDir(env), 'cache');
383
+ let cacheCount = 0;
384
+ try { cacheCount = fs.readdirSync(cacheDir).filter(f => f.endsWith('.json')).length; } catch { /* missing dir */ }
385
+ checks.push(check('response-cache', 'info', `Response cache: ${cacheCount} entr${cacheCount === 1 ? 'y' : 'ies'} (${cacheDir})`, cacheCount > 0 ? 'Clear with: nansen cache clear' : null));
386
+
387
+ const costMap = readJson(path.join(getConfigDir(env), 'cost-map.json'));
388
+ if (costMap?.fetchedAt) {
389
+ // Clamp: a future fetchedAt (clock skew, corrupt cache) is not "fetched -5h ago"
390
+ const ageMs = Math.max(0, Date.now() - costMap.fetchedAt);
391
+ const fresh = ageMs < COST_MAP_STALE_MS;
392
+ checks.push(check('cost-map', 'info', `Credit cost map: ${fresh ? 'fresh' : 'stale'} (fetched ${Math.round(ageMs / 3600000)}h ago)`));
393
+ } else {
394
+ checks.push(check('cost-map', 'info', 'Credit cost map not cached yet (refreshes on next nansen help)'));
395
+ }
396
+
397
+ if (cliVersion) {
398
+ const updateCache = readJson(path.join(getConfigDir(env), 'update-check.json'));
399
+ if (updateCache?.latest) {
400
+ checks.push(isNewer(updateCache.latest, cliVersion)
401
+ ? check('cli-version', 'warn', `Update available: ${cliVersion} → ${updateCache.latest}`, 'Run: npm i -g nansen-cli')
402
+ : check('cli-version', 'ok', `nansen-cli ${cliVersion} is up to date`));
403
+ } else {
404
+ checks.push(check('cli-version', 'info', `nansen-cli ${cliVersion} (no update-check cache yet)`));
405
+ }
406
+ }
407
+
408
+ const loAuth = readJson(path.join(getConfigDir(env), 'limit-order-auth.json'));
409
+ if (loAuth?.expiresAt) {
410
+ const valid = loAuth.expiresAt > Date.now() + 300_000;
411
+ checks.push(check('limit-order-jwt', 'info', `Limit-order session: ${valid ? 'valid' : 'expired'} (re-authenticates automatically)`));
412
+ }
413
+
414
+ // --- telemetry ---
415
+ checks.push(check('telemetry', 'info', isTelemetryDisabled(env) ? 'Telemetry disabled' : 'Anonymous telemetry enabled (disable: DO_NOT_TRACK=1)'));
416
+
417
+ return checks;
418
+ }
419
+
420
+ /**
421
+ * Safe connectivity check: an unauthenticated GET against the configured API
422
+ * base URL. No API key is sent and no credits are consumed. Any HTTP response
423
+ * proves reachability; only a network failure or timeout is a problem — so
424
+ * `doctor` stays useful for diagnosing exactly the "API is unavailable" case.
425
+ */
426
+ export async function runConnectivityChecks(deps = {}) {
427
+ const {
428
+ env = process.env,
429
+ fetchFn = fetch,
430
+ timeoutMs = 5000,
431
+ devConfigPath = DEV_CONFIG_PATH,
432
+ } = deps;
433
+
434
+ const { baseUrl } = resolveAuthConfig(env, devConfigPath);
435
+ const started = Date.now();
436
+ try {
437
+ const controller = new AbortController();
438
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
439
+ let response;
440
+ try {
441
+ response = await fetchFn(`${baseUrl}/openapi.json`, { method: 'GET', signal: controller.signal });
442
+ } finally {
443
+ clearTimeout(timer);
444
+ }
445
+ const ms = Date.now() - started;
446
+ return [check('api-reachable', 'ok', `API reachable: ${baseUrl} (HTTP ${response.status}, ${ms}ms)`)];
447
+ } catch (error) {
448
+ const reason = error.name === 'AbortError'
449
+ ? `timed out after ${timeoutMs}ms`
450
+ : (error.cause?.code || error.message);
451
+ return [check('api-reachable', 'error', `API unreachable: ${baseUrl} (${reason})`, 'Check your network/proxy. The offline checks above remain valid.')];
452
+ }
453
+ }
454
+
455
+ const STATUS_ICONS = { ok: '✓', warn: '⚠️ ', error: '❌', info: 'ℹ' };
456
+
457
+ /**
458
+ * Render doctor checks as human-readable lines with a summary tail.
459
+ */
460
+ export function formatDoctorReport(checks, { cliVersion = null, offline = false } = {}) {
461
+ const lines = [];
462
+ const mode = offline
463
+ ? 'offline diagnostics (no network calls)'
464
+ : 'diagnostics (local checks + a credit-free connectivity probe; --offline to skip network)';
465
+ lines.push(`Nansen CLI doctor${cliVersion ? ` v${cliVersion}` : ''} — ${mode}`);
466
+ lines.push('');
467
+ for (const c of checks) {
468
+ lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`);
469
+ if (c.fix) lines.push(` ${c.fix}`);
470
+ }
471
+ const warnings = checks.filter(c => c.status === 'warn').length;
472
+ const errors = checks.filter(c => c.status === 'error').length;
473
+ lines.push('');
474
+ if (warnings === 0 && errors === 0) {
475
+ lines.push('No problems found.');
476
+ } else {
477
+ lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} found.`);
478
+ }
479
+ return lines.join('\n');
480
+ }
package/src/keychain.js CHANGED
@@ -169,6 +169,52 @@ function credentialsFileDelete() {
169
169
  }
170
170
  }
171
171
 
172
+ // ============= Metadata-Only Checks =============
173
+
174
+ function keychainHasEntry() {
175
+ try {
176
+ if (process.platform === 'darwin') {
177
+ // Without -w this prints attributes only — the secret never leaves the keychain
178
+ execFileSync('/usr/bin/security', [
179
+ 'find-generic-password',
180
+ '-s', SERVICE,
181
+ '-a', ACCOUNT,
182
+ ], { timeout: TIMEOUT_MS, stdio: 'pipe' });
183
+ return true;
184
+ }
185
+
186
+ if (process.platform === 'linux') {
187
+ // `search` prints attributes, unlike `lookup` which prints the secret
188
+ const result = execFileSync('secret-tool', [
189
+ 'search',
190
+ 'service', SERVICE,
191
+ 'account', ACCOUNT,
192
+ ], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] });
193
+ return result.toString().trim().length > 0;
194
+ }
195
+
196
+ return false;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Where a wallet password is stored, without ever materializing the secret:
204
+ * env presence, keychain attribute search, and a regex test on the
205
+ * credentials file (the base64 value is never decoded).
206
+ * @returns {'env'|'keychain'|'file'|null}
207
+ */
208
+ export function passwordSource() {
209
+ if (process.env.NANSEN_WALLET_PASSWORD) return 'env';
210
+ if (keychainHasEntry()) return 'keychain';
211
+ try {
212
+ const content = fs.readFileSync(getCredentialsPath(), 'utf8');
213
+ if (/^NANSEN_WALLET_PASSWORD(_B64)?=.+$/m.test(content)) return 'file';
214
+ } catch { /* missing or unreadable */ }
215
+ return null;
216
+ }
217
+
172
218
  // ============= Public API =============
173
219
 
174
220
  /**