Skip to content

Commit e1e9900

Browse files
authored
Merge pull request #1 from arein/feat/update-available-notification
2 parents 69dd130 + d454e32 commit e1e9900

File tree

3 files changed

+415
-5
lines changed

3 files changed

+415
-5
lines changed

src/__tests__/update-check.test.js

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/**
2+
* Update Check Tests
3+
*
4+
* Tests for:
5+
* - Semver comparison (isNewer via getUpdateNotification)
6+
* - Cache reading (getUpdateNotification)
7+
* - Env var suppression (NO_UPDATE_NOTIFIER, CI)
8+
* - Background check scheduling (scheduleUpdateCheck)
9+
* - CLI integration (notification on stderr)
10+
*/
11+
12+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13+
import fs from 'fs';
14+
import path from 'path';
15+
import os from 'os';
16+
17+
// We need to test with a controlled cache file, so we'll write to
18+
// the real ~/.nansen/update-check.json and clean up after.
19+
const CONFIG_DIR = path.join(os.homedir(), '.nansen');
20+
const CACHE_FILE = path.join(CONFIG_DIR, 'update-check.json');
21+
22+
let savedCacheContent = null;
23+
24+
function backupCache() {
25+
try {
26+
if (fs.existsSync(CACHE_FILE)) {
27+
savedCacheContent = fs.readFileSync(CACHE_FILE, 'utf8');
28+
}
29+
} catch {}
30+
}
31+
32+
function restoreCache() {
33+
try {
34+
if (savedCacheContent !== null) {
35+
fs.writeFileSync(CACHE_FILE, savedCacheContent);
36+
} else if (fs.existsSync(CACHE_FILE)) {
37+
fs.unlinkSync(CACHE_FILE);
38+
}
39+
} catch {}
40+
savedCacheContent = null;
41+
}
42+
43+
function writeCache(data) {
44+
if (!fs.existsSync(CONFIG_DIR)) {
45+
fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
46+
}
47+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data));
48+
}
49+
50+
function removeCache() {
51+
try { fs.unlinkSync(CACHE_FILE); } catch {}
52+
}
53+
54+
// =================== getUpdateNotification ===================
55+
56+
describe('getUpdateNotification', () => {
57+
let getUpdateNotification;
58+
59+
beforeEach(async () => {
60+
backupCache();
61+
// Clear env vars
62+
delete process.env.NO_UPDATE_NOTIFIER;
63+
delete process.env.CI;
64+
// Fresh import each time to avoid module caching issues
65+
const mod = await import('../update-check.js');
66+
getUpdateNotification = mod.getUpdateNotification;
67+
});
68+
69+
afterEach(() => {
70+
restoreCache();
71+
});
72+
73+
it('should return notification when newer version available', () => {
74+
writeCache({ latest: '2.0.0', checkedAt: Date.now() });
75+
const result = getUpdateNotification('1.3.0');
76+
expect(result).toContain('Update available');
77+
expect(result).toContain('1.3.0');
78+
expect(result).toContain('2.0.0');
79+
expect(result).toContain('npm i -g nansen-cli');
80+
});
81+
82+
it('should return null when on latest version', () => {
83+
writeCache({ latest: '1.3.0', checkedAt: Date.now() });
84+
const result = getUpdateNotification('1.3.0');
85+
expect(result).toBeNull();
86+
});
87+
88+
it('should return null when on newer version than registry', () => {
89+
writeCache({ latest: '1.2.0', checkedAt: Date.now() });
90+
const result = getUpdateNotification('1.3.0');
91+
expect(result).toBeNull();
92+
});
93+
94+
it('should compare major versions correctly', () => {
95+
writeCache({ latest: '2.0.0', checkedAt: Date.now() });
96+
expect(getUpdateNotification('1.9.9')).toContain('2.0.0');
97+
});
98+
99+
it('should compare minor versions correctly', () => {
100+
writeCache({ latest: '1.4.0', checkedAt: Date.now() });
101+
expect(getUpdateNotification('1.3.9')).toContain('1.4.0');
102+
});
103+
104+
it('should compare patch versions correctly', () => {
105+
writeCache({ latest: '1.3.1', checkedAt: Date.now() });
106+
expect(getUpdateNotification('1.3.0')).toContain('1.3.1');
107+
});
108+
109+
it('should return null when no cache file exists', () => {
110+
removeCache();
111+
const result = getUpdateNotification('1.3.0');
112+
expect(result).toBeNull();
113+
});
114+
115+
it('should return null when cache has no latest field', () => {
116+
writeCache({ checkedAt: Date.now() });
117+
const result = getUpdateNotification('1.3.0');
118+
expect(result).toBeNull();
119+
});
120+
121+
it('should return null when cache file is invalid JSON', () => {
122+
if (!fs.existsSync(CONFIG_DIR)) {
123+
fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
124+
}
125+
fs.writeFileSync(CACHE_FILE, 'not json');
126+
const result = getUpdateNotification('1.3.0');
127+
expect(result).toBeNull();
128+
});
129+
130+
it('should return null when NO_UPDATE_NOTIFIER is set', () => {
131+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
132+
process.env.NO_UPDATE_NOTIFIER = '1';
133+
const result = getUpdateNotification('1.3.0');
134+
expect(result).toBeNull();
135+
});
136+
137+
it('should return null when CI is set', () => {
138+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
139+
process.env.CI = 'true';
140+
const result = getUpdateNotification('1.3.0');
141+
expect(result).toBeNull();
142+
});
143+
});
144+
145+
// =================== scheduleUpdateCheck ===================
146+
147+
describe('scheduleUpdateCheck', () => {
148+
let scheduleUpdateCheck;
149+
150+
beforeEach(async () => {
151+
backupCache();
152+
delete process.env.NO_UPDATE_NOTIFIER;
153+
delete process.env.CI;
154+
155+
const mod = await import('../update-check.js');
156+
scheduleUpdateCheck = mod.scheduleUpdateCheck;
157+
});
158+
159+
afterEach(() => {
160+
restoreCache();
161+
});
162+
163+
it('should skip when NO_UPDATE_NOTIFIER is set', () => {
164+
process.env.NO_UPDATE_NOTIFIER = '1';
165+
removeCache();
166+
scheduleUpdateCheck();
167+
// No cache file should be written synchronously (spawn is skipped)
168+
expect(fs.existsSync(CACHE_FILE)).toBe(false);
169+
});
170+
171+
it('should skip when CI is set', () => {
172+
process.env.CI = 'true';
173+
removeCache();
174+
scheduleUpdateCheck();
175+
expect(fs.existsSync(CACHE_FILE)).toBe(false);
176+
});
177+
178+
it('should not throw when cache is fresh', () => {
179+
writeCache({ latest: '1.3.0', checkedAt: Date.now() });
180+
expect(() => scheduleUpdateCheck()).not.toThrow();
181+
});
182+
183+
it('should not throw when cache is stale', () => {
184+
writeCache({ latest: '1.3.0', checkedAt: Date.now() - 25 * 60 * 60 * 1000 });
185+
expect(() => scheduleUpdateCheck()).not.toThrow();
186+
});
187+
188+
it('should not throw when no cache exists', () => {
189+
removeCache();
190+
expect(() => scheduleUpdateCheck()).not.toThrow();
191+
});
192+
193+
it('should not throw when cache is invalid JSON', () => {
194+
if (!fs.existsSync(CONFIG_DIR)) {
195+
fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
196+
}
197+
fs.writeFileSync(CACHE_FILE, 'invalid');
198+
expect(() => scheduleUpdateCheck()).not.toThrow();
199+
});
200+
});
201+
202+
// =================== CLI Integration ===================
203+
204+
describe('update notification in CLI', () => {
205+
let outputs;
206+
let errors;
207+
let exitCode;
208+
209+
beforeEach(() => {
210+
backupCache();
211+
delete process.env.NO_UPDATE_NOTIFIER;
212+
delete process.env.CI;
213+
outputs = [];
214+
errors = [];
215+
exitCode = null;
216+
});
217+
218+
afterEach(() => {
219+
restoreCache();
220+
});
221+
222+
const mockDeps = () => ({
223+
output: (msg) => outputs.push(msg),
224+
errorOutput: (msg) => errors.push(msg),
225+
exit: (code) => { exitCode = code; }
226+
});
227+
228+
it('should show update notification on stderr for help command', async () => {
229+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
230+
const { runCLI } = await import('../cli.js');
231+
await runCLI(['help'], mockDeps());
232+
233+
expect(errors.some(e => e.includes('Update available'))).toBe(true);
234+
expect(errors.some(e => e.includes('99.0.0'))).toBe(true);
235+
});
236+
237+
it('should NOT show update notification for --version', async () => {
238+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
239+
const { runCLI } = await import('../cli.js');
240+
await runCLI(['--version'], mockDeps());
241+
242+
expect(errors.length).toBe(0);
243+
expect(outputs.length).toBe(1); // just the version
244+
});
245+
246+
it('should NOT show notification when version is current', async () => {
247+
writeCache({ latest: '1.3.0', checkedAt: Date.now() });
248+
const { runCLI } = await import('../cli.js');
249+
await runCLI(['help'], mockDeps());
250+
251+
expect(errors.length).toBe(0);
252+
});
253+
254+
it('should NOT show notification when NO_UPDATE_NOTIFIER set', async () => {
255+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
256+
process.env.NO_UPDATE_NOTIFIER = '1';
257+
const { runCLI } = await import('../cli.js');
258+
await runCLI(['help'], mockDeps());
259+
260+
expect(errors.length).toBe(0);
261+
});
262+
263+
it('should show notification on stderr for API errors too', async () => {
264+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
265+
const { runCLI } = await import('../cli.js');
266+
const deps = {
267+
...mockDeps(),
268+
NansenAPIClass: function MockAPI() {
269+
this.smartMoneyNetflow = vi.fn().mockRejectedValue(new Error('fail'));
270+
}
271+
};
272+
await runCLI(['smart-money', 'netflow'], deps);
273+
274+
expect(errors.some(e => e.includes('Update available'))).toBe(true);
275+
});
276+
277+
it('should show notification on stderr for successful commands', async () => {
278+
writeCache({ latest: '99.0.0', checkedAt: Date.now() });
279+
const { runCLI } = await import('../cli.js');
280+
const deps = {
281+
...mockDeps(),
282+
NansenAPIClass: function MockAPI() {
283+
this.smartMoneyNetflow = vi.fn().mockResolvedValue({ data: [] });
284+
}
285+
};
286+
await runCLI(['smart-money', 'netflow'], deps);
287+
288+
expect(errors.some(e => e.includes('Update available'))).toBe(true);
289+
// stdout should still have the JSON data
290+
expect(outputs.length).toBe(1);
291+
expect(() => JSON.parse(outputs[0])).not.toThrow();
292+
});
293+
});

src/cli.js

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
*/
55

66
import { NansenAPI, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir } from './api.js';
7+
import { getUpdateNotification, scheduleUpdateCheck } from './update-check.js';
8+
import { createRequire } from 'module';
79
import * as readline from 'readline';
810

11+
const require = createRequire(import.meta.url);
12+
const { version: VERSION } = require('../package.json');
13+
914
// ============= Schema Definition =============
1015

1116
export const SCHEMA = {
12-
version: '1.1.0',
17+
version: VERSION,
1318
commands: {
1419
'smart-money': {
1520
description: 'Smart Money analytics - track sophisticated market participants',
@@ -278,7 +283,7 @@ export function parseArgs(args) {
278283
const key = arg.slice(2);
279284
const next = args[i + 1];
280285

281-
if (key === 'pretty' || key === 'help' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream') {
286+
if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream') {
282287
result.flags[key] = true;
283288
} else if (next && !next.startsWith('-')) {
284289
// Try to parse as JSON first
@@ -849,17 +854,28 @@ export async function runCLI(rawArgs, deps = {}) {
849854
} = deps;
850855

851856
const { _: positional, flags, options } = parseArgs(rawArgs);
852-
857+
853858
const command = positional[0] || 'help';
854859
const subArgs = positional.slice(1);
855860
const pretty = flags.pretty || flags.p;
856861
const table = flags.table || flags.t;
857862
const stream = flags.stream || flags.s;
858863

864+
// Update check (read cached result + schedule background refresh)
865+
const updateNotification = getUpdateNotification(VERSION);
866+
scheduleUpdateCheck();
867+
const notify = () => { if (updateNotification) errorOutput(updateNotification); };
868+
859869
const commands = { ...buildCommands(deps), ...commandOverrides };
860870

871+
if (flags.version || flags.v) {
872+
output(VERSION);
873+
return { type: 'version', data: VERSION };
874+
}
875+
861876
if (command === 'help' || flags.help || flags.h) {
862877
output(BANNER + HELP);
878+
notify();
863879
return { type: 'help' };
864880
}
865881

@@ -870,6 +886,7 @@ export async function runCLI(rawArgs, deps = {}) {
870886
};
871887
const formatted = formatOutput(errorData, { pretty, table });
872888
output(formatted.text);
889+
notify();
873890
exit(1);
874891
return { type: 'error', data: errorData };
875892
}
@@ -882,9 +899,11 @@ export async function runCLI(rawArgs, deps = {}) {
882899
if (command === 'schema' && result) {
883900
const formatted = formatOutput(result, { pretty, table: false });
884901
output(formatted.text);
902+
notify();
885903
return { type: 'schema', data: result };
886904
}
887-
905+
906+
notify();
888907
return { type: 'no-auth', command };
889908
}
890909

@@ -916,17 +935,20 @@ export async function runCLI(rawArgs, deps = {}) {
916935
if (streamOutput) {
917936
output(streamOutput);
918937
}
938+
notify();
919939
return { type: 'stream', data: result };
920940
}
921-
941+
922942
const successData = { success: true, data: result };
923943
const formatted = formatOutput(successData, { pretty, table });
924944
output(formatted.text);
945+
notify();
925946
return { type: 'success', data: result };
926947
} catch (error) {
927948
const errorData = formatError(error);
928949
const formatted = formatOutput(errorData, { pretty, table });
929950
errorOutput(formatted.text);
951+
notify();
930952
exit(1);
931953
return { type: 'error', data: errorData };
932954
}

0 commit comments

Comments
 (0)