Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
node_modules/
pnpm-lock.yaml
CHANGELOG.md
4 changes: 4 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 () => {
Expand Down
115 changes: 115 additions & 0 deletions src/lib/version-check.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions src/lib/version-check.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}