Skip to content
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ shell command cache.
- **AI-Powered:** Uses Claude to intelligently adapt to your project structure
- **Security-First:** Masks API keys, redacts from logs, saves to .env.local
- **Smart Detection:** Auto-detects framework, package manager, router type
- **Greenfield Scaffolding:** Run in an empty directory to scaffold a new Next.js app (via `create-next-app`) before wiring AuthKit
- **Live Documentation:** Fetches latest SDK docs from WorkOS and GitHub
- **Full Integration:** Creates routes, middleware, environment vars, and UI
- **Agent & CI Ready:** Non-TTY auto-detection, JSON output, structured errors, headless installer with NDJSON streaming
Expand Down Expand Up @@ -570,12 +571,13 @@ workos org-domain delete <id>
workos install [options]

--direct, -D Use your own Anthropic API key (bypass llm-gateway)
--integration <name> Framework: nextjs, react, react-router, tanstack-start, vanilla-js, sveltekit, node, python, ruby, go, dotnet, kotlin, elixir, php-laravel, php
--api-key <key> WorkOS API key (required in non-interactive mode)
--client-id <id> WorkOS client ID (required in non-interactive mode)
--redirect-uri <uri> Custom redirect URI
--homepage-url <url> Custom homepage URL
--install-dir <path> Installation directory
--scaffold Scaffold a new Next.js app when run in an empty directory
--pm <manager> Package manager for the scaffolded app: npm, pnpm, yarn, bun
--no-validate Skip post-installation validation
--no-branch Skip branch creation (use current branch)
--no-commit Skip auto-commit after installation
Expand All @@ -585,14 +587,16 @@ workos install [options]
--debug Enable verbose logging
```

**Empty directories:** Running `workos install` in an empty directory scaffolds a new Next.js app with `create-next-app` (App Router, TypeScript, Tailwind, `src/`) and then wires AuthKit into it. This only happens when the directory is empty or contains nothing but VCS/editor metadata (`.git`, `.gitignore`, `LICENSE`, `.idea`, and similar). Any project file — including a `README.md` or a `package.json` — opts out, and the installer treats the directory as an existing project. Interactive runs confirm first (default yes); non-interactive/headless runs (or `--scaffold`) scaffold automatically and report `"scaffolded": true`. The package manager is resolved from how you invoked the CLI (`npm_config_user_agent`) unless you pass `--pm`.

## Examples

```bash
# Interactive (recommended)
npx workos@latest install

# Specify framework
npx workos@latest install --integration react-router
# Greenfield: scaffold a new Next.js app + AuthKit in an empty directory
mkdir my-app && cd my-app && npx workos@latest install

# With visual dashboard (experimental)
npx workos@latest dashboard
Expand Down
14 changes: 10 additions & 4 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,6 @@ const installerOptions = {
describe: 'Directory to install WorkOS AuthKit in',
type: 'string' as const,
},
integration: {
describe: 'Integration to set up',
type: 'string' as const,
},
'force-install': {
default: false,
describe: 'Force install packages even if peer dependency checks fail',
Expand Down Expand Up @@ -235,6 +231,16 @@ const installerOptions = {
describe: 'Check for dirty working tree (use --no-git-check to skip)',
type: 'boolean' as const,
},
scaffold: {
default: false,
describe: 'Scaffold a new Next.js app when run in an empty directory',
type: 'boolean' as const,
},
pm: {
describe: 'Package manager for the scaffolded app',
choices: ['npm', 'pnpm', 'yarn', 'bun'] as const,
type: 'string' as const,
},
};

// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
Expand Down
59 changes: 59 additions & 0 deletions src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export class CLIAdapter implements InstallerAdapter {
private isStarted = false;
private progress = new ProgressTracker();

// Scaffold (empty-dir) state, used to print a "next steps" hint on completion.
private scaffolded = false;
private scaffoldPackageManager = 'npm';

// Store bound handlers for cleanup
private handlers = new Map<string, (...args: unknown[]) => void>();

Expand Down Expand Up @@ -113,6 +117,13 @@ export class CLIAdapter implements InstallerAdapter {
this.subscribe('validation:complete', this.handleValidationComplete);
this.subscribe('complete', this.handleComplete);
this.subscribe('error', this.handleError);
// Scaffold events (empty-directory app scaffolding)
this.subscribe('scaffold:prompt', this.handleScaffoldPrompt);
this.subscribe('scaffold:start', this.handleScaffoldStart);
this.subscribe('scaffold:progress', this.handleScaffoldProgress);
this.subscribe('scaffold:complete', this.handleScaffoldComplete);
this.subscribe('scaffold:failed', this.handleScaffoldFailed);

// Branch check events
this.subscribe('branch:prompt', this.handleBranchPrompt);
this.subscribe('branch:created', this.handleBranchCreated);
Expand Down Expand Up @@ -397,6 +408,12 @@ export class CLIAdapter implements InstallerAdapter {
console.log('');
console.log(renderCompletionSummary(success, summary));
console.log('');

// When we scaffolded a fresh app, the install ran in the current dir, so
// point the user straight at the dev server.
if (success && this.scaffolded) {
clack.log.info(`Start your app: ${chalk.cyan(`${this.scaffoldPackageManager} run dev`)}`);
}
};

private handleError = ({ message, stack }: InstallerEvents['error']): void => {
Expand Down Expand Up @@ -439,6 +456,48 @@ export class CLIAdapter implements InstallerAdapter {
}
};

// ===== Scaffold Event Handlers =====

private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise<void> => {
this.scaffoldPackageManager = packageManager;
this.isPromptActive = true;
const confirmed = await clack.confirm({
message: 'This directory is empty. Scaffold a new Next.js app with AuthKit here?',
initialValue: true,
});
this.isPromptActive = false;
this.flushPendingLogs();

this.sendEvent({
type: clack.isCancel(confirmed) || !confirmed ? 'SCAFFOLD_CANCELLED' : 'SCAFFOLD_CONFIRMED',
});
};

private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => {
this.scaffoldPackageManager = packageManager;
this.spinner = clack.spinner();
this.spinner.start(`Scaffolding a new Next.js app with ${packageManager} (this can take a minute)...`);
};

// create-next-app output is verbose; surface it only under --debug and keep
// the spinner message stable so the CLI stays readable.
private handleScaffoldProgress = ({ text }: InstallerEvents['scaffold:progress']): void => {
const line = text.trim();
if (line) {
this.debugLog(line);
}
};

private handleScaffoldComplete = (): void => {
this.scaffolded = true;
this.stopSpinner('Next.js app created');
};

private handleScaffoldFailed = ({ error }: InstallerEvents['scaffold:failed']): void => {
this.stopSpinner('Scaffold failed');
clack.log.error(`Could not scaffold the app: ${error}`);
};

private handleBranchPrompt = async ({ branch }: InstallerEvents['branch:prompt']): Promise<void> => {
this.isPromptActive = true;
const choice = await clack.select({
Expand Down
30 changes: 30 additions & 0 deletions src/lib/adapters/dashboard-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export class DashboardAdapter implements InstallerAdapter {
this.emitter.on('confirm:response', this.handleConfirmResponse);
this.emitter.on('credentials:response', this.handleCredentialsResponse);

// Scaffold (empty-dir): the TUI has no dedicated scaffold prompt yet, so
// auto-proceed (the user ran the installer in an empty dir) and surface
// progress through the `status` event the Dashboard already renders.
this.emitter.on('scaffold:prompt', this.handleScaffoldPrompt);
this.emitter.on('scaffold:start', this.handleScaffoldStart);
this.emitter.on('scaffold:complete', this.handleScaffoldComplete);
this.emitter.on('scaffold:failed', this.handleScaffoldFailed);

// Track completion for post-exit summary
this.emitter.on('complete', this.handleComplete);
}
Expand All @@ -67,6 +75,10 @@ export class DashboardAdapter implements InstallerAdapter {
// Unsubscribe from events
this.emitter.off('confirm:response', this.handleConfirmResponse);
this.emitter.off('credentials:response', this.handleCredentialsResponse);
this.emitter.off('scaffold:prompt', this.handleScaffoldPrompt);
this.emitter.off('scaffold:start', this.handleScaffoldStart);
this.emitter.off('scaffold:complete', this.handleScaffoldComplete);
this.emitter.off('scaffold:failed', this.handleScaffoldFailed);
this.emitter.off('complete', this.handleComplete);

// Run cleanup (unmount Ink, exit fullscreen)
Expand Down Expand Up @@ -106,4 +118,22 @@ export class DashboardAdapter implements InstallerAdapter {
private handleCredentialsResponse = ({ apiKey, clientId }: { apiKey: string; clientId: string }): void => {
this.sendEvent({ type: 'CREDENTIALS_SUBMITTED', apiKey, clientId });
};

// ===== Scaffold (empty-dir) handlers =====

private handleScaffoldPrompt = (): void => {
this.sendEvent({ type: 'SCAFFOLD_CONFIRMED' });
};

private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => {
this.emitter.emit('status', { message: `Scaffolding a new Next.js app with ${packageManager}...` });
};

private handleScaffoldComplete = (): void => {
this.emitter.emit('status', { message: 'Next.js app created' });
};

private handleScaffoldFailed = ({ error }: InstallerEvents['scaffold:failed']): void => {
this.emitter.emit('status', { message: `Scaffold failed: ${error}` });
};
}
37 changes: 37 additions & 0 deletions src/lib/adapters/headless-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,42 @@ describe('HeadlessAdapter', () => {
});
});

describe('scaffold events', () => {
it('streams scaffold:* and flags the completion as scaffolded', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('scaffold:checking', {});
emitter.emit('scaffold:start', { packageManager: 'pnpm' });
emitter.emit('scaffold:complete', {});
emitter.emit('complete', { success: true, summary: 'Installed' });

expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'scaffold:checking' });
expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'scaffold:start', packageManager: 'pnpm' });
expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'scaffold:complete' });
expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'complete',
success: true,
summary: 'Installed',
scaffolded: true,
});
await adapter.stop();
});

it('writes scaffold:failed with the error', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('scaffold:failed', { error: 'create-next-app exited with code 1' });

expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'scaffold:failed',
error: 'create-next-app exited with code 1',
});
await adapter.stop();
});
});

describe('terminal events', () => {
it('writes complete event', async () => {
const adapter = createAdapter();
Expand All @@ -304,6 +340,7 @@ describe('HeadlessAdapter', () => {
type: 'complete',
success: true,
summary: 'All done',
scaffolded: false,
});
await adapter.stop();
});
Expand Down
34 changes: 33 additions & 1 deletion src/lib/adapters/headless-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class HeadlessAdapter implements InstallerAdapter {
private debug: boolean;
private options: HeadlessOptions;
private isStarted = false;
private scaffolded = false;
private handlers = new Map<string, (...args: unknown[]) => void>();

constructor(config: AdapterConfig & { options: HeadlessOptions }) {
Expand All @@ -46,6 +47,13 @@ export class HeadlessAdapter implements InstallerAdapter {
this.subscribe('auth:success', this.handleAuthSuccess);
this.subscribe('auth:failure', this.handleAuthFailure);

// Scaffold events (empty-directory app scaffolding) — auto-routed, no prompt
this.subscribe('scaffold:checking', this.handleScaffoldChecking);
this.subscribe('scaffold:start', this.handleScaffoldStart);
this.subscribe('scaffold:progress', this.handleScaffoldProgress);
this.subscribe('scaffold:complete', this.handleScaffoldComplete);
this.subscribe('scaffold:failed', this.handleScaffoldFailed);

// Detection events
this.subscribe('detection:complete', this.handleDetectionComplete);
this.subscribe('detection:none', this.handleDetectionNone);
Expand Down Expand Up @@ -134,6 +142,30 @@ export class HeadlessAdapter implements InstallerAdapter {
process.exit(ExitCode.AUTH_REQUIRED);
};

// ===== Scaffold Handlers (auto-routed) =====

private handleScaffoldChecking = (): void => {
writeNDJSON({ type: 'scaffold:checking' });
};

private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => {
writeNDJSON({ type: 'scaffold:start', packageManager });
};

// create-next-app output is verbose; surface it only under --debug.
private handleScaffoldProgress = ({ text }: InstallerEvents['scaffold:progress']): void => {
this.debugLog(text);
};

private handleScaffoldComplete = (): void => {
this.scaffolded = true;
writeNDJSON({ type: 'scaffold:complete' });
};

private handleScaffoldFailed = ({ error }: InstallerEvents['scaffold:failed']): void => {
writeNDJSON({ type: 'scaffold:failed', error });
};

// ===== Detection Handlers =====

private handleDetectionComplete = ({ integration }: InstallerEvents['detection:complete']): void => {
Expand Down Expand Up @@ -332,7 +364,7 @@ export class HeadlessAdapter implements InstallerAdapter {
// ===== Terminal Events =====

private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => {
writeNDJSON({ type: 'complete', success, summary });
writeNDJSON({ type: 'complete', success, summary, scaffolded: this.scaffolded });
};

private handleError = ({ message, stack }: InstallerEvents['error']): void => {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ export interface InstallerEvents {
'validation:issues': { issues: import('./validation/types.js').ValidationIssue[] };
'validation:complete': { passed: boolean; issueCount: number; durationMs: number };

// Scaffold events (empty-directory app scaffolding)
'scaffold:checking': Record<string, never>;
'scaffold:prompt': { packageManager: string };
'scaffold:start': { packageManager: string };
'scaffold:progress': { text: string };
'scaffold:complete': Record<string, never>;
'scaffold:failed': { error: string };
'scaffold:skipped': Record<string, never>;

// Branch check events
'branch:checking': Record<string, never>;
'branch:protected': { branch: string };
Expand Down
15 changes: 14 additions & 1 deletion src/lib/installer-core.events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ import { createActor, fromPromise } from 'xstate';
import { installerMachine } from './installer-core.js';
import { createEventCapture, compareEventSequences, filterDeterministicEvents } from './installer-core.test-utils.js';
import type { InstallerOptions } from '../utils/types.js';
import type { DetectionOutput, GitCheckOutput, AgentOutput, InstallerMachineContext } from './installer-core.types.js';
import type {
DetectionOutput,
GitCheckOutput,
AgentOutput,
InstallerMachineContext,
WorkspaceCheckOutput,
} from './installer-core.types.js';

/**
* Creates mock actor implementations for testing.
Expand All @@ -37,6 +43,13 @@ import type { DetectionOutput, GitCheckOutput, AgentOutput, InstallerMachineCont
function createMockActors() {
return {
checkAuthentication: fromPromise<boolean, { options: InstallerOptions }>(async () => true),
// Default: not an empty dir, so the scaffold state falls straight through.
checkWorkspace: fromPromise<WorkspaceCheckOutput, { options: InstallerOptions }>(async () => ({
scaffoldable: false,
packageManager: 'npm',
autoScaffold: false,
})),
runScaffold: fromPromise<void, { context: InstallerMachineContext }>(async () => {}),
detectIntegration: fromPromise<DetectionOutput, { options: InstallerOptions }>(async () => ({
integration: 'nextjs',
})),
Expand Down
Loading
Loading