Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/integrations/nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};

Expand Down
92 changes: 90 additions & 2 deletions src/lib/adapters/cli-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
64 changes: 52 additions & 12 deletions src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
Comment on lines +388 to +397

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Spinner stopped without a message — inconsistent with all other call sites

Every other call to spinner.stop() in the CLI adapter passes an explicit message string (e.g. stopSpinner('Agent completed'), this.spinner.stop('Cancelled'), this.spinner.stop('Authenticated')). The new logAboveSpinner method at src/lib/adapters/cli-adapter.ts:390 calls this.spinner?.stop() without a message. Depending on clack's internal behavior, this may print the spinner's last message as a "completed" line (with a checkmark), creating a misleading visual artifact where the current phase (e.g. "Configuring middleware") appears completed before the spinner immediately restarts with the same message. The test mocks stop as a no-op so this visual behavior is untested. Consider passing an empty string or a neutral message to stop() to control the output.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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)}`));
};
Comment on lines +399 to +411

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cross-type file dedup: a write followed by an edit to the same path is suppressed

The lastFileOp field in both CLI (src/lib/adapters/cli-adapter.ts:45) and headless (src/lib/adapters/headless-adapter.ts:33) adapters is shared between handleFileWrite and handleFileEdit. This means a file:write to path A immediately followed by a file:edit to path A will suppress the edit notification. This is a plausible agent pattern (create a file, then immediately refine it). The tests only verify same-type dedup (edit→edit). Whether this cross-type suppression is intentional or an oversight depends on the desired UX — it reduces noise but hides a meaningful operation change (create vs. edit).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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)}`));
};
Comment on lines +413 to +416

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 File operation notifications are silently suppressed when a tool call intervenes between two operations on the same file

The deduplication of file-operation notifications does not reset when a tool-call event fires (handleAgentTool at src/lib/adapters/cli-adapter.ts:413), so a write-to-A → tool-call → edit-of-A sequence suppresses the edit notification even though the operations are not truly consecutive.

Impact: Users miss that a file was edited after an intervening command, making the install log incomplete.

Mechanism: shared lastFileOp is never cleared by tool-call handlers

lastFileOp is checked in both handleFileWrite (src/lib/adapters/cli-adapter.ts:400) and handleFileEdit (src/lib/adapters/cli-adapter.ts:407), but handleAgentTool (src/lib/adapters/cli-adapter.ts:413) does not reset it. The same pattern exists in the headless adapter (src/lib/adapters/headless-adapter.ts:302). So the sequence:

  1. file:write { path: 'src/auth.ts' } → logged, lastFileOp = 'src/auth.ts'
  2. agent:tool { detail: 'pnpm add ...' } → logged, but lastFileOp unchanged
  3. file:edit { path: 'src/auth.ts' }silently dropped because path === lastFileOp

The comment at line 400 says "dedupe consecutive same-path ops", but the tool call breaks consecutiveness without resetting the dedup state.

Suggested change
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 handleAgentTool = ({ detail }: InstallerEvents['agent:tool']): void => {
this.lastFileOp = null; // tool call breaks the consecutive-file-op chain
const cmd = detail.length > 80 ? `${detail.slice(0, 77)}…` : detail;
this.logAboveSpinner(() => clack.log.step(`Running ${chalk.dim(cmd)}`));
};
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


private handleValidationStart = (): void => {
this.stopAgentUpdates();
this.stopSpinner('Agent completed');
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions src/lib/adapters/dashboard-adapter.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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();
}

Expand Down
82 changes: 82 additions & 0 deletions src/lib/adapters/headless-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading
Loading