diff --git a/skills/workos-authkit-base/SKILL.md b/skills/workos-authkit-base/SKILL.md index 9b173ca..9f0a854 100644 --- a/skills/workos-authkit-base/SKILL.md +++ b/skills/workos-authkit-base/SKILL.md @@ -17,14 +17,14 @@ README is the source of truth for: install commands, imports, API usage, code pa ## Task Structure (Required) -| Phase | Task | Blocked By | Purpose | -|-------|------|------------|---------| -| 1 | preflight | - | Verify env vars, detect framework | -| 2 | install | preflight | Install SDK package | -| 3 | callback | install | Create OAuth callback route | -| 4 | provider | install | Setup auth context/middleware | -| 5 | ui | callback, provider | Add sign-in/out UI | -| 6 | verify | ui | Build confirmation | +| Phase | Task | Blocked By | Purpose | +| ----- | --------- | ------------------ | --------------------------------- | +| 1 | preflight | - | Verify env vars, detect framework | +| 2 | install | preflight | Install SDK package | +| 3 | callback | install | Create OAuth callback route | +| 4 | provider | install | Setup auth context/middleware | +| 5 | ui | callback, provider | Add sign-in/out UI | +| 6 | verify | ui | Build confirmation | ## Decision Trees @@ -51,57 +51,67 @@ Extract path from `WORKOS_REDIRECT_URI` → create route at that exact path. ## Environment Variables -| Variable | Purpose | When Required | -|----------|---------|---------------| -| `WORKOS_API_KEY` | Server authentication | Server SDKs | -| `WORKOS_CLIENT_ID` | Client identification | All SDKs | -| `WORKOS_REDIRECT_URI` | OAuth callback URL | Server SDKs | -| `WORKOS_COOKIE_PASSWORD` | Session encryption (32+ chars) | Server SDKs | +| Variable | Purpose | When Required | +| ------------------------ | ------------------------------ | ------------- | +| `WORKOS_API_KEY` | Server authentication | Server SDKs | +| `WORKOS_CLIENT_ID` | Client identification | All SDKs | +| `WORKOS_REDIRECT_URI` | OAuth callback URL | Server SDKs | +| `WORKOS_COOKIE_PASSWORD` | Session encryption (32+ chars) | Server SDKs | Note: Some frameworks use prefixed variants (e.g., `NEXT_PUBLIC_*`). Check README. ## Verification Checklists ### After Install + - [ ] SDK package installed in node_modules - [ ] No install errors in output ### After Callback Route + - [ ] Route file exists at path matching `WORKOS_REDIRECT_URI` - [ ] Imports SDK callback handler (not custom OAuth) ### After Provider/Middleware + - [ ] Provider wraps entire app (client-side) - [ ] Middleware configured in correct location (server-side) ### After UI + - [ ] Home page shows conditional auth state - [ ] Uses SDK functions for sign-in/out URLs ### Final Verification + - [ ] Build completes with exit code 0 - [ ] No import resolution errors ## Error Recovery ### Module not found + - [ ] Verify install completed successfully - [ ] Verify SDK exists in node_modules - [ ] Re-run install if missing ### Build import errors + - [ ] Delete `node_modules`, reinstall - [ ] Verify package.json has SDK dependency ### Invalid redirect URI + - [ ] Compare route path to `WORKOS_REDIRECT_URI` - [ ] Paths must match exactly ### Cookie password error + - [ ] Verify `WORKOS_COOKIE_PASSWORD` is 32+ characters - [ ] Generate new: `openssl rand -base64 32` ### Auth state not persisting + - [ ] Verify provider wraps entire app - [ ] Check middleware is in correct location diff --git a/skills/workos-authkit-nextjs/SKILL.md b/skills/workos-authkit-nextjs/SKILL.md index 0a4bff7..fb9ff39 100644 --- a/skills/workos-authkit-nextjs/SKILL.md +++ b/skills/workos-authkit-nextjs/SKILL.md @@ -16,11 +16,14 @@ The README is the source of truth. If this skill conflicts with README, follow R ## Step 2: Pre-Flight Validation ### Project Structure + - Confirm `next.config.js` or `next.config.mjs` exists - Confirm `package.json` contains `"next"` dependency ### Environment Variables + Check `.env.local` for: + - `WORKOS_API_KEY` - starts with `sk_` - `WORKOS_CLIENT_ID` - starts with `client_` - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` - valid callback URL @@ -106,9 +109,11 @@ All checks must pass before marking complete. ## Error Recovery ### "cookies was called outside a request scope" (Next.js 15+) + **Most common cause:** Route handler not properly async or missing await. Fix for callback route: + 1. Check that `handleAuth()` is exported directly: `export const GET = handleAuth();` 2. If using custom wrapper, ensure it's `async` and awaits any cookie operations 3. Verify authkit-nextjs SDK version supports Next.js 15+ (check README for compatibility) @@ -117,25 +122,31 @@ Fix for callback route: This error causes OAuth codes to expire ("invalid_grant"), so fix the handler first. ### "middleware.ts not found" + - Check: File at project root or `src/`, not inside `app/` - Check: Filename matches Next.js version (proxy.ts for 16+, middleware.ts for 13-15) ### "Cannot use getUser in client component" + - Check: Component has no `'use client'` directive, or - Check: Move auth logic to server component/API route ### "Module not found" for SDK import + - Check: SDK installed before writing imports - Check: SDK package directory exists in node_modules ### "withAuth route not covered by middleware" + - Check: Middleware/proxy file exists at correct location - Check: Matcher config includes the route path ### Build fails after AuthKitProvider + - Check: README for correct import path (may be subpath export) - Check: No client/server boundary violations -### NEXT_PUBLIC_ prefix issues +### NEXT*PUBLIC* prefix issues + - Client components need `NEXT_PUBLIC_*` prefix - Server components use plain env var names diff --git a/skills/workos-authkit-react-router/SKILL.md b/skills/workos-authkit-react-router/SKILL.md index 9c6b378..710f719 100644 --- a/skills/workos-authkit-react-router/SKILL.md +++ b/skills/workos-authkit-react-router/SKILL.md @@ -24,14 +24,15 @@ The README is the source of truth. If this skill conflicts with README, **follow ## Phase 2: Detect Router Mode -| Mode | Detection Signal | Key Indicator | -| -------------- | ------------------------------- | --------------------------- | -| v7 Framework | `react-router.config.ts` exists | Routes in `app/routes/` | -| v7 Data | `createBrowserRouter` in source | Loaders in route config | -| v7 Declarative | `` component | Routes as JSX, no loaders | -| v6 | package.json version `"6.x"` | Similar to v7 Declarative | +| Mode | Detection Signal | Key Indicator | +| -------------- | ------------------------------- | ------------------------- | +| v7 Framework | `react-router.config.ts` exists | Routes in `app/routes/` | +| v7 Data | `createBrowserRouter` in source | Loaders in route config | +| v7 Declarative | `` component | Routes as JSX, no loaders | +| v6 | package.json version `"6.x"` | Similar to v7 Declarative | **Detection order:** + 1. Check for `react-router.config.ts` (Framework mode) 2. Grep for `createBrowserRouter` (Data mode) 3. Check package.json version (v6 vs v7) @@ -45,10 +46,10 @@ Based on detected mode, apply the corresponding README section. The README conta ### authLoader vs authkitLoader -| Function | Purpose | Where to use | -| -------------- | -------------------------------- | ----------------------- | -| `authLoader` | OAuth callback handler | Callback route ONLY | -| `authkitLoader`| Fetch user data in routes | Any route needing auth | +| Function | Purpose | Where to use | +| --------------- | ------------------------- | ---------------------- | +| `authLoader` | OAuth callback handler | Callback route ONLY | +| `authkitLoader` | Fetch user data in routes | Any route needing auth | **Common mistake:** Using `authkitLoader` for callback route. Use `authLoader()`. diff --git a/skills/workos-authkit-react/SKILL.md b/skills/workos-authkit-react/SKILL.md index a4416eb..e85d321 100644 --- a/skills/workos-authkit-react/SKILL.md +++ b/skills/workos-authkit-react/SKILL.md @@ -27,10 +27,10 @@ START ## Critical: Build Tool Detection -| Marker File | Build Tool | Env Prefix | Access Pattern | -|-------------|------------|------------|----------------| -| `vite.config.ts` | Vite | `VITE_` | `import.meta.env.VITE_*` | -| `craco.config.js` or none | CRA | `REACT_APP_` | `process.env.REACT_APP_*` | +| Marker File | Build Tool | Env Prefix | Access Pattern | +| ------------------------- | ---------- | ------------ | ------------------------- | +| `vite.config.ts` | Vite | `VITE_` | `import.meta.env.VITE_*` | +| `craco.config.js` or none | CRA | `REACT_APP_` | `process.env.REACT_APP_*` | **Wrong prefix = undefined values at runtime.** This is the #1 integration failure. diff --git a/skills/workos-authkit-tanstack-start/SKILL.md b/skills/workos-authkit-tanstack-start/SKILL.md index 9ee1f96..a98d7c6 100644 --- a/skills/workos-authkit-tanstack-start/SKILL.md +++ b/skills/workos-authkit-tanstack-start/SKILL.md @@ -27,6 +27,7 @@ description: Integrate WorkOS AuthKit with TanStack Start applications. Full-sta WebFetch: `https://github.com/workos/authkit-tanstack-start/blob/main/README.md` From README, extract: + 1. Package name from install command (e.g., `pnpm add @workos/...`) 2. Use that exact name for all imports @@ -41,12 +42,12 @@ From README, extract: ## Environment Variables -| Variable | Format | Required | -|----------|--------|----------| -| `WORKOS_API_KEY` | `sk_...` | Yes | -| `WORKOS_CLIENT_ID` | `client_...` | Yes | -| `WORKOS_REDIRECT_URI` | Full URL | Yes | -| `WORKOS_COOKIE_PASSWORD` | 32+ chars | Yes | +| Variable | Format | Required | +| ------------------------ | ------------ | -------- | +| `WORKOS_API_KEY` | `sk_...` | Yes | +| `WORKOS_CLIENT_ID` | `client_...` | Yes | +| `WORKOS_REDIRECT_URI` | Full URL | Yes | +| `WORKOS_COOKIE_PASSWORD` | 32+ chars | Yes | Generate password if missing: `openssl rand -base64 32` @@ -71,6 +72,7 @@ Logout requires `signOut()` followed by redirect in a route loader. See README f ## Callback Route Path must match `WORKOS_REDIRECT_URI`. If URI is `/api/auth/callback`: + - File: `app/routes/api/auth/callback.tsx` - Use `handleAuth()` from SDK - do not write custom OAuth logic diff --git a/skills/workos-authkit-vanilla-js/SKILL.md b/skills/workos-authkit-vanilla-js/SKILL.md index 791e465..47848df 100644 --- a/skills/workos-authkit-vanilla-js/SKILL.md +++ b/skills/workos-authkit-vanilla-js/SKILL.md @@ -29,6 +29,7 @@ Has package.json with build tool (Vite, webpack, Parcel)? ### Step 4: Implement Per README Follow README examples for: + - Client initialization - Sign in/out handlers - User state management @@ -56,20 +57,21 @@ const authkit = await createClient(clientId); ## Environment Variables **Bundled projects only:** + - Vite: `VITE_WORKOS_CLIENT_ID` - Webpack: `REACT_APP_WORKOS_CLIENT_ID` or custom - No `WORKOS_API_KEY` needed (client-side SDK) ## Error Recovery -| Error | Cause | Fix | -|-------|-------|-----| -| `WorkOS is not defined` | CDN not loaded | Add script to `` before your code | -| `createClient is not a function` | Wrong import | npm: check import path; CDN: use `WorkOS.createClient` | -| `clientId is required` | Undefined env var | Check env prefix matches build tool | -| CORS errors | `file://` protocol | Use local dev server (`npx serve`) | -| Popup blocked | Not user gesture | Call `signIn()` only from click handler | -| Auth state lost | Token not persisted | Check localStorage in dev tools | +| Error | Cause | Fix | +| -------------------------------- | ------------------- | ------------------------------------------------------ | +| `WorkOS is not defined` | CDN not loaded | Add script to `` before your code | +| `createClient is not a function` | Wrong import | npm: check import path; CDN: use `WorkOS.createClient` | +| `clientId is required` | Undefined env var | Check env prefix matches build tool | +| CORS errors | `file://` protocol | Use local dev server (`npx serve`) | +| Popup blocked | Not user gesture | Call `signIn()` only from click handler | +| Auth state lost | Token not persisted | Check localStorage in dev tools | ## Task Flow diff --git a/src/commands/login.ts b/src/commands/login.ts index 67b21d1..dc3ee9a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -89,7 +89,7 @@ export async function runLogin(): Promise { }, body: new URLSearchParams({ client_id: clientId, - scope: 'openid email', + scope: 'openid email staging-environment:credentials:read', }), }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index b1740b6..84d75a6 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -97,6 +97,12 @@ export class CLIAdapter implements WizardAdapter { this.subscribe('git:dirty', this.handleGitDirty); this.subscribe('credentials:found', this.handleCredentialsFound); this.subscribe('credentials:request', this.handleCredentialsRequest); + this.subscribe('credentials:env:prompt', this.handleEnvScanPrompt); + this.subscribe('device:started', this.handleDeviceStarted); + this.subscribe('device:success', this.handleDeviceSuccess); + this.subscribe('staging:fetching', this.handleStagingFetching); + this.subscribe('staging:success', this.handleStagingSuccess); + this.subscribe('credentials:env:found', this.handleEnvCredentialsFound); this.subscribe('config:complete', this.handleConfigComplete); this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); @@ -189,6 +195,58 @@ export class CLIAdapter implements WizardAdapter { clack.log.success('Found existing WorkOS credentials in .env.local'); }; + private handleEnvScanPrompt = async ({ files }: WizardEvents['credentials:env:prompt']): Promise => { + this.isPromptActive = true; + const fileList = files.length === 1 ? files[0] : files.slice(0, 2).join(', '); + const confirmed = await clack.confirm({ + message: `Found ${fileList}. Check for existing WorkOS credentials?`, + initialValue: true, + }); + this.isPromptActive = false; + this.flushPendingLogs(); + + if (clack.isCancel(confirmed)) { + this.sendEvent({ type: 'ENV_SCAN_DECLINED' }); + } else if (confirmed) { + this.sendEvent({ type: 'ENV_SCAN_APPROVED' }); + } else { + this.sendEvent({ type: 'ENV_SCAN_DECLINED' }); + } + }; + + private handleDeviceStarted = ({ verificationUri, userCode }: WizardEvents['device:started']): void => { + clack.log.info(`\nOpen this URL in your browser:\n`); + console.log(` ${chalk.cyan(verificationUri)}`); + console.log(`\nEnter code: ${chalk.bold(userCode)}\n`); + + this.spinner = clack.spinner(); + this.spinner.start('Waiting for authentication...'); + }; + + private handleDeviceSuccess = (): void => { + // Spinner will be stopped by handleStagingFetching + }; + + private handleStagingFetching = (): void => { + if (this.spinner) { + this.spinner.stop('Authenticated'); + } + this.spinner = clack.spinner(); + this.spinner.start('Fetching your WorkOS credentials...'); + }; + + private handleStagingSuccess = (): void => { + if (this.spinner) { + this.spinner.stop('Credentials fetched'); + this.spinner = null; + } + clack.log.success('WorkOS credentials retrieved automatically'); + }; + + private handleEnvCredentialsFound = ({ sourcePath }: WizardEvents['credentials:env:found']): void => { + clack.log.success(`Found existing WorkOS credentials in ${sourcePath}`); + }; + private handleGitDirty = async ({ files }: WizardEvents['git:dirty']): Promise => { clack.log.warn('You have uncommitted or untracked files:'); files.slice(0, 5).forEach((f) => clack.log.info(chalk.dim(` ${f}`))); diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index 9a7063e..413339d 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -100,6 +100,8 @@ export class DashboardAdapter implements WizardAdapter { private handleConfirmResponse = ({ id, confirmed }: { id: string; confirmed: boolean }): void => { if (id === 'git-status') { this.sendEvent({ type: confirmed ? 'GIT_CONFIRMED' : 'GIT_CANCELLED' }); + } else if (id === 'env-scan') { + this.sendEvent({ type: confirmed ? 'ENV_SCAN_APPROVED' : 'ENV_SCAN_DECLINED' }); } }; diff --git a/src/lib/credential-discovery.spec.ts b/src/lib/credential-discovery.spec.ts new file mode 100644 index 0000000..f70b113 --- /dev/null +++ b/src/lib/credential-discovery.spec.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmdirSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { checkForEnvFiles, discoverCredentials, isValidClientId, isValidApiKey } from './credential-discovery.js'; + +describe('credential-discovery', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'cred-discovery-test-')); + }); + + afterEach(() => { + try { + const files = ['.env', '.env.local', '.env.development', '.env.development.local']; + for (const f of files) { + try { + unlinkSync(join(testDir, f)); + } catch { + // noop + } + } + rmdirSync(testDir); + } catch { + // noop + } + }); + + describe('isValidClientId', () => { + it('returns true for valid client IDs', () => { + expect(isValidClientId('client_01ABC123')).toBe(true); + expect(isValidClientId('client_longenoughvalue')).toBe(true); + }); + + it('returns false for invalid client IDs', () => { + expect(isValidClientId('invalid')).toBe(false); + expect(isValidClientId('client_')).toBe(false); // too short + expect(isValidClientId('sk_test_abc')).toBe(false); + }); + }); + + describe('isValidApiKey', () => { + it('returns true for valid API keys', () => { + expect(isValidApiKey('sk_test_abc123def')).toBe(true); + expect(isValidApiKey('sk_live_secretkey123')).toBe(true); + }); + + it('returns false for invalid API keys', () => { + expect(isValidApiKey('invalid')).toBe(false); + expect(isValidApiKey('sk_short')).toBe(false); + expect(isValidApiKey('client_01ABC')).toBe(false); + }); + }); + + describe('checkForEnvFiles', () => { + it('returns exists: false when no env files present', async () => { + const result = await checkForEnvFiles(testDir); + expect(result.exists).toBe(false); + expect(result.files).toEqual([]); + }); + + it('detects .env.local file', async () => { + writeFileSync(join(testDir, '.env.local'), 'FOO=bar'); + + const result = await checkForEnvFiles(testDir); + + expect(result.exists).toBe(true); + expect(result.files).toContain('.env.local'); + }); + + it('detects multiple env files', async () => { + writeFileSync(join(testDir, '.env'), 'A=1'); + writeFileSync(join(testDir, '.env.local'), 'B=2'); + writeFileSync(join(testDir, '.env.development'), 'C=3'); + + const result = await checkForEnvFiles(testDir); + + expect(result.exists).toBe(true); + expect(result.files).toHaveLength(3); + expect(result.files).toContain('.env'); + expect(result.files).toContain('.env.local'); + expect(result.files).toContain('.env.development'); + }); + }); + + describe('discoverCredentials', () => { + it('returns found: false when no env files exist', async () => { + const result = await discoverCredentials(testDir); + expect(result.found).toBe(false); + }); + + it('discovers valid client ID from env file', async () => { + writeFileSync(join(testDir, '.env.local'), 'WORKOS_CLIENT_ID=client_01ABC123'); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(true); + expect(result.clientId).toBe('client_01ABC123'); + expect(result.source).toBe('env'); + }); + + it('discovers both credentials from single file', async () => { + writeFileSync( + join(testDir, '.env.local'), + ` +WORKOS_CLIENT_ID=client_01XYZ789 +WORKOS_API_KEY=sk_live_secretkey123 +`, + ); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(true); + expect(result.clientId).toBe('client_01XYZ789'); + expect(result.apiKey).toBe('sk_live_secretkey123'); + expect(result.sourcePath).toBe('.env.local'); + }); + + it('returns first file with valid clientId (priority order)', async () => { + writeFileSync(join(testDir, '.env'), 'WORKOS_CLIENT_ID=client_from_dotenv'); + writeFileSync(join(testDir, '.env.local'), 'WORKOS_CLIENT_ID=client_from_local'); + + const result = await discoverCredentials(testDir); + + expect(result.clientId).toBe('client_from_local'); + expect(result.sourcePath).toBe('.env.local'); + }); + + it('ignores invalid client ID format', async () => { + writeFileSync(join(testDir, '.env.local'), 'WORKOS_CLIENT_ID=invalid_format'); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(false); + }); + + it('ignores API key without valid client ID', async () => { + writeFileSync(join(testDir, '.env.local'), 'WORKOS_API_KEY=sk_test_key_value'); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(false); + }); + + it('handles double-quoted values', async () => { + writeFileSync(join(testDir, '.env.local'), 'WORKOS_CLIENT_ID="client_01QUOTED"'); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(true); + expect(result.clientId).toBe('client_01QUOTED'); + }); + + it('handles single-quoted values', async () => { + writeFileSync(join(testDir, '.env.local'), "WORKOS_CLIENT_ID='client_01SINGLE'"); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(true); + expect(result.clientId).toBe('client_01SINGLE'); + }); + + it('ignores commented lines', async () => { + writeFileSync( + join(testDir, '.env.local'), + ` +# WORKOS_CLIENT_ID=client_01COMMENTED +WORKOS_CLIENT_ID=client_01ACTUAL +`, + ); + + const result = await discoverCredentials(testDir); + + expect(result.clientId).toBe('client_01ACTUAL'); + }); + + it('returns found: false for empty values', async () => { + writeFileSync(join(testDir, '.env.local'), 'WORKOS_CLIENT_ID='); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(false); + }); + + it('includes invalid API key as undefined when clientId is valid', async () => { + writeFileSync( + join(testDir, '.env.local'), + ` +WORKOS_CLIENT_ID=client_01VALID +WORKOS_API_KEY=invalid +`, + ); + + const result = await discoverCredentials(testDir); + + expect(result.found).toBe(true); + expect(result.clientId).toBe('client_01VALID'); + expect(result.apiKey).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/credential-discovery.ts b/src/lib/credential-discovery.ts new file mode 100644 index 0000000..a0d03b1 --- /dev/null +++ b/src/lib/credential-discovery.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { logInfo } from '../utils/debug.js'; + +export interface DiscoveryResult { + found: boolean; + source?: 'env' | 'declined'; + clientId?: string; + apiKey?: string; + sourcePath?: string; +} + +export interface EnvFileInfo { + exists: boolean; + files: string[]; +} + +const ENV_FILE_NAMES = ['.env.local', '.env.development.local', '.env.development', '.env']; + +// Only extract WorkOS variables - ignore everything else +const WORKOS_CLIENT_ID_PATTERN = /^WORKOS_CLIENT_ID=["']?([^"'\s#]+)["']?/m; +const WORKOS_API_KEY_PATTERN = /^WORKOS_API_KEY=["']?([^"'\s#]+)["']?/m; + +/** + * Check if env files exist in the project directory (without reading contents). + * Returns which files were found so the UI can prompt for consent. + */ +export async function checkForEnvFiles(projectDir: string): Promise { + logInfo('[credential-discovery] Checking for env files in:', projectDir); + const foundFiles: string[] = []; + + for (const fileName of ENV_FILE_NAMES) { + const filePath = path.join(projectDir, fileName); + try { + await fs.access(filePath, fs.constants.R_OK); + foundFiles.push(fileName); + } catch { + // continue + } + } + + logInfo('[credential-discovery] Found env files:', foundFiles); + return { + exists: foundFiles.length > 0, + files: foundFiles, + }; +} + +/** + * Scan a single env file for WorkOS credentials. + * Only extracts WORKOS_CLIENT_ID and WORKOS_API_KEY - ignores all other variables. + */ +export async function scanEnvFile(filePath: string): Promise<{ clientId?: string; apiKey?: string }> { + const content = await fs.readFile(filePath, 'utf-8'); + + // Filter out commented lines before matching + const lines = content.split('\n'); + const uncommentedContent = lines.filter((line) => !line.trim().startsWith('#')).join('\n'); + + const clientIdMatch = uncommentedContent.match(WORKOS_CLIENT_ID_PATTERN); + const apiKeyMatch = uncommentedContent.match(WORKOS_API_KEY_PATTERN); + + return { + clientId: clientIdMatch?.[1], + apiKey: apiKeyMatch?.[1], + }; +} + +/** + * Validate client ID format. + * WorkOS client IDs start with 'client_' prefix. + */ +export function isValidClientId(value: string): boolean { + return value.startsWith('client_') && value.length > 10; +} + +/** + * Validate API key format. + * WorkOS secret keys start with 'sk_' prefix. + */ +export function isValidApiKey(value: string): boolean { + return value.startsWith('sk_') && value.length > 10; +} + +/** + * Discover WorkOS credentials from project env files. + * Must be called AFTER user consent is given. + * + * Scans files in priority order: .env.local > .env.development.local > .env.development > .env + * Returns first complete match (both clientId and apiKey preferred, but clientId-only is valid). + */ +export async function discoverCredentials(projectDir: string): Promise { + logInfo('[credential-discovery] Scanning env files for WorkOS credentials'); + for (const fileName of ENV_FILE_NAMES) { + const filePath = path.join(projectDir, fileName); + + try { + const result = await scanEnvFile(filePath); + + const clientIdValid = result.clientId && isValidClientId(result.clientId); + const apiKeyValid = result.apiKey && isValidApiKey(result.apiKey); + + if (result.clientId && !clientIdValid) { + logInfo('[credential-discovery] Invalid clientId format in', fileName); + } + if (result.apiKey && !apiKeyValid) { + logInfo('[credential-discovery] Invalid apiKey format in', fileName); + } + + if (clientIdValid) { + logInfo('[credential-discovery] Found valid credentials in', fileName); + return { + found: true, + source: 'env', + clientId: result.clientId, + apiKey: apiKeyValid ? result.apiKey : undefined, + sourcePath: fileName, + }; + } + } catch { + // continue + } + } + + logInfo('[credential-discovery] No valid credentials found'); + return { found: false }; +} diff --git a/src/lib/credentials.spec.ts b/src/lib/credentials.spec.ts index 67d067d..8736cd0 100644 --- a/src/lib/credentials.spec.ts +++ b/src/lib/credentials.spec.ts @@ -30,6 +30,8 @@ const { isTokenExpired, getAccessToken, getCredentialsPath, + saveStagingCredentials, + getStagingCredentials, } = await import('./credentials.js'); import type { Credentials } from './credentials.js'; @@ -186,4 +188,69 @@ describe('credentials', () => { expect(getAccessToken()).toBeNull(); }); }); + + describe('saveStagingCredentials', () => { + it('saves staging credentials to existing credentials', () => { + saveCredentials(validCreds); + + saveStagingCredentials({ clientId: 'client_staging', apiKey: 'sk_staging' }); + + const creds = getCredentials(); + expect(creds?.staging).toBeDefined(); + expect(creds?.staging?.clientId).toBe('client_staging'); + expect(creds?.staging?.apiKey).toBe('sk_staging'); + expect(creds?.staging?.fetchedAt).toBeGreaterThan(0); + }); + + it('does nothing if no credentials exist', () => { + saveStagingCredentials({ clientId: 'client_x', apiKey: 'sk_x' }); + expect(getCredentials()).toBeNull(); + }); + + it('overwrites existing staging credentials', () => { + saveCredentials({ + ...validCreds, + staging: { clientId: 'old_client', apiKey: 'old_key', fetchedAt: 1000 }, + }); + + saveStagingCredentials({ clientId: 'new_client', apiKey: 'new_key' }); + + const creds = getCredentials(); + expect(creds?.staging?.clientId).toBe('new_client'); + expect(creds?.staging?.apiKey).toBe('new_key'); + }); + }); + + describe('getStagingCredentials', () => { + it('returns null when no credentials exist', () => { + expect(getStagingCredentials()).toBeNull(); + }); + + it('returns null when credentials exist but no staging cache', () => { + saveCredentials(validCreds); + expect(getStagingCredentials()).toBeNull(); + }); + + it('returns staging credentials when cached and token not expired', () => { + saveCredentials({ + ...validCreds, + staging: { clientId: 'client_cached', apiKey: 'sk_cached', fetchedAt: Date.now() }, + }); + + const result = getStagingCredentials(); + + expect(result).toEqual({ clientId: 'client_cached', apiKey: 'sk_cached' }); + }); + + it('returns null when token is expired (invalidates staging cache)', () => { + const expiredCreds: Credentials = { + ...validCreds, + expiresAt: Date.now() - 1000, + staging: { clientId: 'client_x', apiKey: 'sk_x', fetchedAt: Date.now() }, + }; + saveCredentials(expiredCreds); + + expect(getStagingCredentials()).toBeNull(); + }); + }); }); diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 905dee9..04965a8 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -2,11 +2,18 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +export interface StagingCache { + clientId: string; + apiKey: string; + fetchedAt: number; +} + export interface Credentials { accessToken: string; expiresAt: number; userId: string; email?: string; + staging?: StagingCache; } function getCredentialsDir(): string { @@ -63,3 +70,32 @@ export function getAccessToken(): string | null { if (isTokenExpired(creds)) return null; return creds.accessToken; } + +/** + * Save staging credentials to the credential cache. + * Staging credentials are tied to the access token lifecycle. + */ +export function saveStagingCredentials(staging: { clientId: string; apiKey: string }): void { + const creds = getCredentials(); + if (!creds) return; + + saveCredentials({ + ...creds, + staging: { + ...staging, + fetchedAt: Date.now(), + }, + }); +} + +/** + * Get cached staging credentials if available and access token is still valid. + * Returns null if no cached credentials or if access token has expired. + */ +export function getStagingCredentials(): { clientId: string; apiKey: string } | null { + const creds = getCredentials(); + if (!creds?.staging) return null; + // Invalidate staging credentials when access token expires + if (isTokenExpired(creds)) return null; + return { clientId: creds.staging.clientId, apiKey: creds.staging.apiKey }; +} diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts new file mode 100644 index 0000000..65889c0 --- /dev/null +++ b/src/lib/device-auth.ts @@ -0,0 +1,194 @@ +/** + * Device Authorization Flow + * + * Implements OAuth 2.0 Device Authorization Grant (RFC 8628) for CLI authentication. + * Extracted from login.ts for reuse in wizard credential gathering. + */ + +import { logInfo, logError } from '../utils/debug.js'; + +export interface DeviceAuthResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +export interface DeviceAuthOptions { + clientId: string; + authkitDomain: string; + scopes?: string[]; + timeoutMs?: number; + onPoll?: () => void; + onSlowDown?: (newIntervalMs: number) => void; +} + +export interface DeviceAuthResult { + accessToken: string; + idToken: string; + expiresAt: number; + userId: string; + email?: string; +} + +interface TokenResponse { + access_token: string; + id_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; +} + +interface AuthErrorResponse { + error: string; +} + +export class DeviceAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'DeviceAuthError'; + } +} + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const DEFAULT_SCOPES = ['openid', 'email', 'staging-environment:credentials:read']; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Parse JWT payload + */ +function parseJwt(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); + } catch { + return null; + } +} + +/** + * Extract expiry time from JWT token + */ +function getJwtExpiry(token: string): number | null { + const payload = parseJwt(token); + if (!payload || typeof payload.exp !== 'number') return null; + return payload.exp * 1000; +} + +/** + * Request a device code from the OAuth authorization server. + * Returns the device code, user code, and verification URIs. + */ +export async function requestDeviceCode(options: DeviceAuthOptions): Promise { + const scopes = options.scopes ?? DEFAULT_SCOPES; + const url = `${options.authkitDomain}/oauth2/device_authorization`; + + logInfo('[device-auth] Requesting device code from:', url); + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: options.clientId, + scope: scopes.join(' '), + }), + }); + + logInfo('[device-auth] Device code response status:', res.status); + if (!res.ok) { + const text = await res.text(); + logError('[device-auth] Device authorization failed:', res.status, text); + throw new DeviceAuthError(`Device authorization failed: ${res.status} ${text}`); + } + + const data = (await res.json()) as DeviceAuthResponse; + logInfo('[device-auth] Device code received, user_code:', data.user_code); + return data; +} + +/** + * Poll for token after user has authorized in the browser. + * Handles authorization_pending and slow_down responses per RFC 8628. + */ +export async function pollForToken( + deviceCode: string, + options: DeviceAuthOptions & { interval: number }, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const startTime = Date.now(); + let pollInterval = options.interval * 1000; + const tokenUrl = `${options.authkitDomain}/oauth2/token`; + + logInfo('[device-auth] Starting token polling, timeout:', timeoutMs); + while (Date.now() - startTime < timeoutMs) { + await sleep(pollInterval); + options.onPoll?.(); + + let res: Response; + try { + res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: options.clientId, + }), + }); + } catch (err) { + logInfo('[device-auth] Token poll network error, retrying'); + continue; + } + + let data; + try { + data = await res.json(); + } catch { + logError('[device-auth] Invalid JSON response from auth server'); + throw new DeviceAuthError('Invalid response from auth server'); + } + + logInfo('[device-auth] Token poll response:', res.status, (data as AuthErrorResponse)?.error ?? 'success'); + if (res.ok) { + logInfo('[device-auth] Token received successfully'); + return parseTokenResponse(data as TokenResponse); + } + + const errorData = data as AuthErrorResponse; + + if (errorData.error === 'authorization_pending') { + continue; + } + + if (errorData.error === 'slow_down') { + pollInterval += 5000; + logInfo('[device-auth] Slowing down, new interval:', pollInterval); + options.onSlowDown?.(pollInterval); + continue; + } + + logError('[device-auth] Token error:', errorData.error); + throw new DeviceAuthError(`Token error: ${errorData.error}`); + } + + logError('[device-auth] Authentication timed out'); + throw new DeviceAuthError('Authentication timed out after 5 minutes'); +} + +function parseTokenResponse(data: TokenResponse): DeviceAuthResult { + const idPayload = parseJwt(data.id_token); + const jwtExpiry = getJwtExpiry(data.access_token); + + return { + accessToken: data.access_token, + idToken: data.id_token, + expiresAt: jwtExpiry ?? (data.expires_in ? Date.now() + data.expires_in * 1000 : Date.now() + 15 * 60 * 1000), + userId: String(idPayload?.sub ?? 'unknown'), + email: idPayload?.email as string | undefined, + }; +} diff --git a/src/lib/events.ts b/src/lib/events.ts index b23a717..6388f16 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -30,6 +30,22 @@ export interface WizardEvents { 'git:dirty:cancelled': Record; 'credentials:gathering': { requiresApiKey: boolean }; 'credentials:found': Record; + // Credential discovery events + 'credentials:env:detected': { files: string[] }; + 'credentials:env:prompt': { files: string[] }; + 'credentials:env:scanning': Record; + 'credentials:env:found': { sourcePath: string }; + 'credentials:env:notfound': Record; + // Device auth events + 'device:started': { verificationUri: string; verificationUriComplete: string; userCode: string }; + 'device:polling': Record; + 'device:success': { email?: string }; + 'device:timeout': Record; + 'device:error': { message: string }; + // Staging API events + 'staging:fetching': Record; + 'staging:success': Record; + 'staging:error': { message: string; statusCode?: number }; 'config:start': Record; 'config:complete': Record; 'agent:start': Record; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index ebc4104..ced34ab 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -13,7 +13,17 @@ import { Integration } from './constants.js'; import { parseEnvFile } from '../utils/env-parser.js'; import { enableDebugLogs, initLogFile, logInfo, logError } from '../utils/debug.js'; -import { getAccessToken, getCredentials } from './credentials.js'; +import { + getAccessToken, + getCredentials, + saveCredentials, + getStagingCredentials, + saveStagingCredentials, +} from './credentials.js'; +import { checkForEnvFiles, discoverCredentials } from './credential-discovery.js'; +import { requestDeviceCode, pollForToken } from './device-auth.js'; +import { fetchStagingCredentials as fetchStagingCredentialsApi } from './staging-api.js'; +import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { analytics } from '../utils/analytics.js'; import { getVersion } from './settings.js'; import { getLlmGatewayUrlFromHost } from '../utils/urls.js'; @@ -219,6 +229,80 @@ export async function runWithCore(options: WizardOptions): Promise { }; } }), + + // Credential discovery actors + detectEnvFiles: fromPromise(async ({ input }) => { + return checkForEnvFiles(input.installDir); + }), + + scanEnvFiles: fromPromise(async ({ input }) => { + return discoverCredentials(input.installDir); + }), + + checkStoredAuth: fromPromise(async () => { + const token = getAccessToken(); + return token !== null; + }), + + runDeviceAuth: fromPromise(async ({ input }) => { + const clientId = getCliAuthClientId(); + const authkitDomain = getAuthkitDomain(); + + if (!clientId) { + throw new Error('CLI auth not configured. Set WORKOS_CLI_CLIENT_ID environment variable.'); + } + + const deviceAuth = await requestDeviceCode({ + clientId, + authkitDomain, + }); + + // Emit device started event with verification info + input.emitter.emit('device:started', { + verificationUri: deviceAuth.verification_uri, + verificationUriComplete: deviceAuth.verification_uri_complete, + userCode: deviceAuth.user_code, + }); + + // Open browser + try { + const { default: openFn } = await import('opn'); + await openFn(deviceAuth.verification_uri_complete); + } catch { + // User can open manually + } + + const result = await pollForToken(deviceAuth.device_code, { + clientId, + authkitDomain, + interval: deviceAuth.interval, + onPoll: () => input.emitter.emit('device:polling', {}), + }); + + // Save the auth token + saveCredentials({ + accessToken: result.accessToken, + expiresAt: result.expiresAt, + userId: result.userId, + email: result.email, + }); + + return { result, deviceAuth }; + }), + + fetchStagingCredentials: fromPromise(async () => { + // Check cached staging credentials first + const cached = getStagingCredentials(); + if (cached) return cached; + + // Fetch fresh from API + const token = getAccessToken(); + if (!token) throw new Error('No access token available'); + + const staging = await fetchStagingCredentialsApi(token); + saveStagingCredentials(staging); + return staging; + }), }, }); diff --git a/src/lib/staging-api.spec.ts b/src/lib/staging-api.spec.ts new file mode 100644 index 0000000..917ac56 --- /dev/null +++ b/src/lib/staging-api.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchStagingCredentials, StagingApiError } from './staging-api.js'; + +describe('staging-api', () => { + const mockFetch = vi.fn(); + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('fetchStagingCredentials', () => { + it('returns credentials from camelCase response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ clientId: 'client_123', apiKey: 'sk_test_abc' }), + }); + + const result = await fetchStagingCredentials('token_xyz'); + + expect(result).toEqual({ clientId: 'client_123', apiKey: 'sk_test_abc' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.workos.com/x/installer/staging-environment/credentials', + expect.objectContaining({ + headers: { Authorization: 'Bearer token_xyz' }, + }), + ); + }); + + it('returns credentials from snake_case response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ client_id: 'client_456', api_key: 'sk_test_def' }), + }); + + const result = await fetchStagingCredentials('token_xyz'); + + expect(result).toEqual({ clientId: 'client_456', apiKey: 'sk_test_def' }); + }); + + it('prefers camelCase over snake_case when both present', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + clientId: 'camel_client', + apiKey: 'camel_key', + client_id: 'snake_client', + api_key: 'snake_key', + }), + }); + + const result = await fetchStagingCredentials('token'); + + expect(result).toEqual({ clientId: 'camel_client', apiKey: 'camel_key' }); + }); + + it('throws StagingApiError on 401', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect(fetchStagingCredentials('bad_token')).rejects.toThrow('Authentication expired'); + }); + + it('throws StagingApiError instance on 401', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect(fetchStagingCredentials('bad_token')).rejects.toThrow(StagingApiError); + }); + + it('throws StagingApiError on 403', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('Access denied'); + }); + + it('throws StagingApiError on 404', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('No staging environment found'); + }); + + it('throws StagingApiError on other HTTP errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('Failed to fetch credentials: 500'); + }); + + it('throws StagingApiError when response missing clientId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ apiKey: 'sk_test_abc' }), + }); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('missing clientId or apiKey'); + }); + + it('throws StagingApiError when response missing apiKey', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ clientId: 'client_123' }), + }); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('missing clientId or apiKey'); + }); + + it('throws StagingApiError on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failed')); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('Network error: Network failed'); + }); + + it('throws StagingApiError on timeout (AbortError)', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValueOnce(abortError); + + await expect(fetchStagingCredentials('token')).rejects.toThrow('Request timed out'); + }); + }); +}); diff --git a/src/lib/staging-api.ts b/src/lib/staging-api.ts new file mode 100644 index 0000000..5a1403a --- /dev/null +++ b/src/lib/staging-api.ts @@ -0,0 +1,88 @@ +/** + * Staging Environment Credentials API Client + * + * Fetches WorkOS staging credentials (client_id, api_key) from the staging API. + * Requires an access token with 'staging-environment:credentials:read' scope. + */ + +import { logInfo, logError } from '../utils/debug.js'; + +export interface StagingCredentials { + clientId: string; + apiKey: string; +} + +export class StagingApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = 'StagingApiError'; + } +} + +const STAGING_API_URL = 'https://api.workos.com/x/installer/staging-environment/credentials'; +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * Fetch staging environment credentials from the WorkOS API. + * + * @param accessToken - Bearer token with staging-environment:credentials:read scope + * @returns StagingCredentials containing clientId and apiKey + * @throws StagingApiError on auth failure, permission denied, or other HTTP errors + */ +export async function fetchStagingCredentials(accessToken: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + logInfo('[staging-api] Fetching credentials from:', STAGING_API_URL); + try { + const res = await fetch(STAGING_API_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: controller.signal, + }); + + logInfo('[staging-api] Response status:', res.status); + if (!res.ok) { + const text = await res.text().catch(() => ''); + logError('[staging-api] Error response:', res.status, text); + + if (res.status === 401) { + throw new StagingApiError('Authentication expired. Please log in again.', 401); + } + if (res.status === 403) { + throw new StagingApiError('Access denied. Ensure you have the required permissions.', 403); + } + if (res.status === 404) { + throw new StagingApiError('No staging environment found. Create one in the WorkOS dashboard.', 404); + } + + throw new StagingApiError(`Failed to fetch credentials: ${res.status} ${text}`, res.status); + } + + const data = (await res.json()) as { clientId?: string; apiKey?: string; client_id?: string; api_key?: string }; + + // Handle both camelCase and snake_case responses + const clientId = data.clientId || data.client_id; + const apiKey = data.apiKey || data.api_key; + + if (!clientId || !apiKey) { + logError('[staging-api] Invalid response: missing credentials'); + throw new StagingApiError('Invalid response: missing clientId or apiKey'); + } + + logInfo('[staging-api] Credentials fetched successfully'); + return { clientId, apiKey }; + } catch (error) { + if (error instanceof StagingApiError) throw error; + if (error instanceof Error && error.name === 'AbortError') { + logError('[staging-api] Request timed out'); + throw new StagingApiError('Request timed out. Check your network connection.'); + } + logError('[staging-api] Network error:', error instanceof Error ? error.message : 'Unknown'); + throw new StagingApiError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/validation/rules/tanstack-start.json b/src/lib/validation/rules/tanstack-start.json index 1a7a2f2..1be5249 100644 --- a/src/lib/validation/rules/tanstack-start.json +++ b/src/lib/validation/rules/tanstack-start.json @@ -1,7 +1,11 @@ { "framework": "tanstack-start", "packages": [ - { "name": "@workos/authkit-tanstack-react-start", "location": "dependencies", "alternates": ["@workos-inc/authkit-tanstack-start"] }, + { + "name": "@workos/authkit-tanstack-react-start", + "location": "dependencies", + "alternates": ["@workos-inc/authkit-tanstack-start"] + }, { "name": "@tanstack/start", "location": "any", "alternates": ["@tanstack/react-start"] } ], "envVars": [ diff --git a/src/lib/wizard-core.ts b/src/lib/wizard-core.ts index d7307dc..fa51460 100644 --- a/src/lib/wizard-core.ts +++ b/src/lib/wizard-core.ts @@ -6,8 +6,13 @@ import type { DetectionOutput, GitCheckOutput, AgentOutput, + EnvFileInfo, + DiscoveryResult, + CredentialSource, } from './wizard-core.types.js'; import type { WizardOptions } from '../utils/types.js'; +import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; +import type { StagingCredentials } from './staging-api.js'; export const wizardMachine = setup({ types: { @@ -69,6 +74,44 @@ export const wizardMachine = setup({ emitCredentialsFound: ({ context }) => { context.emitter.emit('credentials:found', {}); }, + emitEnvDetected: ({ context }) => { + context.emitter.emit('credentials:env:detected', { files: context.envFilesDetected ?? [] }); + }, + emitEnvScanPrompt: ({ context }) => { + context.emitter.emit('credentials:env:prompt', { files: context.envFilesDetected ?? [] }); + }, + emitEnvScanning: ({ context }) => { + context.emitter.emit('credentials:env:scanning', {}); + }, + emitEnvCredentialsFound: ({ context }) => { + context.emitter.emit('credentials:env:found', { sourcePath: context.envCredentialPath ?? '.env' }); + }, + emitEnvNotFound: ({ context }) => { + context.emitter.emit('credentials:env:notfound', {}); + }, + emitDeviceAuthStart: () => { + // Emitted by actor when it has verification URL + }, + emitDeviceAuthSuccess: ({ context }) => { + context.emitter.emit('device:success', { email: undefined }); + }, + emitDeviceAuthError: ({ context }) => { + const message = context.error?.message ?? 'Device authorization failed'; + context.emitter.emit('device:error', { message }); + }, + emitDeviceTimeout: ({ context }) => { + context.emitter.emit('device:timeout', {}); + }, + emitStagingFetching: ({ context }) => { + context.emitter.emit('staging:fetching', {}); + }, + emitStagingSuccess: ({ context }) => { + context.emitter.emit('staging:success', {}); + }, + emitStagingError: ({ context }) => { + const message = context.error?.message ?? 'Failed to fetch staging credentials'; + context.emitter.emit('staging:error', { message }); + }, emitConfigStart: ({ context }) => { context.emitter.emit('config:start', {}); }, @@ -159,6 +202,25 @@ export const wizardMachine = setup({ runAgent: fromPromise(async () => { throw new Error('runAgent not implemented - provide via machine.provide()'); }), + // Credential discovery actors + detectEnvFiles: fromPromise(async () => { + throw new Error('detectEnvFiles not implemented - provide via machine.provide()'); + }), + scanEnvFiles: fromPromise(async () => { + throw new Error('scanEnvFiles not implemented - provide via machine.provide()'); + }), + checkStoredAuth: fromPromise(async () => { + throw new Error('checkStoredAuth not implemented - provide via machine.provide()'); + }), + runDeviceAuth: fromPromise< + { result: DeviceAuthResult; deviceAuth: DeviceAuthResponse }, + { emitter: WizardMachineContext['emitter'] } + >(async () => { + throw new Error('runDeviceAuth not implemented - provide via machine.provide()'); + }), + fetchStagingCredentials: fromPromise(async () => { + throw new Error('fetchStagingCredentials not implemented - provide via machine.provide()'); + }), }, }).createMachine({ id: 'wizard', @@ -318,26 +380,202 @@ export const wizardMachine = setup({ gatheringCredentials: { entry: [{ type: 'emitStateEnter', params: { state: 'gatheringCredentials' } }], - initial: 'checking', + initial: 'checkingCliFlags', states: { - checking: { + // Step 1: Check CLI flags (highest priority) + checkingCliFlags: { always: [ { target: '#wizard.configuring', guard: 'hasCredentials', - actions: ['emitCredentialsFound', { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }], - }, - { - target: 'prompting', + actions: [ + assign({ credentialSource: () => 'cli' as CredentialSource }), + 'emitCredentialsFound', + { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }, + ], }, + { target: 'detectingEnvFiles' }, ], }, - prompting: { + + // Step 2: Check if env files exist (don't read yet) + detectingEnvFiles: { + invoke: { + id: 'detectEnvFiles', + src: 'detectEnvFiles', + input: ({ context }) => ({ installDir: context.options.installDir }), + onDone: [ + { + target: 'promptingEnvScan', + guard: ({ event }) => { + const output = event.output as EnvFileInfo; + return output.exists; + }, + actions: [ + assign({ + envFilesDetected: ({ event }) => { + const output = event.output as EnvFileInfo; + return output.files; + }, + }), + 'emitEnvDetected', + ], + }, + { target: 'checkingStoredAuth' }, + ], + onError: { + target: 'checkingStoredAuth', // Non-fatal, continue + }, + }, + }, + + // Step 3: Ask user for consent to scan env files + promptingEnvScan: { + entry: ['emitEnvScanPrompt'], + on: { + ENV_SCAN_APPROVED: { + target: 'scanningEnvFiles', + actions: assign({ envScanConsent: () => true }), + }, + ENV_SCAN_DECLINED: { + target: 'checkingStoredAuth', + actions: assign({ envScanConsent: () => false }), + }, + CANCEL: { + target: '#wizard.cancelled', + actions: { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }, + }, + }, + }, + + // Step 4: Scan env files for credentials (with consent) + scanningEnvFiles: { + entry: ['emitEnvScanning'], + invoke: { + id: 'scanEnvFiles', + src: 'scanEnvFiles', + input: ({ context }) => ({ installDir: context.options.installDir }), + onDone: [ + { + target: '#wizard.configuring', + guard: ({ event }) => { + const result = event.output as DiscoveryResult; + return result.found && !!result.clientId; + }, + actions: [ + assign({ + credentials: ({ event }) => { + const result = event.output as DiscoveryResult; + return { clientId: result.clientId!, apiKey: result.apiKey }; + }, + credentialSource: () => 'env' as CredentialSource, + envCredentialPath: ({ event }) => (event.output as DiscoveryResult).sourcePath, + }), + 'emitEnvCredentialsFound', + { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }, + ], + }, + { + target: 'checkingStoredAuth', + actions: ['emitEnvNotFound'], + }, + ], + onError: { + target: 'checkingStoredAuth', + }, + }, + }, + + // Step 5: Check for stored auth token + checkingStoredAuth: { + invoke: { + id: 'checkStoredAuth', + src: 'checkStoredAuth', + onDone: [ + { + target: 'fetchingStagingCredentials', + guard: ({ event }) => event.output === true, + }, + { target: 'runningDeviceAuth' }, + ], + onError: { + target: 'runningDeviceAuth', + }, + }, + }, + + // Step 6: Run device authorization flow + runningDeviceAuth: { + entry: ['emitDeviceAuthStart'], + invoke: { + id: 'runDeviceAuth', + src: 'runDeviceAuth', + input: ({ context }) => ({ emitter: context.emitter }), + onDone: { + target: 'fetchingStagingCredentials', + actions: [ + 'emitDeviceAuthSuccess', + assign({ + deviceAuth: ({ event }) => { + const output = event.output as { result: DeviceAuthResult; deviceAuth: DeviceAuthResponse }; + return { + verificationUri: output.deviceAuth.verification_uri, + verificationUriComplete: output.deviceAuth.verification_uri_complete, + userCode: output.deviceAuth.user_code, + }; + }, + }), + ], + }, + onError: { + target: 'promptingManual', + actions: ['assignError', 'emitDeviceAuthError'], + }, + }, + }, + + // Step 7: Fetch staging credentials from API + fetchingStagingCredentials: { + entry: ['emitStagingFetching'], + invoke: { + id: 'fetchStagingCredentials', + src: 'fetchStagingCredentials', + onDone: { + target: '#wizard.configuring', + actions: [ + assign({ + credentials: ({ event }) => { + const staging = event.output as StagingCredentials; + return { clientId: staging.clientId, apiKey: staging.apiKey }; + }, + credentialSource: ({ context }) => + context.deviceAuth ? ('device' as CredentialSource) : ('stored' as CredentialSource), + }), + 'emitStagingSuccess', + { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }, + ], + }, + onError: { + target: 'promptingManual', + actions: ['assignError', 'emitStagingError'], + }, + }, + }, + + // Step 8: Manual fallback + promptingManual: { entry: ['emitCredentialsGathering'], on: { CREDENTIALS_SUBMITTED: { target: '#wizard.configuring', - actions: ['assignCredentials', { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }], + actions: [ + 'assignCredentials', + assign({ credentialSource: () => 'manual' as CredentialSource }), + { type: 'emitStateExit', params: { state: 'gatheringCredentials' } }, + ], + }, + RETRY_AUTH: { + target: 'runningDeviceAuth', }, CANCEL: { target: '#wizard.cancelled', diff --git a/src/lib/wizard-core.types.ts b/src/lib/wizard-core.types.ts index 39a9633..aa0adc4 100644 --- a/src/lib/wizard-core.types.ts +++ b/src/lib/wizard-core.types.ts @@ -1,6 +1,16 @@ import type { WizardEventEmitter } from './events.js'; import type { WizardOptions } from '../utils/types.js'; import type { Integration } from './constants.js'; +import type { DeviceAuthResponse } from './device-auth.js'; +import type { EnvFileInfo, DiscoveryResult } from './credential-discovery.js'; + +export type { EnvFileInfo, DiscoveryResult }; +export type { DeviceAuthResponse }; + +/** + * How credentials were resolved. + */ +export type CredentialSource = 'cli' | 'env' | 'stored' | 'device' | 'manual'; /** * Context passed to the wizard state machine. @@ -14,13 +24,27 @@ export interface WizardMachineContext { /** Detected or selected framework integration */ integration: Integration | undefined; /** WorkOS credentials gathered from user */ - credentials: { apiKey: string; clientId: string } | undefined; + credentials: { apiKey?: string; clientId: string } | undefined; /** Whether git working directory is clean */ gitIsClean: boolean; /** List of dirty git files (if any) */ gitDirtyFiles: string[]; /** Error that caused failure (if any) */ error: Error | undefined; + /** How credentials were resolved */ + credentialSource?: CredentialSource; + /** Device auth state for UI display */ + deviceAuth?: { + verificationUri: string; + verificationUriComplete: string; + userCode: string; + }; + /** Whether user consented to env file scanning */ + envScanConsent?: boolean; + /** Env files detected in project (before consent) */ + envFilesDetected?: string[]; + /** Path to env file where credentials were found */ + envCredentialPath?: string; } /** @@ -41,7 +65,11 @@ export type WizardMachineEvent = | { type: 'GIT_CONFIRMED' } | { type: 'GIT_CANCELLED' } | { type: 'CREDENTIALS_SUBMITTED'; apiKey: string; clientId: string } - | { type: 'CANCEL' }; + | { type: 'CANCEL' } + // Credential discovery events + | { type: 'ENV_SCAN_APPROVED' } + | { type: 'ENV_SCAN_DECLINED' } + | { type: 'RETRY_AUTH' }; /** * Output from the detection actor. diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 4eaafdb..f14e178 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -62,15 +62,23 @@ export function getLogFilePath(): string | null { } function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown[]): void { - if (!sessionLogPath) return; + const redactedArgs = args.map((a) => (typeof a === 'object' && a !== null ? redactCredentials(a) : a)); + const msg = redactedArgs.map((a) => prepareMessage(a)).join(' '); - try { - const timestamp = new Date().toISOString(); - const redactedArgs = args.map((a) => (typeof a === 'object' && a !== null ? redactCredentials(a) : a)); - const msg = redactedArgs.map((a) => prepareMessage(a)).join(' '); - appendFileSync(sessionLogPath, `[${timestamp}] ${emoji} ${level}: ${msg}\n`); - } catch { - // Ignore write failures + // Write to console if debug enabled + if (debugEnabled) { + const color = level === 'ERROR' ? chalk.red : level === 'WARN' ? chalk.yellow : chalk.dim; + clack.log.info(color(`${emoji} ${msg}`)); + } + + // Write to log file + if (sessionLogPath) { + try { + const timestamp = new Date().toISOString(); + appendFileSync(sessionLogPath, `[${timestamp}] ${emoji} ${level}: ${msg}\n`); + } catch { + // Ignore write failures + } } }