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/CHANGELOG.md +42 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +2 -0
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/src/api.js +11 -0
- package/src/cli.js +122 -58
- package/src/cost-cache.js +19 -1
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/perp.js +134 -5
- package/src/schema.json +93 -0
- package/src/telemetry.js +59 -2
- package/src/trade-validation.js +441 -0
- package/src/trading.js +387 -18
- package/src/update-check.js +45 -24
- package/src/walletconnect-trading.js +11 -7
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
|
/**
|