From aa875e32946bb979932f3c9bce3e36bd2a45834d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 15:55:14 -0500 Subject: [PATCH 1/8] fix: detect Vite port for TanStack Start and validate redirect URI port detectPort now reads vite.config.{ts,js,mjs} for tanstack-start (modern @tanstack/react-start is Vite-based), falling back to legacy Vinxi app.config.ts, and the shared react/react-router/vanilla-js Vite loop is extracted into a parseViteConfigPortFromDir helper. Redirect-URI validators (Next.js, React Router, TanStack Start) now compare the URI's effective port to the detected dev-server port for local hosts and push an error-severity issue on mismatch, flipping validation to failed instead of silently passing. Route-mismatch hints use the redirect URI's real origin instead of a hardcoded localhost:3000. Fixes the Akshay friction-log trust failure where a Lovable TanStack Start app on port 8080 was configured against localhost:3000 yet certified as "Validation passed". --- src/doctor/checks/framework.spec.ts | 8 ++ src/lib/port-detection.spec.ts | 73 +++++++++++ src/lib/port-detection.ts | 31 +++-- src/lib/validation/validator.spec.ts | 174 ++++++++++++++++++++++++++- src/lib/validation/validator.ts | 41 ++++++- 5 files changed, 310 insertions(+), 17 deletions(-) diff --git a/src/doctor/checks/framework.spec.ts b/src/doctor/checks/framework.spec.ts index 6ac246be..2d5fda05 100644 --- a/src/doctor/checks/framework.spec.ts +++ b/src/doctor/checks/framework.spec.ts @@ -89,6 +89,14 @@ describe('checkFramework - new frameworks', () => { expect(result.name).toBe('SvelteKit'); }); + it('reports the vite.config port for a modern TanStack Start app (detectPort fan-out)', async () => { + await writeFile(join(dir, 'package.json'), makePackageJson({ '@tanstack/react-start': '^1.0.0' })); + await writeFile(join(dir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + const result = await checkFramework({ installDir: dir }); + expect(result.name).toBe('TanStack Start'); + expect(result.detectedPort).toBe(8080); + }); + it('returns null for no frameworks', async () => { await writeFile(join(dir, 'package.json'), makePackageJson({})); const result = await checkFramework({ installDir: dir }); diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index 35591576..0d8628ce 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -155,3 +155,76 @@ describe('port-detection — ruby/rails puma', () => { expect(detectPort('ruby', dir)).toBe(4000); }); }); + +describe('port-detection — tanstack-start config', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-tanstack-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from vite.config.ts (modern @tanstack/react-start)', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { host: '::', port: 8080 }, plugins: [] })\n", + ); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it('falls back to legacy app.config.ts (Vinxi) when no vite config', async () => { + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(4200); + }); + + it('prefers vite.config.ts over app.config.ts when both present', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 8080 } })\n", + ); + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it.each(['vite.config.js', 'vite.config.mjs'])('parses port from %s', async (fileName) => { + await writeFile(join(dir, fileName), 'export default { server: { port: 5555 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(5555); + }); + + it('falls back to default 3000 when no config file present', () => { + expect(detectPort('tanstack-start', dir)).toBe(3000); + }); +}); + +describe('port-detection — vite frameworks', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-vite-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it.each(['react', 'react-router', 'vanilla-js'] as const)( + '%s parses port from vite.config.ts', + async (framework) => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 4200 } })\n", + ); + expect(detectPort(framework, dir)).toBe(4200); + }, + ); + + it.each(['react', 'react-router', 'vanilla-js'] as const)( + '%s falls back to default 5173 when no vite config present', + (framework) => { + expect(detectPort(framework, dir)).toBe(5173); + }, + ); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index f0a8cb74..420de6ad 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -52,6 +52,20 @@ function parseViteConfigPort(configPath: string): number | null { return null; } +/** + * Try vite.config.{ts,js,mjs} in installDir for a server.port value. + * NOTE: parseViteConfigPort uses a greedy first-match /port\s*:\s*.../ regex, + * so an unrelated `port:` (e.g. hmr.port) appearing before server.port wins. + * Pre-existing behavior for all Vite frameworks — not a regression here. + */ +function parseViteConfigPortFromDir(installDir: string): number | null { + for (const p of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) { + const port = parseViteConfigPort(join(installDir, p)); + if (port) return port; + } + return null; +} + /** * Parse port from Next.js package.json scripts. * Next.js uses: "dev": "next dev -p 4000" or --port 4000 @@ -206,24 +220,17 @@ export function detectPort(integration: Integration, installDir: string): number break; case 'tanstack-start': - detectedPort = parseTanStackPort(installDir); + // Modern @tanstack/react-start is Vite-based (port in vite.config.ts); + // legacy Vinxi projects use app.config.ts. Try Vite first. + detectedPort = parseViteConfigPortFromDir(installDir) ?? parseTanStackPort(installDir); break; case 'react': case 'react-router': - case 'vanilla-js': { + case 'vanilla-js': // Vite-based frameworks - const viteConfigs = [ - join(installDir, 'vite.config.ts'), - join(installDir, 'vite.config.js'), - join(installDir, 'vite.config.mjs'), - ]; - for (const configPath of viteConfigs) { - detectedPort = parseViteConfigPort(configPath); - if (detectedPort) break; - } + detectedPort = parseViteConfigPortFromDir(installDir); break; - } case 'dotnet': detectedPort = parseDotnetPort(installDir); diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 8aae97ca..7c1fef7b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -373,7 +373,7 @@ describe('validateInstallation', () => { // Redirect URI says /auth/callback but route is at /callback writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route exists at DIFFERENT path (dot notation) mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -390,7 +390,7 @@ describe('validateInstallation', () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route at correct path using dot notation: auth.callback.tsx mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -819,4 +819,174 @@ describe('validateInstallation', () => { expect(envIssue?.hint).toContain('VITE_WORKOS_CLIENT_ID'); }); }); + + describe('redirect URI port validation', () => { + const portError = (result: { issues: { severity: string; message: string }[] }) => + result.issues.find((i) => i.severity === 'error' && /port/i.test(i.message)); + + it('flags TanStack redirect URI port that mismatches the vite.config port (friction scenario)', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + // dev server runs on 8080 (Vite), but the redirect URI points at :3000 + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('does not flag a port issue when TanStack redirect URI port matches the vite.config port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeUndefined(); + }); + + it('flags React Router redirect URI port against the default 5173 (helper in isolation)', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + // No vite.config → detected port is the RR default 5173; env points at :3000 + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('flags Next.js redirect URI port against a -p dev-script port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 4000' }, + }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('skips the port check for non-local (production) hosts', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=https://app.example.com/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + }); + + it('normalizes a missing port to the protocol default (80) before comparing', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('recognizes the IPv6 localhost form [::1]', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://[::1]:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('route-mismatch hint uses the redirect URI real origin, not a hardcoded localhost:3000', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 8080' }, + }), + ); + // detected port (8080) matches the env port so no port-mismatch noise + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + // route lives at a DIFFERENT path → triggers the mismatch hint + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeDefined(); + expect(mismatchIssue?.hint).toContain('http://localhost:8080'); + expect(mismatchIssue?.hint).not.toContain('localhost:3000'); + }); + }); }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 18146b7a..111b5340 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; +import { detectPort } from '../port-detection.js'; export interface ValidateOptions { variant?: string; @@ -250,6 +251,37 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } +/** + * Compares the redirect URI's effective port to the detected dev server port, + * for local dev hosts only, and records an error-severity issue on mismatch. + * Skips production URIs and placeholder hosts like http://test. + */ +function validateRedirectUriPort( + url: URL, + framework: string, + projectDir: string, + issues: ValidationIssue[], +): void { + // Only enforce for local dev hosts; skip production URIs and placeholder + // hosts like http://test. Node parses IPv6 hostnames with brackets: [::1]. + const localHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + if (!localHosts.has(url.hostname)) return; + + const effectivePort = url.port || (url.protocol === 'https:' ? '443' : '80'); + const detected = detectPort(framework, projectDir); + + if (Number(effectivePort) !== detected) { + issues.push({ + type: 'env', + severity: 'error', + message: `Redirect URI port :${effectivePort} does not match the detected dev server port :${detected}`, + hint: + `Update the redirect URI to use port :${detected} in .env.local and the WorkOS dashboard ` + + `(redirect URI, CORS origin, homepage URL), or change your dev server to run on port ${effectivePort}.`, + }); + } +} + /** * Validates that the Next.js redirect URI matches an existing callback route. * @@ -277,6 +309,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'nextjs', projectDir, issues); } catch { issues.push({ type: 'env', @@ -319,7 +352,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI const actualPath = '/' + existingRoutes[0].replace(/^(src\/)?app\//, '').replace(/\/route\.(ts|tsx|js|jsx)$/, ''); hint = `Found callback route at ${existingRoutes[0]} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/${routePath}/route.ts`; } @@ -360,6 +393,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'react-router', projectDir, issues); } catch { issues.push({ type: 'env', @@ -418,7 +452,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida .replace(/\./g, '/'); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${dotPath}.tsx`; } @@ -458,6 +492,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'tanstack-start', projectDir, issues); } catch { issues.push({ type: 'env', @@ -500,7 +535,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali .replace(/\/index$/, ''); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${routePath}.tsx`; } From f6c81d0f00021f4e68d89687728f4f36433ff24e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 16:00:44 -0500 Subject: [PATCH 2/8] feat: enrich install completion summary and stream agent file ops Phase 4 (Install UX) of the Akshay friction-log fixes. Completion summary: - Add CompletionData type + pure buildCompletionData builder (lockfile-aware dev command via resolveDevCommand, app URL via detectPort, changed files, composed next steps, per-framework docs + dashboard URLs). - New buildingCompletion machine state + actor computes the payload between postInstall and complete; errors degrade to the static fallback box. - renderCompletionSummary renders the structured success box (files capped at 5, next steps, per-framework docs footer); falls back to the static box when no completion data is present. Failure branch unchanged. - Widen the complete event with an optional completion payload; all three adapters (CLI, Dashboard, Headless) render/emit it. Headless spreads the fields only when present, preserving the existing NDJSON shape. Agent progress: - CLI adapter: remove the 2s spinner-message reset that clobbered phase text; persist file:write/file:edit as append-only step lines above the spinner (stop -> log -> restart), path-only with consecutive-path dedupe. - Headless adapter: stream path-only file:write/file:edit NDJSON (never content). - Stretch: surface Bash commands via a new agent:tool event emitted from the post-permission tool_use branch; rendered as step lines (CLI) and NDJSON (headless). - Stretch: optional UIConfig.getSignInSnippet surfaced as a next step; defined for Next.js (client-side refreshAuth guidance, per the AuthKit skill). Validated: pnpm build, pnpm test (2241), pnpm typecheck, oxlint, oxfmt all pass. --- src/integrations/nextjs/index.ts | 4 + src/lib/adapters/cli-adapter.spec.ts | 92 ++++++++++++++++- src/lib/adapters/cli-adapter.ts | 64 +++++++++--- src/lib/adapters/dashboard-adapter.ts | 16 ++- src/lib/adapters/headless-adapter.spec.ts | 82 ++++++++++++++++ src/lib/adapters/headless-adapter.ts | 44 ++++++++- src/lib/agent-interface.ts | 9 ++ src/lib/completion-data.spec.ts | 114 ++++++++++++++++++++++ src/lib/completion-data.ts | 62 ++++++++++++ src/lib/events.ts | 31 +++++- src/lib/framework-config.ts | 7 ++ src/lib/installer-core.test-utils.ts | 1 + src/lib/installer-core.ts | 26 ++++- src/lib/installer-core.types.ts | 4 +- src/lib/run-with-core.ts | 31 ++++++ src/utils/summary-box.spec.ts | 53 +++++++++- src/utils/summary-box.ts | 21 +++- 17 files changed, 633 insertions(+), 28 deletions(-) create mode 100644 src/lib/completion-data.spec.ts create mode 100644 src/lib/completion-data.ts diff --git a/src/integrations/nextjs/index.ts b/src/integrations/nextjs/index.ts index 6e1bcdc7..d7d9179e 100644 --- a/src/integrations/nextjs/index.ts +++ b/src/integrations/nextjs/index.ts @@ -75,6 +75,10 @@ export const config: FrameworkConfig = { 'Start your development server to test authentication', 'Visit the WorkOS Dashboard to manage users and settings', ], + // Client-side sign-in per the AuthKit Next.js guidance: trigger sign-in from + // a click handler with refreshAuth (getSignInUrl must not run during render). + getSignInSnippet: () => + 'Add a sign-in button in a client component: call refreshAuth({ ensureSignedIn: true }) on click', }, }; diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index f7af41f5..7fdee71e 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -222,17 +222,105 @@ describe('CLIAdapter', () => { expect(sendEvent).toHaveBeenCalledWith({ type: 'CANCEL' }); }); - it('shows success summary box on complete', async () => { + it('shows structured success summary box on complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); - emitter.emit('complete', { success: true, summary: 'All done!' }); + emitter.emit('complete', { + success: true, + summary: 'All done!', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['app/auth/route.ts'], + nextSteps: [ + 'Run `pnpm run dev` to start your dev server', + 'Open http://localhost:3000 to test authentication', + ], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + }, + }); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(output).toContain('WorkOS AuthKit Installed'); + expect(output).toContain('pnpm run dev'); + expect(output).toContain('app/auth/route.ts'); consoleSpy.mockRestore(); }); + it('renders persistent step lines for file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'secret' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('src/auth.ts'))).toBe(true); + expect(stepCalls.some((s) => s.includes('src/app.ts'))).toBe(true); + }); + + it('dedupes consecutive same-path file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'b', newContent: 'c' }); + + const appCalls = vi.mocked(clack.default.log.step).mock.calls.filter((c) => String(c[0]).includes('src/app.ts')); + expect(appCalls).toHaveLength(1); + }); + + it('does not clobber the phase message back to a generic string', async () => { + vi.useFakeTimers(); + try { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + vi.advanceTimersByTime(5000); + + const clobbered = spinnerMock.message.mock.calls + .map((c) => String(c[0])) + .filter((m) => /Running AI agent/.test(m)); + expect(clobbered).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('restarts the spinner on the last phase message after logging a file op', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'x' }); + + expect(spinnerMock.stop).toHaveBeenCalled(); + expect(spinnerMock.start).toHaveBeenCalledWith('Configuring middleware'); + }); + + it('renders Bash tool calls as step lines (agent:tool)', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('agent:tool', { kind: 'command', detail: 'pnpm add @workos-inc/authkit-nextjs' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('pnpm add @workos-inc/authkit-nextjs'))).toBe(true); + }); + it('shows error summary box on failure complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 9d1526d5..52a916c1 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -1,5 +1,6 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import { relative } from 'node:path'; import clack from '../../utils/clack.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; @@ -35,9 +36,14 @@ export class CLIAdapter implements InstallerAdapter { // SIGINT handler for cleanup private sigIntHandler: (() => void) | null = null; - // Long-running agent update interval + // Long-running agent update interval (kept as a no-op field; never started) private agentUpdateInterval: NodeJS.Timeout | null = null; + // Last phase message shown on the agent spinner, restored after logging above it. + private lastAgentMessage = 'Running AI agent...'; + // Last file path rendered as a step line, to dedupe consecutive same-path ops. + private lastFileOp: string | null = null; + constructor(config: AdapterConfig) { this.emitter = config.emitter; this.sendEvent = config.sendEvent; @@ -112,6 +118,10 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('config:complete', this.handleConfigComplete); this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // Persistent, append-only log of file operations + tool calls above the spinner. + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); this.subscribe('validation:complete', this.handleValidationComplete); @@ -359,22 +369,52 @@ export class CLIAdapter implements InstallerAdapter { private handleAgentStart = (): void => { this.spinner = clack.spinner(); - this.spinner.start('Running AI agent...'); - - // Periodic status updates for long-running operations - let dots = 0; - this.agentUpdateInterval = setInterval(() => { - dots = (dots + 1) % 4; - const dotStr = '.'.repeat(dots + 1); - this.spinner?.message(`Running AI agent${dotStr}`); - }, 2000); + this.spinner.start(this.lastAgentMessage); + // No setInterval: clack animates its own frames, and the old 2s reset + // clobbered the current phase text set by handleAgentProgress. }; private handleAgentProgress = ({ step, detail }: InstallerEvents['agent:progress']): void => { const message = detail ? `${step}: ${detail}` : step; + this.lastAgentMessage = message; this.spinner?.message(message); }; + /** + * Render a persistent line above the running spinner: stop the spinner to + * finalize its line, emit the log, then restart it on the last phase message. + * Mirrors the existing stop→log and stop→start-new-spinner precedents. + */ + private logAboveSpinner(render: () => void): void { + const wasRunning = this.spinner !== null; + this.spinner?.stop(); + this.spinner = null; + render(); + if (wasRunning) { + this.spinner = clack.spinner(); + this.spinner.start(this.lastAgentMessage); + } + } + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; // dedupe consecutive same-path ops + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Creating ${chalk.dim(rel)}`)); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Editing ${chalk.dim(rel)}`)); + }; + + private handleAgentTool = ({ detail }: InstallerEvents['agent:tool']): void => { + const cmd = detail.length > 80 ? `${detail.slice(0, 77)}…` : detail; + this.logAboveSpinner(() => clack.log.step(`Running ${chalk.dim(cmd)}`)); + }; + private handleValidationStart = (): void => { this.stopAgentUpdates(); this.stopSpinner('Agent completed'); @@ -401,12 +441,12 @@ export class CLIAdapter implements InstallerAdapter { } }; - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { this.stopAgentUpdates(); this.stopSpinner(success ? 'Done' : 'Failed'); console.log(''); - console.log(renderCompletionSummary(success, summary)); + console.log(renderCompletionSummary(success, summary, completion)); console.log(''); // When we scaffolded a fresh app, the install ran in the current dir, so diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index c0a23809..90c832c2 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -1,5 +1,5 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; -import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import type { InstallerEventEmitter, InstallerEvents, CompletionData } from '../events.js'; import { renderCompletionSummary } from '../../utils/summary-box.js'; /** @@ -13,7 +13,7 @@ export class DashboardAdapter implements InstallerAdapter { private sendEvent: AdapterConfig['sendEvent']; private cleanup: (() => void) | null = null; private isStarted = false; - private completionData: { success: boolean; summary?: string } | null = null; + private completionData: { success: boolean; summary?: string; completion?: CompletionData } | null = null; constructor(config: AdapterConfig) { this.emitter = config.emitter; @@ -65,8 +65,8 @@ export class DashboardAdapter implements InstallerAdapter { /** * Capture completion data for display after exit. */ - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - this.completionData = { success, summary }; + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + this.completionData = { success, summary, completion }; }; async stop(): Promise { @@ -87,7 +87,13 @@ export class DashboardAdapter implements InstallerAdapter { if (this.completionData) { console.log(); - console.log(renderCompletionSummary(this.completionData.success, this.completionData.summary)); + console.log( + renderCompletionSummary( + this.completionData.success, + this.completionData.summary, + this.completionData.completion, + ), + ); console.log(); } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 0d788aa4..b8340898 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -329,6 +329,56 @@ describe('HeadlessAdapter', () => { }); }); + describe('file operations', () => { + it('writes path-only NDJSON for file:write (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:write', { path: 'src/auth.ts', content: 'secret' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:write', path: 'src/auth.ts' }); + const leakedContent = mockWriteNDJSON.mock.calls.some((c) => c[0] && 'content' in (c[0] as object)); + expect(leakedContent).toBe(false); + await adapter.stop(); + }); + + it('writes path-only NDJSON for file:edit (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:edit', path: 'src/app.ts' }); + const leaked = mockWriteNDJSON.mock.calls.some( + (c) => c[0] && ('oldContent' in (c[0] as object) || 'newContent' in (c[0] as object)), + ); + expect(leaked).toBe(false); + await adapter.stop(); + }); + + it('dedupes consecutive same-path file operations', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'b', newContent: 'c' }); + + const editCalls = mockWriteNDJSON.mock.calls.filter((c) => (c[0] as { type?: string })?.type === 'file:edit'); + expect(editCalls).toHaveLength(1); + await adapter.stop(); + }); + + it('writes agent:tool NDJSON for surfaced commands', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('agent:tool', { kind: 'command', detail: 'ls' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'agent:tool', kind: 'command', detail: 'ls' }); + await adapter.stop(); + }); + }); + describe('terminal events', () => { it('writes complete event', async () => { const adapter = createAdapter(); @@ -345,6 +395,38 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); + it('spreads structured completion fields into the complete event when present', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('complete', { + success: true, + summary: 'ok', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }, + }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'complete', + success: true, + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + }), + ); + await adapter.stop(); + }); + it('writes error event', async () => { const adapter = createAdapter(); await adapter.start(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 6f167bbe..b40c6bbf 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -30,6 +30,7 @@ export class HeadlessAdapter implements InstallerAdapter { private options: HeadlessOptions; private isStarted = false; private scaffolded = false; + private lastFileOp: string | null = null; private handlers = new Map void>(); constructor(config: AdapterConfig & { options: HeadlessOptions }) { @@ -81,6 +82,11 @@ export class HeadlessAdapter implements InstallerAdapter { this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // File operations + tool calls — path/detail only, never content + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); + // Validation this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); @@ -279,6 +285,24 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'agent:progress', message }); }; + // ===== File Operations (path-only, no content) ===== + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:write', path }); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:edit', path }); + }; + + private handleAgentTool = ({ kind, detail }: InstallerEvents['agent:tool']): void => { + writeNDJSON({ type: 'agent:tool', kind, detail }); + }; + // ===== Validation ===== private handleValidationStart = ({ framework }: InstallerEvents['validation:start']): void => { @@ -363,8 +387,24 @@ export class HeadlessAdapter implements InstallerAdapter { // ===== Terminal Events ===== - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - writeNDJSON({ type: 'complete', success, summary, scaffolded: this.scaffolded }); + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + writeNDJSON({ + type: 'complete', + success, + summary, + scaffolded: this.scaffolded, + // Spread structured fields only when present, so existing consumers that + // emit `complete` without `completion` keep their exact payload shape. + ...(completion + ? { + integration: completion.integration, + devCommand: completion.devCommand, + url: completion.url, + files: completion.files, + nextSteps: completion.nextSteps, + } + : {}), + }); }; private handleError = ({ message, stack }: InstallerEvents['error']): void => { diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index d839e86b..5f6e74d0 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -841,6 +841,15 @@ function handleSDKMessage( } } + // Surface Bash commands (post-permission: this branch only fires for + // tool calls that were actually allowed, unlike the canUseTool closure). + if (toolName === 'Bash' && input) { + const command = input.command as string; + if (command) { + emitter?.emit('agent:tool', { kind: 'command', detail: command }); + } + } + // Track Read operations for caching file content later if (toolName === 'Read' && input && block.id) { const filePath = input.file_path as string; diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts new file mode 100644 index 00000000..1bbe9796 --- /dev/null +++ b/src/lib/completion-data.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { detectPort } from './port-detection.js'; + +describe('buildCompletionData', () => { + let installDir: string; + + beforeEach(() => { + installDir = mkdtempSync(join(tmpdir(), 'workos-completion-test-')); + }); + + afterEach(() => { + rmSync(installDir, { recursive: true, force: true }); + }); + + function writePackageJson(content: Record): void { + writeFileSync(join(installDir, 'package.json'), JSON.stringify(content)); + } + + function writeFile(name: string, content = ''): void { + writeFileSync(join(installDir, name), content); + } + + const baseDeps = { + resolveDevCommand, + detectPort, + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }; + + it('builds a lockfile-aware dev command, url, files, and next steps (happy path)', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + writeFile('pnpm-lock.yaml', 'lockfileVersion: 9\n'); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: ['app/auth/route.ts', '.env.local'], installDir }, + baseDeps, + ); + + expect(data.devCommand).toBe('pnpm run dev'); + expect(data.url).toBe('http://localhost:3000'); + expect(data.files).toHaveLength(2); + expect(data.nextSteps[0]).toContain('pnpm run dev'); + expect(data.nextSteps[1]).toContain('http://localhost:3000'); + expect(data.docsUrl).toBe('https://d'); + expect(data.dashboardUrl).toBe('https://dash'); + expect(data.integration).toBe('nextjs'); + }); + + it('respects a Vite server.port override for react', async () => { + writePackageJson({ scripts: { dev: 'vite' }, dependencies: { react: '18.0.0', vite: '5.0.0' } }); + writeFile('vite.config.ts', 'export default { server: { port: 8080 } };'); + + const data = await buildCompletionData({ integration: 'react', changedFiles: [], installDir }, baseDeps); + + expect(data.url).toBe('http://localhost:8080'); + }); + + it('handles empty changedFiles (--no-commit shape) without throwing', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.files).toEqual([]); + expect(data.nextSteps[0]).toContain('start your dev server'); + expect(data.nextSteps[1]).toContain('test authentication'); + }); + + it('drops the generic "start dev server" framework step but keeps others', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { + ...baseDeps, + frameworkNextSteps: [ + 'Start your development server to test authentication', + 'Visit the WorkOS Dashboard to manage users and settings', + ], + }, + ); + + // The generic "start dev server" duplicate is filtered out... + expect(data.nextSteps.filter((s) => /start your development server/i.test(s))).toHaveLength(0); + // ...but the dashboard step is retained. + expect(data.nextSteps.some((s) => /WorkOS Dashboard/.test(s))).toBe(true); + }); + + it('passes through a sign-in snippet into nextSteps and completion', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const snippet = 'Add a sign-in button using refreshAuth({ ensureSignedIn: true })'; + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { ...baseDeps, signInSnippet: snippet }, + ); + + expect(data.signInSnippet).toBe(snippet); + expect(data.nextSteps).toContain(snippet); + }); + + it('omits the sign-in snippet when not provided', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.signInSnippet).toBeUndefined(); + expect(data.nextSteps.some((s) => /refreshAuth/.test(s))).toBe(false); + }); +}); diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts new file mode 100644 index 00000000..929ccf73 --- /dev/null +++ b/src/lib/completion-data.ts @@ -0,0 +1,62 @@ +import type { CompletionData } from './events.js'; +import type { DevCommandResult } from './dev-command.js'; +import type { Integration } from './constants.js'; + +/** + * Machine-context slice needed to build completion data. + */ +export interface CompletionContext { + integration: string; + changedFiles?: string[]; + installDir: string; +} + +/** + * Injected dependencies. The impure lookups (registry/settings) are resolved by + * the caller and passed as plain values so the builder stays pure + unit-testable. + */ +export interface CompletionDataDeps { + resolveDevCommand: (dir: string) => Promise; + detectPort: (integration: Integration, dir: string) => number; + docsUrl: string; + dashboardUrl: string; + /** Enriched getOutroNextSteps copy for the framework */ + frameworkNextSteps?: string[]; + /** Per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + +/** + * Build the structured completion payload for a successful install. + * + * Deterministic given its inputs. The dev command is derived from the + * lockfile-aware `resolveDevCommand` (never from context.packageManager, which + * is flag/env-derived and unreliable). Copy fields are injected by the caller. + */ +export async function buildCompletionData(ctx: CompletionContext, deps: CompletionDataDeps): Promise { + const dev = await deps.resolveDevCommand(ctx.installDir); + const devCommand = [dev.command, ...dev.args].join(' '); + const port = deps.detectPort(ctx.integration as Integration, ctx.installDir); + const url = `http://localhost:${port}`; + const files = ctx.changedFiles ?? []; + + const concrete = [ + `Run \`${devCommand}\` to start your dev server`, + `Open ${url} to test authentication`, + ...(deps.signInSnippet ? [deps.signInSnippet] : []), + ]; + // Drop the framework's generic "start dev server" line — the concrete step + // above already names the exact lockfile-aware command. + const framework = (deps.frameworkNextSteps ?? []).filter((s) => !/start .*dev(elopment)? server/i.test(s)); + + return { + integration: ctx.integration, + devCommand, + url, + files, + nextSteps: [...concrete, ...framework], + docsUrl: deps.docsUrl, + dashboardUrl: deps.dashboardUrl, + signInSnippet: deps.signInSnippet, + }; +} diff --git a/src/lib/events.ts b/src/lib/events.ts index abedc70a..9b438e77 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,5 +1,32 @@ import { EventEmitter } from 'events'; +/** + * Structured data describing a successful installation, used to render the + * completion summary and enrich the headless `complete` NDJSON event. + * + * Defined here (not in installer-core.types.ts) to avoid an import cycle: + * installer-core.types.ts already imports from this module, so this module + * must not import back. + */ +export interface CompletionData { + /** Integration identifier (e.g. 'nextjs') */ + integration: string; + /** Lockfile-aware dev command, e.g. "pnpm run dev" */ + devCommand: string; + /** App URL with the detected port, e.g. "http://localhost:3000" */ + url: string; + /** Changed files (git-relative), full list — display cap lives in the renderer */ + files: string[]; + /** Composed concrete + framework next-step lines */ + nextSteps: string[]; + /** Per-framework docs URL */ + docsUrl: string; + /** WorkOS dashboard URL */ + dashboardUrl: string; + /** Optional per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + export interface InstallerEvents { status: { message: string }; output: { text: string; isError?: boolean }; @@ -11,7 +38,7 @@ export interface InstallerEvents { 'confirm:response': { id: string; confirmed: boolean }; 'credentials:request': { requiresApiKey: boolean }; 'credentials:response': { apiKey: string; clientId: string }; - complete: { success: boolean; summary?: string }; + complete: { success: boolean; summary?: string; completion?: CompletionData }; error: { message: string; stack?: string }; 'state:enter': { state: string }; @@ -53,6 +80,8 @@ export interface InstallerEvents { 'agent:success': { summary?: string }; 'agent:failure': { message: string; stack?: string }; 'agent:retry': { attempt: number; maxRetries: number }; + // Surfaced agent tool activity (e.g. Bash commands run during install) + 'agent:tool': { kind: 'command'; detail: string }; 'validation:retry:start': { attempt: number }; 'validation:retry:complete': { attempt: number; passed: boolean }; diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index 5ff1e6d8..df2bfdcb 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -143,6 +143,13 @@ export interface UIConfig { /** Generate "Next steps" bullets from context */ getOutroNextSteps: (context: any) => string[]; + + /** + * Optional per-framework "add a sign-in link" snippet, surfaced as a next + * step in the completion summary. Only defined for frameworks that scaffold + * interactive auth UI. Copy must stay accurate to what the agent scaffolds. + */ + getSignInSnippet?: (context: any) => string; } /** diff --git a/src/lib/installer-core.test-utils.ts b/src/lib/installer-core.test-utils.ts index d736b0d0..2cd0b27e 100644 --- a/src/lib/installer-core.test-utils.ts +++ b/src/lib/installer-core.test-utils.ts @@ -47,6 +47,7 @@ export function createEventCapture() { 'agent:progress', 'agent:success', 'agent:failure', + 'agent:tool', 'file:write', 'file:edit', 'prompt:request', diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 3b13c2f9..7bc41430 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -13,6 +13,7 @@ import type { WorkspaceCheckOutput, } from './installer-core.types.js'; import type { InstallerOptions } from '../utils/types.js'; +import type { CompletionData } from './events.js'; import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; import type { StagingCredentials } from './staging-api.js'; import { getManualPrInstructions } from './post-install.js'; @@ -312,8 +313,11 @@ export const installerMachine = setup({ }, emitComplete: ({ context }) => { const summary = context.agentSummary ?? 'WorkOS AuthKit installed successfully!'; - context.emitter.emit('complete', { success: true, summary }); + context.emitter.emit('complete', { success: true, summary, completion: context.completion }); }, + assignCompletion: assign({ + completion: ({ event }) => (event as unknown as { output?: CompletionData }).output, + }), }, guards: { @@ -354,6 +358,9 @@ export const installerMachine = setup({ runAgent: fromPromise(async () => { throw new Error('runAgent not implemented - provide via machine.provide()'); }), + buildCompletion: fromPromise(async () => { + throw new Error('buildCompletion not implemented - provide via machine.provide()'); + }), // Credential discovery actors detectEnvFiles: fromPromise(async () => { throw new Error('detectEnvFiles not implemented - provide via machine.provide()'); @@ -1000,7 +1007,7 @@ export const installerMachine = setup({ checking: { always: [ { - target: '#installer.complete', + target: '#installer.buildingCompletion', guard: 'shouldSkipPostInstall', }, { target: 'detectingChanges' }, @@ -1156,7 +1163,20 @@ export const installerMachine = setup({ }, }, onDone: { - target: 'complete', + target: 'buildingCompletion', + }, + }, + + // Compute the structured completion payload before entering `complete`. + // `emitComplete` is a synchronous entry action, but the builder is async + // (lockfile-aware dev command, port detection), so it must run in an actor + // first. Errors never block completion — they fall through to the static box. + buildingCompletion: { + invoke: { + src: 'buildCompletion', + input: ({ context }) => ({ context }), + onDone: { target: 'complete', actions: ['assignCompletion'] }, + onError: { target: 'complete' }, }, }, diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index f004455e..46aec300 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -1,4 +1,4 @@ -import type { InstallerEventEmitter } from './events.js'; +import type { InstallerEventEmitter, CompletionData } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; import type { Integration } from './constants.js'; import type { PackageManager } from './scaffold/scaffold.js'; @@ -68,6 +68,8 @@ export interface InstallerMachineContext { autoScaffold?: boolean; /** Whether create-next-app actually ran and succeeded (for telemetry) */ scaffolded?: boolean; + /** Structured completion data for the success summary (built pre-complete) */ + completion?: CompletionData; } /** diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 34fda45b..5d2f33b6 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -4,6 +4,10 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; +import type { CompletionData } from './events.js'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; @@ -350,6 +354,33 @@ export async function runWithCore(options: InstallerOptions): Promise { } }), + buildCompletion: fromPromise( + async ({ input }) => { + const { integration, changedFiles, options: installerOptions } = input.context; + if (!integration) return undefined; + try { + const registry = await getRegistry(); + const mod = registry.get(integration); + const cfg = mod?.config; + const settings = getInstallerSettings(); + return await buildCompletionData( + { integration, changedFiles, installDir: installerOptions.installDir }, + { + resolveDevCommand, + detectPort, + docsUrl: cfg?.metadata.docsUrl ?? settings.documentation.workosDocsUrl, + dashboardUrl: settings.documentation.dashboardUrl, + frameworkNextSteps: cfg?.ui.getOutroNextSteps?.({}) ?? [], + signInSnippet: cfg?.ui.getSignInSnippet?.({}), + }, + ); + } catch { + // Degrade to the static fallback box rather than blocking completion. + return undefined; + } + }, + ), + // Credential discovery actors detectEnvFiles: fromPromise(async ({ input }) => { return checkForEnvFiles(input.installDir); diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index 8cc28f83..36d45e89 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { renderSummaryBox, type SummaryBoxItem } from './summary-box.js'; +import { renderSummaryBox, renderCompletionSummary, type SummaryBoxItem } from './summary-box.js'; +import type { CompletionData } from '../lib/events.js'; // Simple ANSI code stripper (avoids needing strip-ansi as a dependency) function strip(str: string): string { @@ -129,4 +130,54 @@ describe('summary-box', () => { } }); }); + + describe('renderCompletionSummary', () => { + function makeCompletion(overrides: Partial = {}): CompletionData { + return { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:8080', + files: ['app/auth/route.ts', '.env.local'], + nextSteps: ['Run `pnpm run dev` to start your dev server', 'Open http://localhost:8080 to test authentication'], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + ...overrides, + }; + } + + it('renders structured success data (dev command, files, url)', () => { + const result = strip(renderCompletionSummary(true, 'ignored on success', makeCompletion())); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('pnpm run dev'); + expect(result).toContain('app/auth/route.ts'); + expect(result).toContain('http://localhost:8080'); + // The old static strings must NOT appear when structured data is present. + expect(result).not.toContain('Start dev server to test authentication'); + expect(result).not.toContain('Visit WorkOS Dashboard to manage users'); + }); + + it('caps the file list at 5 with a "…and N more" line', () => { + const files = Array.from({ length: 7 }, (_, i) => `file-${i + 1}.ts`); + const result = strip(renderCompletionSummary(true, undefined, makeCompletion({ files }))); + + expect(result).toContain('…and 2 more'); + expect(result).not.toContain('file-6.ts'); + expect(result).not.toContain('file-7.ts'); + }); + + it('falls back to the static box when no completion data is present', () => { + const result = strip(renderCompletionSummary(true)); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('Start dev server to test authentication'); + }); + + it('renders the failure box unchanged', () => { + const result = strip(renderCompletionSummary(false, 'Something went wrong')); + + expect(result).toContain('Installation Failed'); + expect(result).toContain('Something went wrong'); + }); + }); }); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index dc50b3a8..88f3b932 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -2,10 +2,29 @@ import chalk from 'chalk'; import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; import { type LockExpression, getLockArt, LOCK_WIDTH } from './lock-art.js'; import { symbols } from './cli-symbols.js'; +import type { CompletionData } from '../lib/events.js'; + +/** Max number of changed files listed in the success box before collapsing. */ +const MAX_SUMMARY_FILES = 5; /** Pre-built completion summaries shared by CLI and Dashboard adapters. */ -export function renderCompletionSummary(success: boolean, summary?: string): string { +export function renderCompletionSummary(success: boolean, summary?: string, completion?: CompletionData): string { if (success) { + if (completion) { + const files = completion.files; + const shown: SummaryBoxItem[] = files.slice(0, MAX_SUMMARY_FILES).map((f) => ({ type: 'done', text: f })); + if (files.length > MAX_SUMMARY_FILES) { + shown.push({ type: 'done', text: `…and ${files.length - MAX_SUMMARY_FILES} more` }); + } + const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); + return renderSummaryBox({ + expression: 'success', + title: 'WorkOS AuthKit Installed', + items: [...shown, ...steps], + footer: completion.docsUrl, + }); + } + // Fallback: preserve the original static box when no structured data is present. return renderSummaryBox({ expression: 'success', title: 'WorkOS AuthKit Installed', From 36c47e9d256cdf8b29fbd065b89de57e68e5f8a5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 16:04:46 -0500 Subject: [PATCH 3/8] fix: unblock agent/CI non-interactive CLI paths Removes the two hard dead-ends an AI agent or CI pipeline hits first when driving the CLI non-interactively: - Bare `workos` in a non-TTY shell now emits the machine-readable command tree (JSON) or the fully-configured help, instead of a degenerate two-line block on stderr with empty stdout. Agents and CI can now discover `install`. - Non-interactive installs no longer hang on interactive prompts. `abortIfCancelled` fails fast with a structured `non_interactive_prompt` error (Layer 1), and Next.js, React Router, and upload-env apply graceful per-site defaults so installs succeed (Layer 2). - `--router app|pages` deterministically selects the Next.js router, winning over detection. WORKOS_MODE=ci now enforces the same required-arg validation as the hidden --ci flag and bridges to the downstream `options.ci` paths (git checks auto-continue, package-manager auto-select). Behavior change: `WORKOS_MODE=ci workos install` without --api-key/--client-id/--install-dir now errors with a structured missing_args message instead of falling through to auto-provisioning. --- src/bin-default-command.integration.spec.ts | 115 +++++++++++++++++ src/bin.ts | 15 ++- src/commands/install.spec.ts | 48 ++++++- src/commands/install.ts | 5 +- src/integrations/nextjs/utils.spec.ts | 118 ++++++++++++++++++ src/integrations/nextjs/utils.ts | 24 +++- src/integrations/react-router/utils.spec.ts | 64 ++++++++++ src/integrations/react-router/utils.ts | 22 ++++ src/run.ts | 7 +- .../index.spec.ts | 61 +++++++++ .../upload-environment-variables/index.ts | 13 ++ src/utils/clack-utils.spec.ts | 80 ++++++++++++ src/utils/clack-utils.ts | 24 ++++ src/utils/help-json.ts | 7 ++ src/utils/types.ts | 3 + 15 files changed, 598 insertions(+), 8 deletions(-) create mode 100644 src/bin-default-command.integration.spec.ts create mode 100644 src/integrations/nextjs/utils.spec.ts create mode 100644 src/integrations/react-router/utils.spec.ts create mode 100644 src/steps/upload-environment-variables/index.spec.ts create mode 100644 src/utils/clack-utils.spec.ts diff --git a/src/bin-default-command.integration.spec.ts b/src/bin-default-command.integration.spec.ts new file mode 100644 index 00000000..42b4b22c --- /dev/null +++ b/src/bin-default-command.integration.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Integration test for the fixed `$0` default-command output. + * + * bin.ts runs runCli() at import and exposes no seams, so the only honest way + * to prove the friction fix is to drive the real CLI as a subprocess and read + * its streams. Pre-fix, a bare non-TTY invocation ran `yargs(rawArgs).showHelp()` + * on a FRESH yargs instance (zero commands registered), emitting a degenerate + * two-line `--help`/`--version` block to stderr and leaving stdout empty — a + * piped agent/CI consumer got nothing and never discovered `install`. + * + * We use a LOCAL spawn helper (not the shared one in + * bin-command-telemetry.integration.spec.ts) because that one hardcodes + * WORKOS_MODE=agent, which the CI and human-force-TTY cases must omit. The env + * is built clean (spawnSync replaces, not merges) so host agent/CI markers are + * absent unless a case adds them. + */ +const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url)); +const forceInsecureStorageImport = new URL('./test/force-insecure-storage.ts', import.meta.url).href; +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +let sandboxTmp: string; + +beforeEach(() => { + sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-default-it-')); +}); + +afterEach(() => { + rmSync(sandboxTmp, { recursive: true, force: true }); +}); + +function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) { + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: sandboxTmp, + USERPROFILE: sandboxTmp, + TMPDIR: sandboxTmp, + TMP: sandboxTmp, + TEMP: sandboxTmp, + // Keep machine streams clean: no telemetry, no update check network calls. + WORKOS_TELEMETRY: 'false', + // Unroutable API base (defense-in-depth; the $0 path never hits the network). + WORKOS_API_URL: 'http://127.0.0.1:59999', + // Per-case env last. Agent/CI markers are absent unless a case adds one. + ...envOverrides, + }; + + return spawnSync(process.execPath, ['--import', 'tsx', '--import', forceInsecureStorageImport, binPath, ...args], { + cwd: repoRoot, + encoding: 'utf-8', + env, + }); +} + +describe('bin $0 default command (non-TTY)', () => { + it('agent mode emits the full machine-readable command tree on stdout', () => { + const result = runCli([], { WORKOS_MODE: 'agent' }); + + expect(result.status).toBe(0); + + const tree = JSON.parse(result.stdout.trim()); + expect(tree.name).toBe('workos'); + expect(tree.version).toBeTruthy(); + + const names = tree.commands.map((c: { name: string }) => c.name); + expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor'])); + // The installer must be discoverable — this is the friction being fixed. + expect(tree.commands.some((c: { name: string }) => c.name === 'install')).toBe(true); + + // Regression guard: never emit the degenerate fresh-yargs help block. + expect(result.stderr).not.toContain('Show version number'); + }, 20_000); + + it('CI mode (CI=1, no WORKOS_MODE) emits the command tree on stdout', () => { + const result = runCli([], { CI: '1' }); + + expect(result.status).toBe(0); + + const tree = JSON.parse(result.stdout.trim()); + expect(tree.name).toBe('workos'); + expect(tree.version).toBeTruthy(); + + const names = tree.commands.map((c: { name: string }) => c.name); + expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor'])); + expect(result.stderr).not.toContain('Show version number'); + }, 20_000); + + it('human non-TTY edge (WORKOS_FORCE_TTY=1) shows the configured help, not JSON', () => { + // WORKOS_MODE omitted entirely (never set to '' — that throws InvalidInteractionModeError). + const result = runCli([], { WORKOS_FORCE_TTY: '1' }); + + expect(result.status).toBe(0); + + const combined = `${result.stdout}\n${result.stderr}`; + // parser.showHelp() (not the fresh-yargs block) lists the full command set. + expect(combined).toMatch(/install/); + expect(combined).toMatch(/organization/); + expect(combined).toMatch(/auth/); + }, 20_000); + + it('--help --json still returns the command tree (regression for the intercept)', () => { + const result = runCli(['--help', '--json'], { WORKOS_MODE: 'agent' }); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(Array.isArray(parsed.commands)).toBe(true); + expect(parsed.commands.length).toBeGreaterThan(0); + }, 20_000); +}); diff --git a/src/bin.ts b/src/bin.ts index 2312ba09..eb6b88be 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -241,6 +241,11 @@ const installerOptions = { choices: ['npm', 'pnpm', 'yarn', 'bun'] as const, type: 'string' as const, }, + router: { + choices: ['app', 'pages'] as const, + describe: 'Next.js router to target when detection is ambiguous (app or pages)', + type: 'string' as const, + }, }; // Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean) @@ -2677,9 +2682,15 @@ async function runCli(): Promise { 'WorkOS AuthKit CLI', (yargs) => yargs.options(insecureStorageOption), async (argv) => { - // Non-human modes: show help instead of prompting + // Non-human modes: emit machine-readable command tree (JSON) or the + // fully-configured parser help (human non-TTY edge) instead of prompting. if (!isPromptAllowed()) { - yargs(rawArgs).showHelp(); + if (isJsonMode()) { + const { buildCommandTree } = await import('./utils/help-json.js'); + outputJson(buildCommandTree()); + } else { + parser.showHelp(); + } return; } diff --git a/src/commands/install.spec.ts b/src/commands/install.spec.ts index df19b169..b686bd90 100644 --- a/src/commands/install.spec.ts +++ b/src/commands/install.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('../run.js', () => ({ runInstaller: vi.fn(), @@ -31,8 +31,9 @@ const { runInstaller } = await import('../run.js'); const { autoInstallSkills } = await import('./install-skill.js'); const { maybeOfferMcpInstall } = await import('../lib/mcp-notice.js'); const clack = (await import('../utils/clack.js')).default; -const { isJsonMode } = await import('../utils/output.js'); +const { isJsonMode, exitWithError } = await import('../utils/output.js'); const { CliExit } = await import('../utils/cli-exit.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); const { handleInstall } = await import('./install.js'); @@ -41,6 +42,10 @@ describe('handleInstall', () => { vi.clearAllMocks(); }); + afterEach(() => { + resetInteractionModeForTests(); + }); + it('calls autoInstallSkills after successful install', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); vi.mocked(autoInstallSkills).mockResolvedValue(null); @@ -126,4 +131,43 @@ describe('handleInstall', () => { expect(runInstaller).toHaveBeenCalledOnce(); expect(autoInstallSkills).toHaveBeenCalledOnce(); }); + + describe('CI-mode required-arg validation', () => { + it('WORKOS_MODE=ci requires --api-key (validation triggered without the --ci flag)', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + setInteractionMode({ mode: 'ci', source: 'env' }); + + await handleInstall({ _: ['install'], $0: 'workos' } as any); + + expect(exitWithError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'missing_args', message: expect.stringContaining('--api-key') }), + ); + }); + + it('WORKOS_MODE=ci with all required args does not error', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + setInteractionMode({ mode: 'ci', source: 'env' }); + + await handleInstall({ + _: ['install'], + $0: 'workos', + apiKey: 'sk_test', + clientId: 'client_x', + installDir: '/tmp/x', + } as any); + + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it('default (human) mode does not trigger CI validation', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + + await handleInstall({ _: ['install'], $0: 'workos' } as any); + + expect(exitWithError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/commands/install.ts b/src/commands/install.ts index cc538be6..8e4eb75f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -3,6 +3,7 @@ import type { InstallerArgs } from '../run.js'; import clack from '../utils/clack.js'; import { exitWithError, isJsonMode } from '../utils/output.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { isCiMode } from '../utils/interaction-mode.js'; import type { ArgumentsCamelCase } from 'yargs'; import { autoInstallSkills } from './install-skill.js'; import { maybeOfferMcpInstall } from '../lib/mcp-notice.js'; @@ -13,8 +14,8 @@ import { maybeOfferMcpInstall } from '../lib/mcp-notice.js'; export async function handleInstall(argv: ArgumentsCamelCase): Promise { const options = { ...argv }; - // CI mode validation - if (options.ci) { + // CI mode validation — trigger for the hidden --ci flag or WORKOS_MODE=ci. + if (options.ci || isCiMode()) { if (!options.apiKey) { exitWithError({ code: 'missing_args', message: 'CI mode requires --api-key (WorkOS API key sk_xxx)' }); } diff --git a/src/integrations/nextjs/utils.spec.ts b/src/integrations/nextjs/utils.spec.ts new file mode 100644 index 00000000..13832f1d --- /dev/null +++ b/src/integrations/nextjs/utils.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { InteractionMode } from '../../utils/interaction-mode.js'; + +vi.mock('fast-glob', () => ({ default: vi.fn() })); + +vi.mock('../../utils/clack.js', () => ({ + default: { + select: vi.fn(), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() }, + }, +})); + +// Passthrough — the guard itself is covered by clack-utils.spec.ts; here we only +// need clack.select's resolved value to flow through in the human path. +vi.mock('../../utils/clack-utils.js', () => ({ + abortIfCancelled: vi.fn(async (p) => await p), +})); + +const fg = (await import('fast-glob')).default; +const clack = (await import('../../utils/clack.js')).default; +const { getNextJsRouter, NextJsRouter } = await import('./utils.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); + +/** Configure fast-glob to report presence of pages/ and/or app/ dirs. */ +function mockDetection({ pages, app }: { pages: boolean; app: boolean }): void { + vi.mocked(fg).mockImplementation((async (pattern: string) => { + if (String(pattern).includes('pages')) return pages ? ['pages/_app.tsx'] : []; + return app ? ['app/layout.tsx'] : []; + }) as never); +} + +const modes: InteractionMode[] = ['human', 'agent', 'ci']; + +describe('getNextJsRouter', () => { + beforeEach(() => { + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it.each(modes)('pages-only detection returns pages router without prompting (%s mode)', async (mode) => { + setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' }); + mockDetection({ pages: true, app: false }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it.each(modes)('app-only detection returns app router without prompting (%s mode)', async (mode) => { + setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' }); + mockDetection({ pages: false, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('ambiguous detection in human mode prompts and uses the answer', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: true }); + vi.mocked(clack.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).toHaveBeenCalledOnce(); + }); + + it('ambiguous detection in agent mode defaults to app router with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('ambiguous detection in ci mode defaults to app router with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('--router pages overrides ambiguous detection with no prompt', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj', router: 'pages' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('--router app wins over detection with no prompt', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: false }); + + const result = await getNextJsRouter({ installDir: '/proj', router: 'app' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/nextjs/utils.ts b/src/integrations/nextjs/utils.ts index e627faa8..f6235e85 100644 --- a/src/integrations/nextjs/utils.ts +++ b/src/integrations/nextjs/utils.ts @@ -4,6 +4,7 @@ import clack from '../../utils/clack.js'; import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; +import { isPromptAllowed } from '../../utils/interaction-mode.js'; export function getNextJsVersionBucket(version: string | undefined): string { return getVersionBucket(version, 11); @@ -14,7 +15,17 @@ export enum NextJsRouter { PAGES_ROUTER = 'pages-router', } -export async function getNextJsRouter({ installDir }: Pick): Promise { +export async function getNextJsRouter({ + installDir, + router, +}: Pick): Promise { + // Explicit flag wins over detection (deterministic for agents). + if (router) { + const chosen = router === 'pages' ? NextJsRouter.PAGES_ROUTER : NextJsRouter.APP_ROUTER; + clack.log.info(`Using ${getNextJsRouterName(chosen)} (--router)`); + return chosen; + } + const pagesMatches = await fg('**/pages/_app.@(ts|tsx|js|jsx)', { dot: true, cwd: installDir, @@ -41,6 +52,17 @@ export async function getNextJsRouter({ installDir }: Pick ({ default: vi.fn(async () => []) })); + +vi.mock('../../utils/clack.js', () => ({ + default: { + select: vi.fn(), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() }, + }, +})); + +// Passthrough abortIfCancelled + a package.json with no react-router version, so +// getReactRouterMode always hits the ambiguous "no version" prompt branch. +vi.mock('../../utils/clack-utils.js', () => ({ + abortIfCancelled: vi.fn(async (p) => await p), + getPackageDotJson: vi.fn(async () => ({})), +})); + +const clack = (await import('../../utils/clack.js')).default; +const { getReactRouterMode, ReactRouterMode } = await import('./utils.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); + +describe('getReactRouterMode — ambiguous-branch defaults', () => { + beforeEach(() => { + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it('agent mode with no detectable version defaults to v7 Framework with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('ci mode with no detectable version defaults to v7 Framework with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('human mode with no detectable version prompts and uses the answer', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + vi.mocked(clack.select).mockResolvedValueOnce(ReactRouterMode.V6 as never); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V6); + expect(clack.select).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/integrations/react-router/utils.ts b/src/integrations/react-router/utils.ts index 3680097f..e52f713f 100644 --- a/src/integrations/react-router/utils.ts +++ b/src/integrations/react-router/utils.ts @@ -6,6 +6,7 @@ import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; import { getPackageVersion } from '../../utils/package-json.js'; +import { isPromptAllowed } from '../../utils/interaction-mode.js'; import chalk from 'chalk'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -85,6 +86,13 @@ export async function getReactRouterMode(options: InstallerOptions): Promise