diff --git a/scripts/command-smoke.sh b/scripts/command-smoke.sh index d0ba5603..829d9e7c 100644 --- a/scripts/command-smoke.sh +++ b/scripts/command-smoke.sh @@ -111,6 +111,44 @@ case "$err" in esac if [ "$code" -eq 1 ] && [ "$json_ok" -eq 1 ]; then pass "unknown command exits 1 with structured error"; else fail "unknown command contract (exit $code, want 1): $err"; fi +# Unsupported Pages Router installs must stop before provisioning, even with --force. +pages_project="$SANDBOX/pages-project" +mkdir -p "$pages_project/src/pages" +printf '%s\n' 'export default function App() {}' >"$pages_project/src/pages/_app.tsx" +printf '%s\n' '{"dependencies":{"next":"16.0.0"}}' >"$pages_project/package.json" +for command in install integrate dashboard; do + err=$("$BIN" "$command" --install-dir "$pages_project" --force --json --insecure-storage 2>&1 >/dev/null) + code=$? + case "$err" in + *'"code":"unsupported_nextjs_router"'*) json_ok=1 ;; + *) json_ok=0 ;; + esac + if [ "$code" -eq 1 ] && [ "$json_ok" -eq 1 ] && [ ! -e "$pages_project/.env.local" ] && [ ! -e "$pages_project/src/app" ]; then + pass "$command declines Pages Router before project writes or login" + else + fail "$command Pages Router preflight (exit $code): $err" + fi +done + +# A pre-existing sign-in page must not be overwritten by a route handler. +signin_project="$SANDBOX/signin-project" +mkdir -p "$signin_project/src/app/(auth)/sign-in" +printf '%s\n' '{"dependencies":{"next":"16.0.0"}}' >"$signin_project/package.json" +printf '%s\n' 'export default function Layout() {}' >"$signin_project/src/app/layout.tsx" +printf '%s\n' 'export default function ExistingSignIn() {}' >"$SANDBOX/original-signin" +cp "$SANDBOX/original-signin" "$signin_project/src/app/(auth)/sign-in/page.tsx" +err=$("$BIN" install --install-dir "$signin_project" --force --json --insecure-storage 2>&1 >/dev/null) +code=$? +case "$err" in + *'"code":"conflicting_sign_in_route"'*) json_ok=1 ;; + *) json_ok=0 ;; +esac +if [ "$code" -eq 1 ] && [ "$json_ok" -eq 1 ] && [ ! -e "$signin_project/.env.local" ] && cmp -s "$SANDBOX/original-signin" "$signin_project/src/app/(auth)/sign-in/page.tsx"; then + pass "install preserves an existing grouped sign-in page before provisioning" +else + fail "sign-in page preflight (exit $code): $err" +fi + # Doctor must use installed tools, not shims planted in its project directory. # This runs against the shipped Bun binary on native Windows release runners, # where CWD-first lookup is implicit. Do not emulate it with "." in POSIX PATH: diff --git a/src/bin.ts b/src/bin.ts index d3a2506c..7f6959a5 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -13,6 +13,7 @@ import { getVersion } from './lib/settings.js'; import yargs from 'yargs'; import { ensureAuthenticated } from './lib/ensure-auth.js'; +import { InstallDeclinedError } from './lib/installer-errors.js'; import { checkForUpdates } from './lib/version-check.js'; import { @@ -268,8 +269,8 @@ const installerOptions = { type: 'string' as const, }, router: { - choices: ['app', 'pages'] as const, - describe: 'Next.js router to target when detection is ambiguous (app or pages)', + choices: ['app'] as const, + describe: 'Use Next.js App Router (App Router only; Pages Router is not supported)', type: 'string' as const, }, ...forceOption, @@ -3188,11 +3189,13 @@ async function runCli(): Promise { (yargs) => yargs.options(installerOptions), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - // MUST run before credential resolution below: that provisions a WorkOS - // environment and writes its credentials into the project's env file, - // so a guard placed after it is no guard at all. + // Preflight must precede credential provisioning and project writes. const preflight = await import('./lib/preflight-authkit.js'); - await preflight.assertNoExistingAuthKit({ installDir: argv.installDir ?? process.cwd(), force: argv.force }); + await preflight.assertInstallPreflight({ + installDir: argv.installDir ?? process.cwd(), + force: argv.force, + router: argv.router, + }); await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); await handleInstall(argv); @@ -3407,7 +3410,11 @@ async function runCli(): Promise { await applyInsecureStorage(argv.insecureStorage); // Guard first, before credential resolution — see the `install` handler above. const preflight = await import('./lib/preflight-authkit.js'); - await preflight.assertNoExistingAuthKit({ installDir: argv.installDir ?? process.cwd(), force: argv.force }); + await preflight.assertInstallPreflight({ + installDir: argv.installDir ?? process.cwd(), + force: argv.force, + router: argv.router, + }); await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); await handleInstall({ ...argv, dashboard: true }); @@ -3448,7 +3455,7 @@ async function runCli(): Promise { // After the confirm above (two prompts back to back is worse UX), but // still before credential resolution touches the project. const preflight = await import('./lib/preflight-authkit.js'); - await preflight.assertNoExistingAuthKit({ installDir: process.cwd(), force: argv.force }); + await preflight.assertInstallPreflight({ installDir: process.cwd(), force: argv.force }); await resolveInstallCredentials(undefined, undefined, false, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); @@ -3493,6 +3500,13 @@ async function runCli(): Promise { apiContext: error.context?.apiContext, }, }; + } else if (error instanceof InstallDeclinedError) { + process.exitCode = 1; + commandOutcome = { + success: false, + options: { flags, reason: 'validation_error', errorCode: error.code }, + }; + outputError({ code: error.code, message: error.message }); } else if (error instanceof PromptUnavailableError) { // A prompt was attempted where the user can't answer (--json, or non-TTY // stdin) on a direct command. Not a crash — surface a clear, structured diff --git a/src/integrations/nextjs/utils.spec.ts b/src/integrations/nextjs/utils.spec.ts index d568d1e6..ac11bffc 100644 --- a/src/integrations/nextjs/utils.spec.ts +++ b/src/integrations/nextjs/utils.spec.ts @@ -19,12 +19,6 @@ vi.mock('../../utils/ui.js', () => ({ }, })); -// Passthrough — the guard itself is covered by ui-utils.spec.ts; here we only -// need ui.select's resolved value to flow through in the human path. -vi.mock('../../utils/ui-utils.js', () => ({ - abortIfCancelled: vi.fn(async (p) => await p), -})); - const fg = (await import('fast-glob')).default; const ui = (await import('../../utils/ui.js')).default; const { getNextJsRouter, NextJsRouter } = await import('./utils.js'); @@ -71,15 +65,14 @@ describe('getNextJsRouter', () => { expect(ui.select).not.toHaveBeenCalled(); }); - it('ambiguous detection in human mode prompts and uses the answer', async () => { + it('mixed-router detection uses App Router without offering unsupported Pages Router', async () => { setInteractionMode({ mode: 'human', source: 'default' }); mockDetection({ pages: true, app: true }); - vi.mocked(ui.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never); const result = await getNextJsRouter({ installDir: '/proj' }); - expect(result).toBe(NextJsRouter.PAGES_ROUTER); - expect(ui.select).toHaveBeenCalledOnce(); + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(ui.select).not.toHaveBeenCalled(); }); it('ambiguous detection in agent mode defaults to app router with a warning (no prompt)', async () => { @@ -104,16 +97,23 @@ describe('getNextJsRouter', () => { expect(ui.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); + it('does not warn about nonexistent Pages Router routes in a fresh project', async () => { + mockDetection({ pages: false, app: false }); + expect(await getNextJsRouter({ installDir: '/proj' })).toBe(NextJsRouter.APP_ROUTER); + expect(ui.log.warn).not.toHaveBeenCalled(); expect(ui.select).not.toHaveBeenCalled(); }); + it.each(['pages', 'unknown', '', null, false, 0])( + 'rejects unsupported runtime router input %j before detection', + async (router) => { + const input = JSON.parse(JSON.stringify({ installDir: '/proj', router })); + await expect(getNextJsRouter(input)).rejects.toMatchObject({ code: 'unsupported_nextjs_router' }); + expect(fg).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); + }, + ); + it('--router app wins over detection with no prompt', async () => { setInteractionMode({ mode: 'human', source: 'default' }); mockDetection({ pages: true, app: false }); diff --git a/src/integrations/nextjs/utils.ts b/src/integrations/nextjs/utils.ts index cfcd016c..982e75f7 100644 --- a/src/integrations/nextjs/utils.ts +++ b/src/integrations/nextjs/utils.ts @@ -1,10 +1,9 @@ import fg from 'fast-glob'; -import { abortIfCancelled } from '../../utils/ui-utils.js'; import ui from '../../utils/ui.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'; +import { InstallDeclinedError } from '../../lib/installer-errors.js'; export function getNextJsVersionBucket(version: string | undefined): string { return getVersionBucket(version, 11); @@ -15,13 +14,31 @@ export enum NextJsRouter { PAGES_ROUTER = 'pages-router', } +/** The SDK uses App Router request/cookie APIs; Pages Router handlers are not compatible. */ +export function assertSupportedNextJsRouter(router: NextJsRouter): void { + if (router !== NextJsRouter.PAGES_ROUTER) return; + const message = + 'AuthKit for Next.js supports App Router only. This installer cannot configure Pages Router. ' + + 'Use an App Router project or follow the manual setup guide: https://workos.com/docs/authkit/nextjs'; + ui.log.warn(message); + throw new InstallDeclinedError(message, 'unsupported_nextjs_router'); +} + export async function getNextJsRouter({ installDir, router, }: Pick): Promise { + // TypeScript and yargs constrain normal callers, but runtime input must not + // silently turn an unsupported selection into permission to change App Router files. + if (router !== undefined && router !== 'app') { + const message = + 'Unsupported Next.js router selection. Only App Router is supported; use --router app or omit --router.'; + ui.log.warn(message); + throw new InstallDeclinedError(message, 'unsupported_nextjs_router'); + } // Explicit flag wins over detection (deterministic for agents). if (router) { - const chosen = router === 'pages' ? NextJsRouter.PAGES_ROUTER : NextJsRouter.APP_ROUTER; + const chosen = NextJsRouter.APP_ROUTER; ui.log.info(`Using ${getNextJsRouterName(chosen)} (--router)`); return chosen; } @@ -52,35 +69,37 @@ export async function getNextJsRouter({ return NextJsRouter.APP_ROUTER; } - // Ambiguous (both app/ and pages/ present, or neither). In non-interactive - // mode default to the app router (dominant/new-project case) with a warning - // instead of prompting — the --router flag above is the escape hatch. - if (!isPromptAllowed()) { - ui.log.warn( - 'Could not determine the Next.js router (both app/ and pages/ present, or neither). ' + - 'Defaulting to app router. Pass --router app|pages to override.', - ); - return NextJsRouter.APP_ROUTER; + // Only App Router is supported. Do not offer a Pages Router choice that the + // installer will subsequently reject. Mixed projects keep their pages tree. + if (hasPagesDir && hasAppDir) { + ui.log.warn('Only App Router is supported. Using App Router; Pages Router routes will not be configured.'); } + return NextJsRouter.APP_ROUTER; +} - const result: NextJsRouter = await abortIfCancelled( - ui.select({ - message: 'What router are you using?', - options: [ - { - label: getNextJsRouterName(NextJsRouter.APP_ROUTER), - value: NextJsRouter.APP_ROUTER, - }, - { - label: getNextJsRouterName(NextJsRouter.PAGES_ROUTER), - value: NextJsRouter.PAGES_ROUTER, - }, - ], - }), - 'nextjs', - ); +/** Route groups change file locations, not the public URL. */ +export function nextjsRoutePath(file: string): string { + const segments = file + .replace(/^(src\/)?app\//, '') + .split('/') + .slice(0, -1); + return '/' + segments.filter((segment) => !/^\(.*\)$/.test(segment)).join('/'); +} + +export async function findNextjsSignInPage(installDir: string): Promise { + const pages = await fg('{,src/}app/**/sign-in/page.{ts,tsx,js,jsx}', { cwd: installDir, ignore: IGNORE_PATTERNS }); + return pages.find((file) => nextjsRoutePath(file) === '/sign-in'); +} - return result; +/** The current installer owns /sign-in; never overwrite a page at that URL. */ +export async function assertNextjsSignInRouteAvailable(installDir: string): Promise { + if (await findNextjsSignInPage(installDir)) { + const message = + 'This installer requires a dedicated /sign-in route, but a page already serves that URL. ' + + 'It was left unchanged. Configure AuthKit manually or move that page before running the installer.'; + ui.log.warn(message); + throw new InstallDeclinedError(message, 'conflicting_sign_in_route'); + } } export const getNextJsRouterName = (router: NextJsRouter) => { diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 7ff90d12..e9ff6625 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -413,6 +413,15 @@ describe('HeadlessAdapter', () => { it('spreads structured completion fields into the complete event when present', async () => { const adapter = createAdapter(); await adapter.start(); + const applicationSetup = { + clientId: 'client_app', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified: false, + callbackRegistered: true, + reason: 'Callback registered. Sign-out and initiate-login settings still need verification.', + }; emitter.emit('complete', { success: true, @@ -423,6 +432,7 @@ describe('HeadlessAdapter', () => { url: 'http://localhost:3000', files: ['a.ts'], nextSteps: ['x'], + applicationSetup, docsUrl: 'https://d', dashboardUrl: 'https://dash', }, @@ -437,6 +447,7 @@ describe('HeadlessAdapter', () => { url: 'http://localhost:3000', files: ['a.ts'], nextSteps: ['x'], + applicationSetup, }), ); await adapter.stop(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 819ab504..6766beb4 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -420,6 +420,7 @@ export class HeadlessAdapter implements InstallerAdapter { url: completion.url, files: completion.files, nextSteps: completion.nextSteps, + ...(completion.applicationSetup ? { applicationSetup: completion.applicationSetup } : {}), } : {}), }); diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts new file mode 100644 index 00000000..27f1fa06 --- /dev/null +++ b/src/lib/agent-runner.spec.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FrameworkConfig } from './framework-config.js'; +import type { InstallerOptions } from '../utils/types.js'; + +vi.mock('./skills-assets.js', () => ({ getReference: vi.fn() })); +vi.mock('./agent-interface.js', () => ({ initializeAgent: vi.fn(), runAgent: vi.fn() })); +vi.mock('./validation/index.js', () => ({ validateInstallation: vi.fn(), quickCheckValidateAndFormat: vi.fn() })); +vi.mock('./validation/security-checks.js', () => ({ + runInstallSecurityChecks: vi.fn(async () => ({ findings: [], blocking: [] })), + securityFindingsToIssues: vi.fn(() => []), + formatSecurityFindingsForAgent: vi.fn(() => ''), +})); +vi.mock('../steps/index.js', () => ({})); +vi.mock('./workos-management.js', () => ({ autoConfigureWorkOSEnvironment: vi.fn() })); +vi.mock('./env-writer.js', () => ({ writeEnvLocal: vi.fn() })); +vi.mock('../utils/ui-utils.js', () => ({ + ensurePackageIsInstalled: vi.fn(), + getOrAskForWorkOSCredentials: vi.fn(async () => ({ apiKey: 'test-key', clientId: 'client_test' })), + getPackageDotJson: vi.fn(async () => ({ dependencies: { next: '16.3.5' } })), + isUsingTypeScript: vi.fn(() => true), +})); +vi.mock('../utils/analytics.js', () => ({ + analytics: { setTag: vi.fn(), capture: vi.fn(), shutdown: vi.fn() }, +})); + +import { getReference } from './skills-assets.js'; +import { initializeAgent, runAgent } from './agent-interface.js'; +import { runAgentInstaller } from './agent-runner.js'; +import { validateInstallation, quickCheckValidateAndFormat } from './validation/index.js'; +import { autoConfigureWorkOSEnvironment } from './workos-management.js'; +import { writeEnvLocal } from './env-writer.js'; +import { getOrAskForWorkOSCredentials } from '../utils/ui-utils.js'; + +const options: InstallerOptions = { + debug: false, + forceInstall: false, + installDir: '/tmp/test-authkit-app', + local: false, + ci: true, + skipAuth: true, + clientId: 'client_test', + noValidate: true, +}; + +const config: FrameworkConfig = { + metadata: { + name: 'Next.js', + integration: 'nextjs', + skillName: 'workos-authkit-nextjs', + language: 'javascript', + docsUrl: 'https://workos.com/docs/authkit/nextjs', + stability: 'stable', + priority: 100, + }, + detection: { packageName: 'next', packageDisplayName: 'Next.js', getVersion: () => '16.3.5' }, + environment: { requiresApiKey: true, uploadToHosting: false, getEnvVars: () => ({}) }, + analytics: { getTags: () => ({}) }, + prompts: { getAdditionalContextLines: () => ['Router: app'] }, + ui: { successMessage: 'Installed', getOutroChanges: () => [], getOutroNextSteps: () => [] }, +}; + +const setupContent = 'Configure and read back Sign-out URI and Initiate login URI. Report unverified flows.'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getReference).mockImplementation(async (name) => { + if (name === 'workos-authkit-setup') return setupContent; + return `Instructions from ${name}`; + }); + vi.mocked(runAgent).mockResolvedValue({}); + vi.mocked(quickCheckValidateAndFormat).mockResolvedValue(null); + vi.mocked(validateInstallation).mockResolvedValue({ passed: true, framework: 'nextjs', issues: [], durationMs: 0 }); +}); + +describe('installer prompt', () => { + it.each(['javascript', 'php'] as const)('injects shared application setup for %s integrations', async (language) => { + const framework = { + ...config, + metadata: { + ...config.metadata, + language, + integration: language === 'javascript' ? 'nextjs' : 'php', + skillName: language === 'javascript' ? 'workos-authkit-nextjs' : 'workos-php', + }, + }; + await runAgentInstaller(framework, options); + + const prompt = vi.mocked(runAgent).mock.calls[0][1]; + if (framework.metadata.integration === 'nextjs') { + expect(getReference).toHaveBeenCalledWith('workos-authkit-setup'); + expect(prompt).toContain(setupContent); + } else { + expect(getReference).not.toHaveBeenCalledWith('workos-authkit-setup'); + expect(prompt).not.toContain(setupContent); + expect(prompt).not.toContain('installer handles supported dashboard configuration'); + } + expect(prompt).toContain(`Instructions from ${framework.metadata.skillName}`); + expect(prompt).toContain('Router: app'); + expect(prompt).not.toContain('test-key'); + if (language === 'javascript') { + expect(prompt).toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI'); + expect(prompt).toContain('Instructions from workos-authkit-base'); + } else { + expect(prompt).toContain('WORKOS_REDIRECT_URI'); + expect(prompt).not.toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI'); + expect(getReference).not.toHaveBeenCalledWith('workos-authkit-base'); + } + }); + + it('declines Pages Router before requesting credentials, writing files, or starting the agent', async () => { + const framework = { + ...config, + metadata: { ...config.metadata, gatherContext: async () => ({ router: 'pages-router' }) }, + }; + await expect(runAgentInstaller(framework, options)).rejects.toMatchObject({ + code: 'unsupported_nextjs_router', + }); + expect(getOrAskForWorkOSCredentials).not.toHaveBeenCalled(); + expect(writeEnvLocal).not.toHaveBeenCalled(); + expect(initializeAgent).not.toHaveBeenCalled(); + }); + + it('does not register a callback in the API-key environment when run directly', async () => { + await runAgentInstaller(config, { ...options, clientId: undefined }); + expect(autoConfigureWorkOSEnvironment).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalled(); + }); + + it.each(['tanstack-start', 'react', 'react-router', 'vanilla-js'])( + 'keeps %s completeness checks advisory', + async (integration) => { + const framework = { ...config, metadata: { ...config.metadata, integration } }; + vi.mocked(validateInstallation).mockResolvedValue({ + passed: false, + framework: integration, + durationMs: 0, + issues: [ + { type: 'file', severity: 'error', message: 'Legacy layout missing', hint: 'Install obsolete package' }, + ], + }); + await expect(runAgentInstaller(framework, { ...options, noValidate: false })).resolves.toContain('Successfully'); + const retry = vi.mocked(runAgent).mock.calls[0][5]!; + expect(await retry.validateAndFormat(options.installDir)).toBeNull(); + vi.mocked(quickCheckValidateAndFormat).mockResolvedValue('Fix a genuine build failure'); + expect(await retry.validateAndFormat(options.installDir)).toBe('Fix a genuine build failure'); + }, + ); + + it('blocks success when an application route is still missing after retries', async () => { + vi.mocked(validateInstallation).mockResolvedValue({ + passed: false, + framework: 'nextjs', + durationMs: 0, + issues: [{ type: 'file', severity: 'error', message: 'Missing sign-in route', hint: 'Create /sign-in' }], + }); + await expect(runAgentInstaller(config, { ...options, noValidate: false })).rejects.toThrow('Missing sign-in route'); + const retry = vi.mocked(runAgent).mock.calls[0][5]; + expect(await retry!.validateAndFormat(options.installDir)).toContain('Create /sign-in'); + }); + + it('does not start the agent when the bundled setup reference is missing', async () => { + vi.mocked(getReference).mockImplementation(async (name) => { + if (name === 'workos-authkit-setup') throw new Error('Missing bundled setup reference'); + return `Instructions from ${name}`; + }); + + await expect(runAgentInstaller(config, options)).rejects.toThrow('Missing bundled setup reference'); + expect(initializeAgent).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index bcbf95c4..52016bcb 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -19,6 +19,7 @@ import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.js'; import { uploadEnvironmentVariablesStep } from '../steps/index.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; +import { assertSupportedNextJsRouter, assertNextjsSignInRouteAvailable } from '../integrations/nextjs/utils.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; @@ -55,6 +56,13 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal integration: config.metadata.integration, }); + // Reject unsupported routers before requesting credentials or changing the project. + const frameworkContext = config.metadata.gatherContext ? await config.metadata.gatherContext(options) : {}; + if (config.metadata.integration === 'nextjs') { + assertSupportedNextJsRouter(frameworkContext.router); + await assertNextjsSignInRouteAvailable(options.installDir); + } + // Get WorkOS credentials (API key optional for client-only SDKs) const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey); @@ -64,7 +72,9 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal // Auto-configure WorkOS environment (redirect URI, CORS, homepage) // Skip if caller already handled this (prevents duplicate dashboard config output) - if (!callerHandledConfig && apiKey && config.environment.requiresApiKey) { + // Next.js URL setup runs after validation in the caller, which chooses either + // dashboard targeting or the API-only callback path, never both. + if (!callerHandledConfig && apiKey && config.environment.requiresApiKey && config.metadata.integration !== 'nextjs') { const port = detectPort(config.metadata.integration, options.installDir); await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { homepageUrl: options.homepageUrl, @@ -72,9 +82,6 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal }); } - // Gather framework-specific context (e.g., Next.js router, React Native platform) - const frameworkContext = config.metadata.gatherContext ? await config.metadata.gatherContext(options) : {}; - // Write environment variables to .env.local BEFORE agent runs // Skip if caller already handled this (prevents double-writing) if (!callerHandledConfig) { @@ -136,8 +143,16 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal validateAndFormat: async (workingDirectory: string) => { const quickPrompt = await quickCheckValidateAndFormat(workingDirectory); const security = await runInstallSecurityChecks(integration, workingDirectory); - if (quickPrompt === null && security.blocking.length === 0) return null; - return [quickPrompt, formatSecurityFindingsForAgent(security.findings)] + // Only the Next.js rules were curated for a blocking gate in this installer. + const errors = + integration === 'nextjs' + ? (await validateInstallation(integration, workingDirectory, { runBuild: false })).issues.filter( + (issue) => issue.severity === 'error', + ) + : []; + if (quickPrompt === null && security.blocking.length === 0 && errors.length === 0) return null; + const completenessPrompt = errors.map((issue) => `${issue.message}. ${issue.hint ?? ''}`).join('\n'); + return [quickPrompt, completenessPrompt, formatSecurityFindingsForAgent(security.findings)] .filter((p): p is string => Boolean(p)) .join('\n\n'); }, @@ -205,6 +220,15 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal await analytics.shutdown('error'); throw new Error(formatBlockingSecurityError(security.blocking)); } + if (integration === 'nextjs' && !validationResult.passed) { + await analytics.shutdown('error'); + throw new Error( + `Installation validation failed:\n${validationResult.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => `${issue.message}. ${issue.hint ?? ''}`) + .join('\n')}`, + ); + } } // Track retry metrics AFTER the security gate. `passed_after_retry` must @@ -281,9 +305,12 @@ async function buildIntegrationPrompt( // Base template has JS-centric assumptions (node_modules, lockfiles, AuthKitProvider) // so only load it for JavaScript integrations; backend SDKs bypass this entirely const isJavaScript = config.metadata.language === 'javascript'; - const [baseContent, refContent] = await Promise.all([ + // Inline shared setup too: relative links in the framework reference do not + // resolve from the app directory, and agents can skip them entirely. + const [baseContent, refContent, setupContent] = await Promise.all([ isJavaScript ? getReference('workos-authkit-base') : Promise.resolve(''), getReference(skillName), + config.metadata.integration === 'nextjs' ? getReference('workos-authkit-setup') : Promise.resolve(''), ]); // Build env var list dynamically based on what was actually configured @@ -311,6 +338,23 @@ ${baseContent ? `## General Guidelines\n\n${baseContent}\n\n` : ''}## Integratio ${refContent} +${ + setupContent + ? `## Required Application Setup and Verification + +${setupContent} + +## Installer execution boundary + +The setup reference above is already included in this prompt. Do not read a relative workos-authkit-setup.md from the app directory. +The agent's shell permissions do not allow WorkOS management commands. Do not run workos, install another CLI, use curl or SDK scripts to bypass that boundary, or attempt dashboard authentication. Implement and validate the app code only. The installer handles supported dashboard configuration outside the agent after code validation; unavailable configuration must remain explicitly unverified. + +Create a dedicated /sign-in GET route in the App Router using getSignInUrl() from @workos-inc/authkit-nextjs and redirect(await getSignInUrl()) from next/navigation. Keep the OAuth callback using handleAuth() separate. The Initiate login URI is the app origin plus /sign-in, NEVER the callback URI. Read existing files before editing; do not replace an unrelated existing sign-in flow. If a page already serves /sign-in (including inside route groups), stop: this installer cannot place a route handler alongside that page. Never delete the page or create a conflicting route. Keep /sign-in public and follow the SDK README for PKCE cookie handling. +Do not claim the full integration or browser flows are verified. Report code implementation separately from application configuration and browser testing. +` + : '' +} + Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts new file mode 100644 index 00000000..9b2f95d3 --- /dev/null +++ b/src/lib/authkit-application-setup.spec.ts @@ -0,0 +1,457 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('./command-auth.js', () => ({ refreshIfExpired: vi.fn() })); +vi.mock('./api-key.js', () => ({ + resolveApiBaseUrl: () => 'https://api.workos.com', + resolveApiKey: vi.fn(), +})); +vi.mock('./environment-target.js', () => ({ fetchTeamEnvironments: vi.fn() })); +vi.mock('./dashboard-graphql.js', () => ({ dashboardGraphqlRequest: vi.fn() })); +vi.mock('../catalog/operation.js', () => ({ + getOperation: (name: string) => ({ name }), + resolveExecutableDocument: (operation: { name: string }) => operation.name, +})); + +import { refreshIfExpired } from './command-auth.js'; +import { fetchTeamEnvironments } from './environment-target.js'; +import { dashboardGraphqlRequest } from './dashboard-graphql.js'; +import { configureAuthkitApplication, readNextjsApplicationSetup } from './authkit-application-setup.js'; +import { applicationSetupNextSteps } from './completion-data.js'; + +const setup = { + clientId: 'client_app', + redirectUri: 'http://localhost:4000/callback', + signOutUri: 'http://localhost:4000/', + initiateLoginUri: 'http://localhost:4000/sign-in', + verified: false, +}; +let application: { + id: string; + clientId: string; + redirectUris: { uri: string; isDefault?: boolean }[]; + logoutUris: { id?: string; uri: string; isDefault: boolean }[]; + initiateLoginUri: string | null; + appHomepageUrl?: string; +}; +const writes = () => + vi + .mocked(dashboardGraphqlRequest) + .mock.calls.filter( + ([name, options]) => + name !== 'defaultAuthkitApplication' && !(options.variables?.input as { dryRun?: boolean })?.dryRun, + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(refreshIfExpired).mockResolvedValue({ accessToken: 'test-token', refreshed: false }); + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_app', name: 'Sandbox', sandbox: true, clientId: setup.clientId }, + ]); + application = { + id: 'app_1', + clientId: setup.clientId, + redirectUris: [{ uri: setup.redirectUri, isDefault: true }], + logoutUris: [{ id: 'uri_old', uri: 'https://old.example/', isDefault: false }], + initiateLoginUri: null, + }; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'defaultAuthkitApplication') return { defaultUserlandApplication: structuredClone(application) }; + if (name === 'setAuthkitApplicationLogoutUris') { + const input = options.variables!.input as { + applicationId: string; + logoutUris: typeof application.logoutUris; + dryRun: boolean; + }; + expect(input.applicationId).toBe('app_1'); + if (!input.dryRun) application.logoutUris = input.logoutUris; + return { setUserlandApplicationLogoutUris: { __typename: 'LogoutUrisSet' } }; + } + if (name === 'setRedirectUris') { + const input = options.variables!.input as { + applicationId: string; + redirectUris: typeof application.redirectUris; + dryRun: boolean; + }; + expect(input.applicationId).toBe('app_1'); + if (!input.dryRun) application.redirectUris = input.redirectUris; + return { setRedirectUris: { __typename: 'RedirectUrisSet' } }; + } + if (name === 'updateAuthkitApplication') { + const input = options.variables!.input as { + applicationId: string; + initiateLoginUri?: string; + appHomepageUrl?: string; + }; + expect(input.applicationId).toBe('app_1'); + if (input.initiateLoginUri !== undefined) application.initiateLoginUri = input.initiateLoginUri; + if (input.appHomepageUrl !== undefined) application.appHomepageUrl = input.appHomepageUrl; + return { updateUserlandApplication: { __typename: 'UserlandApplicationUpdated' } }; + } + throw new Error(`Unexpected operation: ${name}`); + }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('native application URL setup', () => { + it('registers the callback with an API key without a dashboard session', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + const request = vi.fn(async () => new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', request); + const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + expect(request).toHaveBeenCalledWith( + 'https://api.workos.com/user_management/redirect_uris', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer sk_test_unclaimed' }), + body: JSON.stringify({ uri: setup.redirectUri }), + }), + ); + expect(result.callbackRegistered).toBe(true); + expect(result.verified).toBe(false); + expect(result.reason).toContain('Sign-out URI and Initiate login URI'); + expect(applicationSetupNextSteps(result)).toContain(`Redirect URI: ${setup.redirectUri} (registered)`); + expect(fetchTeamEnvironments).not.toHaveBeenCalled(); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('accepts an already registered API-only callback without claiming full setup', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"message":"already exists"}', { status: 409 })), + ); + const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + expect(result.callbackRegistered).toBe(true); + expect(result.verified).toBe(false); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('fails the install when the API-only callback cannot be registered', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"message":"private details"}', { status: 403 })), + ); + await expect(configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed')).rejects.toThrow( + 'Could not register the callback URL', + ); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each(['sk_live_production', 'sk_unknown'])( + 'refuses API-only callback writes without a sandbox key (%s)', + async (apiKey) => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + const request = vi.fn(); + vi.stubGlobal('fetch', request); + await expect(configureAuthkitApplication(setup, setup.clientId, apiKey)).rejects.toThrow('sandbox API key'); + expect(request).not.toHaveBeenCalled(); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }, + ); + + it('never combines API-key writes with dashboard writes, even after a partial failure', async () => { + const request = vi.fn(); + vi.stubGlobal('fetch', request); + application.redirectUris = []; + expect((await configureAuthkitApplication(setup, setup.clientId, 'sk_test_other_environment')).verified).toBe(true); + expect(writes()).toHaveLength(3); + expect(request).not.toHaveBeenCalled(); + vi.mocked(dashboardGraphqlRequest).mockRejectedValue(new Error('dashboard unavailable')); + await expect(configureAuthkitApplication(setup, setup.clientId, 'sk_test_other_environment')).rejects.toThrow( + /Callback/, + ); + expect(request).not.toHaveBeenCalled(); + }); + + it('validates, preserves existing URLs, writes to the matched application, and reads back', async () => { + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(true); + expect(application.logoutUris).toContainEqual({ id: 'uri_old', uri: 'https://old.example/', isDefault: false }); + expect(application.logoutUris).toContainEqual({ uri: setup.signOutUri, isDefault: true }); + expect(application.initiateLoginUri).toBe(setup.initiateLoginUri); + expect(fetchTeamEnvironments).toHaveBeenCalledTimes(1); + for (const [, options] of vi.mocked(dashboardGraphqlRequest).mock.calls) + expect(options.environmentId).toBe('env_app'); + expect(vi.mocked(dashboardGraphqlRequest).mock.calls.at(-1)?.[0]).toBe('defaultAuthkitApplication'); + expect(writes()).toHaveLength(2); + }); + + it('leaves the homepage alone unless --homepage-url was explicitly supplied', async () => { + application.appHomepageUrl = 'https://existing.example/'; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(application.appHomepageUrl).toBe('https://existing.example/'); + const result = await configureAuthkitApplication( + { ...setup, homepageUrl: 'https://requested.example/' }, + setup.clientId, + ); + expect(result.verified).toBe(true); + expect(application.appHomepageUrl).toBe('https://requested.example/'); + expect(application.initiateLoginUri).toBe(setup.initiateLoginUri); + }); + + it('preserves an equivalent existing root URL without adding a duplicate', async () => { + application.logoutUris = [{ uri: 'http://localhost:4000', isDefault: true }]; + application.initiateLoginUri = setup.initiateLoginUri; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(true); + expect(result.signOutUri).toBe('http://localhost:4000'); + expect(writes()).toHaveLength(0); + }); + + it('does not write when settings already match', async () => { + application.logoutUris.push({ uri: setup.signOutUri, isDefault: true }); + application.initiateLoginUri = setup.initiateLoginUri; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(writes()).toHaveLength(0); + }); + + it.each(['sign-out', 'initiate-login'] as const)('preserves an existing conflicting %s setting', async (setting) => { + if (setting === 'sign-out') application.logoutUris[0].isDefault = true; + else application.initiateLoginUri = 'https://old.example/login'; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).toContain('left unchanged'); + expect(result.callbackRegistered).toBe(true); + expect(writes()).toHaveLength(1); + }); + + it('registers the callback with the supplied key when the session belongs to another team', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_other', name: 'Other team', clientId: 'client_other', sandbox: true }, + ]); + const request = vi.fn(async () => new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', request); + const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_team_a'); + expect(result.callbackRegistered).toBe(true); + expect(result.verified).toBe(false); + expect(request).toHaveBeenCalledTimes(1); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each(['sign-out', 'initiate-login'] as const)( + 'registers the callback despite a conflicting %s setting', + async (setting) => { + application.redirectUris = []; + if (setting === 'sign-out') application.logoutUris[0].isDefault = true; + else application.initiateLoginUri = 'https://existing.example/sign-in'; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(application.redirectUris.some((uri) => uri.uri === setup.redirectUri)).toBe(true); + expect(result.callbackRegistered).toBe(true); + expect(result.verified).toBe(false); + expect(result.reason).toContain('left unchanged'); + expect(writes()[0][0]).toBe('setRedirectUris'); + if (setting === 'sign-out') { + expect(application.logoutUris).toEqual([{ id: 'uri_old', uri: 'https://old.example/', isDefault: true }]); + expect(application.initiateLoginUri).toBe(setup.initiateLoginUri); + } else { + expect(application.initiateLoginUri).toBe('https://existing.example/sign-in'); + expect(application.logoutUris.some((uri) => uri.uri === setup.signOutUri && uri.isDefault)).toBe(true); + } + }, + ); + + it('fails instead of completing when the dashboard cannot confirm the callback', async () => { + vi.mocked(dashboardGraphqlRequest).mockRejectedValue(new Error('private backend error')); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow(/callback/i); + }); + + it('does not use the active profile when the client ID cannot be matched', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_other', name: 'Other', clientId: 'client_other', sandbox: true }, + ]); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow(/Callback/); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('does not mutate production or a different application', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_prod', name: 'Production', clientId: setup.clientId, sandbox: false }, + ]); + const production = await configureAuthkitApplication(setup, setup.clientId); + expect(production.verified).toBe(false); + expect(production.callbackRegistered).toBe(true); + expect(production.reason).toContain('restricted to sandbox'); + expect(writes()).toHaveLength(0); + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_app', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + ]); + application.clientId = 'client_other'; + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow(/Callback/); + expect(writes()).toHaveLength(0); + }); + + it('fails on a missing production callback without making production or API-key writes', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_prod', name: 'Production', clientId: setup.clientId, sandbox: false }, + ]); + application.redirectUris = []; + const request = vi.fn(); + vi.stubGlobal('fetch', request); + await expect(configureAuthkitApplication(setup, setup.clientId, 'sk_test_other')).rejects.toThrow( + 'restricted to sandbox', + ); + expect(writes()).toHaveLength(0); + expect(request).not.toHaveBeenCalled(); + }); + + it('fails without credentials rather than claiming an unregistered callback works', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow( + 'No usable dashboard environment or API key', + ); + expect(fetchTeamEnvironments).not.toHaveBeenCalled(); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses a changed app client ID before accessing the account', async () => { + await expect(configureAuthkitApplication(setup, 'client_other')).rejects.toThrow('client ID changed'); + expect(refreshIfExpired).not.toHaveBeenCalled(); + }); + + it('fails safely when team discovery fails before callback verification', async () => { + vi.mocked(fetchTeamEnvironments).mockRejectedValue(new Error('private backend details')); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow(/Callback/); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('registers a missing callback while retaining an existing callback and its default', async () => { + application.redirectUris = [{ uri: 'https://old.example/callback', isDefault: true }]; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(application.redirectUris).toEqual([ + { uri: 'https://old.example/callback', isDefault: true }, + { uri: setup.redirectUri, isDefault: false }, + ]); + expect(writes()).toHaveLength(3); + }); + + it('uses one client-ID-matched environment for callback, sign-out and initiate-login writes', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_key', name: 'API key environment', sandbox: true, clientId: 'client_other' }, + { id: 'env_app', name: 'App environment', sandbox: true, clientId: setup.clientId }, + ]); + application.redirectUris = []; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(true); + expect(writes().map(([name]) => name)).toEqual([ + 'setRedirectUris', + 'setAuthkitApplicationLogoutUris', + 'updateAuthkitApplication', + ]); + for (const [, options] of vi.mocked(dashboardGraphqlRequest).mock.calls) { + expect(options.environmentId).toBe('env_app'); + } + for (const [, options] of writes()) { + expect(options.variables?.input).toMatchObject({ applicationId: 'app_1' }); + } + }); + + it('fails if the callback write reports success but read-back is missing it', async () => { + application.redirectUris = []; + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'setRedirectUris') return { setRedirectUris: { __typename: 'RedirectUrisSet' } }; + return original(name, options); + }); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow('Callback read-back'); + expect(writes().map(([name]) => name)).toEqual(['setRedirectUris']); + }); + + it('rejects incomplete application reads rather than overwriting an unknown list', async () => { + vi.mocked(dashboardGraphqlRequest).mockResolvedValue({ + defaultUserlandApplication: { id: 'app_1', clientId: setup.clientId }, + }); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow(/Callback/); + expect(writes()).toHaveLength(0); + }); + + it('does not apply any mutation when the sign-out dry run is rejected', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'setAuthkitApplicationLogoutUris') + return { setUserlandApplicationLogoutUris: { __typename: 'InvalidLogoutUriError' } }; + return original(name, options); + }); + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).toContain('validation failed'); + expect(writes()).toHaveLength(0); + }); + + it('rejects ambiguous environment matches before any request can mutate settings', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_one', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + { id: 'env_two', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + ]); + await expect(configureAuthkitApplication(setup, setup.clientId)).rejects.toThrow('Could not uniquely match'); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('detects a concurrent edit between validation and the full-list write', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + const result = await original(name, options); + if (name === 'setAuthkitApplicationLogoutUris') + application.logoutUris.push({ uri: 'https://concurrent.example/', isDefault: false }); + return result; + }); + expect((await configureAuthkitApplication(setup, setup.clientId)).reason).toContain('changed during setup'); + expect(writes()).toHaveLength(0); + }); + + it('does not treat a successful mutation response as verified configuration', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + const result = await original(name, options); + if (name === 'updateAuthkitApplication') application.initiateLoginUri = null; + return result; + }); + expect((await configureAuthkitApplication(setup, setup.clientId)).reason).toContain('read-back'); + }); + + it('reports a partial write as unverified without leaking the underlying error', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'updateAuthkitApplication') throw new Error('private backend details'); + return original(name, options); + }); + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).not.toContain('private backend details'); + expect(application.logoutUris.some((uri) => uri.uri === setup.signOutUri)).toBe(true); + }); +}); + +describe('app URL derivation', () => { + let directory: string; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'authkit-urls-')); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('uses the actual callback origin and custom port while keeping the two routes distinct', async () => { + await writeFile( + join(directory, '.env.local'), + 'WORKOS_CLIENT_ID=client_app\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:4567/auth/callback\n', + ); + const result = await readNextjsApplicationSetup(directory); + expect(result.initiateLoginUri).toBe('http://localhost:4567/sign-in'); + expect(result.signOutUri).toBe('http://localhost:4567/'); + expect(result.redirectUri).toBe('http://localhost:4567/auth/callback'); + expect(result.verified).toBe(false); + }); + + it('rejects using the same path for sign-in and callback', async () => { + await writeFile( + join(directory, '.env.local'), + 'WORKOS_CLIENT_ID=client_app\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/sign-in\n', + ); + await expect(readNextjsApplicationSetup(directory)).rejects.toThrow('cannot use /sign-in'); + }); +}); diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts new file mode 100644 index 00000000..c7dd6c31 --- /dev/null +++ b/src/lib/authkit-application-setup.ts @@ -0,0 +1,302 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parseEnvFile } from '../utils/env-parser.js'; +import { refreshIfExpired } from './command-auth.js'; +import { fetchTeamEnvironments } from './environment-target.js'; +import { dashboardGraphqlRequest } from './dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; +import { InstallDeclinedError } from './installer-errors.js'; + +export interface AuthkitApplicationSetup { + clientId: string; + redirectUri: string; + signOutUri: string; + initiateLoginUri: string; + homepageUrl?: string; + verified: boolean; + /** The callback was registered; this alone does not verify the other URLs or browser flows. */ + callbackRegistered?: boolean; + reason?: string; +} + +interface Uri { + id?: string | null; + uri: string; + isDefault?: boolean | null; +} + +interface Application { + id: string; + clientId: string; + redirectUris: Uri[]; + logoutUris: Uri[]; + initiateLoginUri: string | null; + appHomepageUrl?: string | null; +} + +/** Use the app's saved callback, not the active profile or a guessed localhost port. */ +export async function readNextjsApplicationSetup( + installDir: string, + homepageUrl?: string, +): Promise { + const env = parseEnvFile(await readFile(join(installDir, '.env.local'), 'utf8')); + const clientId = env.WORKOS_CLIENT_ID; + const redirectUri = env.NEXT_PUBLIC_WORKOS_REDIRECT_URI; + if (!clientId || !redirectUri) throw new Error('Missing AuthKit client ID or callback URL in .env.local.'); + const callback = new URL(redirectUri); + if (!['http:', 'https:'].includes(callback.protocol) || callback.username || callback.password || callback.hash) { + throw new Error('The AuthKit callback must be an HTTP(S) URL without credentials or a fragment.'); + } + if (callback.pathname.replace(/\/$/, '') === '/sign-in') { + throw new Error( + 'The OAuth callback cannot use /sign-in; that route starts authentication. Use a separate callback.', + ); + } + if (homepageUrl !== undefined) { + const homepage = new URL(homepageUrl); + if (!['http:', 'https:'].includes(homepage.protocol) || homepage.username || homepage.password) { + throw new Error('The homepage must be an HTTP(S) URL without credentials.'); + } + } + return { + clientId, + redirectUri, + ...(homepageUrl !== undefined ? { homepageUrl } : {}), + signOutUri: `${callback.origin}/`, + initiateLoginUri: `${callback.origin}/sign-in`, + verified: false, + }; +} + +/** + * Callback registration is mandatory; remaining settings may require manual setup. + * Choose one write target: a uniquely matched dashboard sandbox, or the sandbox + * API key when no session/team match exists. Never fall back after dashboard writes. + */ +export async function configureAuthkitApplication( + setup: AuthkitApplicationSetup, + expectedClientId: string, + apiKey?: string, +): Promise { + let callbackRegistered = false; + const pending = (reason: string): AuthkitApplicationSetup => { + if (!callbackRegistered) { + throw new InstallDeclinedError(`Callback URL is not registered or verified. ${reason}`, 'callback_unregistered'); + } + return { ...setup, callbackRegistered, verified: false, reason }; + }; + const isSignOutDestination = (uri: string): boolean => { + try { + return new URL(uri).href === new URL(setup.signOutUri).href; + } catch { + return false; + } + }; + if (setup.clientId !== expectedClientId) { + return pending('The app client ID changed during installation. Confirm the application before configuring it.'); + } + const registerApiCallback = async (): Promise => { + if (!apiKey) + return pending( + 'No usable dashboard environment or API key is available. Configure the callback in the dashboard.', + ); + if (!apiKey.startsWith('sk_test_')) { + return pending( + 'Automatic callback registration requires a sandbox API key (sk_test_). Configure production URLs explicitly in the dashboard.', + ); + } + try { + const { createWorkOSClient } = await import('./workos-client.js'); + await createWorkOSClient(apiKey).redirectUris.add(setup.redirectUri); + } catch { + return pending('Could not register the callback URL. Check the API key and connection, then retry setup.'); + } + callbackRegistered = true; + return pending( + 'Callback registered using the API key. Sign-out URI and Initiate login URI still require dashboard setup and verification. Sign in to the correct team (and claim the environment if needed) to manage those settings.', + ); + }; + const session = await refreshIfExpired().catch(() => { + throw new InstallDeclinedError( + 'Callback URL is not registered or verified. Could not check the dashboard session. Retry setup.', + 'callback_unregistered', + ); + }); + if (!session) return registerApiCallback(); + + try { + const environments = await fetchTeamEnvironments(session.accessToken); + const matches = environments.filter((environment) => environment.clientId === setup.clientId); + // A session for another team must not disable API-key-only onboarding. No + // dashboard mutation has happened, and this branch returns before any can. + if (matches.length === 0) return registerApiCallback(); + if (matches.length !== 1) return pending('Could not uniquely match the app client ID to a WorkOS environment.'); + const environment = matches[0]; + // Already validated by the team catalog and the application read below. + // Do not resolve again: that would re-fetch and mutate stored profiles. + const environmentId = environment.id; + const request = (name: string, variables: Record): Promise => + dashboardGraphqlRequest(resolveExecutableDocument(getOperation(name)), { + token: session.accessToken, + environmentId, + variables, + }); + const readApplication = async (): Promise => { + const data = await request<{ defaultUserlandApplication: Application | null }>('defaultAuthkitApplication', { + environmentId, + }); + const application = data.defaultUserlandApplication; + if ( + !application || + application.clientId !== setup.clientId || + !application.id || + !Array.isArray(application.logoutUris) || + !Array.isArray(application.redirectUris) || + !(application.initiateLoginUri === null || typeof application.initiateLoginUri === 'string') || + !application.logoutUris.every( + (uri) => typeof uri.uri === 'string' && (uri.isDefault === null || typeof uri.isDefault === 'boolean'), + ) || + !application.redirectUris.every( + (uri) => typeof uri.uri === 'string' && (uri.isDefault === null || typeof uri.isDefault === 'boolean'), + ) + ) + throw new Error('Application configuration unavailable'); + return application; + }; + let original = await readApplication(); + if (environment.sandbox !== true) { + // Production can use an already registered callback, but is read-only here. + callbackRegistered = original.redirectUris.some((uri) => uri.uri === setup.redirectUri); + return pending( + 'Automatic URL setup is restricted to sandbox environments. Configure this environment explicitly in the dashboard.', + ); + } + + // Register and verify the additive callback FIRST. Conflicting settings for + // other apps may block their own updates, but must not block basic sign-in. + if (!original.redirectUris.some((uri) => uri.uri === setup.redirectUri)) { + const input = { + applicationId: original.id, + redirectUris: [ + ...original.redirectUris, + { uri: setup.redirectUri, isDefault: original.redirectUris.length === 0 }, + ], + }; + const validated = await request<{ setRedirectUris: { __typename: string } }>('setRedirectUris', { + input: { ...input, dryRun: true }, + }); + if (validated.setRedirectUris.__typename !== 'RedirectUrisSet') return pending('Callback URL validation failed.'); + if (JSON.stringify(await readApplication()) !== JSON.stringify(original)) + return pending('Application settings changed during setup. Recheck them before applying changes.'); + const written = await request<{ setRedirectUris: { __typename: string } }>('setRedirectUris', { + input: { ...input, dryRun: false }, + }); + if (written.setRedirectUris.__typename !== 'RedirectUrisSet') return pending('Could not save the callback URL.'); + const saved = await readApplication(); + if ( + saved.id !== original.id || + !saved.redirectUris.some((uri) => uri.uri === setup.redirectUri) || + !original.redirectUris.every((old) => + saved.redirectUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ) + ) { + return pending('Callback read-back did not match the required settings.'); + } + original = saved; + } + callbackRegistered = true; + + const reasons: string[] = []; + const defaults = original.logoutUris.filter((uri) => uri.isDefault); + const signOutConflict = defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri)); + const initiateConflict = !!original.initiateLoginUri && original.initiateLoginUri !== setup.initiateLoginUri; + if (signOutConflict) + reasons.push( + 'An existing sign-out default differs from this app. It was left unchanged; confirm the intended default in the dashboard.', + ); + if (initiateConflict) + reasons.push( + 'An existing Initiate login URI differs from this app. It was left unchanged; confirm the intended sign-in route in the dashboard.', + ); + + const needsLogout = + !signOutConflict && !original.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault); + if (needsLogout) { + const logoutUris = original.logoutUris.map((uri) => ({ ...uri, isDefault: isSignOutDestination(uri.uri) })); + if (!logoutUris.some((uri) => isSignOutDestination(uri.uri))) + logoutUris.push({ uri: setup.signOutUri, isDefault: true }); + const input = { applicationId: original.id, logoutUris }; + const validated = await request<{ setUserlandApplicationLogoutUris: { __typename: string } }>( + 'setAuthkitApplicationLogoutUris', + { input: { ...input, dryRun: true } }, + ); + if (validated.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') + return pending('Sign-out URL validation failed. Its settings were not changed.'); + if (JSON.stringify(await readApplication()) !== JSON.stringify(original)) + return pending('Application settings changed during setup. Recheck them before applying changes.'); + const saved = await request<{ setUserlandApplicationLogoutUris: { __typename: string } }>( + 'setAuthkitApplicationLogoutUris', + { input: { ...input, dryRun: false } }, + ); + if (saved.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') + return pending('Could not save the sign-out URL. Check the dashboard before continuing.'); + } + const needsInitiate = !initiateConflict && original.initiateLoginUri !== setup.initiateLoginUri; + const needsHomepage = setup.homepageUrl !== undefined && original.appHomepageUrl !== setup.homepageUrl; + if (needsInitiate || needsHomepage) { + const current = await readApplication(); + if ( + current.id !== original.id || + (needsInitiate && current.initiateLoginUri && current.initiateLoginUri !== setup.initiateLoginUri) + ) { + return pending('The application or Initiate login URI changed during setup. It was not overwritten.'); + } + if ( + (needsInitiate && !current.initiateLoginUri) || + (needsHomepage && current.appHomepageUrl !== setup.homepageUrl) + ) { + const saved = await request<{ updateUserlandApplication: { __typename: string } }>('updateAuthkitApplication', { + input: { + applicationId: original.id, + ...(needsInitiate && !current.initiateLoginUri ? { initiateLoginUri: setup.initiateLoginUri } : {}), + ...(needsHomepage ? { appHomepageUrl: setup.homepageUrl } : {}), + }, + }); + if (saved.updateUserlandApplication.__typename !== 'UserlandApplicationUpdated') + return pending('Could not save application URLs. Check the dashboard before continuing.'); + } + } + const saved = await readApplication(); + callbackRegistered = saved.id === original.id && saved.redirectUris.some((uri) => uri.uri === setup.redirectUri); + if (!callbackRegistered) return pending('Callback read-back did not match the required settings.'); + if (reasons.length) return pending(reasons.join(' ')); + if ( + !original.redirectUris.every((old) => + saved.redirectUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ) || + !original.logoutUris.every((old) => + saved.logoutUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ) || + saved.logoutUris.filter((uri) => uri.isDefault).length !== 1 || + !saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) || + saved.initiateLoginUri !== setup.initiateLoginUri || + (setup.homepageUrl !== undefined && saved.appHomepageUrl !== setup.homepageUrl) + ) + return pending( + 'URL read-back did not match the required settings. Check the dashboard before testing authentication.', + ); + return { + ...setup, + signOutUri: saved.logoutUris.find((uri) => uri.isDefault)!.uri, + callbackRegistered, + verified: true, + }; + } catch (error) { + if (error instanceof InstallDeclinedError) throw error; + // Callback failures are fatal. Once it is confirmed, other settings may be + // reported as incomplete, without exposing private backend errors or switching targets. + return pending( + 'Could not verify WorkOS application settings. Check dashboard access and read back all three URLs before continuing.', + ); + } +} diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts index bc049955..42f5c20c 100644 --- a/src/lib/completion-data.spec.ts +++ b/src/lib/completion-data.spec.ts @@ -51,6 +51,25 @@ describe('buildCompletionData', () => { expect(data.integration).toBe('nextjs'); }); + it.each([true, false])('retains application setup status and concrete URLs when verified=%s', async (verified) => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '16.0.0' } }); + const applicationSetup = { + clientId: 'client_app', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified, + reason: verified ? undefined : 'No dashboard session is available.', + }; + const data = await buildCompletionData({ integration: 'nextjs', installDir }, { ...baseDeps, applicationSetup }); + expect(data.applicationSetup).toEqual(applicationSetup); + expect(data.nextSteps.join('\n')).toContain('Initiate login URI: http://localhost:3000/sign-in'); + expect(data.nextSteps.join('\n')).toContain(verified ? 'browser flows are not yet tested' : 'setup is incomplete'); + expect(data.nextSteps.join('\n')).toContain( + `Redirect URI: ${applicationSetup.redirectUri} (${verified ? 'registered' : 'not registered or verified'})`, + ); + }); + 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 } };'); diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts index b8160655..501f256b 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -1,6 +1,20 @@ import type { CompletionData } from './events.js'; import type { DevCommandResult } from './dev-command.js'; import type { Integration } from './constants.js'; +import type { AuthkitApplicationSetup } from './authkit-application-setup.js'; + +export function applicationSetupNextSteps(setup: AuthkitApplicationSetup): string[] { + return [ + setup.verified + ? 'Application URLs were read back and verified; browser flows are not yet tested.' + : `Application setup is incomplete: ${setup.reason ?? 'Settings have not been verified.'}`, + `Redirect URI: ${setup.redirectUri} (${setup.callbackRegistered || setup.verified ? 'registered' : 'not registered or verified'})`, + `Sign-out URI: ${setup.signOutUri}`, + `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)`, + ...(setup.homepageUrl !== undefined ? [`Homepage URL: ${setup.homepageUrl}`] : []), + 'Test sign-in, sign-out, protected-page access, and a password-reset or invitation login before calling the integration complete.', + ]; +} /** * Machine-context slice needed to build completion data. @@ -30,6 +44,7 @@ export interface CompletionDataDeps { * Resolved by the caller, which owns the config lookup. */ claimCommand?: string; + applicationSetup?: AuthkitApplicationSetup; } /** @@ -68,7 +83,13 @@ export async function buildCompletionData(ctx: CompletionContext, deps: Completi devCommand, url, files, - nextSteps: [...claim, ...concrete, ...framework], + nextSteps: [ + ...claim, + ...(deps.applicationSetup ? applicationSetupNextSteps(deps.applicationSetup) : []), + ...concrete, + ...framework, + ], + ...(deps.applicationSetup ? { applicationSetup: deps.applicationSetup } : {}), docsUrl: deps.docsUrl, dashboardUrl: deps.dashboardUrl, signInSnippet: deps.signInSnippet, diff --git a/src/lib/events.ts b/src/lib/events.ts index 32d2d77e..1dec90f2 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -25,6 +25,8 @@ export interface CompletionData { dashboardUrl: string; /** Optional per-framework "add a sign-in link" snippet */ signInSnippet?: string; + /** Saved application configuration is separate from untested browser flows. */ + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; } export interface InstallerEvents { diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index fd05033f..dfb71214 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createActor, fromPromise } from 'xstate'; +import { createActor, fromPromise, waitFor } from 'xstate'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; @@ -309,6 +309,32 @@ describe('InstallerCore State Machine', () => { }); describe('full flow', () => { + it('retains pending application setup for the completion actor', async () => { + const applicationSetup = { + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified: false, + reason: 'No dashboard session.', + }; + const { actor } = createTestActor( + { skipAuth: true, noCommit: true, apiKey: 'sk_test_123', clientId: 'client_123' }, + { + runAgent: fromPromise(async () => ({ + success: true, + summary: 'App code installed; setup pending.', + applicationSetup, + })), + }, + ); + actor.start(); + actor.send({ type: 'START' }); + await waitFor(actor, (snapshot) => snapshot.context.applicationSetup !== undefined); + expect(actor.getSnapshot().context.applicationSetup).toEqual(applicationSetup); + actor.stop(); + }); + it('completes the full wizard flow with provided credentials', async () => { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index abf8b2a3..2d85a9d5 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -1016,6 +1016,7 @@ export const installerMachine = setup({ const output = event.output as AgentOutput; return output.summary; }, + applicationSetup: ({ event }) => (event.output as AgentOutput).applicationSetup, }), ({ context, event }) => { const output = event.output as AgentOutput; diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index 46aec300..f8270cfe 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -60,6 +60,7 @@ export interface InstallerMachineContext { prUrl?: string; /** Summary message from agent execution */ agentSummary?: string; + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; /** Whether the install directory is empty and can be scaffolded into */ scaffoldable?: boolean; /** Package manager resolved for the scaffolded app */ @@ -129,6 +130,7 @@ export interface GitCheckOutput { export interface AgentOutput { success: boolean; summary?: string; + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; error?: Error; } diff --git a/src/lib/preflight-authkit.spec.ts b/src/lib/preflight-authkit.spec.ts index 7aa3be2d..83137848 100644 --- a/src/lib/preflight-authkit.spec.ts +++ b/src/lib/preflight-authkit.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -9,7 +9,7 @@ import { setOutputMode } from '../utils/output.js'; // Mock the UI facade — the interactive branch prompts, which has no place in a unit test. const mockConfirm = vi.fn(); const mockUi = { - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn(), success: vi.fn() }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn(), success: vi.fn(), detail: vi.fn() }, rows: vi.fn(), confirm: (...args: unknown[]) => mockConfirm(...args), // Mirrors the real facade: only the CANCEL symbol counts as a cancellation. @@ -17,7 +17,8 @@ const mockUi = { }; vi.mock('../utils/ui.js', () => ({ default: mockUi })); -const { assertNoExistingAuthKit, detectExistingAuthKit } = await import('./preflight-authkit.js'); +const { assertInstallPreflight, assertNoExistingAuthKit, detectExistingAuthKit } = + await import('./preflight-authkit.js'); function writePackageJson(dir: string, deps: Record, devDeps?: Record): void { writeFileSync( @@ -44,6 +45,70 @@ describe('preflight-authkit', () => { errorSpy.mockRestore(); }); + describe('unsupported Next.js routers', () => { + it.each(['pages', 'src/pages'])('declines a %s-only project even with --force', async (pages) => { + writePackageJson(testDir, { next: '16.0.0' }); + mkdirSync(join(testDir, pages), { recursive: true }); + writeFileSync(join(testDir, pages, '_app.tsx'), 'export default function App() {}'); + const before = readdirSync(testDir); + await expect(assertInstallPreflight({ installDir: testDir, force: true })).rejects.toMatchObject({ + code: 'unsupported_nextjs_router', + }); + expect(readdirSync(testDir)).toEqual(before); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'rejects runtime Pages selection before provisioning (Next.js manifest: %s)', + async (hasManifest) => { + if (hasManifest) writePackageJson(testDir, { next: '16.0.0' }); + const before = readdirSync(testDir); + const runtimeOptions = JSON.parse(JSON.stringify({ installDir: testDir, router: 'pages', force: true })); + await expect(assertInstallPreflight(runtimeOptions)).rejects.toMatchObject({ + code: 'unsupported_nextjs_router', + }); + expect(readdirSync(testDir)).toEqual(before); + expect(mockConfirm).not.toHaveBeenCalled(); + }, + ); + + it('allows pages/api-only projects using the same detection as the installer', async () => { + writePackageJson(testDir, { next: '16.0.0' }); + mkdirSync(join(testDir, 'pages/api'), { recursive: true }); + writeFileSync(join(testDir, 'pages/api/health.ts'), 'export default function handler() {}'); + await expect(assertInstallPreflight({ installDir: testDir })).resolves.toBeUndefined(); + expect(mockUi.log.warn).not.toHaveBeenCalled(); + }); + + it.each(['app/sign-in', 'src/app/(auth)/sign-in'])( + 'declines an existing %s page without modifying it', + async (path) => { + writePackageJson(testDir, { next: '16.0.0' }); + mkdirSync(join(testDir, path), { recursive: true }); + writeFileSync(join(testDir, path, 'page.tsx'), 'export default function ExistingLogin() {}'); + await expect(assertInstallPreflight({ installDir: testDir, force: true })).rejects.toMatchObject({ + code: 'conflicting_sign_in_route', + }); + expect(readdirSync(join(testDir, path))).toEqual(['page.tsx']); + }, + ); + + it('does not mistake a non-Next.js pages directory for a Pages Router project', async () => { + writePackageJson(testDir, { react: '19.0.0' }); + mkdirSync(join(testDir, 'pages')); + await expect(assertInstallPreflight({ installDir: testDir })).resolves.toBeUndefined(); + }); + + it('allows mixed-router projects', async () => { + writePackageJson(testDir, { next: '16.0.0' }); + mkdirSync(join(testDir, 'pages')); + mkdirSync(join(testDir, 'app')); + writeFileSync(join(testDir, 'pages/_app.tsx'), 'export default function App() {}'); + writeFileSync(join(testDir, 'app/layout.tsx'), 'export default function Layout() {}'); + await expect(assertInstallPreflight({ installDir: testDir })).resolves.toBeUndefined(); + }); + }); + describe('detectExistingAuthKit', () => { it('returns [] when the project has no package.json', () => { expect(detectExistingAuthKit(testDir)).toEqual([]); diff --git a/src/lib/preflight-authkit.ts b/src/lib/preflight-authkit.ts index c3f8d485..070a8c49 100644 --- a/src/lib/preflight-authkit.ts +++ b/src/lib/preflight-authkit.ts @@ -1,5 +1,5 @@ /** - * Install preflight: refuse to run over an existing AuthKit install. + * Install preflight: reject unsupported Next.js routers and protect existing AuthKit installs. * * `workos install` provisions a fresh WorkOS environment and writes its * credentials into the project's env file before the installer state machine @@ -9,6 +9,12 @@ */ import { AUTHKIT_PACKAGES } from '../doctor/checks/sdk.js'; +import { + assertSupportedNextJsRouter, + getNextJsRouter, + assertNextjsSignInRouteAvailable, +} from '../integrations/nextjs/utils.js'; +import type { InstallerOptions } from '../utils/types.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { isPromptAllowed } from '../utils/interaction-mode.js'; @@ -37,6 +43,18 @@ export function detectExistingAuthKit(installDir: string): DetectedAuthKitPackag .filter((pkg): pkg is DetectedAuthKitPackage => !!pkg.version); } +/** Reject known unsupported routers before credential provisioning can write files. */ +export async function assertInstallPreflight( + opts: Pick & { force?: boolean }, +): Promise { + const isNextjs = !!getPackageVersion('next', readPackageJson(opts.installDir) ?? {}); + if (isNextjs || opts.router !== undefined) { + assertSupportedNextJsRouter(await getNextJsRouter(opts)); + await assertNextjsSignInRouteAvailable(opts.installDir); + } + await assertNoExistingAuthKit(opts); +} + /** * Stop the install before anything is written when AuthKit is already present. * diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts new file mode 100644 index 00000000..e4c6d678 --- /dev/null +++ b/src/lib/run-with-core.setup.spec.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { InstallerOptions } from '../utils/types.js'; +import { configureInstallEnvironment } from './run-with-core.js'; +import { readProjectEnvCredentials } from './project-env.js'; + +let directory: string; +let options: InstallerOptions; +const fetchSpy = vi.fn(); + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'install-setup-')); + options = { + installDir: directory, + router: 'app', + debug: false, + forceInstall: false, + local: false, + ci: true, + skipAuth: true, + }; + fetchSpy.mockReset().mockResolvedValue(new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(async () => { + vi.unstubAllGlobals(); + await rm(directory, { recursive: true, force: true }); +}); + +describe('Next.js environment preparation', () => { + it.each([true, false])( + 'does not mutate the API-key environment for a mixed pair (explicit key: %s)', + async (explicitKey) => { + await writeFile(join(directory, '.env.local'), 'WORKOS_CLIENT_ID=client_environment_b\n'); + if (!explicitKey) await writeFile(join(directory, '.env'), 'WORKOS_API_KEY=sk_test_environment_a\n'); + const project = readProjectEnvCredentials(directory); + await configureInstallEnvironment({ + options, + integration: 'nextjs', + credentials: { + apiKey: explicitKey ? 'sk_test_environment_a' : project.apiKey!, + clientId: project.clientId!, + }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + const env = await readFile(join(directory, '.env.local'), 'utf8'); + expect(env).toContain('WORKOS_CLIENT_ID=client_environment_b'); + }, + ); + + it.each(['pages', 'src/pages'])('declines %s projects before writing credentials or settings', async (pages) => { + await mkdir(join(directory, pages), { recursive: true }); + await writeFile(join(directory, pages, '_app.tsx'), 'export default function App() {}'); + await expect( + configureInstallEnvironment({ + options: { ...options, router: undefined }, + integration: 'nextjs', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + }), + ).rejects.toMatchObject({ code: 'unsupported_nextjs_router' }); + expect(fetchSpy).not.toHaveBeenCalled(); + await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects runtime Pages selection before writing credentials or making API calls', async () => { + const runtimeOptions = JSON.parse(JSON.stringify({ ...options, router: 'pages' })); + await expect( + configureInstallEnvironment({ + options: runtimeOptions, + integration: 'nextjs', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + }), + ).rejects.toMatchObject({ code: 'unsupported_nextjs_router' }); + expect(fetchSpy).not.toHaveBeenCalled(); + await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not mutate the state machine options while detecting the router', async () => { + await mkdir(join(directory, 'app'), { recursive: true }); + await writeFile(join(directory, 'app/layout.tsx'), 'export default function Layout() {}'); + const inputOptions = Object.freeze({ ...options, router: undefined }); + await configureInstallEnvironment({ + options: inputOptions, + integration: 'nextjs', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + }); + expect(inputOptions.router).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index fe9a3e44..94869303 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -3,7 +3,9 @@ import open from 'open'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import type { CompletionData } from './events.js'; -import { buildCompletionData } from './completion-data.js'; +import { buildCompletionData, applicationSetupNextSteps } from './completion-data.js'; +import { readNextjsApplicationSetup, configureAuthkitApplication } from './authkit-application-setup.js'; +import { validateInstallation } from './validation/index.js'; import { resolveDevCommand } from './dev-command.js'; import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; @@ -48,6 +50,11 @@ import { generatePrDescription as generatePrDescriptionAi, } from './ai-content.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; +import { + assertSupportedNextJsRouter, + getNextJsRouter, + assertNextjsSignInRouteAvailable, +} from '../integrations/nextjs/utils.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; @@ -176,6 +183,41 @@ export function resolveCredentialSource( return backfilledFromProjectEnv ? 'env' : options.credentialSource; } +export async function configureInstallEnvironment( + context: Pick, +): Promise { + const { options: installerOptions, integration, credentials } = context; + if (!integration || !credentials) throw new Error('Missing integration or credentials'); + + const registry = await getRegistry(); + const mod = registry.get(integration); + if (mod?.config.metadata.language !== 'javascript') return; + + if (integration === 'nextjs') { + assertSupportedNextJsRouter(await getNextJsRouter(installerOptions)); + await assertNextjsSignInRouteAvailable(installerOptions.installDir); + } + + const port = detectPort(integration, installerOptions.installDir); + const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; + // Next.js URL writes happen after code validation. That step chooses ONE + // target: the dashboard application, or an API-key-only callback without a session. + const requiresApiKey = ['tanstack-start', 'react-router'].includes(integration); + if (credentials.apiKey && requiresApiKey) { + await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { + homepageUrl: installerOptions.homepageUrl, + redirectUri: installerOptions.redirectUri, + }); + } + + const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; + writeEnvLocal(installerOptions.installDir, { + ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), + WORKOS_CLIENT_ID: credentials.clientId, + [redirectUriKey]: redirectUri, + }); +} + export async function runWithCore(options: InstallerOptions): Promise { // Initialize debug/logging early so we capture all failures initLogFile(); @@ -302,43 +344,9 @@ export async function runWithCore(options: InstallerOptions): Promise { return { isClean: files.length === 0, files }; }), - configureEnvironment: fromPromise(async ({ input }) => { - const { context } = input; - const { options: installerOptions, integration, credentials } = context; - - if (!integration || !credentials) { - throw new Error('Missing integration or credentials'); - } - - // Non-JS integrations own their env file writing (e.g. Python writes - // .env inside its own run()). Skip here so we don't leak a .env.local - // with JS-flavored vars (WORKOS_COOKIE_PASSWORD, wrong redirect port). - const registry = await getRegistry(); - const mod = registry.get(integration); - if (mod?.config.metadata.language !== 'javascript') { - return; - } - - const port = detectPort(integration, installerOptions.installDir); - const callbackPath = getCallbackPath(integration); - const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${callbackPath}`; - - const requiresApiKey = ['nextjs', 'tanstack-start', 'react-router'].includes(integration); - if (credentials.apiKey && requiresApiKey) { - await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { - homepageUrl: installerOptions.homepageUrl, - redirectUri: installerOptions.redirectUri, - }); - } - - const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; - - writeEnvLocal(installerOptions.installDir, { - ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), - WORKOS_CLIENT_ID: credentials.clientId, - [redirectUriKey]: redirectUri, - }); - }), + configureEnvironment: fromPromise(({ input }) => + configureInstallEnvironment(input.context), + ), runAgent: fromPromise(async ({ input }) => { const { context } = input; @@ -351,15 +359,51 @@ export async function runWithCore(options: InstallerOptions): Promise { try { const agentOptions: InstallerOptions = { ...installerOptions, + ...(integration === 'nextjs' ? { router: 'app' as const } : {}), apiKey: credentials?.apiKey, clientId: credentials?.clientId, credentialSource: context.credentialSource, emitter: context.emitter, }; const summary = await runIntegrationInstallerFn(integration, agentOptions); + let applicationSetup; + if (integration === 'nextjs') { + applicationSetup = await readNextjsApplicationSetup( + installerOptions.installDir, + installerOptions.homepageUrl, + ); + const expectedRedirectUri = + installerOptions.redirectUri || + `http://localhost:${detectPort(integration, installerOptions.installDir)}${getCallbackPath(integration)}`; + if (applicationSetup.redirectUri !== expectedRedirectUri) { + throw new Error( + 'The app callback URL changed during installation. Confirm it before configuring WorkOS.', + ); + } + // Even --no-validate must not point the dashboard at a missing route. + const validation = await validateInstallation(integration, installerOptions.installDir, { + runBuild: false, + }); + if (!validation.passed) { + throw new Error( + `Application setup is incomplete:\n${validation.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => `${issue.message}. ${issue.hint ?? ''}`) + .join('\n')}`, + ); + } + applicationSetup = await configureAuthkitApplication( + applicationSetup, + credentials?.clientId ?? '', + credentials?.apiKey, + ); + } return { success: true, - summary: summary || `Successfully installed WorkOS AuthKit for ${integration}!`, + applicationSetup, + summary: applicationSetup + ? ['App code installed.', ...applicationSetupNextSteps(applicationSetup)].join('\n') + : summary || `Successfully installed WorkOS AuthKit for ${integration}!`, }; } catch (error) { return { @@ -371,7 +415,7 @@ export async function runWithCore(options: InstallerOptions): Promise { buildCompletion: fromPromise( async ({ input }) => { - const { integration, changedFiles, options: installerOptions, credentials } = input.context; + const { integration, changedFiles, options: installerOptions, credentials, applicationSetup } = input.context; if (!integration) return undefined; try { const registry = await getRegistry(); @@ -403,6 +447,7 @@ export async function runWithCore(options: InstallerOptions): Promise { frameworkNextSteps: cfg?.ui.getOutroNextSteps?.({}) ?? [], signInSnippet: cfg?.ui.getSignInSnippet?.({}), claimCommand: usedUnclaimedEnv ? formatWorkOSCommand('profile claim') : undefined, + applicationSetup, }, ); } catch { diff --git a/src/lib/validation/rules/nextjs.json b/src/lib/validation/rules/nextjs.json index f46e41d9..4f76123a 100644 --- a/src/lib/validation/rules/nextjs.json +++ b/src/lib/validation/rules/nextjs.json @@ -12,6 +12,12 @@ "path": "{,src/}app/**/callback/**/route.{ts,tsx,js,jsx}", "mustContain": ["handleAuth", "@workos-inc/authkit-nextjs"] }, + { + "path": "{,src/}app/**/sign-in/route.{ts,tsx,js,jsx}", + "urlPath": "/sign-in", + "mustContain": ["getSignInUrl", "@workos-inc/authkit-nextjs", "redirect", "GET"], + "severity": "error" + }, { "path": "{,src/}{middleware,proxy}.{ts,js}", "mustContainAny": ["authkitMiddleware", "authkit"] diff --git a/src/lib/validation/types.ts b/src/lib/validation/types.ts index 25a5ea56..cf91aabf 100644 --- a/src/lib/validation/types.ts +++ b/src/lib/validation/types.ts @@ -29,8 +29,10 @@ export interface EnvVarRule { export interface FileRule { path: string; // glob pattern, e.g., "middleware.ts" or "app/**/callback/**/route.ts" + urlPath?: string; // Next.js public path; route groups do not contribute URL segments mustContain?: string[]; // strings that must appear in file mustContainAny?: string[]; // at least one must appear + severity?: ValidationSeverity; // missing content patterns default to warning } export interface VariantRules { diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 7c1fef7b..8c9cea02 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -155,6 +155,76 @@ describe('validateInstallation', () => { }); describe('file validation', () => { + it.each(['app', 'src/app'])('accepts route groups for both sign-in and callback under %s', async (app) => { + const files = { + 'package.json': JSON.stringify({ dependencies: { '@workos-inc/authkit-nextjs': '^2.0.0' } }), + '.env.local': `WORKOS_API_KEY=sk_test_key\nWORKOS_CLIENT_ID=client_test\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback\nWORKOS_COOKIE_PASSWORD=${'x'.repeat(32)}\n`, + [`${app}/layout.tsx`]: 'export default function Layout() { return ; }', + [`${app.startsWith('src/') ? 'src/' : ''}middleware.ts`]: 'export const authkitMiddleware = () => {};', + [`${app}/(auth)/callback/route.ts`]: + "import { handleAuth } from '@workos-inc/authkit-nextjs'; export const GET = handleAuth();", + [`${app}/(auth)/(public)/sign-in/route.ts`]: + "import { getSignInUrl } from '@workos-inc/authkit-nextjs'; import { redirect } from 'next/navigation'; export async function GET() { return redirect(await getSignInUrl()); }", + }; + for (const [file, content] of Object.entries(files)) { + mkdirSync(join(testDir, file, '..'), { recursive: true }); + writeFileSync(join(testDir, file), content); + } + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.filter((issue) => issue.severity === 'error')).toEqual([]); + expect(result.passed).toBe(true); + }); + + it('does not mistake /account/sign-in for /sign-in', async () => { + mkdirSync(join(testDir, 'app/account/sign-in'), { recursive: true }); + writeFileSync( + join(testDir, 'app/account/sign-in/route.ts'), + "import { getSignInUrl } from '@workos-inc/authkit-nextjs'; export async function GET() { return redirect(await getSignInUrl()); }", + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.some((issue) => issue.type === 'file' && issue.message.includes('sign-in'))).toBe(true); + }); + + it('requires an initiate-login route distinct from the callback', async () => { + mkdirSync(join(testDir, 'app', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs'; export const GET = handleAuth();", + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'file', severity: 'error', message: expect.stringContaining('sign-in') }), + ]), + ); + }); + + it('rejects a callback handler masquerading as the sign-in route', async () => { + mkdirSync(join(testDir, 'app', 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'sign-in', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs'; export const GET = handleAuth();", + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('getSignInUrl') && issue.severity === 'error')).toBe( + true, + ); + }); + + it.each(['app', 'src/app'])('accepts the SDK-backed sign-in route under %s', async (appDir) => { + mkdirSync(join(testDir, appDir, 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, appDir, 'sign-in', 'route.ts'), + ` + import { getSignInUrl } from '@workos-inc/authkit-nextjs'; + import { redirect } from 'next/navigation'; + export async function GET() { return redirect(await getSignInUrl()); } + `, + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.filter((issue) => issue.message.includes('sign-in'))).toEqual([]); + }); + it('detects missing callback route file', async () => { writeFileSync( join(testDir, 'package.json'), @@ -291,6 +361,15 @@ describe('validateInstallation', () => { writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + mkdirSync(join(testDir, 'app', 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'sign-in', 'route.ts'), + ` + import { getSignInUrl } from '@workos-inc/authkit-nextjs'; + import { redirect } from 'next/navigation'; + export async function GET() { return redirect(await getSignInUrl()); } + `, + ); const result = await validateInstallation('nextjs', testDir); // All required files exist with required patterns, should pass diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index b6a7a06a..099d0036 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -5,6 +5,7 @@ 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'; +import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; import reactRouterRules from './rules/react-router.json' with { type: 'json' }; import reactRules from './rules/react.json' with { type: 'json' }; @@ -168,6 +169,9 @@ export async function validateFiles(rules: ValidationRules, projectDir: string): let matches: string[]; try { matches = await fg(rule.path, { cwd: projectDir }); + if (rules.framework === 'nextjs' && rule.urlPath) { + matches = matches.filter((file) => nextjsRoutePath(file) === rule.urlPath); + } } catch { // Invalid glob pattern - skip continue; @@ -200,7 +204,7 @@ export async function validateFiles(rules: ValidationRules, projectDir: string): if (!content.includes(pattern)) { issues.push({ type: 'pattern', - severity: 'warning', + severity: rule.severity ?? 'warning', message: `File ${matches[0]} missing expected pattern: "${pattern}"`, hint: `Ensure ${matches[0]} contains: ${pattern}`, }); @@ -214,7 +218,7 @@ export async function validateFiles(rules: ValidationRules, projectDir: string): if (!hasAny) { issues.push({ type: 'pattern', - severity: 'warning', + severity: rule.severity ?? 'warning', message: `File ${matches[0]} missing one of: ${rule.mustContainAny.join(', ')}`, hint: `Ensure ${matches[0]} contains one of these patterns`, }); @@ -238,11 +242,20 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s // Framework-specific validations switch (framework) { - case 'nextjs': + case 'nextjs': { + const signInPage = await findNextjsSignInPage(projectDir); + if (signInPage) + issues.push({ + type: 'file', + severity: 'error', + message: `Page ${signInPage} conflicts with the required /sign-in route handler`, + hint: 'Keep the existing page unchanged and configure AuthKit manually, or move it before running the installer.', + }); await validateNextjsRedirectUri(projectDir, issues); await validateNextjsMiddlewarePlacement(projectDir, issues); await validateCookiePasswordLength(projectDir, issues, 'WORKOS_COOKIE_PASSWORD'); break; + } case 'react': await validateReactProviderWrapping(projectDir, issues); break; @@ -326,19 +339,8 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI // Remove leading slash for path matching const routePath = callbackPath.replace(/^\//, ''); - // Check if route file exists at expected location (Next.js App Router) - const routePatterns = [ - `app/${routePath}/route.ts`, - `app/${routePath}/route.tsx`, - `app/${routePath}/route.js`, - `app/${routePath}/route.jsx`, - `src/app/${routePath}/route.ts`, - `src/app/${routePath}/route.tsx`, - `src/app/${routePath}/route.js`, - `src/app/${routePath}/route.jsx`, - ]; - - const routeExists = routePatterns.some((pattern) => existsSync(join(projectDir, pattern))); + const routeFiles = await fg('{,src/}app/**/route.{ts,tsx,js,jsx}', { cwd: projectDir }); + const routeExists = routeFiles.some((file) => nextjsRoutePath(file) === callbackPath); if (!routeExists) { // Check what routes DO exist to give a better hint @@ -352,7 +354,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI let hint = `Create a route handler at app/${routePath}/route.ts`; if (existingRoutes.length > 0) { // Found a route at a different path - likely the mismatch - const actualPath = '/' + existingRoutes[0].replace(/^(src\/)?app\//, '').replace(/\/route\.(ts|tsx|js|jsx)$/, ''); + const actualPath = nextjsRoutePath(existingRoutes[0]); hint = `Found callback route at ${existingRoutes[0]} but redirect URI points to ${callbackPath}. Either:\n` + ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + diff --git a/src/run.ts b/src/run.ts index 91d0195f..5d051a81 100644 --- a/src/run.ts +++ b/src/run.ts @@ -34,7 +34,7 @@ export type InstallerArgs = { direct?: boolean; scaffold?: boolean; pm?: string; - router?: 'app' | 'pages'; + router?: 'app'; }; /** diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index c3b93d38..0aaad5cc 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -67,6 +67,15 @@ describe('help-json', () => { ); }); + it('only advertises App Router for the installer', () => { + const tree = buildCommandTree('install') as { + options: { name: string; choices?: string[]; description: string }[]; + }; + const router = tree.options.find((option) => option.name === 'router'); + expect(router?.choices).toEqual(['app']); + expect(router?.description).toContain('App Router only'); + }); + it('does not include hidden dashboard command', () => { const tree = buildCommandTree(); const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 3c1ce2bd..5c664b61 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -2153,7 +2153,8 @@ const commands: CommandSchema[] = [ { name: 'router', type: 'string', - description: 'Next.js router to target when detection is ambiguous (app or pages)', + description: 'Use Next.js App Router (App Router only; Pages Router is not supported)', + choices: ['app'], required: false, hidden: false, }, diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index a496f8d6..81512e54 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -173,6 +173,32 @@ describe('summary-box', () => { expect(result).toContain('Start dev server to test authentication'); }); + it('labels pending dashboard configuration rather than claiming complete setup', () => { + const result = strip( + renderCompletionSummary( + true, + undefined, + makeCompletion({ + applicationSetup: { + clientId: 'client_app', + redirectUri: 'http://localhost:8080/callback', + signOutUri: 'http://localhost:8080/', + initiateLoginUri: 'http://localhost:8080/sign-in', + verified: false, + }, + nextSteps: ['Configure Initiate login URI: http://localhost:8080/sign-in'], + }), + ), + ); + expect(result).toContain('App code installed; WorkOS setup required'); + expect(result).toContain('http://localhost:8080/sign-in'); + }); + + it('retains manual setup instructions when structured completion data is unavailable', () => { + const result = strip(renderCompletionSummary(true, 'Application setup is incomplete: configure /sign-in')); + expect(result).toContain('Application setup is incomplete: configure /sign-in'); + }); + it('renders the failure summary', () => { const result = strip(renderCompletionSummary(false, 'Something went wrong')); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index cc38c112..2cfd6dad 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -19,7 +19,10 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); return renderFlatSummary({ expression: 'success', - title: 'WorkOS AuthKit Installed', + title: + completion.applicationSetup && !completion.applicationSetup.verified + ? 'App code installed; WorkOS setup required' + : 'WorkOS AuthKit Installed', items: [...shown, ...steps], footer: completion.docsUrl, }); @@ -29,6 +32,7 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp expression: 'success', title: 'WorkOS AuthKit Installed', items: [ + ...(summary ? [{ type: 'pending' as const, text: summary }] : []), { type: 'pending', text: 'Start dev server to test authentication' }, { type: 'pending', text: 'Visit WorkOS Dashboard to manage users' }, ], diff --git a/src/utils/types.ts b/src/utils/types.ts index 6ed7f00c..71443fcf 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -131,8 +131,8 @@ export type InstallerOptions = { */ pm?: string; - /** Next.js router to target when detection is ambiguous (from --router). */ - router?: 'app' | 'pages'; + /** Force App Router when detecting a mixed project. */ + router?: 'app'; }; export interface Feature { diff --git a/src/utils/ui-utils.ts b/src/utils/ui-utils.ts index 1b27dd4f..f33fa823 100644 --- a/src/utils/ui-utils.ts +++ b/src/utils/ui-utils.ts @@ -70,7 +70,7 @@ export async function abortIfCancelled( code: 'non_interactive_prompt', message: `This step requires interactive input${integration ? ` for ${integration}` : ''}, but the CLI is running ` + - `in a non-interactive mode (agent/CI/non-TTY). Pass the required flags (e.g. --router app|pages for Next.js) ` + + `in a non-interactive mode (agent/CI/non-TTY). Pass the required flags (e.g. --router app for Next.js) ` + `or run in an interactive terminal.`, recovery: { hints: [