From 7a9449746355320de251f02600704b64dee7fef4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 31 Jan 2026 07:59:20 -0600 Subject: [PATCH 1/2] feat: add startup version check to warn users of outdated CLI Checks npm registry for latest version on startup and displays a warning if the current version is outdated, suggesting `npx workos@latest`. - Blocks up to 500ms for the check before any commands run - Fails silently on network errors or timeouts - Uses semver for version comparison --- src/bin.ts | 4 ++ src/lib/version-check.spec.ts | 115 ++++++++++++++++++++++++++++++++++ src/lib/version-check.ts | 53 ++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/lib/version-check.spec.ts create mode 100644 src/lib/version-check.ts 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; +} From c21dbb83d5c3dbc945dcd572267771069c0222af Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 31 Jan 2026 08:01:27 -0600 Subject: [PATCH 2/2] chore: ignore CHANGELOG.md in prettier --- .prettierignore | 1 + 1 file changed, 1 insertion(+) 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