Skip to content

Commit 014fbbc

Browse files
authored
feat: major workos doctor overhaul — visual refresh, multi-language, AI analysis (#62)
* feat: add auth pattern analysis to workos doctor Detect AuthKit-specific integration anti-patterns by scanning project code via file existence checks and regex matching (no AST parsing). Checks (11 total): - SIGNOUT_GET_HANDLER: GET route at signout/logout path (error) - SIGNOUT_LINK_PREFETCH: <Link>/<NextLink> to signout path (warning) - MISSING_MIDDLEWARE: No middleware.ts/proxy.ts for Next.js (error) - MIDDLEWARE_WRONG_LOCATION: middleware inside app/ dir (warning) - MISSING_AUTHKIT_PROVIDER: No AuthKitProvider in root layout (warning) - CALLBACK_ROUTE_MISSING: No route at redirect URI path (error) - API_KEY_LEAKED_TO_CLIENT: Secret key in NEXT_PUBLIC_/VITE_/etc (error) - WRONG_CALLBACK_LOADER: authkitLoader on callback route (warning) - MISSING_ROOT_AUTH_LOADER: Root route missing authkitLoader (warning) - MISSING_AUTHKIT_MIDDLEWARE: TanStack start.ts missing middleware (warning) - COOKIE_PASSWORD_TOO_SHORT: Password < 32 chars (warning) Framework-aware for Next.js, React Router, and TanStack Start. Resolves callback path from NEXT_PUBLIC_WORKOS_REDIRECT_URI when set. * chore: formatting * feat: add branded lock character and summary box to doctor and installer Add a WorkOS lock ASCII art character with expressions (success/warning/error) and a summary box renderer used by both doctor output and installer completion screens. The lock shackle opens on error state. Box width adapts to terminal size with word-wrapping for narrow terminals. * feat: add multi-language detection and expanded framework support to doctor Add language detection (Python, Ruby, Go, Java, PHP, .NET, JS/TS) from manifest files. Expand framework detection with Expo, React Native, SvelteKit, Vue/Nuxt, Astro, and Svelte. Detect non-JS WorkOS SDKs from requirements.txt, Gemfile, go.mod, pom.xml, composer.json, and .csproj. Language-aware install hints in issue remediation. * feat: add AI-powered project analysis to workos doctor Add Claude Agent SDK (Sonnet) integration for doctor that analyzes the project with read-only tools and provides tailored, framework-specific recommendations. Runs through LLM gateway with credential proxy auth. Includes --skip-ai flag for offline use, 60s timeout, graceful degradation on auth/parse failures, and structured JSON response parsing. * fix: rewrite AI analysis as direct API call with one-shot token refresh Replace Claude Agent SDK subprocess with a single Anthropic SDK API call to the LLM gateway. No credential proxy — reads access token directly, refreshes once if expired. Adds configurable doctorModel (default Haiku) and a spinner during analysis. Fixes credential corruption caused by proxy token rotation during short-lived doctor runs. * fix: improve AI prompt accuracy, credential resilience, and lock art - Add WorkOS SDK knowledge to AI prompt to prevent false positives (e.g. flagging @workos-inc/node as incompatible with Expo/RN) - Tighten AI analysis rules: fewer speculative findings, no contradicting SDK knowledge section - Fix credential storage: always write file fallback alongside keyring to survive binary rebuilds (macOS code signature invalidation) - Add credential diagnostics for debugging auth failures - Fix error lock art: shackle present but not connected to body - Formatting pass * fix: improve look of lock guy * feat: add cross-language checks and fix non-JS project support - Add 3 cross-language auth pattern checks that run for all projects: API_KEY_IN_SOURCE, ENV_FILE_NOT_GITIGNORED, MIXED_ENVIRONMENT - Remove isAuthKit gate so auth patterns run for non-JS projects too - Fix process.exit crash when running doctor in projects without package.json (replaced getPackageDotJson with direct file reads) - Hide Node.js runtime line for non-JS projects * chore: remove verbose logging and redundant comments * refactor: simplify doctor code and reduce duplication - Extract shared readPackageJson into package-json.ts - Extract renderCompletionSummary to dedupe adapter code - Inline single-use callApi into callModel - Consolidate token expiration branches - Replace dotenv with inline parseEnvFile in auth-patterns - Remove dynamic imports, unused params, and no-op ternary * chore: formatting
1 parent 4c7553f commit 014fbbc

31 files changed

Lines changed: 3238 additions & 127 deletions

src/bin.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ yargs(hideBin(process.argv))
204204
default: false,
205205
description: 'Skip API calls (offline mode)',
206206
},
207+
'skip-ai': {
208+
type: 'boolean',
209+
default: false,
210+
description: 'Skip AI-powered analysis',
211+
},
207212
'install-dir': {
208213
type: 'string',
209214
default: process.cwd(),

src/cli.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const version = pkg.version;
55

66
export const config = {
77
model: 'claude-opus-4-5-20251101',
8+
doctorModel: 'claude-haiku-4-5-20251001',
89

910
// Production defaults - override via env vars for local dev
1011
workos: {

src/commands/doctor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import clack from '../utils/clack.js';
55
interface DoctorArgs {
66
verbose?: boolean;
77
skipApi?: boolean;
8+
skipAi?: boolean;
89
installDir?: string;
910
json?: boolean;
1011
copy?: boolean;
@@ -15,6 +16,7 @@ export async function handleDoctor(argv: ArgumentsCamelCase<DoctorArgs>): Promis
1516
installDir: argv.installDir ?? process.cwd(),
1617
verbose: argv.verbose ?? false,
1718
skipApi: argv.skipApi ?? false,
19+
skipAi: argv.skipAi ?? false,
1820
json: argv.json ?? false,
1921
copy: argv.copy ?? false,
2022
};

src/dashboard/components/CompletionView.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState } from 'react';
22
import { Box, Text, useInput, useApp } from 'ink';
3+
import { getLockArt, type LockExpression } from '../../utils/lock-art.js';
34

45
interface OutputLine {
56
text: string;
@@ -29,20 +30,28 @@ export function CompletionView({ success, summary, outputLog }: CompletionViewPr
2930

3031
const visibleLines = outputLog.slice(scrollOffset, scrollOffset + 20);
3132

33+
const expression: LockExpression = success ? 'success' : 'error';
34+
const lockLines = getLockArt(expression, false);
35+
const color = success ? 'green' : 'red';
36+
3237
return (
3338
<Box flexDirection="column" padding={1} width="100%" height="100%">
34-
{/* Header */}
39+
{/* Lock + Header */}
3540
<Box marginBottom={1}>
36-
<Text bold color={success ? 'green' : 'red'}>
37-
{success ? '✓ Installation Complete' : '✗ Installation Failed'}
38-
</Text>
39-
</Box>
40-
41-
{summary && (
42-
<Box marginBottom={1}>
43-
<Text>{summary}</Text>
41+
<Box flexDirection="column" marginRight={2}>
42+
{lockLines.map((line, i) => (
43+
<Text key={i} color={color}>
44+
{line}
45+
</Text>
46+
))}
4447
</Box>
45-
)}
48+
<Box flexDirection="column" justifyContent="center">
49+
<Text bold color={color}>
50+
{success ? 'WorkOS AuthKit Installed' : 'Installation Failed'}
51+
</Text>
52+
{summary && <Text dimColor>{summary}</Text>}
53+
</Box>
54+
</Box>
4655

4756
{/* Scrollable Log */}
4857
<Box flexDirection="column" borderStyle="round" borderColor="gray" flexGrow={1} paddingX={1}>

src/doctor/agent-prompt.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { LanguageInfo, FrameworkInfo, SdkInfo, EnvironmentInfo, Issue } from './types.js';
2+
3+
export interface AnalysisContext {
4+
language: LanguageInfo;
5+
framework: FrameworkInfo;
6+
sdk: SdkInfo;
7+
environment: EnvironmentInfo;
8+
existingIssues: Issue[];
9+
}
10+
11+
export function buildDoctorPrompt(context: AnalysisContext): string {
12+
const { language, framework, sdk, environment, existingIssues } = context;
13+
14+
const projectContext = [
15+
`- Language: ${language.name}`,
16+
framework.name
17+
? `- Framework: ${framework.name} ${framework.version ?? ''}${framework.variant ? ` (${framework.variant})` : ''}`
18+
: null,
19+
sdk.name ? `- SDK: ${sdk.name}${sdk.version ? ` v${sdk.version}` : ''}` : '- SDK: Not installed',
20+
`- Environment: ${environment.apiKeyType ?? 'Unknown'}`,
21+
environment.baseUrl ? `- Base URL: ${environment.baseUrl}` : null,
22+
]
23+
.filter(Boolean)
24+
.join('\n');
25+
26+
const existingIssuesList =
27+
existingIssues.length > 0
28+
? existingIssues.map((i) => `- [${i.severity}] ${i.code}: ${i.message}`).join('\n')
29+
: 'None detected.';
30+
31+
return `You are a WorkOS integration analyst. Analyze this project and identify potential issues with the WorkOS integration.
32+
33+
## Project Context
34+
${projectContext}
35+
36+
## WorkOS SDK Knowledge (IMPORTANT — do not contradict this)
37+
- \`@workos-inc/node\` and \`@workos/node\` work in ANY JavaScript runtime (Node.js, browsers, React Native, Expo, Cloudflare Workers, Deno, Bun). Despite the name, they have NO Node.js-specific API dependencies (no \`node:crypto\`, no \`node:fs\`, etc.).
38+
- \`@workos-inc/node\` supports PKCE (Proof Key for Code Exchange) for client-side authentication flows. Using it in Expo, React Native, or browser SPAs is the CORRECT and recommended approach.
39+
- AuthKit SDKs (\`@workos/authkit-nextjs\`, \`@workos/authkit-react-router\`, \`@workos/authkit-react\`, \`@workos/authkit-tanstack-react-start\`, etc.) are framework-specific wrappers around the core SDK that add session management, middleware, and auth providers.
40+
- Legacy scope \`@workos-inc/*\` and new scope \`@workos/*\` are the same SDKs — the org is migrating package names.
41+
- For non-JS languages: \`workos-python\`, \`workos-ruby\`, \`workos-go\`, \`workos-java\`, \`workos-php\`, \`WorkOS.net\` are server-side SDKs.
42+
- Do NOT flag \`@workos-inc/node\` as incompatible with client-side, mobile, or non-Node environments. It is designed for universal JavaScript use.
43+
44+
## Already Detected Issues
45+
${existingIssuesList}
46+
47+
## Your Task
48+
1. Analyze the project's WorkOS integration based on the context above
49+
2. Check for framework-specific anti-patterns
50+
3. Verify the integration follows WorkOS best practices
51+
4. Identify potential runtime issues
52+
53+
## Output Format
54+
Return your analysis as a JSON object wrapped in a markdown code block:
55+
\`\`\`json
56+
{
57+
"findings": [
58+
{
59+
"severity": "error | warning | info",
60+
"title": "Short description",
61+
"detail": "What's wrong and why it matters",
62+
"remediation": "How to fix it",
63+
"filePath": "path/to/relevant/file"
64+
}
65+
],
66+
"summary": "One paragraph summary of the integration health"
67+
}
68+
\`\`\`
69+
70+
## Rules
71+
- Do NOT repeat issues already detected (listed above)
72+
- Do NOT contradict the SDK Knowledge section above. If you think an SDK is incompatible with a runtime, re-read that section first.
73+
- Only report issues you are confident about. Do NOT speculate about potential problems that might exist — report problems that DO exist based on the project context.
74+
- Focus on framework-specific patterns the static checks can't catch
75+
- Be specific — reference actual file paths and line patterns
76+
- Keep findings actionable — every finding must have a remediation
77+
- Limit to 3-5 most important findings. Fewer high-quality findings beat many speculative ones.
78+
- If the integration looks good, return an empty findings array and say so in the summary. Do NOT invent problems.
79+
- The filePath field is optional — only include it if you found a specific file`;
80+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { parseAiResponse } from './ai-analysis.js';
3+
import { buildDoctorPrompt, type AnalysisContext } from '../agent-prompt.js';
4+
5+
describe('ai-analysis', () => {
6+
describe('parseAiResponse', () => {
7+
it('parses valid JSON with findings', () => {
8+
const json = JSON.stringify({
9+
findings: [
10+
{
11+
severity: 'warning',
12+
title: 'Missing middleware',
13+
detail: 'No auth middleware found',
14+
remediation: 'Add middleware.ts',
15+
filePath: 'src/middleware.ts',
16+
},
17+
],
18+
summary: 'Integration needs work',
19+
});
20+
const result = parseAiResponse(json);
21+
expect(result.findings).toHaveLength(1);
22+
expect(result.findings[0].severity).toBe('warning');
23+
expect(result.findings[0].title).toBe('Missing middleware');
24+
expect(result.findings[0].filePath).toBe('src/middleware.ts');
25+
expect(result.summary).toBe('Integration needs work');
26+
});
27+
28+
it('parses JSON inside markdown code block', () => {
29+
const text = `Here is my analysis:
30+
31+
\`\`\`json
32+
{
33+
"findings": [
34+
{
35+
"severity": "info",
36+
"title": "Good setup",
37+
"detail": "Everything looks correct",
38+
"remediation": "No action needed"
39+
}
40+
],
41+
"summary": "Looks good"
42+
}
43+
\`\`\`
44+
45+
That's my analysis.`;
46+
const result = parseAiResponse(text);
47+
expect(result.findings).toHaveLength(1);
48+
expect(result.findings[0].severity).toBe('info');
49+
expect(result.summary).toBe('Looks good');
50+
});
51+
52+
it('handles empty findings array', () => {
53+
const json = JSON.stringify({ findings: [], summary: 'All clear' });
54+
const result = parseAiResponse(json);
55+
expect(result.findings).toHaveLength(0);
56+
expect(result.summary).toBe('All clear');
57+
});
58+
59+
it('defaults invalid severity to info', () => {
60+
const json = JSON.stringify({
61+
findings: [{ severity: 'critical', title: 'Test', detail: 'Test', remediation: 'Test' }],
62+
summary: '',
63+
});
64+
const result = parseAiResponse(json);
65+
expect(result.findings[0].severity).toBe('info');
66+
});
67+
68+
it('handles malformed JSON gracefully', () => {
69+
const result = parseAiResponse('This is not JSON at all');
70+
expect(result.findings).toHaveLength(0);
71+
expect(result.summary).toBe('This is not JSON at all');
72+
});
73+
74+
it('extracts JSON from mixed text', () => {
75+
const text = `Let me analyze this.
76+
{"findings": [{"severity": "error", "title": "Bad", "detail": "Very bad", "remediation": "Fix it"}], "summary": "Issues found"}
77+
Hope this helps.`;
78+
const result = parseAiResponse(text);
79+
expect(result.findings).toHaveLength(1);
80+
expect(result.findings[0].title).toBe('Bad');
81+
});
82+
83+
it('handles missing optional filePath', () => {
84+
const json = JSON.stringify({
85+
findings: [{ severity: 'warning', title: 'T', detail: 'D', remediation: 'R' }],
86+
summary: '',
87+
});
88+
const result = parseAiResponse(json);
89+
expect(result.findings[0].filePath).toBeUndefined();
90+
});
91+
92+
it('truncates very long fallback summary', () => {
93+
const longText = 'x'.repeat(1000);
94+
const result = parseAiResponse(longText);
95+
expect(result.summary.length).toBeLessThanOrEqual(500);
96+
});
97+
});
98+
99+
describe('buildDoctorPrompt', () => {
100+
const baseContext: AnalysisContext = {
101+
language: { name: 'JavaScript/TypeScript', manifestFile: 'package.json' },
102+
framework: { name: 'Next.js', version: '14.0.0', variant: 'app-router' },
103+
sdk: {
104+
name: '@workos/authkit-nextjs',
105+
version: '1.0.0',
106+
latest: '1.0.0',
107+
outdated: false,
108+
isAuthKit: true,
109+
language: 'javascript',
110+
},
111+
environment: {
112+
apiKeyConfigured: true,
113+
apiKeyType: 'staging',
114+
clientId: 'client_01J...',
115+
redirectUri: null,
116+
cookieDomain: null,
117+
baseUrl: 'https://api.workos.com',
118+
},
119+
existingIssues: [],
120+
};
121+
122+
it('includes project context in prompt', () => {
123+
const prompt = buildDoctorPrompt(baseContext);
124+
expect(prompt).toContain('JavaScript/TypeScript');
125+
expect(prompt).toContain('Next.js');
126+
expect(prompt).toContain('@workos/authkit-nextjs');
127+
expect(prompt).toContain('staging');
128+
});
129+
130+
it('includes existing issues for deduplication', () => {
131+
const context: AnalysisContext = {
132+
...baseContext,
133+
existingIssues: [{ code: 'MISSING_API_KEY', severity: 'error', message: 'API key not set' }],
134+
};
135+
const prompt = buildDoctorPrompt(context);
136+
expect(prompt).toContain('MISSING_API_KEY');
137+
expect(prompt).toContain('API key not set');
138+
});
139+
140+
it('shows "None detected" when no existing issues', () => {
141+
const prompt = buildDoctorPrompt(baseContext);
142+
expect(prompt).toContain('None detected');
143+
});
144+
145+
it('handles null framework', () => {
146+
const context: AnalysisContext = {
147+
...baseContext,
148+
framework: { name: null, version: null },
149+
};
150+
const prompt = buildDoctorPrompt(context);
151+
expect(prompt).not.toContain('Framework:');
152+
});
153+
154+
it('handles null SDK', () => {
155+
const context: AnalysisContext = {
156+
...baseContext,
157+
sdk: { ...baseContext.sdk, name: null, version: null },
158+
};
159+
const prompt = buildDoctorPrompt(context);
160+
expect(prompt).toContain('SDK: Not installed');
161+
});
162+
163+
it('includes output format instructions', () => {
164+
const prompt = buildDoctorPrompt(baseContext);
165+
expect(prompt).toContain('"findings"');
166+
expect(prompt).toContain('"summary"');
167+
expect(prompt).toContain('JSON');
168+
});
169+
170+
it('includes SDK knowledge to prevent false positives', () => {
171+
const prompt = buildDoctorPrompt(baseContext);
172+
expect(prompt).toContain('SDK Knowledge');
173+
expect(prompt).toContain('PKCE');
174+
expect(prompt).toContain('@workos-inc/node');
175+
expect(prompt).toContain('ANY JavaScript runtime');
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)