diff --git a/src/integrations/nextjs/index.ts b/src/integrations/nextjs/index.ts index 6e1bcdc7..d7d9179e 100644 --- a/src/integrations/nextjs/index.ts +++ b/src/integrations/nextjs/index.ts @@ -75,6 +75,10 @@ export const config: FrameworkConfig = { 'Start your development server to test authentication', 'Visit the WorkOS Dashboard to manage users and settings', ], + // Client-side sign-in per the AuthKit Next.js guidance: trigger sign-in from + // a click handler with refreshAuth (getSignInUrl must not run during render). + getSignInSnippet: () => + 'Add a sign-in button in a client component: call refreshAuth({ ensureSignedIn: true }) on click', }, }; diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index f7af41f5..7fdee71e 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -222,17 +222,105 @@ describe('CLIAdapter', () => { expect(sendEvent).toHaveBeenCalledWith({ type: 'CANCEL' }); }); - it('shows success summary box on complete', async () => { + it('shows structured success summary box on complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); - emitter.emit('complete', { success: true, summary: 'All done!' }); + emitter.emit('complete', { + success: true, + summary: 'All done!', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['app/auth/route.ts'], + nextSteps: [ + 'Run `pnpm run dev` to start your dev server', + 'Open http://localhost:3000 to test authentication', + ], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + }, + }); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(output).toContain('WorkOS AuthKit Installed'); + expect(output).toContain('pnpm run dev'); + expect(output).toContain('app/auth/route.ts'); consoleSpy.mockRestore(); }); + it('renders persistent step lines for file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'secret' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('src/auth.ts'))).toBe(true); + expect(stepCalls.some((s) => s.includes('src/app.ts'))).toBe(true); + }); + + it('dedupes consecutive same-path file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'b', newContent: 'c' }); + + const appCalls = vi.mocked(clack.default.log.step).mock.calls.filter((c) => String(c[0]).includes('src/app.ts')); + expect(appCalls).toHaveLength(1); + }); + + it('does not clobber the phase message back to a generic string', async () => { + vi.useFakeTimers(); + try { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + vi.advanceTimersByTime(5000); + + const clobbered = spinnerMock.message.mock.calls + .map((c) => String(c[0])) + .filter((m) => /Running AI agent/.test(m)); + expect(clobbered).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('restarts the spinner on the last phase message after logging a file op', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'x' }); + + expect(spinnerMock.stop).toHaveBeenCalled(); + expect(spinnerMock.start).toHaveBeenCalledWith('Configuring middleware'); + }); + + it('renders Bash tool calls as step lines (agent:tool)', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('agent:tool', { kind: 'command', detail: 'pnpm add @workos-inc/authkit-nextjs' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('pnpm add @workos-inc/authkit-nextjs'))).toBe(true); + }); + it('shows error summary box on failure complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 9d1526d5..52a916c1 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -1,5 +1,6 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import { relative } from 'node:path'; import clack from '../../utils/clack.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; @@ -35,9 +36,14 @@ export class CLIAdapter implements InstallerAdapter { // SIGINT handler for cleanup private sigIntHandler: (() => void) | null = null; - // Long-running agent update interval + // Long-running agent update interval (kept as a no-op field; never started) private agentUpdateInterval: NodeJS.Timeout | null = null; + // Last phase message shown on the agent spinner, restored after logging above it. + private lastAgentMessage = 'Running AI agent...'; + // Last file path rendered as a step line, to dedupe consecutive same-path ops. + private lastFileOp: string | null = null; + constructor(config: AdapterConfig) { this.emitter = config.emitter; this.sendEvent = config.sendEvent; @@ -112,6 +118,10 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('config:complete', this.handleConfigComplete); this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // Persistent, append-only log of file operations + tool calls above the spinner. + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); this.subscribe('validation:complete', this.handleValidationComplete); @@ -359,22 +369,52 @@ export class CLIAdapter implements InstallerAdapter { private handleAgentStart = (): void => { this.spinner = clack.spinner(); - this.spinner.start('Running AI agent...'); - - // Periodic status updates for long-running operations - let dots = 0; - this.agentUpdateInterval = setInterval(() => { - dots = (dots + 1) % 4; - const dotStr = '.'.repeat(dots + 1); - this.spinner?.message(`Running AI agent${dotStr}`); - }, 2000); + this.spinner.start(this.lastAgentMessage); + // No setInterval: clack animates its own frames, and the old 2s reset + // clobbered the current phase text set by handleAgentProgress. }; private handleAgentProgress = ({ step, detail }: InstallerEvents['agent:progress']): void => { const message = detail ? `${step}: ${detail}` : step; + this.lastAgentMessage = message; this.spinner?.message(message); }; + /** + * Render a persistent line above the running spinner: stop the spinner to + * finalize its line, emit the log, then restart it on the last phase message. + * Mirrors the existing stop→log and stop→start-new-spinner precedents. + */ + private logAboveSpinner(render: () => void): void { + const wasRunning = this.spinner !== null; + this.spinner?.stop(); + this.spinner = null; + render(); + if (wasRunning) { + this.spinner = clack.spinner(); + this.spinner.start(this.lastAgentMessage); + } + } + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; // dedupe consecutive same-path ops + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Creating ${chalk.dim(rel)}`)); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Editing ${chalk.dim(rel)}`)); + }; + + private handleAgentTool = ({ detail }: InstallerEvents['agent:tool']): void => { + const cmd = detail.length > 80 ? `${detail.slice(0, 77)}…` : detail; + this.logAboveSpinner(() => clack.log.step(`Running ${chalk.dim(cmd)}`)); + }; + private handleValidationStart = (): void => { this.stopAgentUpdates(); this.stopSpinner('Agent completed'); @@ -401,12 +441,12 @@ export class CLIAdapter implements InstallerAdapter { } }; - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { this.stopAgentUpdates(); this.stopSpinner(success ? 'Done' : 'Failed'); console.log(''); - console.log(renderCompletionSummary(success, summary)); + console.log(renderCompletionSummary(success, summary, completion)); console.log(''); // When we scaffolded a fresh app, the install ran in the current dir, so diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index c0a23809..90c832c2 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -1,5 +1,5 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; -import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import type { InstallerEventEmitter, InstallerEvents, CompletionData } from '../events.js'; import { renderCompletionSummary } from '../../utils/summary-box.js'; /** @@ -13,7 +13,7 @@ export class DashboardAdapter implements InstallerAdapter { private sendEvent: AdapterConfig['sendEvent']; private cleanup: (() => void) | null = null; private isStarted = false; - private completionData: { success: boolean; summary?: string } | null = null; + private completionData: { success: boolean; summary?: string; completion?: CompletionData } | null = null; constructor(config: AdapterConfig) { this.emitter = config.emitter; @@ -65,8 +65,8 @@ export class DashboardAdapter implements InstallerAdapter { /** * Capture completion data for display after exit. */ - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - this.completionData = { success, summary }; + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + this.completionData = { success, summary, completion }; }; async stop(): Promise { @@ -87,7 +87,13 @@ export class DashboardAdapter implements InstallerAdapter { if (this.completionData) { console.log(); - console.log(renderCompletionSummary(this.completionData.success, this.completionData.summary)); + console.log( + renderCompletionSummary( + this.completionData.success, + this.completionData.summary, + this.completionData.completion, + ), + ); console.log(); } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 0d788aa4..b8340898 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -329,6 +329,56 @@ describe('HeadlessAdapter', () => { }); }); + describe('file operations', () => { + it('writes path-only NDJSON for file:write (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:write', { path: 'src/auth.ts', content: 'secret' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:write', path: 'src/auth.ts' }); + const leakedContent = mockWriteNDJSON.mock.calls.some((c) => c[0] && 'content' in (c[0] as object)); + expect(leakedContent).toBe(false); + await adapter.stop(); + }); + + it('writes path-only NDJSON for file:edit (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:edit', path: 'src/app.ts' }); + const leaked = mockWriteNDJSON.mock.calls.some( + (c) => c[0] && ('oldContent' in (c[0] as object) || 'newContent' in (c[0] as object)), + ); + expect(leaked).toBe(false); + await adapter.stop(); + }); + + it('dedupes consecutive same-path file operations', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'b', newContent: 'c' }); + + const editCalls = mockWriteNDJSON.mock.calls.filter((c) => (c[0] as { type?: string })?.type === 'file:edit'); + expect(editCalls).toHaveLength(1); + await adapter.stop(); + }); + + it('writes agent:tool NDJSON for surfaced commands', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('agent:tool', { kind: 'command', detail: 'ls' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'agent:tool', kind: 'command', detail: 'ls' }); + await adapter.stop(); + }); + }); + describe('terminal events', () => { it('writes complete event', async () => { const adapter = createAdapter(); @@ -345,6 +395,38 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); + it('spreads structured completion fields into the complete event when present', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('complete', { + success: true, + summary: 'ok', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }, + }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'complete', + success: true, + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + }), + ); + await adapter.stop(); + }); + it('writes error event', async () => { const adapter = createAdapter(); await adapter.start(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 6f167bbe..b40c6bbf 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -30,6 +30,7 @@ export class HeadlessAdapter implements InstallerAdapter { private options: HeadlessOptions; private isStarted = false; private scaffolded = false; + private lastFileOp: string | null = null; private handlers = new Map void>(); constructor(config: AdapterConfig & { options: HeadlessOptions }) { @@ -81,6 +82,11 @@ export class HeadlessAdapter implements InstallerAdapter { this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // File operations + tool calls — path/detail only, never content + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); + // Validation this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); @@ -279,6 +285,24 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'agent:progress', message }); }; + // ===== File Operations (path-only, no content) ===== + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:write', path }); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:edit', path }); + }; + + private handleAgentTool = ({ kind, detail }: InstallerEvents['agent:tool']): void => { + writeNDJSON({ type: 'agent:tool', kind, detail }); + }; + // ===== Validation ===== private handleValidationStart = ({ framework }: InstallerEvents['validation:start']): void => { @@ -363,8 +387,24 @@ export class HeadlessAdapter implements InstallerAdapter { // ===== Terminal Events ===== - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - writeNDJSON({ type: 'complete', success, summary, scaffolded: this.scaffolded }); + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + writeNDJSON({ + type: 'complete', + success, + summary, + scaffolded: this.scaffolded, + // Spread structured fields only when present, so existing consumers that + // emit `complete` without `completion` keep their exact payload shape. + ...(completion + ? { + integration: completion.integration, + devCommand: completion.devCommand, + url: completion.url, + files: completion.files, + nextSteps: completion.nextSteps, + } + : {}), + }); }; private handleError = ({ message, stack }: InstallerEvents['error']): void => { diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index d839e86b..5f6e74d0 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -841,6 +841,15 @@ function handleSDKMessage( } } + // Surface Bash commands (post-permission: this branch only fires for + // tool calls that were actually allowed, unlike the canUseTool closure). + if (toolName === 'Bash' && input) { + const command = input.command as string; + if (command) { + emitter?.emit('agent:tool', { kind: 'command', detail: command }); + } + } + // Track Read operations for caching file content later if (toolName === 'Read' && input && block.id) { const filePath = input.file_path as string; diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts new file mode 100644 index 00000000..1bbe9796 --- /dev/null +++ b/src/lib/completion-data.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { detectPort } from './port-detection.js'; + +describe('buildCompletionData', () => { + let installDir: string; + + beforeEach(() => { + installDir = mkdtempSync(join(tmpdir(), 'workos-completion-test-')); + }); + + afterEach(() => { + rmSync(installDir, { recursive: true, force: true }); + }); + + function writePackageJson(content: Record): void { + writeFileSync(join(installDir, 'package.json'), JSON.stringify(content)); + } + + function writeFile(name: string, content = ''): void { + writeFileSync(join(installDir, name), content); + } + + const baseDeps = { + resolveDevCommand, + detectPort, + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }; + + it('builds a lockfile-aware dev command, url, files, and next steps (happy path)', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + writeFile('pnpm-lock.yaml', 'lockfileVersion: 9\n'); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: ['app/auth/route.ts', '.env.local'], installDir }, + baseDeps, + ); + + expect(data.devCommand).toBe('pnpm run dev'); + expect(data.url).toBe('http://localhost:3000'); + expect(data.files).toHaveLength(2); + expect(data.nextSteps[0]).toContain('pnpm run dev'); + expect(data.nextSteps[1]).toContain('http://localhost:3000'); + expect(data.docsUrl).toBe('https://d'); + expect(data.dashboardUrl).toBe('https://dash'); + expect(data.integration).toBe('nextjs'); + }); + + it('respects a Vite server.port override for react', async () => { + writePackageJson({ scripts: { dev: 'vite' }, dependencies: { react: '18.0.0', vite: '5.0.0' } }); + writeFile('vite.config.ts', 'export default { server: { port: 8080 } };'); + + const data = await buildCompletionData({ integration: 'react', changedFiles: [], installDir }, baseDeps); + + expect(data.url).toBe('http://localhost:8080'); + }); + + it('handles empty changedFiles (--no-commit shape) without throwing', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.files).toEqual([]); + expect(data.nextSteps[0]).toContain('start your dev server'); + expect(data.nextSteps[1]).toContain('test authentication'); + }); + + it('drops the generic "start dev server" framework step but keeps others', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { + ...baseDeps, + frameworkNextSteps: [ + 'Start your development server to test authentication', + 'Visit the WorkOS Dashboard to manage users and settings', + ], + }, + ); + + // The generic "start dev server" duplicate is filtered out... + expect(data.nextSteps.filter((s) => /start your development server/i.test(s))).toHaveLength(0); + // ...but the dashboard step is retained. + expect(data.nextSteps.some((s) => /WorkOS Dashboard/.test(s))).toBe(true); + }); + + it('passes through a sign-in snippet into nextSteps and completion', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const snippet = 'Add a sign-in button using refreshAuth({ ensureSignedIn: true })'; + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { ...baseDeps, signInSnippet: snippet }, + ); + + expect(data.signInSnippet).toBe(snippet); + expect(data.nextSteps).toContain(snippet); + }); + + it('omits the sign-in snippet when not provided', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.signInSnippet).toBeUndefined(); + expect(data.nextSteps.some((s) => /refreshAuth/.test(s))).toBe(false); + }); +}); diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts new file mode 100644 index 00000000..929ccf73 --- /dev/null +++ b/src/lib/completion-data.ts @@ -0,0 +1,62 @@ +import type { CompletionData } from './events.js'; +import type { DevCommandResult } from './dev-command.js'; +import type { Integration } from './constants.js'; + +/** + * Machine-context slice needed to build completion data. + */ +export interface CompletionContext { + integration: string; + changedFiles?: string[]; + installDir: string; +} + +/** + * Injected dependencies. The impure lookups (registry/settings) are resolved by + * the caller and passed as plain values so the builder stays pure + unit-testable. + */ +export interface CompletionDataDeps { + resolveDevCommand: (dir: string) => Promise; + detectPort: (integration: Integration, dir: string) => number; + docsUrl: string; + dashboardUrl: string; + /** Enriched getOutroNextSteps copy for the framework */ + frameworkNextSteps?: string[]; + /** Per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + +/** + * Build the structured completion payload for a successful install. + * + * Deterministic given its inputs. The dev command is derived from the + * lockfile-aware `resolveDevCommand` (never from context.packageManager, which + * is flag/env-derived and unreliable). Copy fields are injected by the caller. + */ +export async function buildCompletionData(ctx: CompletionContext, deps: CompletionDataDeps): Promise { + const dev = await deps.resolveDevCommand(ctx.installDir); + const devCommand = [dev.command, ...dev.args].join(' '); + const port = deps.detectPort(ctx.integration as Integration, ctx.installDir); + const url = `http://localhost:${port}`; + const files = ctx.changedFiles ?? []; + + const concrete = [ + `Run \`${devCommand}\` to start your dev server`, + `Open ${url} to test authentication`, + ...(deps.signInSnippet ? [deps.signInSnippet] : []), + ]; + // Drop the framework's generic "start dev server" line — the concrete step + // above already names the exact lockfile-aware command. + const framework = (deps.frameworkNextSteps ?? []).filter((s) => !/start .*dev(elopment)? server/i.test(s)); + + return { + integration: ctx.integration, + devCommand, + url, + files, + nextSteps: [...concrete, ...framework], + docsUrl: deps.docsUrl, + dashboardUrl: deps.dashboardUrl, + signInSnippet: deps.signInSnippet, + }; +} diff --git a/src/lib/events.ts b/src/lib/events.ts index abedc70a..9b438e77 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,5 +1,32 @@ import { EventEmitter } from 'events'; +/** + * Structured data describing a successful installation, used to render the + * completion summary and enrich the headless `complete` NDJSON event. + * + * Defined here (not in installer-core.types.ts) to avoid an import cycle: + * installer-core.types.ts already imports from this module, so this module + * must not import back. + */ +export interface CompletionData { + /** Integration identifier (e.g. 'nextjs') */ + integration: string; + /** Lockfile-aware dev command, e.g. "pnpm run dev" */ + devCommand: string; + /** App URL with the detected port, e.g. "http://localhost:3000" */ + url: string; + /** Changed files (git-relative), full list — display cap lives in the renderer */ + files: string[]; + /** Composed concrete + framework next-step lines */ + nextSteps: string[]; + /** Per-framework docs URL */ + docsUrl: string; + /** WorkOS dashboard URL */ + dashboardUrl: string; + /** Optional per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + export interface InstallerEvents { status: { message: string }; output: { text: string; isError?: boolean }; @@ -11,7 +38,7 @@ export interface InstallerEvents { 'confirm:response': { id: string; confirmed: boolean }; 'credentials:request': { requiresApiKey: boolean }; 'credentials:response': { apiKey: string; clientId: string }; - complete: { success: boolean; summary?: string }; + complete: { success: boolean; summary?: string; completion?: CompletionData }; error: { message: string; stack?: string }; 'state:enter': { state: string }; @@ -53,6 +80,8 @@ export interface InstallerEvents { 'agent:success': { summary?: string }; 'agent:failure': { message: string; stack?: string }; 'agent:retry': { attempt: number; maxRetries: number }; + // Surfaced agent tool activity (e.g. Bash commands run during install) + 'agent:tool': { kind: 'command'; detail: string }; 'validation:retry:start': { attempt: number }; 'validation:retry:complete': { attempt: number; passed: boolean }; diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index 5ff1e6d8..df2bfdcb 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -143,6 +143,13 @@ export interface UIConfig { /** Generate "Next steps" bullets from context */ getOutroNextSteps: (context: any) => string[]; + + /** + * Optional per-framework "add a sign-in link" snippet, surfaced as a next + * step in the completion summary. Only defined for frameworks that scaffold + * interactive auth UI. Copy must stay accurate to what the agent scaffolds. + */ + getSignInSnippet?: (context: any) => string; } /** diff --git a/src/lib/installer-core.test-utils.ts b/src/lib/installer-core.test-utils.ts index d736b0d0..2cd0b27e 100644 --- a/src/lib/installer-core.test-utils.ts +++ b/src/lib/installer-core.test-utils.ts @@ -47,6 +47,7 @@ export function createEventCapture() { 'agent:progress', 'agent:success', 'agent:failure', + 'agent:tool', 'file:write', 'file:edit', 'prompt:request', diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 3b13c2f9..7bc41430 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -13,6 +13,7 @@ import type { WorkspaceCheckOutput, } from './installer-core.types.js'; import type { InstallerOptions } from '../utils/types.js'; +import type { CompletionData } from './events.js'; import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; import type { StagingCredentials } from './staging-api.js'; import { getManualPrInstructions } from './post-install.js'; @@ -312,8 +313,11 @@ export const installerMachine = setup({ }, emitComplete: ({ context }) => { const summary = context.agentSummary ?? 'WorkOS AuthKit installed successfully!'; - context.emitter.emit('complete', { success: true, summary }); + context.emitter.emit('complete', { success: true, summary, completion: context.completion }); }, + assignCompletion: assign({ + completion: ({ event }) => (event as unknown as { output?: CompletionData }).output, + }), }, guards: { @@ -354,6 +358,9 @@ export const installerMachine = setup({ runAgent: fromPromise(async () => { throw new Error('runAgent not implemented - provide via machine.provide()'); }), + buildCompletion: fromPromise(async () => { + throw new Error('buildCompletion not implemented - provide via machine.provide()'); + }), // Credential discovery actors detectEnvFiles: fromPromise(async () => { throw new Error('detectEnvFiles not implemented - provide via machine.provide()'); @@ -1000,7 +1007,7 @@ export const installerMachine = setup({ checking: { always: [ { - target: '#installer.complete', + target: '#installer.buildingCompletion', guard: 'shouldSkipPostInstall', }, { target: 'detectingChanges' }, @@ -1156,7 +1163,20 @@ export const installerMachine = setup({ }, }, onDone: { - target: 'complete', + target: 'buildingCompletion', + }, + }, + + // Compute the structured completion payload before entering `complete`. + // `emitComplete` is a synchronous entry action, but the builder is async + // (lockfile-aware dev command, port detection), so it must run in an actor + // first. Errors never block completion — they fall through to the static box. + buildingCompletion: { + invoke: { + src: 'buildCompletion', + input: ({ context }) => ({ context }), + onDone: { target: 'complete', actions: ['assignCompletion'] }, + onError: { target: 'complete' }, }, }, diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index f004455e..46aec300 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -1,4 +1,4 @@ -import type { InstallerEventEmitter } from './events.js'; +import type { InstallerEventEmitter, CompletionData } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; import type { Integration } from './constants.js'; import type { PackageManager } from './scaffold/scaffold.js'; @@ -68,6 +68,8 @@ export interface InstallerMachineContext { autoScaffold?: boolean; /** Whether create-next-app actually ran and succeeded (for telemetry) */ scaffolded?: boolean; + /** Structured completion data for the success summary (built pre-complete) */ + completion?: CompletionData; } /** diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 34fda45b..5d2f33b6 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -4,6 +4,10 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; +import type { CompletionData } from './events.js'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; @@ -350,6 +354,33 @@ export async function runWithCore(options: InstallerOptions): Promise { } }), + buildCompletion: fromPromise( + async ({ input }) => { + const { integration, changedFiles, options: installerOptions } = input.context; + if (!integration) return undefined; + try { + const registry = await getRegistry(); + const mod = registry.get(integration); + const cfg = mod?.config; + const settings = getInstallerSettings(); + return await buildCompletionData( + { integration, changedFiles, installDir: installerOptions.installDir }, + { + resolveDevCommand, + detectPort, + docsUrl: cfg?.metadata.docsUrl ?? settings.documentation.workosDocsUrl, + dashboardUrl: settings.documentation.dashboardUrl, + frameworkNextSteps: cfg?.ui.getOutroNextSteps?.({}) ?? [], + signInSnippet: cfg?.ui.getSignInSnippet?.({}), + }, + ); + } catch { + // Degrade to the static fallback box rather than blocking completion. + return undefined; + } + }, + ), + // Credential discovery actors detectEnvFiles: fromPromise(async ({ input }) => { return checkForEnvFiles(input.installDir); diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index 8cc28f83..36d45e89 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { renderSummaryBox, type SummaryBoxItem } from './summary-box.js'; +import { renderSummaryBox, renderCompletionSummary, type SummaryBoxItem } from './summary-box.js'; +import type { CompletionData } from '../lib/events.js'; // Simple ANSI code stripper (avoids needing strip-ansi as a dependency) function strip(str: string): string { @@ -129,4 +130,54 @@ describe('summary-box', () => { } }); }); + + describe('renderCompletionSummary', () => { + function makeCompletion(overrides: Partial = {}): CompletionData { + return { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:8080', + files: ['app/auth/route.ts', '.env.local'], + nextSteps: ['Run `pnpm run dev` to start your dev server', 'Open http://localhost:8080 to test authentication'], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + ...overrides, + }; + } + + it('renders structured success data (dev command, files, url)', () => { + const result = strip(renderCompletionSummary(true, 'ignored on success', makeCompletion())); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('pnpm run dev'); + expect(result).toContain('app/auth/route.ts'); + expect(result).toContain('http://localhost:8080'); + // The old static strings must NOT appear when structured data is present. + expect(result).not.toContain('Start dev server to test authentication'); + expect(result).not.toContain('Visit WorkOS Dashboard to manage users'); + }); + + it('caps the file list at 5 with a "…and N more" line', () => { + const files = Array.from({ length: 7 }, (_, i) => `file-${i + 1}.ts`); + const result = strip(renderCompletionSummary(true, undefined, makeCompletion({ files }))); + + expect(result).toContain('…and 2 more'); + expect(result).not.toContain('file-6.ts'); + expect(result).not.toContain('file-7.ts'); + }); + + it('falls back to the static box when no completion data is present', () => { + const result = strip(renderCompletionSummary(true)); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('Start dev server to test authentication'); + }); + + it('renders the failure box unchanged', () => { + const result = strip(renderCompletionSummary(false, 'Something went wrong')); + + expect(result).toContain('Installation Failed'); + expect(result).toContain('Something went wrong'); + }); + }); }); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index dc50b3a8..88f3b932 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -2,10 +2,29 @@ import chalk from 'chalk'; import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; import { type LockExpression, getLockArt, LOCK_WIDTH } from './lock-art.js'; import { symbols } from './cli-symbols.js'; +import type { CompletionData } from '../lib/events.js'; + +/** Max number of changed files listed in the success box before collapsing. */ +const MAX_SUMMARY_FILES = 5; /** Pre-built completion summaries shared by CLI and Dashboard adapters. */ -export function renderCompletionSummary(success: boolean, summary?: string): string { +export function renderCompletionSummary(success: boolean, summary?: string, completion?: CompletionData): string { if (success) { + if (completion) { + const files = completion.files; + const shown: SummaryBoxItem[] = files.slice(0, MAX_SUMMARY_FILES).map((f) => ({ type: 'done', text: f })); + if (files.length > MAX_SUMMARY_FILES) { + shown.push({ type: 'done', text: `…and ${files.length - MAX_SUMMARY_FILES} more` }); + } + const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); + return renderSummaryBox({ + expression: 'success', + title: 'WorkOS AuthKit Installed', + items: [...shown, ...steps], + footer: completion.docsUrl, + }); + } + // Fallback: preserve the original static box when no structured data is present. return renderSummaryBox({ expression: 'success', title: 'WorkOS AuthKit Installed',