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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions packages/create-next-app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ async function run(): Promise<void> {
* If the user does not provide the necessary flags, prompt them for their
* preferences, unless `--yes` option was specified, or when running in CI.
*/
const skipPrompt = ciInfo.isCI || opts.yes
let skipPrompt = ciInfo.isCI || opts.yes
let useRecommendedDefaults = false

if (!example) {
const defaults: typeof preferences = {
Expand All @@ -242,8 +243,124 @@ async function run(): Promise<void> {
disableGit: false,
reactCompiler: false,
}
const getPrefOrDefault = (field: string) =>
preferences[field] ?? defaults[field]

type DisplayConfigItem = {
key: keyof typeof defaults
values?: Record<string, string>
}

const displayConfig: DisplayConfigItem[] = [
{
key: 'typescript',
values: { true: 'TypeScript', false: 'JavaScript' },
},
{ key: 'linter', values: { eslint: 'ESLint', biome: 'Biome' } },
{ key: 'reactCompiler', values: { true: 'React Compiler' } },
{ key: 'tailwind', values: { true: 'Tailwind CSS' } },
{ key: 'srcDir', values: { true: 'src/ dir' } },
{ key: 'app', values: { true: 'App Router', false: 'Pages Router' } },
{ key: 'turbopack', values: { true: 'Turbopack' } },
]

// Helper to format settings for display based on displayConfig
const formatSettingsDescription = (
settings: Record<string, boolean | string>
) => {
const descriptions: string[] = []

for (const config of displayConfig) {
const value = settings[config.key]

if (config.values) {
// Look up the display label for this value
const label = config.values[String(value)]
if (label) {
descriptions.push(label)
}
}
}

return descriptions.join(', ')
}

// Check if we have saved preferences
const hasSavedPreferences = Object.keys(preferences).length > 0

// Check if user provided any configuration flags
// If they did, skip the "recommended defaults" prompt and go straight to
// individual prompts for any missing options
const hasProvidedOptions = process.argv.some((arg) => arg.startsWith('--'))

// Only show the "recommended defaults" prompt if:
// - Not in CI and not using --yes flag
// - User hasn't provided any custom options
if (!skipPrompt && !hasProvidedOptions) {
const choices: Array<{
title: string
value: string
description?: string
}> = [
{
title: 'Yes, use recommended defaults',
value: 'recommended',
description: formatSettingsDescription(defaults),
},
{
title: 'No, customize settings',
value: 'customize',
description: 'Choose your own preferences',
},
]

// Add "reuse previous settings" option if we have saved preferences
if (hasSavedPreferences) {
const prefDescription = formatSettingsDescription(preferences)
choices.splice(1, 0, {
title: 'No, reuse previous settings',
value: 'reuse',
description: prefDescription,
})
}

const { setupChoice } = await prompts(
{
type: 'select',
name: 'setupChoice',
message: 'Would you like to use the recommended Next.js defaults?',
choices,
initial: 0,
},
{
onCancel: () => {
console.error('Exiting.')
process.exit(1)
},
}
)

if (setupChoice === 'recommended') {
useRecommendedDefaults = true
skipPrompt = true
} else if (setupChoice === 'reuse') {
skipPrompt = true
}
}

// If using recommended defaults, populate preferences with defaults
// This ensures they are saved for reuse next time
if (useRecommendedDefaults) {
Object.assign(preferences, defaults)
}

const getPrefOrDefault = (field: string) => {
// If using recommended defaults, always use hardcoded defaults
if (useRecommendedDefaults) {
return defaults[field]
}

// If not using the recommended template, we prefer saved preferences, otherwise defaults.
return preferences[field] ?? defaults[field]
}

if (!opts.typescript && !opts.javascript) {
if (skipPrompt) {
Expand Down
7 changes: 5 additions & 2 deletions test/integration/create-next-app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ const cli = require.resolve('create-next-app/dist/index.js')
export const createNextApp = (
args: string[],
options?: SpawnOptions,
testVersion?: string
testVersion?: string,
clearPreferences: boolean = true
) => {
const conf = new Conf({ projectName: 'create-next-app' })
conf.clear()
if (clearPreferences) {
conf.clear()
}

console.log(`[TEST] $ ${cli} ${args.join(' ')}`, { options })

Expand Down
100 changes: 100 additions & 0 deletions test/integration/create-next-app/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,106 @@ describe('create-next-app prompts', () => {
})
})

it('should use recommended defaults when user selects that option', async () => {
await useTempDir(async (cwd) => {
const projectName = 'recommended-defaults'
const childProcess = createNextApp(
[projectName],
{
cwd,
},
nextTgzFilename
)

await new Promise<void>((resolve) => {
childProcess.on('exit', async (exitCode) => {
expect(exitCode).toBe(0)
projectFilesShouldExist({
cwd,
projectName,
files: [
'app',
'package.json',
'postcss.config.mjs', // tailwind
'tsconfig.json', // typescript
],
})
resolve()
})

// Select "Yes, use recommended defaults" (default option, just press enter)
childProcess.stdin.write('\n')
})

const pkg = require(join(cwd, projectName, 'package.json'))
expect(pkg.name).toBe(projectName)
// Verify turbopack is in dev script
expect(pkg.scripts.dev).toContain('--turbo')
})
})

it('should show reuse previous settings option when preferences exist', async () => {
const Conf = require('next/dist/compiled/conf')

await useTempDir(async (cwd) => {
// Manually set preferences to simulate a previous run
const conf = new Conf({ projectName: 'create-next-app' })
conf.set('preferences', {
typescript: false,
eslint: true,
linter: 'eslint',
tailwind: false,
app: false,
srcDir: false,
importAlias: '@/*',
customizeImportAlias: false,
turbopack: false,
reactCompiler: false,
})

const projectName = 'reuse-prefs-project'
const childProcess = createNextApp(
[projectName],
{
cwd,
},
nextTgzFilename,
false // Don't clear preferences
)

await new Promise<void>(async (resolve) => {
let output = ''
childProcess.stdout.on('data', (data) => {
output += data
process.stdout.write(data)
})

// Select "reuse previous settings" (cursor down once, then enter)
childProcess.stdin.write('\u001b[B\n')

// Wait for the prompt to appear with "reuse previous settings"
await check(() => output, /No, reuse previous settings/)

Comment on lines +290 to +302
Copy link
Contributor

Choose a reason for hiding this comment

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

The test structure creates a potential race condition. The outer promise might resolve (via the exit event handler) before the inner await check() completes. This could lead to intermittent test failures if the process exits before the check verifies the prompt text. Consider either:

  1. Moving the check() outside the promise constructor
  2. Using a more sequential flow where the check completes before setting up the exit handler
  3. Adding a flag that ensures the exit handler only resolves after the check has completed

This would make the test more deterministic and less prone to timing-related failures.

Suggested change
await new Promise<void>(async (resolve) => {
let output = ''
childProcess.stdout.on('data', (data) => {
output += data
process.stdout.write(data)
})
// Select "reuse previous settings" (cursor down once, then enter)
childProcess.stdin.write('\u001b[B\n')
// Wait for the prompt to appear with "reuse previous settings"
await check(() => output, /No, reuse previous settings/)
await new Promise<void>((resolve) => {
let output = ''
childProcess.stdout.on('data', (data) => {
output += data
process.stdout.write(data)
})
// Flag to track if check has completed
let checkCompleted = false
// Set up exit handler that only resolves after check completes
childProcess.on('exit', () => {
if (checkCompleted) {
resolve()
}
})
// Select "reuse previous settings" (cursor down once, then enter)
childProcess.stdin.write('\u001b[B\n')
// Wait for the prompt to appear with "reuse previous settings"
check(() => output, /No, reuse previous settings/).then(() => {
checkCompleted = true
// If process has already exited, resolve now
if (childProcess.exitCode !== null) {
resolve()
}
})

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

childProcess.on('exit', async (exitCode) => {
expect(exitCode).toBe(0)
projectFilesShouldExist({
cwd,
projectName,
files: [
'pages', // pages router (not app)
'package.json',
'jsconfig.json', // javascript
],
})
resolve()
})
})

const pkg = require(join(cwd, projectName, 'package.json'))
expect(pkg.name).toBe(projectName)
})
})

it('should prompt user to confirm reset preferences', async () => {
await useTempDir(async (cwd) => {
const childProcess = createNextApp(
Expand Down
Loading