Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ tests/eval-results/

# Local ideation artifacts
docs/

# Case harness markers
.case-*
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ workos [command]

Commands:
install Install WorkOS AuthKit into your project
claim Claim an unclaimed environment (link to your account)
auth Manage authentication (login, logout, status)
env Manage environment configurations
doctor Diagnose WorkOS integration issues
Expand Down Expand Up @@ -85,6 +86,24 @@ Workflows:

All management commands support `--json` for structured output (auto-enabled in non-TTY) and `--api-key` to override the active environment's key.

### Unclaimed Environments

When you run `workos install` without credentials, the CLI automatically provisions a temporary WorkOS environment — no account needed. This lets you try AuthKit immediately.

```bash
# Install with zero setup — environment provisioned automatically
workos install

# Check your environment
workos env list
# Shows: unclaimed (unclaimed) ← active

# Claim the environment to link it to your WorkOS account
workos claim
```

Management commands work on unclaimed environments with a warning reminding you to claim.

### Workflows

The compound workflow commands compose multiple API calls into common operations. These are the highest-value commands for both developers and AI agents.
Expand Down
176 changes: 156 additions & 20 deletions src/bin.ts

Large diffs are not rendered by default.

322 changes: 322 additions & 0 deletions src/commands/claim.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// Mock debug utilities
vi.mock('../utils/debug.js', () => ({
logInfo: vi.fn(),
logError: vi.fn(),
}));

// Mock opn (browser open)
const mockOpen = vi.fn().mockResolvedValue(undefined);
vi.mock('opn', () => ({ default: mockOpen }));

// Mock clack
const mockSpinner = {
start: vi.fn(),
stop: vi.fn(),
};
const mockClack = {
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
step: vi.fn(),
success: vi.fn(),
},
spinner: () => mockSpinner,
};
vi.mock('../utils/clack.js', () => ({ default: mockClack }));

// Mock output utilities
const mockOutputJson = vi.fn();
let jsonMode = false;
vi.mock('../utils/output.js', () => ({
isJsonMode: () => jsonMode,
outputJson: (...args: unknown[]) => mockOutputJson(...args),
}));

// Mock helper-functions
vi.mock('../lib/helper-functions.js', () => ({
sleep: vi.fn((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))),
}));

// Mock config-store
const mockGetConfig = vi.fn();
const mockSaveConfig = vi.fn();
const mockGetActiveEnvironment = vi.fn();
const mockIsUnclaimedEnvironment = vi.fn();
const mockMarkEnvironmentClaimed = vi.fn();
vi.mock('../lib/config-store.js', () => ({
getConfig: (...args: unknown[]) => mockGetConfig(...args),
saveConfig: (...args: unknown[]) => mockSaveConfig(...args),
getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args),
isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args),
markEnvironmentClaimed: (...args: unknown[]) => mockMarkEnvironmentClaimed(...args),
}));

// Mock unclaimed-env-api
const mockCreateClaimNonce = vi.fn();
class MockUnclaimedEnvApiError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
) {
super(message);
this.name = 'UnclaimedEnvApiError';
}
}
vi.mock('../lib/unclaimed-env-api.js', () => ({
createClaimNonce: (...args: unknown[]) => mockCreateClaimNonce(...args),
UnclaimedEnvApiError: MockUnclaimedEnvApiError,
}));

const { runClaim } = await import('./claim.js');

describe('claim command', () => {
beforeEach(() => {
vi.clearAllMocks();
jsonMode = false;
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.useRealTimers();
});

describe('runClaim', () => {
it('exits with info when no active environment', async () => {
mockGetActiveEnvironment.mockReturnValue(null);
mockIsUnclaimedEnvironment.mockReturnValue(false);

await runClaim();

expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found'));
});

it('exits with info when active environment is not unclaimed', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'production',
type: 'production',
apiKey: 'sk_live_xxx',
});
mockIsUnclaimedEnvironment.mockReturnValue(false);

await runClaim();

expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found'));
});

it('outputs JSON when no unclaimed environment in JSON mode', async () => {
jsonMode = true;
mockGetActiveEnvironment.mockReturnValue(null);
mockIsUnclaimedEnvironment.mockReturnValue(false);

await runClaim();

expect(mockOutputJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'no_unclaimed_environment' }));
});

it('exits with error when missing claim token', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
// no claimToken
});
mockIsUnclaimedEnvironment.mockReturnValue(true);

await runClaim();

expect(mockClack.log.error).toHaveBeenCalledWith(expect.stringContaining('Missing claim token'));
});

it('exits with error when missing clientId', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
claimToken: 'ct_token',
// no clientId
});
mockIsUnclaimedEnvironment.mockReturnValue(true);

await runClaim();

expect(mockClack.log.error).toHaveBeenCalledWith(expect.stringContaining('Missing claim token or client ID'));
});

it('handles already-claimed environment immediately', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
});
mockIsUnclaimedEnvironment.mockReturnValue(true);
mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true });

await runClaim();

expect(mockClack.log.success).toHaveBeenCalledWith('Environment already claimed!');
expect(mockMarkEnvironmentClaimed).toHaveBeenCalled();
});

it('generates nonce, opens browser, and polls for claim', async () => {
const unclaimedEnv = {
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
};
mockGetActiveEnvironment.mockReturnValue(unclaimedEnv);
mockIsUnclaimedEnvironment.mockReturnValue(true);

// First call: returns nonce
mockCreateClaimNonce.mockResolvedValueOnce({
nonce: 'nonce_abc123',
alreadyClaimed: false,
});
// Second call (poll): returns claimed
mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true });

const claimPromise = runClaim();

// Advance past poll interval
await vi.advanceTimersByTimeAsync(6_000);
await claimPromise;

expect(mockOpen).toHaveBeenCalledWith(
expect.stringContaining('https://dashboard.workos.com/claim?nonce=nonce_abc123'),
{ wait: false },
);
expect(mockSpinner.start).toHaveBeenCalledWith('Waiting for claim...');
expect(mockSpinner.stop).toHaveBeenCalledWith('Environment claimed!');
expect(mockMarkEnvironmentClaimed).toHaveBeenCalled();
});

it('outputs JSON with claim URL in JSON mode', async () => {
jsonMode = true;
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
});
mockIsUnclaimedEnvironment.mockReturnValue(true);
mockCreateClaimNonce.mockResolvedValueOnce({
nonce: 'nonce_abc123',
alreadyClaimed: false,
});

await runClaim();

expect(mockOutputJson).toHaveBeenCalledWith({
status: 'claim_url',
claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123',
nonce: 'nonce_abc123',
});
// Should NOT open browser or start polling in JSON mode
expect(mockOpen).not.toHaveBeenCalled();
expect(mockSpinner.start).not.toHaveBeenCalled();
});

it('outputs JSON for already-claimed in JSON mode', async () => {
jsonMode = true;
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
});
mockIsUnclaimedEnvironment.mockReturnValue(true);
mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true });

await runClaim();

expect(mockOutputJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'already_claimed' }));
});

it('times out after 5 minutes of polling', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
});
mockIsUnclaimedEnvironment.mockReturnValue(true);

// First call: returns nonce
mockCreateClaimNonce.mockResolvedValueOnce({
nonce: 'nonce_abc123',
alreadyClaimed: false,
});
// All poll calls: not yet claimed
mockCreateClaimNonce.mockResolvedValue({
nonce: 'nonce_abc123',
alreadyClaimed: false,
});

const claimPromise = runClaim();

// Advance past 5 minute timeout
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000);
await claimPromise;

expect(mockSpinner.stop).toHaveBeenCalledWith('Claim timed out');
expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Complete the claim in your browser'));
});

it('continues polling on transient poll errors', async () => {
const unclaimedEnv = {
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
};
mockGetActiveEnvironment.mockReturnValue(unclaimedEnv);
mockIsUnclaimedEnvironment.mockReturnValue(true);

// First call: returns nonce
mockCreateClaimNonce.mockResolvedValueOnce({
nonce: 'nonce_abc123',
alreadyClaimed: false,
});
// Second poll call: transient error
mockCreateClaimNonce.mockRejectedValueOnce(new Error('Network blip'));
// Third poll call: claimed
mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true });

const claimPromise = runClaim();

// Advance through two poll intervals
await vi.advanceTimersByTimeAsync(11_000);
await claimPromise;

expect(mockSpinner.stop).toHaveBeenCalledWith('Environment claimed!');
});

it('handles claim nonce generation failure', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_xxx',
clientId: 'client_01ABC',
claimToken: 'ct_token',
});
mockIsUnclaimedEnvironment.mockReturnValue(true);
mockCreateClaimNonce.mockRejectedValueOnce(new Error('Invalid claim token.'));

await runClaim();

expect(mockClack.log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid claim token'));
expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Try again'));
});
});
});
Loading
Loading