diff --git a/src/cli.ts b/src/cli.ts index cfee3986..6306c632 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -190,10 +190,14 @@ program }); } - const globals = program.opts(); - void checkForUpdates({ - json: Boolean(globals.json || globals.quiet), - }).catch(() => {}); + try { + const globals = program.opts(); + checkForUpdates({ + json: Boolean(globals.json || globals.quiet), + }); + } catch { + /* update check is non-critical */ + } }) .catch((err) => { outputError({ diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts index e548ad1a..499a4b1f 100644 --- a/src/lib/update-check.ts +++ b/src/lib/update-check.ts @@ -1,38 +1,85 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getConfigDir } from './config'; import { VERSION } from './version'; -const CHECK_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour +const CHECK_INTERVAL_MS = 1 * 60 * 60 * 1000; export const GITHUB_RELEASES_URL = 'https://api.github.com/repos/resend/resend-cli/releases/latest'; type UpdateState = { - lastChecked: number; - latestVersion: string; + readonly lastChecked: number; + readonly latestVersion: string; }; -function getStatePath(): string { - return join(getConfigDir(), 'update-state.json'); -} +const getStatePath = (): string => join(getConfigDir(), 'update-state.json'); -function readState(): UpdateState | null { +const readState = (): UpdateState | null => { try { return JSON.parse(readFileSync(getStatePath(), 'utf-8')) as UpdateState; } catch { return null; } -} +}; + +export const resolveNodePath = (): string => { + const nodePattern = /(?:^|[\\/])node(?:\.exe)?$/i; + if (nodePattern.test(process.execPath)) { + return process.execPath; + } + if (process.argv[0] && nodePattern.test(process.argv[0])) { + return process.argv[0]; + } + return process.execPath; +}; + +export const buildRefreshScript = ( + url: string, + configDir: string, + statePath: string, + fallbackVersion: string, +): string => { + const u = JSON.stringify(url); + const d = JSON.stringify(configDir); + const p = JSON.stringify(statePath); + const fv = JSON.stringify(fallbackVersion); + + return [ + 'const{mkdirSync:m,writeFileSync:w}=require("node:fs");', + `const s=v=>{m(${d},{recursive:true,mode:0o700});`, + `w(${p},JSON.stringify({lastChecked:Date.now(),latestVersion:v}),{mode:0o600})};`, + '(async()=>{try{', + `const r=await fetch(${u},{headers:{Accept:"application/vnd.github.v3+json"},signal:AbortSignal.timeout(5000)});`, + `if(!r.ok){s(${fv});return}const d=await r.json();`, + `if(d.prerelease||d.draft){s(${fv});return}`, + 'const v=d.tag_name?.replace(/^v/,"");', + `if(!v||!/^\\d+\\.\\d+\\.\\d+$/.test(v)){s(${fv});return}`, + `s(v)}catch{s(${fv})}})();`, + ].join(''); +}; -function writeState(state: UpdateState): void { - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - writeFileSync(getStatePath(), JSON.stringify(state), { mode: 0o600 }); -} +export const spawnBackgroundRefresh = (): void => { + try { + const configDir = getConfigDir(); + const statePath = getStatePath(); + const script = buildRefreshScript( + GITHUB_RELEASES_URL, + configDir, + statePath, + VERSION, + ); + const child = spawn(resolveNodePath(), ['-e', script], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + } catch { + /* spawn failure is non-fatal */ + } +}; -/** - * Compare two semver strings. Returns true if remote > local. - */ -export function isNewer(local: string, remote: string): boolean { +export const isNewer = (local: string, remote: string): boolean => { const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); const [lMaj, lMin, lPat] = parse(local); const [rMaj, rMin, rPat] = parse(remote); @@ -43,9 +90,9 @@ export function isNewer(local: string, remote: string): boolean { return rMin > lMin; } return rPat > lPat; -} +}; -export async function fetchLatestVersion(): Promise { +export const fetchLatestVersion = async (): Promise => { try { const res = await fetch(GITHUB_RELEASES_URL, { headers: { Accept: 'application/vnd.github.v3+json' }, @@ -59,7 +106,6 @@ export async function fetchLatestVersion(): Promise { prerelease?: boolean; draft?: boolean; }; - // /releases/latest already excludes prereleases, but guard anyway if (data.prerelease || data.draft) { return null; } @@ -71,13 +117,13 @@ export async function fetchLatestVersion(): Promise { } catch { return null; } -} +}; export type UpdateCheckOptions = { readonly json?: boolean; }; -function shouldSkipCheck(opts?: UpdateCheckOptions): boolean { +const shouldSkipCheck = (opts?: UpdateCheckOptions): boolean => { if (opts?.json) { return true; } @@ -94,29 +140,12 @@ function shouldSkipCheck(opts?: UpdateCheckOptions): boolean { return true; } return false; -} - -export function detectInstallMethodName(): string { - const full = detectInstallMethod(); - if (full.startsWith('npm')) { - return 'npm'; - } - if (full.startsWith('brew')) { - return 'homebrew'; - } - if (full.startsWith('curl') || full.startsWith('irm')) { - return 'install-script'; - } - return 'manual'; -} +}; -export function detectInstallMethod(): string { +export const detectInstallMethod = (): string => { const execPath = process.execPath || process.argv[0] || ''; const scriptPath = process.argv[1] || ''; - // npm / npx global install — check first because npm_execpath and - // the script path inside node_modules are the most reliable signals, - // even when Node itself was installed via Homebrew. if ( process.env.npm_execpath || /node_modules/.test(scriptPath) || @@ -125,12 +154,10 @@ export function detectInstallMethod(): string { return 'npm install -g resend-cli'; } - // Homebrew (direct tap install, not npm-via-brew) if (/\/(Cellar|homebrew)\//i.test(execPath)) { return 'brew update && brew upgrade resend'; } - // Install script (default install location ~/.resend/bin/) if (/[/\\]\.resend[/\\]bin[/\\]/.test(execPath)) { if (process.platform === 'win32') { return 'irm https://resend.com/install.ps1 | iex'; @@ -138,11 +165,24 @@ export function detectInstallMethod(): string { return 'curl -fsSL https://resend.com/install.sh | bash'; } - // Unknown — likely a manual download from GitHub Releases return 'https://github.com/resend/resend-cli/releases/latest'; -} +}; + +export const detectInstallMethodName = (): string => { + const full = detectInstallMethod(); + if (full.startsWith('npm')) { + return 'npm'; + } + if (full.startsWith('brew')) { + return 'homebrew'; + } + if (full.startsWith('curl') || full.startsWith('irm')) { + return 'install-script'; + } + return 'manual'; +}; -function formatNotice(latestVersion: string): string { +const formatNotice = (latestVersion: string): string => { const upgrade = detectInstallMethod(); const isUrl = upgrade.startsWith('http'); @@ -158,23 +198,17 @@ function formatNotice(latestVersion: string): string { ]; if (process.platform === 'win32') { - lines.push( + return [ + ...lines, `${dim}Or download from: ${cyan}https://github.com/resend/resend-cli/releases/latest${reset}`, - ); + '', + ].join('\n'); } - lines.push(''); - return lines.join('\n'); -} + return [...lines, ''].join('\n'); +}; -/** - * Check for updates and print a notice to stderr if one is available. - * Designed to be called after the main command completes — never blocks - * or throws. - */ -export async function checkForUpdates( - opts?: UpdateCheckOptions, -): Promise { +export const checkForUpdates = (opts?: UpdateCheckOptions): void => { if (shouldSkipCheck(opts)) { return; } @@ -182,7 +216,6 @@ export async function checkForUpdates( const state = readState(); const now = Date.now(); - // If we have a cached check that's still fresh, just use it if (state && now - state.lastChecked < CHECK_INTERVAL_MS) { if (isNewer(VERSION, state.latestVersion)) { process.stderr.write(formatNotice(state.latestVersion)); @@ -190,15 +223,9 @@ export async function checkForUpdates( return; } - // Stale or missing — fetch in the background - const latest = await fetchLatestVersion(); - if (!latest) { - return; - } - - writeState({ lastChecked: now, latestVersion: latest }); + spawnBackgroundRefresh(); - if (isNewer(VERSION, latest)) { - process.stderr.write(formatNotice(latest)); + if (state && isNewer(VERSION, state.latestVersion)) { + process.stderr.write(formatNotice(state.latestVersion)); } -} +}; diff --git a/tests/lib/update-check.test.ts b/tests/lib/update-check.test.ts index 1e463d10..c37e107f 100644 --- a/tests/lib/update-check.test.ts +++ b/tests/lib/update-check.test.ts @@ -1,10 +1,4 @@ -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -14,28 +8,37 @@ import { expect, it, type MockInstance, - test, vi, } from 'vitest'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn(() => ({ unref: vi.fn() })) }; +}); + +import { spawn } from 'node:child_process'; import { + buildRefreshScript, checkForUpdates, detectInstallMethod, + resolveNodePath, + spawnBackgroundRefresh, } from '../../src/lib/update-check'; import { VERSION } from '../../src/lib/version'; import { captureTestEnv } from '../helpers'; -// Use a version guaranteed to be "newer" than whatever VERSION is const NEWER_VERSION = '99.0.0'; const testConfigDir = join(tmpdir(), `resend-update-check-test-${process.pid}`); const testResendDir = join(testConfigDir, 'resend'); const statePath = join(testResendDir, 'update-state.json'); +const mockedSpawn = vi.mocked(spawn); + describe('checkForUpdates', () => { const restoreEnv = captureTestEnv(); let stderrOutput: string; let stderrSpy: MockInstance; - let fetchSpy: MockInstance; beforeEach(() => { mkdirSync(testResendDir, { recursive: true }); @@ -47,10 +50,10 @@ describe('checkForUpdates', () => { return true; }); - // Point getConfigDir() at our temp dir via XDG_CONFIG_HOME + mockedSpawn.mockReturnValue({ unref: vi.fn() } as never); + process.env.XDG_CONFIG_HOME = testConfigDir; - // Ensure TTY and no CI Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true, @@ -62,151 +65,259 @@ describe('checkForUpdates', () => { afterEach(() => { stderrSpy.mockRestore(); - if (fetchSpy) { - fetchSpy.mockRestore(); - } + mockedSpawn.mockClear(); restoreEnv(); if (existsSync(testConfigDir)) { rmSync(testConfigDir, { recursive: true, force: true }); } }); - function mockFetch(tagName: string, extra: Record = {}) { - fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ tag_name: tagName, ...extra }), - } as Response); - } - - function mockFetchFailure() { - fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockRejectedValue(new Error('network error')); - } - - test('skips check when RESEND_NO_UPDATE_NOTIFIER=1', async () => { + it('skips check when RESEND_NO_UPDATE_NOTIFIER=1', () => { process.env.RESEND_NO_UPDATE_NOTIFIER = '1'; - await checkForUpdates(); + checkForUpdates(); expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); }); - test('skips check when CI=true', async () => { + it('skips check when CI=true', () => { process.env.CI = 'true'; - await checkForUpdates(); + checkForUpdates(); expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); }); - test('skips check when not a TTY', async () => { + it('skips check when not a TTY', () => { Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true, }); - await checkForUpdates(); + checkForUpdates(); expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); }); - test('prints notice when newer version available from fresh fetch', async () => { - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates(); + it('skips check when json option is true', () => { + checkForUpdates({ json: true }); + expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + it('skips check when json option is true with cached state', () => { + writeFileSync( + statePath, + JSON.stringify({ + lastChecked: Date.now(), + latestVersion: NEWER_VERSION, + }), + ); + checkForUpdates({ json: true }); + expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + it('prints notice from fresh cache when newer version available', () => { + writeFileSync( + statePath, + JSON.stringify({ + lastChecked: Date.now(), + latestVersion: NEWER_VERSION, + }), + ); + + checkForUpdates(); expect(stderrOutput).toContain('Update available'); expect(stderrOutput).toContain(`v${VERSION}`); expect(stderrOutput).toContain(`v${NEWER_VERSION}`); + expect(mockedSpawn).not.toHaveBeenCalled(); }); - test('prints nothing when already on latest', async () => { - mockFetch(`v${VERSION}`); - await checkForUpdates(); + it('prints notice when json option is explicitly false', () => { + writeFileSync( + statePath, + JSON.stringify({ + lastChecked: Date.now(), + latestVersion: NEWER_VERSION, + }), + ); + + checkForUpdates({ json: false }); + + expect(stderrOutput).toContain('Update available'); + }); + + it('prints nothing from fresh cache when already on latest', () => { + writeFileSync( + statePath, + JSON.stringify({ lastChecked: Date.now(), latestVersion: VERSION }), + ); + + checkForUpdates(); expect(stderrOutput).toBe(''); + expect(mockedSpawn).not.toHaveBeenCalled(); }); - test('uses cached state when fresh (no fetch)', async () => { + it('spawns background refresh when cache is stale', () => { + const staleTime = Date.now() - 25 * 60 * 60 * 1000; writeFileSync( statePath, - JSON.stringify({ lastChecked: Date.now(), latestVersion: NEWER_VERSION }), + JSON.stringify({ lastChecked: staleTime, latestVersion: VERSION }), ); - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates(); + checkForUpdates(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(stderrOutput).toContain(`v${NEWER_VERSION}`); + expect(mockedSpawn).toHaveBeenCalledWith( + expect.any(String), + ['-e', expect.any(String)], + { detached: true, stdio: 'ignore' }, + ); }); - test('refetches when cache is stale', async () => { - const staleTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago + it('spawns background refresh when cache is missing', () => { + checkForUpdates(); + expect(mockedSpawn).toHaveBeenCalled(); + }); + + it('shows stale notice and spawns refresh when cache is stale with newer version', () => { + const staleTime = Date.now() - 25 * 60 * 60 * 1000; writeFileSync( statePath, - JSON.stringify({ lastChecked: staleTime, latestVersion: VERSION }), + JSON.stringify({ + lastChecked: staleTime, + latestVersion: NEWER_VERSION, + }), ); - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates(); + checkForUpdates(); - expect(fetchSpy).toHaveBeenCalled(); - expect(stderrOutput).toContain(`v${NEWER_VERSION}`); - // Verify cache was updated - const state = JSON.parse(readFileSync(statePath, 'utf-8')); - expect(state.latestVersion).toBe(NEWER_VERSION); + expect(stderrOutput).toContain('Update available'); + expect(mockedSpawn).toHaveBeenCalled(); }); - test('handles fetch failure gracefully', async () => { - mockFetchFailure(); - await checkForUpdates(); + it('does not show notice when cache is missing (no stale version)', () => { + checkForUpdates(); expect(stderrOutput).toBe(''); + expect(mockedSpawn).toHaveBeenCalled(); + }); +}); + +describe('buildRefreshScript', () => { + it('produces a script containing fetch and fs operations', () => { + const script = buildRefreshScript( + 'https://api.github.com/repos/resend/resend-cli/releases/latest', + '/tmp/resend-test', + '/tmp/resend-test/update-state.json', + '1.0.0', + ); + + expect(script).toContain('fetch'); + expect(script).toContain('mkdirSync'); + expect(script).toContain('writeFileSync'); + expect(script).toContain('"1.0.0"'); }); - test('writes state file after successful fetch', async () => { - mockFetch('v1.0.0'); - await checkForUpdates(); + it('embeds the config directory and state path', () => { + const dir = '/home/user/.config/resend'; + const path = `${dir}/update-state.json`; + const script = buildRefreshScript( + 'https://example.com/releases', + dir, + path, + '2.0.0', + ); - expect(existsSync(statePath)).toBe(true); - const state = JSON.parse(readFileSync(statePath, 'utf-8')); - expect(state.latestVersion).toBe('1.0.0'); + expect(script).toContain(JSON.stringify(dir)); + expect(script).toContain(JSON.stringify(path)); }); - test('ignores prerelease versions', async () => { - fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ tag_name: 'v99.0.0', prerelease: true }), - } as Response); + it('writes fallback version on non-ok response', () => { + const script = buildRefreshScript( + 'https://example.com/releases', + '/tmp/dir', + '/tmp/dir/state.json', + '3.0.0', + ); - await checkForUpdates(); - expect(stderrOutput).toBe(''); + expect(script).toContain('!r.ok'); + expect(script).toContain('"3.0.0"'); }); +}); - test('ignores non-semver tag names', async () => { - mockFetch('canary-20260311'); - await checkForUpdates(); - expect(stderrOutput).toBe(''); +describe('resolveNodePath', () => { + let origExecPath: string; + let origArgv0: string; + + beforeEach(() => { + origExecPath = process.execPath; + origArgv0 = process.argv[0]; }); - it('skips check when json option is true (TTY + --json)', async () => { - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates({ json: true }); - expect(stderrOutput).toBe(''); - expect(fetchSpy).not.toHaveBeenCalled(); + afterEach(() => { + Object.defineProperty(process, 'execPath', { value: origExecPath }); + process.argv[0] = origArgv0; }); - it('skips check when json option is true from cached state (TTY + --quiet)', async () => { - writeFileSync( - statePath, - JSON.stringify({ lastChecked: Date.now(), latestVersion: NEWER_VERSION }), - ); - await checkForUpdates({ json: true }); - expect(stderrOutput).toBe(''); + it('returns execPath when it ends with node', () => { + Object.defineProperty(process, 'execPath', { value: '/usr/bin/node' }); + expect(resolveNodePath()).toBe('/usr/bin/node'); }); - it('still prints notice on TTY without json option', async () => { - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates(); - expect(stderrOutput).toContain('Update available'); + it('returns execPath when it ends with node.exe', () => { + Object.defineProperty(process, 'execPath', { + value: 'C:\\Program Files\\nodejs\\node.exe', + }); + expect(resolveNodePath()).toBe('C:\\Program Files\\nodejs\\node.exe'); }); - it('still prints notice when json option is explicitly false', async () => { - mockFetch(`v${NEWER_VERSION}`); - await checkForUpdates({ json: false }); - expect(stderrOutput).toContain('Update available'); + it('falls back to argv[0] when execPath is not node', () => { + Object.defineProperty(process, 'execPath', { + value: '/usr/local/bin/resend', + }); + process.argv[0] = '/usr/bin/node'; + expect(resolveNodePath()).toBe('/usr/bin/node'); + }); + + it('falls back to execPath for standalone binary installs', () => { + Object.defineProperty(process, 'execPath', { + value: '/usr/local/bin/resend', + }); + process.argv[0] = '/usr/local/bin/resend'; + expect(resolveNodePath()).toBe('/usr/local/bin/resend'); + }); +}); + +describe('spawnBackgroundRefresh', () => { + beforeEach(() => { + mockedSpawn.mockClear(); + }); + + it('does not throw when spawn throws', () => { + mockedSpawn.mockImplementation(() => { + throw new Error('spawn failed'); + }); + + expect(() => spawnBackgroundRefresh()).not.toThrow(); + }); + + it('calls spawn with detached and ignored stdio', () => { + mockedSpawn.mockReturnValue({ unref: vi.fn() } as never); + + spawnBackgroundRefresh(); + + expect(mockedSpawn).toHaveBeenCalledWith( + expect.any(String), + ['-e', expect.any(String)], + { detached: true, stdio: 'ignore' }, + ); + }); + + it('unrefs the child process', () => { + const unrefFn = vi.fn(); + mockedSpawn.mockReturnValue({ unref: unrefFn } as never); + + spawnBackgroundRefresh(); + + expect(unrefFn).toHaveBeenCalled(); }); }); @@ -227,7 +338,7 @@ describe('detectInstallMethod', () => { restoreEnv(); }); - test('detects npm when script path contains node_modules', () => { + it('detects npm when script path contains node_modules', () => { Object.defineProperty(process, 'execPath', { value: '/opt/homebrew/bin/node', }); @@ -236,7 +347,7 @@ describe('detectInstallMethod', () => { expect(detectInstallMethod()).toBe('npm install -g resend-cli'); }); - test('detects npm when npm_execpath is set even with homebrew node', () => { + it('detects npm when npm_execpath is set even with homebrew node', () => { Object.defineProperty(process, 'execPath', { value: '/opt/homebrew/bin/node', }); @@ -247,7 +358,7 @@ describe('detectInstallMethod', () => { expect(detectInstallMethod()).toBe('npm install -g resend-cli'); }); - test('detects homebrew when no npm signals present', () => { + it('detects homebrew when no npm signals present', () => { Object.defineProperty(process, 'execPath', { value: '/opt/homebrew/Cellar/resend/1.4.0/bin/resend', }); @@ -256,7 +367,7 @@ describe('detectInstallMethod', () => { expect(detectInstallMethod()).toBe('brew update && brew upgrade resend'); }); - test('detects install script', () => { + it('detects install script', () => { Object.defineProperty(process, 'execPath', { value: '/Users/test/.resend/bin/resend', });