From a0977927aa35a5deeb7e9774d0c5cebc311a3465 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 6 Jun 2026 10:51:05 -0500 Subject: [PATCH 1/9] feat: scaffold a Next.js app when installing in an empty directory Running `workos install` in an empty dir was a dead end: detection found no framework and the state machine errored at the hasIntegration guard. Now a new compound `scaffold` state runs before `preparing`. It checks whether the dir is scaffoldable (empty or only create-next-app-safe files), confirms once interactively (auto in headless / --scaffold), runs a pinned create-next-app@16 deterministically via child_process, then converges into the existing detect -> credentials -> agent pipeline that wires AuthKit unchanged. - New src/lib/scaffold module: isScaffoldableEmptyDir, resolvePackageManager, buildCreateNextAppArgs, runCreateNextApp. The agent Bash allowlist is untouched (the scaffold spawns directly, never through the LLM). - scaffold:* event family plus adapter wiring (CLI confirm + next-steps hint, headless NDJSON with scaffolded:true, dashboard auto-proceed). - --scaffold and --pm flags; package manager resolved from npm_config_user_agent (npm fallback). - scaffolded recorded as a session telemetry tag in run-with-core. SAFE_EMPTY_FILES is a verified subset of create-next-app v16 validFiles (README.md/.vscode excluded since upstream does not allow them). Review cycle: 1 (PASS). --- src/bin.ts | 10 ++ src/lib/adapters/cli-adapter.ts | 59 +++++++ src/lib/adapters/dashboard-adapter.ts | 30 ++++ src/lib/adapters/headless-adapter.spec.ts | 37 ++++ src/lib/adapters/headless-adapter.ts | 34 +++- src/lib/events.ts | 9 + src/lib/installer-core.events.spec.ts | 15 +- src/lib/installer-core.spec.ts | 195 ++++++++++++++++++++- src/lib/installer-core.ts | 126 +++++++++++++- src/lib/installer-core.types.ts | 24 +++ src/lib/run-with-core.ts | 30 +++- src/lib/scaffold/index.ts | 9 + src/lib/scaffold/scaffold.spec.ts | 196 ++++++++++++++++++++++ src/lib/scaffold/scaffold.ts | 148 ++++++++++++++++ src/run.ts | 4 + src/utils/types.ts | 12 ++ 16 files changed, 924 insertions(+), 14 deletions(-) create mode 100644 src/lib/scaffold/index.ts create mode 100644 src/lib/scaffold/scaffold.spec.ts create mode 100644 src/lib/scaffold/scaffold.ts diff --git a/src/bin.ts b/src/bin.ts index ecb7ddc1..2a2352b3 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -235,6 +235,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) diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 24a91104..9d1526d5 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -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 void>(); @@ -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); @@ -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 => { @@ -439,6 +456,48 @@ export class CLIAdapter implements InstallerAdapter { } }; + // ===== Scaffold Event Handlers ===== + + private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise => { + 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 => { this.isPromptActive = true; const choice = await clack.select({ diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index 42f86ca5..c0a23809 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -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); } @@ -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) @@ -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}` }); + }; } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 5f7a3208..0d788aa4 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -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(); @@ -304,6 +340,7 @@ describe('HeadlessAdapter', () => { type: 'complete', success: true, summary: 'All done', + scaffolded: false, }); await adapter.stop(); }); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 2d414a92..6f167bbe 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -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 void>(); constructor(config: AdapterConfig & { options: HeadlessOptions }) { @@ -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); @@ -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 => { @@ -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 => { diff --git a/src/lib/events.ts b/src/lib/events.ts index 027bd31d..abedc70a 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -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; + 'scaffold:prompt': { packageManager: string }; + 'scaffold:start': { packageManager: string }; + 'scaffold:progress': { text: string }; + 'scaffold:complete': Record; + 'scaffold:failed': { error: string }; + 'scaffold:skipped': Record; + // Branch check events 'branch:checking': Record; 'branch:protected': { branch: string }; diff --git a/src/lib/installer-core.events.spec.ts b/src/lib/installer-core.events.spec.ts index b1ba052a..093eacc7 100644 --- a/src/lib/installer-core.events.spec.ts +++ b/src/lib/installer-core.events.spec.ts @@ -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. @@ -37,6 +43,13 @@ import type { DetectionOutput, GitCheckOutput, AgentOutput, InstallerMachineCont function createMockActors() { return { checkAuthentication: fromPromise(async () => true), + // Default: not an empty dir, so the scaffold state falls straight through. + checkWorkspace: fromPromise(async () => ({ + scaffoldable: false, + packageManager: 'npm', + autoScaffold: false, + })), + runScaffold: fromPromise(async () => {}), detectIntegration: fromPromise(async () => ({ integration: 'nextjs', })), diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index 8c62dd7b..01449b83 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -9,6 +9,7 @@ import type { AgentOutput, InstallerMachineContext, BranchCheckOutput, + WorkspaceCheckOutput, } from './installer-core.types.js'; import type { EnvFileInfo } from './credential-discovery.js'; import type { StagingCredentials } from './staging-api.js'; @@ -16,6 +17,13 @@ import type { StagingCredentials } from './staging-api.js'; // Shared mock actors for reuse across tests const baseMockActors = { checkAuthentication: fromPromise(async () => true), + // Default: not an empty dir, so the scaffold state falls straight through to preparing. + checkWorkspace: fromPromise(async () => ({ + scaffoldable: false, + packageManager: 'npm', + autoScaffold: false, + })), + runScaffold: fromPromise(async () => {}), detectIntegration: fromPromise(async () => ({ integration: 'nextjs', })), @@ -37,7 +45,27 @@ const baseMockActors = { })), }; -function createTestActor(overrides?: Partial) { +const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +// Parallel-state actors that resolve slowly, so the `preparing` parallel snapshot +// (detection/gitCheck/branchCheck all "running") is observable after a short wait. +// Needed because the async scaffold gate now precedes `preparing`. +const slowPreparingActors = { + detectIntegration: fromPromise(async () => { + await delay(200); + return { integration: 'nextjs' }; + }), + checkGitStatus: fromPromise(async () => { + await delay(200); + return { isClean: true, files: [] }; + }), + checkBranch: fromPromise(async () => { + await delay(200); + return { branch: 'main', isProtected: false }; + }), +}; + +function createTestActor(overrides?: Partial, actorOverrides?: Partial) { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { debug: false, @@ -54,7 +82,7 @@ function createTestActor(overrides?: Partial) { // Provide mock implementations for actors const machine = installerMachine.provide({ - actors: baseMockActors, + actors: { ...baseMockActors, ...actorOverrides }, }); const actor = createActor(machine, { @@ -83,11 +111,13 @@ describe('InstallerCore State Machine', () => { actor.stop(); }); - it('skips auth when skipAuth option is true', () => { - const { actor } = createTestActor({ skipAuth: true }); + it('skips auth when skipAuth option is true', async () => { + const { actor } = createTestActor({ skipAuth: true }, slowPreparingActors); actor.start(); actor.send({ type: 'START' }); - // Should go directly to preparing (parallel state with detection, gitCheck, and branchCheck) + // The scaffold workspace-check runs first (async, instant); once it resolves + // not-scaffoldable, the machine lands in preparing (no authenticating state). + await delay(40); expect(actor.getSnapshot().value).toEqual({ preparing: { detection: 'running', gitCheck: 'running', branchCheck: 'running' }, }); @@ -97,9 +127,10 @@ describe('InstallerCore State Machine', () => { describe('parallel states', () => { it('runs detection, git check, and branch check in parallel', async () => { - const { actor } = createTestActor({ skipAuth: true }); + const { actor } = createTestActor({ skipAuth: true }, slowPreparingActors); actor.start(); actor.send({ type: 'START' }); + await delay(40); // All three should be running in parallel const snapshot = actor.getSnapshot(); @@ -123,14 +154,16 @@ describe('InstallerCore State Machine', () => { actor.stop(); }); - it('emits state:enter for each state transition', () => { + it('emits state:enter for each state transition', async () => { const { actor, emitter } = createTestActor({ skipAuth: true }); const states: string[] = []; emitter.on('state:enter', ({ state }) => states.push(state)); actor.start(); actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 50)); + expect(states).toContain('scaffold'); expect(states).toContain('preparing'); actor.stop(); }); @@ -430,4 +463,152 @@ describe('InstallerCore State Machine', () => { actor.stop(); }); }); + + describe('scaffold flow', () => { + function createScaffoldActor(opts: { + workspace: WorkspaceCheckOutput; + runScaffoldImpl?: () => Promise; + }) { + const emitter = createInstallerEventEmitter(); + const options: InstallerOptions = { + debug: false, + forceInstall: false, + installDir: '/test/project', + local: true, + ci: false, + skipAuth: true, + dashboard: false, + emitter, + }; + + const machine = installerMachine.provide({ + actors: { + ...baseMockActors, + checkWorkspace: fromPromise(async () => opts.workspace), + runScaffold: fromPromise(opts.runScaffoldImpl ?? (async () => {})), + }, + }); + + const actor = createActor(machine, { input: { emitter, options } }); + return { actor, emitter, options }; + } + + it('skips scaffolding when the directory is not empty', async () => { + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: false, packageManager: 'npm', autoScaffold: false }, + }); + const events: string[] = []; + emitter.on('scaffold:checking', () => events.push('scaffold:checking')); + emitter.on('scaffold:start', () => events.push('scaffold:start')); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 50)); + + // Checked the workspace, but never started a scaffold; fell through to the + // normal install path (left the scaffold state entirely). + expect(events).toEqual(['scaffold:checking']); + expect(actor.getSnapshot().context.scaffolded).toBeFalsy(); + expect(actor.getSnapshot().matches('scaffold')).toBe(false); + actor.stop(); + }); + + it('auto-scaffolds without prompting (headless / --scaffold)', async () => { + let ran = false; + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'pnpm', autoScaffold: true }, + runScaffoldImpl: async () => { + ran = true; + }, + }); + const started: string[] = []; + emitter.on('scaffold:start', ({ packageManager }) => started.push(packageManager)); + emitter.on('error', () => {}); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 100)); + + expect(ran).toBe(true); + expect(started).toEqual(['pnpm']); + expect(actor.getSnapshot().context.scaffolded).toBe(true); + actor.stop(); + }); + + it('prompts then scaffolds on confirm', async () => { + let ran = false; + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: false }, + runScaffoldImpl: async () => { + ran = true; + }, + }); + let prompted = false; + emitter.on('scaffold:prompt', () => { + prompted = true; + }); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 50)); + + expect(prompted).toBe(true); + expect(actor.getSnapshot().value).toMatchObject({ scaffold: 'prompting' }); + expect(ran).toBe(false); + + actor.send({ type: 'SCAFFOLD_CONFIRMED' }); + await new Promise((r) => setTimeout(r, 50)); + + expect(ran).toBe(true); + actor.stop(); + }); + + it('cancels without scaffolding when the prompt is declined', async () => { + let ran = false; + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: false }, + runScaffoldImpl: async () => { + ran = true; + }, + }); + let skipped = false; + emitter.on('scaffold:skipped', () => { + skipped = true; + }); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 50)); + + actor.send({ type: 'SCAFFOLD_CANCELLED' }); + await new Promise((r) => setTimeout(r, 50)); + + expect(ran).toBe(false); + expect(skipped).toBe(true); + expect(actor.getSnapshot().value).toBe('cancelled'); + actor.stop(); + }); + + it('errors when create-next-app fails, preserving the message', async () => { + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, + runScaffoldImpl: async () => { + throw new Error('create-next-app exited with code 1'); + }, + }); + let failure: string | undefined; + emitter.on('scaffold:failed', ({ error }) => { + failure = error; + }); + emitter.on('error', () => {}); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 100)); + + expect(actor.getSnapshot().value).toBe('error'); + expect(failure).toContain('create-next-app exited with code 1'); + actor.stop(); + }); + }); }); diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index cc0ed6e1..7fb782bc 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -10,6 +10,7 @@ import type { DiscoveryResult, CredentialSource, BranchCheckOutput, + WorkspaceCheckOutput, } from './installer-core.types.js'; import type { InstallerOptions } from '../utils/types.js'; import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; @@ -69,6 +70,34 @@ export const installerMachine = setup({ emitGitCancelled: ({ context }) => { context.emitter.emit('git:dirty:cancelled', {}); }, + emitScaffoldChecking: ({ context }) => { + context.emitter.emit('scaffold:checking', {}); + }, + emitScaffoldPrompt: ({ context }) => { + context.emitter.emit('scaffold:prompt', { packageManager: context.packageManager ?? 'npm' }); + }, + emitScaffoldStart: ({ context }) => { + context.emitter.emit('scaffold:start', { packageManager: context.packageManager ?? 'npm' }); + }, + emitScaffoldComplete: ({ context }) => { + context.emitter.emit('scaffold:complete', {}); + }, + emitScaffoldFailed: ({ context }) => { + const message = context.error?.message ?? 'Scaffold failed'; + context.emitter.emit('scaffold:failed', { error: message }); + }, + emitScaffoldSkipped: ({ context }) => { + context.emitter.emit('scaffold:skipped', {}); + }, + assignWorkspaceResult: assign({ + scaffoldable: ({ event }) => (event as unknown as { output: WorkspaceCheckOutput }).output?.scaffoldable ?? false, + packageManager: ({ event }) => + (event as unknown as { output: WorkspaceCheckOutput }).output?.packageManager ?? 'npm', + autoScaffold: ({ event }) => (event as unknown as { output: WorkspaceCheckOutput }).output?.autoScaffold ?? false, + }), + assignScaffolded: assign({ + scaffolded: () => true, + }), emitBranchChecking: ({ context }) => { context.emitter.emit('branch:checking', {}); }, @@ -294,12 +323,25 @@ export const installerMachine = setup({ hasIntegration: ({ context }) => context.integration !== undefined, shouldSkipPostInstall: ({ context }) => context.options.noCommit === true, hasGhCli: () => hasGhCli(), + // Read from the actor's done event (output), not context: the + // assignWorkspaceResult action has not run yet when guards are evaluated. + notScaffoldable: ({ event }) => !(event as unknown as { output: WorkspaceCheckOutput }).output?.scaffoldable, + shouldAutoScaffold: ({ event }) => { + const output = (event as unknown as { output: WorkspaceCheckOutput }).output; + return !!output?.scaffoldable && !!output?.autoScaffold; + }, }, actors: { checkAuthentication: fromPromise(async () => { throw new Error('checkAuthentication not implemented - provide via machine.provide()'); }), + checkWorkspace: fromPromise(async () => { + throw new Error('checkWorkspace not implemented - provide via machine.provide()'); + }), + runScaffold: fromPromise(async () => { + throw new Error('runScaffold not implemented - provide via machine.provide()'); + }), detectIntegration: fromPromise(async () => { throw new Error('detectIntegration not implemented - provide via machine.provide()'); }), @@ -390,9 +432,9 @@ export const installerMachine = setup({ on: { START: [ { - target: 'preparing', + target: 'scaffold', guard: 'shouldSkipAuth', - actions: { type: 'emitStateEnter', params: { state: 'preparing' } }, + actions: { type: 'emitStateEnter', params: { state: 'scaffold' } }, }, { target: 'authenticating', @@ -409,11 +451,11 @@ export const installerMachine = setup({ src: 'checkAuthentication', input: ({ context }) => ({ options: context.options }), onDone: { - target: 'preparing', + target: 'scaffold', actions: [ 'emitAuthSuccess', { type: 'emitStateExit', params: { state: 'authenticating' } }, - { type: 'emitStateEnter', params: { state: 'preparing' } }, + { type: 'emitStateEnter', params: { state: 'scaffold' } }, ], }, onError: { @@ -423,6 +465,82 @@ export const installerMachine = setup({ }, }, + scaffold: { + initial: 'checking', + states: { + checking: { + entry: ['emitScaffoldChecking'], + invoke: { + id: 'checkWorkspace', + src: 'checkWorkspace', + input: ({ context }) => ({ options: context.options }), + onDone: [ + { + // Not an empty dir: fall through to today's behavior. + target: 'done', + guard: 'notScaffoldable', + actions: ['assignWorkspaceResult'], + }, + { + // Headless or --scaffold: skip the prompt. + target: 'running', + guard: 'shouldAutoScaffold', + actions: ['assignWorkspaceResult'], + }, + { + // Interactive empty dir: ask first. + target: 'prompting', + actions: ['assignWorkspaceResult'], + }, + ], + onError: { + // Workspace check failure is non-fatal: proceed to preparing, + // which errors on no-integration exactly as before this feature. + target: 'done', + }, + }, + }, + prompting: { + entry: ['emitScaffoldPrompt'], + on: { + SCAFFOLD_CONFIRMED: { + target: 'running', + }, + SCAFFOLD_CANCELLED: { + target: '#installer.cancelled', + actions: ['emitScaffoldSkipped', { type: 'emitStateExit', params: { state: 'scaffold' } }], + }, + }, + }, + running: { + entry: ['emitScaffoldStart'], + invoke: { + id: 'runScaffold', + src: 'runScaffold', + input: ({ context }) => ({ context }), + onDone: { + target: 'done', + actions: ['assignScaffolded', 'emitScaffoldComplete'], + }, + onError: { + target: '#installer.error', + actions: ['assignError', 'emitScaffoldFailed', { type: 'emitStateExit', params: { state: 'scaffold' } }], + }, + }, + }, + done: { + type: 'final', + }, + }, + onDone: { + target: 'preparing', + actions: [ + { type: 'emitStateExit', params: { state: 'scaffold' } }, + { type: 'emitStateEnter', params: { state: 'preparing' } }, + ], + }, + }, + preparing: { type: 'parallel', states: { diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index ddd55c3b..f004455e 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -1,6 +1,7 @@ import type { InstallerEventEmitter } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; import type { Integration } from './constants.js'; +import type { PackageManager } from './scaffold/scaffold.js'; import type { DeviceAuthResponse } from './device-auth.js'; import type { EnvFileInfo, DiscoveryResult } from './credential-discovery.js'; @@ -59,6 +60,14 @@ export interface InstallerMachineContext { prUrl?: string; /** Summary message from agent execution */ agentSummary?: string; + /** Whether the install directory is empty and can be scaffolded into */ + scaffoldable?: boolean; + /** Package manager resolved for the scaffolded app */ + packageManager?: PackageManager; + /** Whether to scaffold without prompting (headless mode or --scaffold) */ + autoScaffold?: boolean; + /** Whether create-next-app actually ran and succeeded (for telemetry) */ + scaffolded?: boolean; } /** @@ -78,6 +87,9 @@ export type InstallerMachineEvent = | { type: 'SKIP_AUTH' } | { type: 'GIT_CONFIRMED' } | { type: 'GIT_CANCELLED' } + // Scaffold events + | { type: 'SCAFFOLD_CONFIRMED' } + | { type: 'SCAFFOLD_CANCELLED' } | { type: 'CREDENTIALS_SUBMITTED'; apiKey: string; clientId: string } | { type: 'CANCEL' } // Credential discovery events @@ -125,3 +137,15 @@ export interface BranchCheckOutput { branch: string | null; isProtected: boolean; } + +/** + * Output from the workspace check actor (scaffold gate). + */ +export interface WorkspaceCheckOutput { + /** Whether the install directory is empty enough to scaffold into */ + scaffoldable: boolean; + /** Package manager resolved for the scaffolded app */ + packageManager: PackageManager; + /** Whether to scaffold without prompting (headless or --scaffold) */ + autoScaffold: boolean; +} diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 2977c4f6..fd8ea46a 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -16,7 +16,9 @@ import type { GitCheckOutput, AgentOutput, BranchCheckOutput, + WorkspaceCheckOutput, } from './installer-core.types.js'; +import { isScaffoldableEmptyDir, resolvePackageManager, runCreateNextApp } from './scaffold/index.js'; import type { Integration } from './constants.js'; import { parseEnvFile } from '../utils/env-parser.js'; import { enableDebugLogs, initLogFile, logInfo, logError } from '../utils/debug.js'; @@ -248,6 +250,26 @@ export async function runWithCore(options: InstallerOptions): Promise { return true; }), + checkWorkspace: fromPromise(async ({ input }) => { + const scaffoldable = await isScaffoldableEmptyDir(input.options.installDir); + const packageManager = resolvePackageManager({ + pm: input.options.pm, + userAgent: process.env.npm_config_user_agent, + }); + // headlessMode is computed above; --scaffold opts in during interactive runs. + const autoScaffold = scaffoldable && (headlessMode || !!input.options.scaffold); + return { scaffoldable, packageManager, autoScaffold }; + }), + + runScaffold: fromPromise(async ({ input }) => { + const { options: installerOptions, packageManager, emitter: ctxEmitter } = input.context; + await runCreateNextApp({ + installDir: installerOptions.installDir, + packageManager: packageManager ?? 'npm', + emitter: ctxEmitter, + }); + }), + detectIntegration: fromPromise(async ({ input }) => { const integration = await detectIntegrationFn({ installDir: input.options.installDir }); return { integration }; @@ -568,10 +590,16 @@ export async function runWithCore(options: InstallerOptions): Promise { // tag install metrics by integration). Known only after detection runs, so // it's read from the final machine snapshot here; absent if the session // aborted before detection. - const detectedIntegration = actor?.getSnapshot().context.integration; + const finalContext = actor?.getSnapshot().context; + const detectedIntegration = finalContext?.integration; if (detectedIntegration) { analytics.setTag('installer.integration', detectedIntegration); } + // Record whether the empty-dir flow scaffolded a new app, so session.end + // carries it for adoption + scaffold-failure tracking. + if (finalContext?.scaffolded) { + analytics.setTag('scaffolded', true); + } await analytics.shutdown(installerStatus); await adapter.stop(); } diff --git a/src/lib/scaffold/index.ts b/src/lib/scaffold/index.ts new file mode 100644 index 00000000..f3b5268b --- /dev/null +++ b/src/lib/scaffold/index.ts @@ -0,0 +1,9 @@ +export { + CREATE_NEXT_APP_VERSION, + SAFE_EMPTY_FILES, + buildCreateNextAppArgs, + isScaffoldableEmptyDir, + resolvePackageManager, + runCreateNextApp, + type PackageManager, +} from './scaffold.js'; diff --git a/src/lib/scaffold/scaffold.spec.ts b/src/lib/scaffold/scaffold.spec.ts new file mode 100644 index 00000000..f841ed68 --- /dev/null +++ b/src/lib/scaffold/scaffold.spec.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock the spawn used by runCreateNextApp. Hoisted by vitest. +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); +import { spawn } from 'node:child_process'; + +import { + CREATE_NEXT_APP_VERSION, + SAFE_EMPTY_FILES, + buildCreateNextAppArgs, + isScaffoldableEmptyDir, + resolvePackageManager, + runCreateNextApp, + type PackageManager, +} from './scaffold.js'; +import { InstallerEventEmitter } from '../events.js'; + +describe('isScaffoldableEmptyDir', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'workos-scaffold-test-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function write(name: string, content = ''): void { + writeFileSync(join(dir, name), content); + } + + it('returns true for a truly empty directory', async () => { + expect(await isScaffoldableEmptyDir(dir)).toBe(true); + }); + + it('returns true when only safe files are present (.git + .gitignore)', async () => { + mkdirSync(join(dir, '.git')); + write('.gitignore', 'node_modules'); + write('LICENSE', 'MIT'); + expect(await isScaffoldableEmptyDir(dir)).toBe(true); + }); + + it('returns false when a package.json is present', async () => { + write('package.json', '{}'); + expect(await isScaffoldableEmptyDir(dir)).toBe(false); + }); + + it('returns false when an unrelated stray file is present', async () => { + mkdirSync(join(dir, '.git')); + write('notes.txt', 'hello'); + expect(await isScaffoldableEmptyDir(dir)).toBe(false); + }); + + it('returns false for a non-JS manifest (go.mod)', async () => { + write('go.mod', 'module example.com/app'); + expect(await isScaffoldableEmptyDir(dir)).toBe(false); + }); + + it('SAFE_EMPTY_FILES excludes README.md (not in create-next-app validFiles)', () => { + expect(SAFE_EMPTY_FILES.has('README.md')).toBe(false); + expect(SAFE_EMPTY_FILES.has('.vscode')).toBe(false); + }); +}); + +describe('resolvePackageManager', () => { + it('parses pnpm from the user agent', () => { + expect(resolvePackageManager({ userAgent: 'pnpm/8.6.0 npm/? node/v20.0.0 darwin arm64' })).toBe('pnpm'); + }); + + it('parses bun from the user agent', () => { + expect(resolvePackageManager({ userAgent: 'bun/1.1.0 npm/? node/v20' })).toBe('bun'); + }); + + it('parses yarn from the user agent', () => { + expect(resolvePackageManager({ userAgent: 'yarn/4.0.0 npm/? node/v20' })).toBe('yarn'); + }); + + it('falls back to npm when the user agent is absent', () => { + expect(resolvePackageManager({})).toBe('npm'); + }); + + it('falls back to npm for an unrecognized user agent', () => { + expect(resolvePackageManager({ userAgent: 'cnpm/1.0.0 node/v20' })).toBe('npm'); + }); + + it('lets a valid --pm override beat the user agent', () => { + expect(resolvePackageManager({ pm: 'pnpm', userAgent: 'npm/10.0.0 node/v20' })).toBe('pnpm'); + }); + + it('ignores an invalid --pm and falls through to the user agent', () => { + expect(resolvePackageManager({ pm: 'cargo', userAgent: 'pnpm/8 npm/? node/v20' })).toBe('pnpm'); + }); + + it('ignores an invalid --pm and falls back to npm with no user agent', () => { + expect(resolvePackageManager({ pm: 'cargo' })).toBe('npm'); + }); +}); + +describe('buildCreateNextAppArgs', () => { + it('produces the exact pinned arg array for pnpm', () => { + expect(buildCreateNextAppArgs('pnpm')).toEqual([ + '.', + '--ts', + '--app', + '--eslint', + '--tailwind', + '--src-dir', + '--import-alias', + '@/*', + '--use-pnpm', + '--yes', + ]); + }); + + it('ends with --use- for each package manager', () => { + const managers: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun']; + for (const pm of managers) { + const args = buildCreateNextAppArgs(pm); + expect(args.at(-1)).toBe('--yes'); + expect(args).toContain(`--use-${pm}`); + // App shape stays constant regardless of PM. + expect(args).toEqual(expect.arrayContaining(['--ts', '--app', '--tailwind', '--src-dir'])); + } + }); + + it('does not pass a turbopack flag (relies on --yes defaults)', () => { + const args = buildCreateNextAppArgs('npm'); + expect(args).not.toContain('--turbopack'); + expect(args).not.toContain('--no-turbopack'); + }); +}); + +describe('runCreateNextApp', () => { + function makeFakeChild(): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + return child; + } + + beforeEach(() => { + (spawn as unknown as Mock).mockReset(); + }); + + it('spawns npx with the pinned create-next-app version and cwd, streaming progress', async () => { + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const emitter = new InstallerEventEmitter(); + const progress: string[] = []; + emitter.on('scaffold:progress', (p) => progress.push(p.text)); + + const promise = runCreateNextApp({ installDir: '/tmp/wos-empty', packageManager: 'pnpm', emitter }); + + child.stdout.emit('data', Buffer.from('Creating a new Next.js app...')); + child.emit('close', 0); + + await expect(promise).resolves.toBeUndefined(); + expect(progress.join('')).toContain('Creating a new Next.js app'); + expect(spawn).toHaveBeenCalledWith( + 'npx', + expect.arrayContaining([`create-next-app@${CREATE_NEXT_APP_VERSION}`, '--use-pnpm']), + expect.objectContaining({ cwd: '/tmp/wos-empty' }), + ); + }); + + it('rejects when create-next-app exits non-zero, preserving stderr', async () => { + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const emitter = new InstallerEventEmitter(); + const promise = runCreateNextApp({ installDir: '/tmp/wos-empty', packageManager: 'npm', emitter }); + + child.stderr.emit('data', Buffer.from('network error')); + child.emit('close', 1); + + await expect(promise).rejects.toThrow(/exited with code 1.*network error/s); + }); + + it('rejects on spawn error', async () => { + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const emitter = new InstallerEventEmitter(); + const promise = runCreateNextApp({ installDir: '/tmp/wos-empty', packageManager: 'npm', emitter }); + + child.emit('error', new Error('spawn npx ENOENT')); + + await expect(promise).rejects.toThrow(/ENOENT/); + }); +}); diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts new file mode 100644 index 00000000..5765337b --- /dev/null +++ b/src/lib/scaffold/scaffold.ts @@ -0,0 +1,148 @@ +import { spawn } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import { SPAWN_OPTS } from '../../utils/platform.js'; +import type { InstallerEventEmitter } from '../events.js'; + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +/** + * The `create-next-app` major we pin to. Matches `authkit-nextjs/examples/next` + * (Next 16). A major float (`@16`) keeps the pinned flag set stable while still + * receiving patch/minor updates. Re-pin when AuthKit bumps its supported Next major. + * `@latest` is intentionally avoided — it would reintroduce the flag-drift + * non-determinism this deterministic step exists to prevent. + */ +export const CREATE_NEXT_APP_VERSION = '16'; + +const VALID_PACKAGE_MANAGERS: ReadonlySet = new Set(['npm', 'pnpm', 'yarn', 'bun']); + +/** + * Files that may be present in a directory we still consider "scaffoldable empty". + * + * INVARIANT: this MUST stay a subset of create-next-app's own `validFiles` + * (verified against the v{@link CREATE_NEXT_APP_VERSION} tag). If ours is + * stricter, we simply scaffold less often (safe). If ours were looser, + * create-next-app would accept our offer and then refuse mid-run. + * + * We omit `docs` and `mkdocs.yml` from the upstream list on purpose — their + * presence usually signals real project content, so we err toward NOT scaffolding. + * We also omit `README.md`/`.vscode`: they are NOT in create-next-app's list, so a + * directory containing them is left to the normal (non-scaffold) install path. + */ +export const SAFE_EMPTY_FILES: ReadonlySet = new Set([ + '.DS_Store', + '.git', + '.gitattributes', + '.gitignore', + '.gitlab-ci.yml', + '.hg', + '.hgcheck', + '.hgignore', + '.idea', + '.npmignore', + '.travis.yml', + 'LICENSE', + 'Thumbs.db', + 'npm-debug.log', + 'yarn-debug.log', + 'yarn-error.log', + 'yarnrc.yml', + '.yarn', +]); + +/** + * True iff `dir` is empty or contains only {@link SAFE_EMPTY_FILES} entries. + * + * Because no project manifest (`package.json`, `go.mod`, `Gemfile`, etc.) appears + * in {@link SAFE_EMPTY_FILES}, the presence of any manifest makes this return + * false — i.e. an existing project always takes the normal install path. + */ +export async function isScaffoldableEmptyDir(dir: string): Promise { + const entries = await readdir(dir); + return entries.every((entry) => SAFE_EMPTY_FILES.has(entry)); +} + +/** + * Resolve the package manager for the scaffolded app. + * + * Precedence: validated `--pm` override > `npm_config_user_agent` parse > `npm`. + * An empty directory has no lockfile, so lockfile-based detection does not apply; + * we read the runner from the user agent instead (e.g. `pnpm dlx` → `pnpm`). + */ +export function resolvePackageManager(opts: { pm?: string; userAgent?: string }): PackageManager { + const { pm, userAgent } = opts; + + // `--pm` is validated by yargs `choices` upstream; guard again defensively and + // fall through on anything unexpected rather than throwing here. + if (pm && VALID_PACKAGE_MANAGERS.has(pm)) { + return pm as PackageManager; + } + + if (userAgent) { + // e.g. "pnpm/8.6.0 npm/? node/v20.0.0 darwin arm64" → "pnpm" + const name = userAgent.split(/\s+/)[0]?.split('/')[0]; + if (name && VALID_PACKAGE_MANAGERS.has(name)) { + return name as PackageManager; + } + } + + return 'npm'; +} + +/** + * The pinned `create-next-app` flag set. Kept pure so a unit test can assert the + * exact array — flag drift across a pinned major is the primary failure mode. + * + * App Router + TypeScript + ESLint + Tailwind + `src/` + `@/*` alias, installed + * with the resolved package manager. `--yes` accepts all remaining defaults + * (including the major's Turbopack default) and prevents interactive hangs. + */ +export function buildCreateNextAppArgs(pm: PackageManager): string[] { + return ['.', '--ts', '--app', '--eslint', '--tailwind', '--src-dir', '--import-alias', '@/*', `--use-${pm}`, '--yes']; +} + +/** + * Spawn `npx create-next-app@ .` in `installDir`, streaming output as + * `scaffold:progress` events. Resolves on exit 0; rejects on non-zero exit or + * spawn error so the state machine can route to its error state. + */ +export function runCreateNextApp(opts: { + installDir: string; + packageManager: PackageManager; + emitter: InstallerEventEmitter; +}): Promise { + const { installDir, packageManager, emitter } = opts; + const args = [`--yes`, `create-next-app@${CREATE_NEXT_APP_VERSION}`, ...buildCreateNextAppArgs(packageManager)]; + + return new Promise((resolve, reject) => { + const child = spawn('npx', args, { + cwd: installDir, + env: process.env, + ...SPAWN_OPTS, + }); + + let stderr = ''; + + const stream = (data: Buffer): void => { + emitter.emit('scaffold:progress', { text: data.toString() }); + }; + + child.stdout?.on('data', stream); + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + stream(data); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`create-next-app exited with code ${code ?? 1}${stderr ? `: ${stderr.trim()}` : ''}`)); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} diff --git a/src/run.ts b/src/run.ts index fcb0dc4f..a5c32c66 100644 --- a/src/run.ts +++ b/src/run.ts @@ -33,6 +33,8 @@ export type InstallerArgs = { noGitCheck?: boolean; gitCheck?: boolean; direct?: boolean; + scaffold?: boolean; + pm?: string; }; /** @@ -73,6 +75,8 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { createPr: merged.createPr ?? false, noGitCheck: merged.noGitCheck ?? merged.gitCheck === false, direct: merged.direct ?? false, + scaffold: merged.scaffold ?? false, + pm: merged.pm, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore }; } diff --git a/src/utils/types.ts b/src/utils/types.ts index fbc29ade..6361ffbc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -113,6 +113,18 @@ export type InstallerOptions = { * Default: 2. Set to 0 to disable retries entirely. */ maxRetries?: number; + + /** + * Scaffold a new Next.js app when run in an empty directory. + * Auto-enabled in headless mode; opt-in via --scaffold in interactive mode. + */ + scaffold?: boolean; + + /** + * Package manager for the scaffolded app (npm/pnpm/yarn/bun). + * Overrides detection from npm_config_user_agent. + */ + pm?: string; }; export interface Feature { From 3cf2ad38364a9a29366243ec7688a29122274f97 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 7 Jun 2026 12:58:46 -0500 Subject: [PATCH 2/9] fix: explain why install fails when no framework is found The empty-dir error was a bare "Could not detect framework integration", which gave no hint about the empty-dir-vs-existing-project fork. Replace it with an actionable message that names the directory and points to both paths: run in an empty directory to scaffold, or run from a project root / pass --install-dir for an existing project. --- src/lib/installer-core.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 7fb782bc..0b7b466d 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -695,7 +695,19 @@ export const installerMachine = setup({ { target: 'error', actions: [ - assign({ error: () => new Error('Could not detect framework integration') }), + assign({ + error: ({ context }) => { + const dir = context.options.installDir; + return new Error( + `No supported framework was detected in ${dir}.\n` + + `Because this directory isn't empty, a new app wasn't scaffolded, and no recognized ` + + `framework (such as a package.json with Next.js) was found to install into.\n\n` + + `Next steps:\n` + + ` - New app: run \`workos install\` in an empty directory to scaffold Next.js + AuthKit.\n` + + ` - Existing project: run from the directory that contains your package.json, or pass --install-dir .`, + ); + }, + }), { type: 'emitStateExit', params: { state: 'preparing' } }, ], }, From 84e935c05ab8604fdde95fe759e54869e8a2f67c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 7 Jun 2026 12:58:46 -0500 Subject: [PATCH 3/9] docs: document empty-directory scaffolding and --scaffold/--pm flags Add a Features bullet, the --scaffold and --pm options to the Installer Options list, a note explaining the empty-dir scaffold behavior (and that a README.md / package.json opts the directory out), and a greenfield example. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index facf85cc..47fb6cea 100644 --- a/README.md +++ b/README.md @@ -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 @@ -576,6 +577,8 @@ workos install [options] --redirect-uri Custom redirect URI --homepage-url Custom homepage URL --install-dir Installation directory + --scaffold Scaffold a new Next.js app when run in an empty directory + --pm 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 @@ -585,12 +588,17 @@ 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 +# Greenfield: scaffold a new Next.js app + AuthKit in an empty directory +mkdir my-app && cd my-app && npx workos@latest install + # Specify framework npx workos@latest install --integration react-router From b229131f1b97c53b299ac3314045a0ebd0d61fbe Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 7 Jun 2026 14:27:21 -0500 Subject: [PATCH 4/9] feat: warn when --integration conflicts with the Next.js-only scaffold v1 scaffolding only supports Next.js, so passing e.g. `--integration react` in an empty directory silently produced a Next.js app. Emit a one-shot `scaffold:notice` before prompting/auto-scaffolding when a non-Next integration was requested, surfaced by all three adapters (CLI warn, headless NDJSON, dashboard status). True multi-framework scaffolding remains a tracked follow-up (noted in scaffold.ts). --- src/lib/adapters/cli-adapter.ts | 5 +++ src/lib/adapters/dashboard-adapter.ts | 6 +++ src/lib/adapters/headless-adapter.spec.ts | 15 ++++++++ src/lib/adapters/headless-adapter.ts | 5 +++ src/lib/events.ts | 1 + src/lib/installer-core.spec.ts | 45 +++++++++++++++++++++++ src/lib/installer-core.ts | 15 +++++++- src/lib/scaffold/scaffold.ts | 5 +++ 8 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 9d1526d5..5f85d928 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -118,6 +118,7 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('complete', this.handleComplete); this.subscribe('error', this.handleError); // Scaffold events (empty-directory app scaffolding) + this.subscribe('scaffold:notice', this.handleScaffoldNotice); this.subscribe('scaffold:prompt', this.handleScaffoldPrompt); this.subscribe('scaffold:start', this.handleScaffoldStart); this.subscribe('scaffold:progress', this.handleScaffoldProgress); @@ -458,6 +459,10 @@ export class CLIAdapter implements InstallerAdapter { // ===== Scaffold Event Handlers ===== + private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { + this.queueableLog(() => clack.log.warn(message)); + }; + private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise => { this.scaffoldPackageManager = packageManager; this.isPromptActive = true; diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index c0a23809..b92dc3c3 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -53,6 +53,7 @@ export class DashboardAdapter implements InstallerAdapter { // 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:notice', this.handleScaffoldNotice); this.emitter.on('scaffold:prompt', this.handleScaffoldPrompt); this.emitter.on('scaffold:start', this.handleScaffoldStart); this.emitter.on('scaffold:complete', this.handleScaffoldComplete); @@ -75,6 +76,7 @@ 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:notice', this.handleScaffoldNotice); this.emitter.off('scaffold:prompt', this.handleScaffoldPrompt); this.emitter.off('scaffold:start', this.handleScaffoldStart); this.emitter.off('scaffold:complete', this.handleScaffoldComplete); @@ -121,6 +123,10 @@ export class DashboardAdapter implements InstallerAdapter { // ===== Scaffold (empty-dir) handlers ===== + private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { + this.emitter.emit('status', { message }); + }; + private handleScaffoldPrompt = (): void => { this.sendEvent({ type: 'SCAFFOLD_CONFIRMED' }); }; diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 0d788aa4..158bb489 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -315,6 +315,21 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); + it('writes scaffold:notice', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('scaffold:notice', { + message: 'Scaffolding currently supports Next.js only; ignoring --integration react.', + }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'scaffold:notice', + message: 'Scaffolding currently supports Next.js only; ignoring --integration react.', + }); + await adapter.stop(); + }); + it('writes scaffold:failed with the error', 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..d0a0e1fc 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -49,6 +49,7 @@ export class HeadlessAdapter implements InstallerAdapter { // Scaffold events (empty-directory app scaffolding) — auto-routed, no prompt this.subscribe('scaffold:checking', this.handleScaffoldChecking); + this.subscribe('scaffold:notice', this.handleScaffoldNotice); this.subscribe('scaffold:start', this.handleScaffoldStart); this.subscribe('scaffold:progress', this.handleScaffoldProgress); this.subscribe('scaffold:complete', this.handleScaffoldComplete); @@ -148,6 +149,10 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'scaffold:checking' }); }; + private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { + writeNDJSON({ type: 'scaffold:notice', message }); + }; + private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => { writeNDJSON({ type: 'scaffold:start', packageManager }); }; diff --git a/src/lib/events.ts b/src/lib/events.ts index abedc70a..4962b43e 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -63,6 +63,7 @@ export interface InstallerEvents { // Scaffold events (empty-directory app scaffolding) 'scaffold:checking': Record; + 'scaffold:notice': { message: string }; 'scaffold:prompt': { packageManager: string }; 'scaffold:start': { packageManager: string }; 'scaffold:progress': { text: string }; diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index 01449b83..7deb2221 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -468,6 +468,7 @@ describe('InstallerCore State Machine', () => { function createScaffoldActor(opts: { workspace: WorkspaceCheckOutput; runScaffoldImpl?: () => Promise; + integration?: string; }) { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { @@ -478,6 +479,7 @@ describe('InstallerCore State Machine', () => { ci: false, skipAuth: true, dashboard: false, + integration: opts.integration, emitter, }; @@ -589,6 +591,49 @@ describe('InstallerCore State Machine', () => { actor.stop(); }); + it('warns but still scaffolds when --integration is not Next.js', async () => { + let ran = false; + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, + integration: 'react', + runScaffoldImpl: async () => { + ran = true; + }, + }); + const notices: string[] = []; + emitter.on('scaffold:notice', ({ message }) => notices.push(message)); + emitter.on('error', () => {}); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 100)); + + expect(notices).toHaveLength(1); + expect(notices[0]).toContain('react'); + expect(ran).toBe(true); // v1 still scaffolds Next.js + actor.stop(); + }); + + it('does not warn when --integration is nextjs', async () => { + const { actor, emitter } = createScaffoldActor({ + workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, + integration: 'nextjs', + runScaffoldImpl: async () => {}, + }); + let noticed = false; + emitter.on('scaffold:notice', () => { + noticed = true; + }); + emitter.on('error', () => {}); + + actor.start(); + actor.send({ type: 'START' }); + await new Promise((r) => setTimeout(r, 100)); + + expect(noticed).toBe(false); + actor.stop(); + }); + it('errors when create-next-app fails, preserving the message', async () => { const { actor, emitter } = createScaffoldActor({ workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 0b7b466d..dea34a62 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -73,6 +73,17 @@ export const installerMachine = setup({ emitScaffoldChecking: ({ context }) => { context.emitter.emit('scaffold:checking', {}); }, + // v1 scaffolding is Next.js-only (see scaffold.ts). If the user pre-selected a + // different integration, the scaffold can't honor it — say so once, before we + // prompt or auto-scaffold, rather than silently handing them a Next.js app. + emitScaffoldIntegrationNotice: ({ context }) => { + const requested = context.options.integration; + if (requested && requested !== 'nextjs' && requested !== 'next') { + context.emitter.emit('scaffold:notice', { + message: `Scaffolding currently supports Next.js only; ignoring --integration ${requested}.`, + }); + } + }, emitScaffoldPrompt: ({ context }) => { context.emitter.emit('scaffold:prompt', { packageManager: context.packageManager ?? 'npm' }); }, @@ -485,12 +496,12 @@ export const installerMachine = setup({ // Headless or --scaffold: skip the prompt. target: 'running', guard: 'shouldAutoScaffold', - actions: ['assignWorkspaceResult'], + actions: ['assignWorkspaceResult', 'emitScaffoldIntegrationNotice'], }, { // Interactive empty dir: ask first. target: 'prompting', - actions: ['assignWorkspaceResult'], + actions: ['assignWorkspaceResult', 'emitScaffoldIntegrationNotice'], }, ], onError: { diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index 5765337b..a4572e28 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -93,6 +93,11 @@ export function resolvePackageManager(opts: { pm?: string; userAgent?: string }) * The pinned `create-next-app` flag set. Kept pure so a unit test can assert the * exact array — flag drift across a pinned major is the primary failure mode. * + * v1 scaffolds Next.js only. `--integration ` cannot be honored here yet; + * the state machine emits a `scaffold:notice` to say so. Multi-framework + * scaffolding (e.g. `--integration react` -> a Vite React app via its own + * `create-*` tool) is a tracked follow-up, not implemented. + * * App Router + TypeScript + ESLint + Tailwind + `src/` + `@/*` alias, installed * with the resolved package manager. `--yes` accepts all remaining defaults * (including the major's Turbopack default) and prevents interactive hangs. From ba1524c07ab8f5857d5af9717a0d9b1f737dde3c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 7 Jun 2026 15:38:41 -0500 Subject: [PATCH 5/9] refactor: remove the inert --integration install flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --integration was read in only two places: it seeded context.integration at startup, which the detection actor then unconditionally overwrote. So it never affected which installer ran — vestigial surface that predated the XState detection step. Remove the flag plus its option/type/buildOptions plumbing and README docs. This also drops the scaffold:notice machinery added earlier in this branch, which existed solely to warn that --integration couldn't steer the Next.js-only scaffold. With the flag gone, there's nothing to warn about. Detection is unchanged and still selects the integration. The install command is non-strict, so a leftover --integration is silently ignored rather than erroring — behavior is unchanged (the flag was already a no-op). True multi-framework scaffolding remains a tracked follow-up. --- README.md | 4 -- src/bin.ts | 4 -- src/lib/adapters/cli-adapter.ts | 5 --- src/lib/adapters/dashboard-adapter.ts | 6 --- src/lib/adapters/headless-adapter.spec.ts | 15 -------- src/lib/adapters/headless-adapter.ts | 5 --- src/lib/events.ts | 1 - src/lib/installer-core.spec.ts | 45 ----------------------- src/lib/installer-core.ts | 18 ++------- src/lib/scaffold/scaffold.ts | 6 +-- src/run.ts | 3 -- src/utils/types.ts | 5 --- 12 files changed, 6 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 47fb6cea..f958c5ad 100644 --- a/README.md +++ b/README.md @@ -571,7 +571,6 @@ workos org-domain delete workos install [options] --direct, -D Use your own Anthropic API key (bypass llm-gateway) - --integration Framework: nextjs, react, react-router, tanstack-start, vanilla-js, sveltekit, node, python, ruby, go, dotnet, kotlin, elixir, php-laravel, php --api-key WorkOS API key (required in non-interactive mode) --client-id WorkOS client ID (required in non-interactive mode) --redirect-uri Custom redirect URI @@ -599,9 +598,6 @@ npx workos@latest install # Greenfield: scaffold a new Next.js app + AuthKit in an empty directory mkdir my-app && cd my-app && npx workos@latest install -# Specify framework -npx workos@latest install --integration react-router - # With visual dashboard (experimental) npx workos@latest dashboard diff --git a/src/bin.ts b/src/bin.ts index 2a2352b3..4cc0122f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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', diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 5f85d928..9d1526d5 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -118,7 +118,6 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('complete', this.handleComplete); this.subscribe('error', this.handleError); // Scaffold events (empty-directory app scaffolding) - this.subscribe('scaffold:notice', this.handleScaffoldNotice); this.subscribe('scaffold:prompt', this.handleScaffoldPrompt); this.subscribe('scaffold:start', this.handleScaffoldStart); this.subscribe('scaffold:progress', this.handleScaffoldProgress); @@ -459,10 +458,6 @@ export class CLIAdapter implements InstallerAdapter { // ===== Scaffold Event Handlers ===== - private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { - this.queueableLog(() => clack.log.warn(message)); - }; - private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise => { this.scaffoldPackageManager = packageManager; this.isPromptActive = true; diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index b92dc3c3..c0a23809 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -53,7 +53,6 @@ export class DashboardAdapter implements InstallerAdapter { // 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:notice', this.handleScaffoldNotice); this.emitter.on('scaffold:prompt', this.handleScaffoldPrompt); this.emitter.on('scaffold:start', this.handleScaffoldStart); this.emitter.on('scaffold:complete', this.handleScaffoldComplete); @@ -76,7 +75,6 @@ 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:notice', this.handleScaffoldNotice); this.emitter.off('scaffold:prompt', this.handleScaffoldPrompt); this.emitter.off('scaffold:start', this.handleScaffoldStart); this.emitter.off('scaffold:complete', this.handleScaffoldComplete); @@ -123,10 +121,6 @@ export class DashboardAdapter implements InstallerAdapter { // ===== Scaffold (empty-dir) handlers ===== - private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { - this.emitter.emit('status', { message }); - }; - private handleScaffoldPrompt = (): void => { this.sendEvent({ type: 'SCAFFOLD_CONFIRMED' }); }; diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 158bb489..0d788aa4 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -315,21 +315,6 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); - it('writes scaffold:notice', async () => { - const adapter = createAdapter(); - await adapter.start(); - - emitter.emit('scaffold:notice', { - message: 'Scaffolding currently supports Next.js only; ignoring --integration react.', - }); - - expect(mockWriteNDJSON).toHaveBeenCalledWith({ - type: 'scaffold:notice', - message: 'Scaffolding currently supports Next.js only; ignoring --integration react.', - }); - await adapter.stop(); - }); - it('writes scaffold:failed with the error', async () => { const adapter = createAdapter(); await adapter.start(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index d0a0e1fc..6f167bbe 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -49,7 +49,6 @@ export class HeadlessAdapter implements InstallerAdapter { // Scaffold events (empty-directory app scaffolding) — auto-routed, no prompt this.subscribe('scaffold:checking', this.handleScaffoldChecking); - this.subscribe('scaffold:notice', this.handleScaffoldNotice); this.subscribe('scaffold:start', this.handleScaffoldStart); this.subscribe('scaffold:progress', this.handleScaffoldProgress); this.subscribe('scaffold:complete', this.handleScaffoldComplete); @@ -149,10 +148,6 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'scaffold:checking' }); }; - private handleScaffoldNotice = ({ message }: InstallerEvents['scaffold:notice']): void => { - writeNDJSON({ type: 'scaffold:notice', message }); - }; - private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => { writeNDJSON({ type: 'scaffold:start', packageManager }); }; diff --git a/src/lib/events.ts b/src/lib/events.ts index 4962b43e..abedc70a 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -63,7 +63,6 @@ export interface InstallerEvents { // Scaffold events (empty-directory app scaffolding) 'scaffold:checking': Record; - 'scaffold:notice': { message: string }; 'scaffold:prompt': { packageManager: string }; 'scaffold:start': { packageManager: string }; 'scaffold:progress': { text: string }; diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index 7deb2221..01449b83 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -468,7 +468,6 @@ describe('InstallerCore State Machine', () => { function createScaffoldActor(opts: { workspace: WorkspaceCheckOutput; runScaffoldImpl?: () => Promise; - integration?: string; }) { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { @@ -479,7 +478,6 @@ describe('InstallerCore State Machine', () => { ci: false, skipAuth: true, dashboard: false, - integration: opts.integration, emitter, }; @@ -591,49 +589,6 @@ describe('InstallerCore State Machine', () => { actor.stop(); }); - it('warns but still scaffolds when --integration is not Next.js', async () => { - let ran = false; - const { actor, emitter } = createScaffoldActor({ - workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, - integration: 'react', - runScaffoldImpl: async () => { - ran = true; - }, - }); - const notices: string[] = []; - emitter.on('scaffold:notice', ({ message }) => notices.push(message)); - emitter.on('error', () => {}); - - actor.start(); - actor.send({ type: 'START' }); - await new Promise((r) => setTimeout(r, 100)); - - expect(notices).toHaveLength(1); - expect(notices[0]).toContain('react'); - expect(ran).toBe(true); // v1 still scaffolds Next.js - actor.stop(); - }); - - it('does not warn when --integration is nextjs', async () => { - const { actor, emitter } = createScaffoldActor({ - workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, - integration: 'nextjs', - runScaffoldImpl: async () => {}, - }); - let noticed = false; - emitter.on('scaffold:notice', () => { - noticed = true; - }); - emitter.on('error', () => {}); - - actor.start(); - actor.send({ type: 'START' }); - await new Promise((r) => setTimeout(r, 100)); - - expect(noticed).toBe(false); - actor.stop(); - }); - it('errors when create-next-app fails, preserving the message', async () => { const { actor, emitter } = createScaffoldActor({ workspace: { scaffoldable: true, packageManager: 'npm', autoScaffold: true }, diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index dea34a62..3b13c2f9 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -73,17 +73,6 @@ export const installerMachine = setup({ emitScaffoldChecking: ({ context }) => { context.emitter.emit('scaffold:checking', {}); }, - // v1 scaffolding is Next.js-only (see scaffold.ts). If the user pre-selected a - // different integration, the scaffold can't honor it — say so once, before we - // prompt or auto-scaffold, rather than silently handing them a Next.js app. - emitScaffoldIntegrationNotice: ({ context }) => { - const requested = context.options.integration; - if (requested && requested !== 'nextjs' && requested !== 'next') { - context.emitter.emit('scaffold:notice', { - message: `Scaffolding currently supports Next.js only; ignoring --integration ${requested}.`, - }); - } - }, emitScaffoldPrompt: ({ context }) => { context.emitter.emit('scaffold:prompt', { packageManager: context.packageManager ?? 'npm' }); }, @@ -421,7 +410,8 @@ export const installerMachine = setup({ context: ({ input }) => ({ emitter: input.emitter, options: input.options, - integration: input.options.integration, + // Set by the detection actor; no pre-seeding (the --integration flag is gone). + integration: undefined, credentials: input.options.apiKey && input.options.clientId ? { apiKey: input.options.apiKey, clientId: input.options.clientId } @@ -496,12 +486,12 @@ export const installerMachine = setup({ // Headless or --scaffold: skip the prompt. target: 'running', guard: 'shouldAutoScaffold', - actions: ['assignWorkspaceResult', 'emitScaffoldIntegrationNotice'], + actions: ['assignWorkspaceResult'], }, { // Interactive empty dir: ask first. target: 'prompting', - actions: ['assignWorkspaceResult', 'emitScaffoldIntegrationNotice'], + actions: ['assignWorkspaceResult'], }, ], onError: { diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index a4572e28..7bc66563 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -93,10 +93,8 @@ export function resolvePackageManager(opts: { pm?: string; userAgent?: string }) * The pinned `create-next-app` flag set. Kept pure so a unit test can assert the * exact array — flag drift across a pinned major is the primary failure mode. * - * v1 scaffolds Next.js only. `--integration ` cannot be honored here yet; - * the state machine emits a `scaffold:notice` to say so. Multi-framework - * scaffolding (e.g. `--integration react` -> a Vite React app via its own - * `create-*` tool) is a tracked follow-up, not implemented. + * v1 scaffolds Next.js only. Multi-framework scaffolding (e.g. a Vite React app + * via its own `create-*` tool) is a tracked follow-up, not implemented. * * App Router + TypeScript + ESLint + Tailwind + `src/` + `@/*` alias, installed * with the resolved package manager. `--yes` accepts all remaining defaults diff --git a/src/run.ts b/src/run.ts index a5c32c66..1a61a5ca 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,7 +1,6 @@ import { readEnvironment } from './utils/environment.js'; import { runWithCore } from './lib/run-with-core.js'; import type { InstallerOptions } from './utils/types.js'; -import type { Integration } from './lib/constants.js'; import { createInstallerEventEmitter } from './lib/events.js'; import path from 'path'; import { EventEmitter } from 'events'; @@ -9,7 +8,6 @@ import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 50; export type InstallerArgs = { - integration?: Integration; debug?: boolean; forceInstall?: boolean; installDir?: string; @@ -67,7 +65,6 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { homepageUrl: merged.homepageUrl, redirectUri: merged.redirectUri, dashboard: merged.dashboard ?? false, - integration: merged.integration, inspect: merged.inspect ?? false, noValidate: merged.noValidate ?? merged.validate === false, noCommit: merged.noCommit ?? merged.commit === false, diff --git a/src/utils/types.ts b/src/utils/types.ts index 6361ffbc..4f65d2a9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -66,11 +66,6 @@ export type InstallerOptions = { */ emitter?: import('../lib/events.js').InstallerEventEmitter; - /** - * Pre-selected framework integration (bypasses detection) - */ - integration?: import('../lib/constants.js').Integration; - /** * Enable XState inspector - opens browser to visualize state machine live */ From fb014198e6c5c9a7b3e2617375ec56c7962d73c5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 8 Jun 2026 09:57:03 -0500 Subject: [PATCH 6/9] chore: formatting --- src/lib/installer-core.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index 01449b83..a8b2495d 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -465,10 +465,7 @@ describe('InstallerCore State Machine', () => { }); describe('scaffold flow', () => { - function createScaffoldActor(opts: { - workspace: WorkspaceCheckOutput; - runScaffoldImpl?: () => Promise; - }) { + function createScaffoldActor(opts: { workspace: WorkspaceCheckOutput; runScaffoldImpl?: () => Promise }) { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { debug: false, @@ -485,7 +482,9 @@ describe('InstallerCore State Machine', () => { actors: { ...baseMockActors, checkWorkspace: fromPromise(async () => opts.workspace), - runScaffold: fromPromise(opts.runScaffoldImpl ?? (async () => {})), + runScaffold: fromPromise( + opts.runScaffoldImpl ?? (async () => {}), + ), }, }); From 63a9c6352dde6c75cc3662c784c919a51beda74f Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 8 Jun 2026 10:07:51 -0500 Subject: [PATCH 7/9] fix: run create-next-app via the resolved PM runner; cap scaffold stderr Addresses Greptile review on PR #173. - runCreateNextApp hardcoded `npx`, which ENOENTs on a bun-only machine (no npm/npx on PATH) even when --pm bun was passed. Route through each package manager's own runner: npm -> `npx --yes`, pnpm -> `pnpm dlx`, yarn -> `yarn dlx`, bun -> `bunx`. (`yarn dlx` assumes Yarn >= 2; Yarn 1 users can fall back to --pm npm.) - The thrown error appended the full unbounded create-next-app stderr; cap it at 2000 chars so a long dependency-resolution trace stays actionable. Tests: runner-per-PM matrix + stderr cap. --- src/lib/scaffold/scaffold.spec.ts | 50 +++++++++++++++++++++++++++++-- src/lib/scaffold/scaffold.ts | 29 ++++++++++++++---- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/lib/scaffold/scaffold.spec.ts b/src/lib/scaffold/scaffold.spec.ts index f841ed68..9f27216f 100644 --- a/src/lib/scaffold/scaffold.spec.ts +++ b/src/lib/scaffold/scaffold.spec.ts @@ -147,7 +147,7 @@ describe('runCreateNextApp', () => { (spawn as unknown as Mock).mockReset(); }); - it('spawns npx with the pinned create-next-app version and cwd, streaming progress', async () => { + it('runs create-next-app via the package manager runner, streaming progress', async () => { const child = makeFakeChild(); (spawn as unknown as Mock).mockReturnValue(child); @@ -163,12 +163,40 @@ describe('runCreateNextApp', () => { await expect(promise).resolves.toBeUndefined(); expect(progress.join('')).toContain('Creating a new Next.js app'); expect(spawn).toHaveBeenCalledWith( - 'npx', - expect.arrayContaining([`create-next-app@${CREATE_NEXT_APP_VERSION}`, '--use-pnpm']), + 'pnpm', + expect.arrayContaining(['dlx', `create-next-app@${CREATE_NEXT_APP_VERSION}`, '--use-pnpm']), expect.objectContaining({ cwd: '/tmp/wos-empty' }), ); }); + it('uses each package manager its own runner (npx / pnpm dlx / yarn dlx / bunx)', async () => { + const cases: Array<[PackageManager, string, string[]]> = [ + ['npm', 'npx', ['--yes', `create-next-app@${CREATE_NEXT_APP_VERSION}`]], + ['pnpm', 'pnpm', ['dlx', `create-next-app@${CREATE_NEXT_APP_VERSION}`]], + ['yarn', 'yarn', ['dlx', `create-next-app@${CREATE_NEXT_APP_VERSION}`]], + ['bun', 'bunx', [`create-next-app@${CREATE_NEXT_APP_VERSION}`]], + ]; + + for (const [pm, bin, leadingArgs] of cases) { + (spawn as unknown as Mock).mockReset(); + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const promise = runCreateNextApp({ + installDir: '/tmp/x', + packageManager: pm, + emitter: new InstallerEventEmitter(), + }); + child.emit('close', 0); + await expect(promise).resolves.toBeUndefined(); + + const [calledBin, calledArgs] = (spawn as unknown as Mock).mock.calls[0] as [string, string[]]; + expect(calledBin).toBe(bin); + expect(calledArgs.slice(0, leadingArgs.length)).toEqual(leadingArgs); + expect(calledArgs).toContain(`--use-${pm}`); + } + }); + it('rejects when create-next-app exits non-zero, preserving stderr', async () => { const child = makeFakeChild(); (spawn as unknown as Mock).mockReturnValue(child); @@ -193,4 +221,20 @@ describe('runCreateNextApp', () => { await expect(promise).rejects.toThrow(/ENOENT/); }); + + it('caps stderr in the rejection message', async () => { + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const emitter = new InstallerEventEmitter(); + const promise = runCreateNextApp({ installDir: '/tmp/wos-empty', packageManager: 'npm', emitter }); + + child.stderr.emit('data', Buffer.from('e'.repeat(5000))); + child.emit('close', 1); + + const err = (await promise.catch((e: unknown) => e)) as Error; + expect(err).toBeInstanceOf(Error); + // 2000-char stderr cap + the "create-next-app exited with code 1: " prefix. + expect(err.message.length).toBeLessThan(2100); + }); }); diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index 7bc66563..4cfe6ac6 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -105,9 +105,23 @@ export function buildCreateNextAppArgs(pm: PackageManager): string[] { } /** - * Spawn `npx create-next-app@ .` in `installDir`, streaming output as - * `scaffold:progress` events. Resolves on exit 0; rejects on non-zero exit or - * spawn error so the state machine can route to its error state. + * Each package manager's own package runner. Running create-next-app through the + * resolved PM's runner (instead of always `npx`) means we don't require npm/npx + * to be on PATH — e.g. a bun-only machine has no `npx`. `yarn dlx` assumes + * Yarn >= 2 (Berry); a Yarn 1 user would need `--pm npm`. + */ +const PM_RUNNER: Record = { + npm: { bin: 'npx', args: ['--yes'] }, + pnpm: { bin: 'pnpm', args: ['dlx'] }, + yarn: { bin: 'yarn', args: ['dlx'] }, + bun: { bin: 'bunx', args: [] }, +}; + +/** + * Spawn `create-next-app@ .` in `installDir` via the resolved package + * manager's runner, streaming output as `scaffold:progress` events. Resolves on + * exit 0; rejects on non-zero exit or spawn error so the state machine can route + * to its error state. */ export function runCreateNextApp(opts: { installDir: string; @@ -115,10 +129,11 @@ export function runCreateNextApp(opts: { emitter: InstallerEventEmitter; }): Promise { const { installDir, packageManager, emitter } = opts; - const args = [`--yes`, `create-next-app@${CREATE_NEXT_APP_VERSION}`, ...buildCreateNextAppArgs(packageManager)]; + const runner = PM_RUNNER[packageManager]; + const args = [...runner.args, `create-next-app@${CREATE_NEXT_APP_VERSION}`, ...buildCreateNextAppArgs(packageManager)]; return new Promise((resolve, reject) => { - const child = spawn('npx', args, { + const child = spawn(runner.bin, args, { cwd: installDir, env: process.env, ...SPAWN_OPTS, @@ -140,7 +155,9 @@ export function runCreateNextApp(opts: { if (code === 0) { resolve(); } else { - reject(new Error(`create-next-app exited with code ${code ?? 1}${stderr ? `: ${stderr.trim()}` : ''}`)); + // Cap stderr so a long npm/dependency-resolution trace doesn't bloat the error. + const detail = stderr ? `: ${stderr.trim().slice(0, 2000)}` : ''; + reject(new Error(`create-next-app exited with code ${code ?? 1}${detail}`)); } }); From a2c18a426bd9ee82d86d5e5f397fef81e41f8e77 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 8 Jun 2026 10:31:20 -0500 Subject: [PATCH 8/9] fix: bound scaffold stderr at collection + report kill signal Addresses the two P2s from the greptile --agent review on PR #173. - stderr was accumulated unbounded and only sliced when building the error message; cap it at collection time so a pathological create-next-app failure can't buffer hundreds of KB. The message-time slice stays for the single-large-chunk case. - When create-next-app is killed by a signal, the close handler received code=null and `code ?? 1` masked it as "exited with code 1". Capture the signal and report "was killed by signal ". The review's critical finding (empty-CWD scaffolding "fails silently" on an undefined installDir) is a false positive: installDir is typed `string` and buildOptions always resolves it to process.cwd() via resolveInstallDir, so it is never undefined. No change. Tests: kill-signal rejection path. --- src/lib/scaffold/scaffold.spec.ts | 12 ++++++++++++ src/lib/scaffold/scaffold.ts | 14 ++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/lib/scaffold/scaffold.spec.ts b/src/lib/scaffold/scaffold.spec.ts index 9f27216f..d4e8b378 100644 --- a/src/lib/scaffold/scaffold.spec.ts +++ b/src/lib/scaffold/scaffold.spec.ts @@ -237,4 +237,16 @@ describe('runCreateNextApp', () => { // 2000-char stderr cap + the "create-next-app exited with code 1: " prefix. expect(err.message.length).toBeLessThan(2100); }); + + it('reports the signal when create-next-app is killed (null exit code)', async () => { + const child = makeFakeChild(); + (spawn as unknown as Mock).mockReturnValue(child); + + const emitter = new InstallerEventEmitter(); + const promise = runCreateNextApp({ installDir: '/tmp/wos-empty', packageManager: 'npm', emitter }); + + child.emit('close', null, 'SIGTERM'); + + await expect(promise).rejects.toThrow(/killed by signal SIGTERM/); + }); }); diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index 4cfe6ac6..b5941606 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -147,17 +147,23 @@ export function runCreateNextApp(opts: { child.stdout?.on('data', stream); child.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); + // Bound the buffer at collection time so a pathological failure (e.g. a full + // npm resolution trace) can't accumulate hundreds of KB. Progress still streams. + if (stderr.length < 2000) { + stderr += data.toString(); + } stream(data); }); - child.on('close', (code) => { + child.on('close', (code, signal) => { if (code === 0) { resolve(); } else { - // Cap stderr so a long npm/dependency-resolution trace doesn't bloat the error. const detail = stderr ? `: ${stderr.trim().slice(0, 2000)}` : ''; - reject(new Error(`create-next-app exited with code ${code ?? 1}${detail}`)); + // code is null when the process was killed by a signal (e.g. SIGTERM from a + // timeout layer); surface that instead of masking it as "exited with code 1". + const exitInfo = code !== null ? `exited with code ${code}` : `was killed by signal ${signal ?? 'unknown'}`; + reject(new Error(`create-next-app ${exitInfo}${detail}`)); } }); From be85c9f5ebdbd3e3d51124dd54c34d2803b29ab4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 8 Jun 2026 10:32:06 -0500 Subject: [PATCH 9/9] chore: formatting --- src/lib/scaffold/scaffold.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index b5941606..5e49730e 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -130,7 +130,11 @@ export function runCreateNextApp(opts: { }): Promise { const { installDir, packageManager, emitter } = opts; const runner = PM_RUNNER[packageManager]; - const args = [...runner.args, `create-next-app@${CREATE_NEXT_APP_VERSION}`, ...buildCreateNextAppArgs(packageManager)]; + const args = [ + ...runner.args, + `create-next-app@${CREATE_NEXT_APP_VERSION}`, + ...buildCreateNextAppArgs(packageManager), + ]; return new Promise((resolve, reject) => { const child = spawn(runner.bin, args, {