diff --git a/.prettierignore b/.prettierignore index 29c69b2..685a995 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist/ node_modules/ pnpm-lock.yaml +CHANGELOG.md diff --git a/src/bin.ts b/src/bin.ts index da3c5b1..573f2df 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -15,6 +15,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import chalk from 'chalk'; import { ensureAuthenticated } from './lib/ensure-auth.js'; +import { checkForUpdates } from './lib/version-check.js'; const NODE_VERSION_RANGE = getConfig().nodeVersion; @@ -122,6 +123,9 @@ const installerOptions = { }, }; +// Check for updates (blocks up to 500ms) +await checkForUpdates(); + yargs(hideBin(process.argv)) .env('WORKOS_INSTALLER') .command('login', 'Authenticate with WorkOS', {}, async () => { diff --git a/src/lib/version-check.spec.ts b/src/lib/version-check.spec.ts new file mode 100644 index 0000000..5c1d617 --- /dev/null +++ b/src/lib/version-check.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock logging +vi.mock('../utils/logging.js', () => ({ + yellow: vi.fn(), + dim: vi.fn(), +})); + +// Mock settings +vi.mock('./settings.js', () => ({ + getVersion: vi.fn(() => '0.3.0'), +})); + +const { checkForUpdates, _resetWarningState } = await import('./version-check.js'); +const { yellow, dim } = await import('../utils/logging.js'); + +describe('version-check', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetWarningState(); + }); + + it('shows warning when outdated', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '0.4.0' }), + }); + + await checkForUpdates(); + + expect(yellow).toHaveBeenCalledWith(expect.stringContaining('0.3.0 → 0.4.0')); + expect(dim).toHaveBeenCalledWith('Run: npx workos@latest'); + }); + + it('no warning when up to date', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '0.3.0' }), + }); + + await checkForUpdates(); + + expect(yellow).not.toHaveBeenCalled(); + }); + + it('no warning when ahead of npm', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '0.2.0' }), + }); + + await checkForUpdates(); + + expect(yellow).not.toHaveBeenCalled(); + }); + + it('silently handles fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await expect(checkForUpdates()).resolves.toBeUndefined(); + expect(yellow).not.toHaveBeenCalled(); + }); + + it('silently handles timeout', async () => { + mockFetch.mockRejectedValueOnce(new DOMException('Aborted', 'AbortError')); + + await expect(checkForUpdates()).resolves.toBeUndefined(); + expect(yellow).not.toHaveBeenCalled(); + }); + + it('silently handles non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + + await expect(checkForUpdates()).resolves.toBeUndefined(); + expect(yellow).not.toHaveBeenCalled(); + }); + + it('silently handles invalid JSON', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }); + + await expect(checkForUpdates()).resolves.toBeUndefined(); + expect(yellow).not.toHaveBeenCalled(); + }); + + it('silently handles invalid semver', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: 'not-valid-semver' }), + }); + + await expect(checkForUpdates()).resolves.toBeUndefined(); + expect(yellow).not.toHaveBeenCalled(); + }); + + it('only warns once', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ version: '0.4.0' }), + }); + + await checkForUpdates(); + await checkForUpdates(); + + expect(yellow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts new file mode 100644 index 0000000..357f0d6 --- /dev/null +++ b/src/lib/version-check.ts @@ -0,0 +1,53 @@ +import { lt, valid } from 'semver'; +import { yellow, dim } from '../utils/logging.js'; +import { getVersion } from './settings.js'; + +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/workos/latest'; +const TIMEOUT_MS = 500; + +let hasWarned = false; + +interface NpmPackageInfo { + version: string; +} + +/** + * Check npm registry for latest version and warn if outdated. + * Runs asynchronously, fails silently on any error. + * Safe to call without awaiting (fire-and-forget). + */ +export async function checkForUpdates(): Promise { + if (hasWarned) return; + + try { + const response = await fetch(NPM_REGISTRY_URL, { + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (!response.ok) return; + + const data = (await response.json()) as NpmPackageInfo; + const latestVersion = data.version; + const currentVersion = getVersion(); + + // Validate both versions are valid semver + if (!valid(latestVersion) || !valid(currentVersion)) return; + + // Only warn if current < latest + if (lt(currentVersion, latestVersion)) { + hasWarned = true; + yellow(`Update available: ${currentVersion} → ${latestVersion}`); + dim(`Run: npx workos@latest`); + } + } catch { + // Silently ignore all errors (timeout, network, parse, etc.) + } +} + +/** + * Reset warning state (for testing). + * @internal + */ +export function _resetWarningState(): void { + hasWarned = false; +}