Files
Jude GaoandGitHub 9a6346302a [create-next-app] Skip interactive prompts when CLI flags are provided (#91840)
When AI agents run `create-next-app` with explicit flags like
`--typescript --tailwind --eslint --app --src-dir`, the CLI still enters
interactive mode and prompts for any unspecified options:

```
➜ npx create-next-app my-app --typescript --tailwind --eslint --app --src-dir --use-pnpm
✔ Would you like to use React Compiler? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
? Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? › No / Yes
```

Agents can sometimes answer these interactive prompts correctly, but
often they can't. When they fail to navigate the prompts, they fall back
to scaffolding the project themselves from scratch — generating files
based on stale training data. This means they might initialize an app
using deprecated patterns or pin to an older version of Next.js (e.g.
15) instead of the latest. Using `create-next-app` non-interactively is
the best way to ensure agents always produce up-to-date scaffolding.

Previously, `hasProvidedOptions` only skipped the initial "use
recommended defaults?" meta-prompt but still showed individual prompts
for each missing option. Now when any config flags are provided, all
remaining options use the recommended defaults without prompting.

The resolved defaults are printed to stdout so the caller knows exactly
what was assumed and which flags to pass to override:

```
Using defaults for unprovided options:

  --eslint                ESLint (use --biome for Biome, --no-eslint for None)
  --no-react-compiler     No React Compiler (use --react-compiler for React Compiler)
  --agents-md             AGENTS.md (use --no-agents-md for No AGENTS.md)
  --import-alias          "@/*"

To customize, re-run with explicit flags.
```

The `displayConfig` array is extended with a `flags` field so this
output is auto-generated from the same source of truth used for the
interactive prompts. Existing behavior for `--yes`, CI mode, and fully
interactive mode (no flags) is unchanged.
2026-03-25 11:36:01 -07:00

826 lines
25 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable import/no-extraneous-dependencies */
import ciInfo from 'ci-info'
import { Command } from 'commander'
import Conf from 'conf'
import { existsSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { blue, bold, cyan, green, red, yellow } from 'picocolors'
import type { InitialReturnValue } from 'prompts'
import prompts from 'prompts'
import updateCheck from 'update-check'
import { createApp, DownloadError } from './create-app'
import type { PackageManager } from './helpers/get-pkg-manager'
import { getPkgManager } from './helpers/get-pkg-manager'
import { isFolderEmpty } from './helpers/is-folder-empty'
import { validateNpmName } from './helpers/validate-pkg'
import packageJson from './package.json'
import { Bundler } from './templates'
let projectPath: string = ''
const handleSigTerm = () => process.exit(0)
process.on('SIGINT', handleSigTerm)
process.on('SIGTERM', handleSigTerm)
const onPromptState = (state: {
value: InitialReturnValue
aborted: boolean
exited: boolean
}) => {
if (state.aborted) {
// If we don't re-enable the terminal cursor before exiting
// the program, the cursor will remain hidden
process.stdout.write('\x1B[?25h')
process.stdout.write('\n')
process.exit(1)
}
}
const program = new Command(packageJson.name)
.version(
packageJson.version,
'-v, --version',
'Output the current version of create-next-app.'
)
.argument('[directory]')
.usage('[directory] [options]')
.helpOption('-h, --help', 'Display this help message.')
.option('--ts, --typescript', 'Initialize as a TypeScript project. (default)')
.option('--js, --javascript', 'Initialize as a JavaScript project.')
.option('--tailwind', 'Initialize with Tailwind CSS config. (default)')
.option('--react-compiler', 'Initialize with React Compiler enabled.')
.option('--eslint', 'Initialize with ESLint config.')
.option('--biome', 'Initialize with Biome config.')
.option('--app', 'Initialize as an App Router project.')
.option('--src-dir', "Initialize inside a 'src/' directory.")
.option('--rspack', 'Enable Rspack as the bundler.')
.option(
'--import-alias <prefix/*>',
'Specify import alias to use (default "@/*").'
)
.option('--api', 'Initialize a headless API using the App Router.')
.option('--empty', 'Initialize an empty project.')
.option(
'--use-npm',
'Explicitly tell the CLI to bootstrap the application using npm.'
)
.option(
'--use-pnpm',
'Explicitly tell the CLI to bootstrap the application using pnpm.'
)
.option(
'--use-yarn',
'Explicitly tell the CLI to bootstrap the application using Yarn.'
)
.option(
'--use-bun',
'Explicitly tell the CLI to bootstrap the application using Bun.'
)
.option(
'--reset, --reset-preferences',
'Reset the preferences saved for create-next-app.'
)
.option(
'--skip-install',
'Explicitly tell the CLI to skip installing packages.'
)
.option('--yes', 'Use saved preferences or defaults for unprovided options.')
.option(
'-e, --example <example-name|github-url>',
`
An example to bootstrap the app with. You can use an example name
from the official Next.js repo or a public GitHub URL. The URL can use
any branch and/or subdirectory.
`
)
.option(
'--example-path <path-to-example>',
`
In a rare case, your GitHub URL might contain a branch name with
a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar).
In this case, you must specify the path to the example separately:
--example-path foo/bar
`
)
.option(
'--agents-md',
'Include AGENTS.md to guide coding agents to write up-to-date Next.js code. (default)'
)
.option('--disable-git', `Skip initializing a git repository.`)
.action((name) => {
// Commander does not implicitly support negated options. When they are used
// by the user they will be interpreted as the positional argument (name) in
// the action handler. See https://github.com/tj/commander.js/pull/1355
if (name && !name.startsWith('--no-')) {
projectPath = name
}
})
.allowUnknownOption()
.parse(process.argv)
const opts = program.opts()
const { args } = program
const packageManager: PackageManager = !!opts.useNpm
? 'npm'
: !!opts.usePnpm
? 'pnpm'
: !!opts.useYarn
? 'yarn'
: !!opts.useBun
? 'bun'
: getPkgManager()
async function run(): Promise<void> {
const conf = new Conf({ projectName: 'create-next-app' })
if (opts.resetPreferences) {
const { resetPreferences } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'resetPreferences',
message: 'Would you like to reset the saved preferences?',
initial: false,
active: 'Yes',
inactive: 'No',
})
if (resetPreferences) {
conf.clear()
console.log('The preferences have been reset successfully!')
}
process.exit(0)
}
if (typeof projectPath === 'string') {
projectPath = projectPath.trim()
}
if (!projectPath) {
const res = await prompts({
onState: onPromptState,
type: 'text',
name: 'path',
message: 'What is your project named?',
initial: 'my-app',
validate: (name) => {
const validation = validateNpmName(basename(resolve(name)))
if (validation.valid) {
return true
}
return 'Invalid project name: ' + validation.problems[0]
},
})
if (typeof res.path === 'string') {
projectPath = res.path.trim()
}
}
if (!projectPath) {
console.log(
'\nPlease specify the project directory:\n' +
` ${cyan(opts.name())} ${green('<project-directory>')}\n` +
'For example:\n' +
` ${cyan(opts.name())} ${green('my-next-app')}\n\n` +
`Run ${cyan(`${opts.name()} --help`)} to see all options.`
)
process.exit(1)
}
const appPath = resolve(projectPath)
const appName = basename(appPath)
const validation = validateNpmName(appName)
if (!validation.valid) {
console.error(
`Could not create a project called ${red(
`"${appName}"`
)} because of npm naming restrictions:`
)
validation.problems.forEach((p) =>
console.error(` ${red(bold('*'))} ${p}`)
)
process.exit(1)
}
if (opts.example === true) {
console.error(
'Please provide an example name or url, otherwise remove the example option.'
)
process.exit(1)
}
if (existsSync(appPath) && !isFolderEmpty(appPath, appName)) {
process.exit(1)
}
const example = typeof opts.example === 'string' && opts.example.trim()
const preferences = (conf.get('preferences') || {}) as Record<
string,
boolean | string
>
/**
* If the user does not provide the necessary flags, prompt them for their
* preferences, unless `--yes` option was specified, or when running in CI.
*/
let skipPrompt = ciInfo.isCI || opts.yes
let useRecommendedDefaults = false
if (!example) {
const defaults: typeof preferences = {
typescript: true,
eslint: false,
linter: 'eslint',
tailwind: true,
app: true,
srcDir: false,
importAlias: '@/*',
customizeImportAlias: false,
empty: false,
disableGit: false,
reactCompiler: false,
agentsMd: true,
}
type DisplayConfigItem = {
key: keyof typeof defaults
values?: Record<string, string>
flags?: Record<string, string>
}
const displayConfig: DisplayConfigItem[] = [
{
key: 'typescript',
values: { true: 'TypeScript', false: 'JavaScript' },
flags: { true: '--ts', false: '--js' },
},
{
key: 'linter',
values: { eslint: 'ESLint', biome: 'Biome', none: 'None' },
flags: { eslint: '--eslint', biome: '--biome', none: '--no-eslint' },
},
{
key: 'reactCompiler',
values: { true: 'React Compiler', false: 'No React Compiler' },
flags: { true: '--react-compiler', false: '--no-react-compiler' },
},
{
key: 'tailwind',
values: { true: 'Tailwind CSS', false: 'No Tailwind CSS' },
flags: { true: '--tailwind', false: '--no-tailwind' },
},
{
key: 'srcDir',
values: { true: 'src/ directory', false: 'No src/ directory' },
flags: { true: '--src-dir', false: '--no-src-dir' },
},
{
key: 'app',
values: { true: 'App Router', false: 'Pages Router' },
flags: { true: '--app', false: '--no-app' },
},
{
key: 'agentsMd',
values: { true: 'AGENTS.md', false: 'No AGENTS.md' },
flags: { true: '--agents-md', false: '--no-agents-md' },
},
]
// 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 all prompts and use recommended defaults for unspecified
// options. This is critical for AI agents, which pass flags like
// --typescript --tailwind --app and expect the rest to use sensible defaults
// without entering interactive mode.
const hasProvidedOptions = process.argv.some((arg) => arg.startsWith('--'))
if (!skipPrompt && hasProvidedOptions) {
skipPrompt = true
useRecommendedDefaults = true
}
// 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) {
// default to TypeScript in CI as we can't prompt to
// prevent breaking setup flows
opts.typescript = getPrefOrDefault('typescript')
} else {
const styledTypeScript = blue('TypeScript')
const { typescript } = await prompts(
{
type: 'toggle',
name: 'typescript',
message: `Would you like to use ${styledTypeScript}?`,
initial: getPrefOrDefault('typescript'),
active: 'Yes',
inactive: 'No',
},
{
/**
* User inputs Ctrl+C or Ctrl+D to exit the prompt. We should close the
* process and not write to the file system.
*/
onCancel: () => {
console.error('Exiting.')
process.exit(1)
},
}
)
/**
* Depending on the prompt response, set the appropriate program flags.
*/
opts.typescript = Boolean(typescript)
opts.javascript = !typescript
preferences.typescript = Boolean(typescript)
}
}
// Determine linter choice if not specified via CLI flags
// Support both --no-linter (new) and --no-eslint (legacy) for backward compatibility
const noLinter =
args.includes('--no-linter') || args.includes('--no-eslint')
if (!opts.eslint && !opts.biome && !noLinter && !opts.api) {
if (skipPrompt) {
const preferredLinter = getPrefOrDefault('linter')
opts.eslint = preferredLinter === 'eslint'
opts.biome = preferredLinter === 'biome'
// No need to set noLinter flag since we check args at runtime
} else {
const linterIndexMap = {
eslint: 0,
biome: 1,
none: 2,
}
const { linter } = await prompts({
onState: onPromptState,
type: 'select',
name: 'linter',
message: 'Which linter would you like to use?',
choices: [
{
title: 'ESLint',
value: 'eslint',
description: 'More comprehensive lint rules',
},
{
title: 'Biome',
value: 'biome',
description: 'Fast formatter and linter (fewer rules)',
},
{
title: 'None',
value: 'none',
description: 'Skip linter configuration',
},
],
initial:
linterIndexMap[
getPrefOrDefault('linter') as keyof typeof linterIndexMap
],
})
opts.eslint = linter === 'eslint'
opts.biome = linter === 'biome'
preferences.linter = linter
// Keep backwards compatibility with old eslint preference
preferences.eslint = linter === 'eslint'
}
} else if (opts.eslint) {
opts.biome = false
preferences.linter = 'eslint'
preferences.eslint = true
} else if (opts.biome) {
opts.eslint = false
preferences.linter = 'biome'
preferences.eslint = false
} else if (noLinter) {
opts.eslint = false
opts.biome = false
preferences.linter = 'none'
preferences.eslint = false
}
if (
!opts.reactCompiler &&
!args.includes('--no-react-compiler') &&
!opts.api
) {
if (skipPrompt) {
opts.reactCompiler = getPrefOrDefault('reactCompiler')
} else {
const styledReactCompiler = blue('React Compiler')
const { reactCompiler } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'reactCompiler',
message: `Would you like to use ${styledReactCompiler}?`,
initial: getPrefOrDefault('reactCompiler'),
active: 'Yes',
inactive: 'No',
})
opts.reactCompiler = Boolean(reactCompiler)
preferences.reactCompiler = Boolean(reactCompiler)
}
}
if (!opts.tailwind && !args.includes('--no-tailwind') && !opts.api) {
if (skipPrompt) {
opts.tailwind = getPrefOrDefault('tailwind')
} else {
const tw = blue('Tailwind CSS')
const { tailwind } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'tailwind',
message: `Would you like to use ${tw}?`,
initial: getPrefOrDefault('tailwind'),
active: 'Yes',
inactive: 'No',
})
opts.tailwind = Boolean(tailwind)
preferences.tailwind = Boolean(tailwind)
}
}
if (!opts.srcDir && !args.includes('--no-src-dir')) {
if (skipPrompt) {
opts.srcDir = getPrefOrDefault('srcDir')
} else {
const styledSrcDir = blue('`src/` directory')
const { srcDir } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'srcDir',
message: `Would you like your code inside a ${styledSrcDir}?`,
initial: getPrefOrDefault('srcDir'),
active: 'Yes',
inactive: 'No',
})
opts.srcDir = Boolean(srcDir)
preferences.srcDir = Boolean(srcDir)
}
}
if (!opts.app && !args.includes('--no-app') && !opts.api) {
if (skipPrompt) {
opts.app = getPrefOrDefault('app')
} else {
const styledAppDir = blue('App Router')
const { app } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'app',
message: `Would you like to use ${styledAppDir}? (recommended)`,
initial: getPrefOrDefault('app'),
active: 'Yes',
inactive: 'No',
})
opts.app = Boolean(app)
preferences.app = Boolean(app)
}
}
const importAliasPattern = /^[^*"]+\/\*\s*$/
if (
typeof opts.importAlias !== 'string' ||
!importAliasPattern.test(opts.importAlias)
) {
if (skipPrompt) {
// We don't use preferences here because the default value is @/* regardless of existing preferences
opts.importAlias = defaults.importAlias
} else if (args.includes('--no-import-alias')) {
opts.importAlias = defaults.importAlias
} else {
const styledImportAlias = blue('import alias')
const { customizeImportAlias } = await prompts({
onState: onPromptState,
type: 'toggle',
name: 'customizeImportAlias',
message: `Would you like to customize the ${styledImportAlias} (\`${defaults.importAlias}\` by default)?`,
initial: getPrefOrDefault('customizeImportAlias'),
active: 'Yes',
inactive: 'No',
})
if (!customizeImportAlias) {
// We don't use preferences here because the default value is @/* regardless of existing preferences
opts.importAlias = defaults.importAlias
} else {
const { importAlias } = await prompts({
onState: onPromptState,
type: 'text',
name: 'importAlias',
message: `What ${styledImportAlias} would you like configured?`,
initial: getPrefOrDefault('importAlias'),
validate: (value) =>
importAliasPattern.test(value)
? true
: 'Import alias must follow the pattern <prefix>/*',
})
opts.importAlias = importAlias
preferences.importAlias = importAlias
}
}
}
if (args.includes('--no-agents-md')) {
opts.agentsMd = false
} else if (!opts.agentsMd) {
if (skipPrompt) {
opts.agentsMd = getPrefOrDefault('agentsMd')
} else {
const { agentsMd } = await prompts(
{
type: 'toggle',
name: 'agentsMd',
message:
'Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code?',
initial: getPrefOrDefault('agentsMd'),
active: 'Yes',
inactive: 'No',
},
{
onCancel: () => {
console.error('Exiting.')
process.exit(1)
},
}
)
opts.agentsMd = Boolean(agentsMd)
preferences.agentsMd = Boolean(agentsMd)
}
}
// When prompts were skipped because flags were provided, print the
// defaults that were assumed so agents and users know what to override.
if (hasProvidedOptions && useRecommendedDefaults) {
const lines: string[] = []
for (const config of displayConfig) {
if (!config.flags || !config.values) continue
// Skip options the user already specified explicitly
const wasExplicit = process.argv.some((arg) =>
Object.values(config.flags!).includes(arg)
)
if (wasExplicit) continue
const value = String(defaults[config.key])
const flag = config.flags[value]
const label = config.values[value]
if (!flag || !label) continue
// Show alternatives the user could pass instead
const alts: string[] = []
for (const [k, f] of Object.entries(config.flags)) {
if (k !== value && config.values[k]) {
alts.push(`${f} for ${config.values[k]}`)
}
}
const altText = alts.length > 0 ? ` (use ${alts.join(', ')})` : ''
lines.push(` ${flag.padEnd(24)}${label}${altText}`)
}
// Import alias is not a boolean toggle, handle separately
const hasImportAlias = process.argv.some(
(arg) =>
arg.startsWith('--import-alias') ||
arg.startsWith('--no-import-alias')
)
if (!hasImportAlias) {
lines.push(` ${'--import-alias'.padEnd(24)}"${defaults.importAlias}"`)
}
if (lines.length > 0) {
console.log(
'\nUsing defaults for unprovided options:\n\n' +
lines.join('\n') +
'\n'
)
}
}
}
const bundler: Bundler = opts.rspack ? Bundler.Rspack : Bundler.Turbopack
try {
await createApp({
appPath,
packageManager,
example: example && example !== 'default' ? example : undefined,
examplePath: opts.examplePath,
typescript: opts.typescript,
tailwind: opts.tailwind,
eslint: opts.eslint,
biome: opts.biome,
app: opts.app,
srcDir: opts.srcDir,
importAlias: opts.importAlias,
skipInstall: opts.skipInstall,
empty: opts.empty,
api: opts.api,
bundler,
disableGit: opts.disableGit,
reactCompiler: opts.reactCompiler,
agentsMd: opts.agentsMd,
})
} catch (reason) {
if (!(reason instanceof DownloadError)) {
throw reason
}
const res = await prompts({
onState: onPromptState,
type: 'confirm',
name: 'builtin',
message:
`Could not download "${example}" because of a connectivity issue between your machine and GitHub.\n` +
`Do you want to use the default template instead?`,
initial: true,
})
if (!res.builtin) {
throw reason
}
await createApp({
appPath,
packageManager,
typescript: opts.typescript,
eslint: opts.eslint,
biome: opts.biome,
tailwind: opts.tailwind,
app: opts.app,
srcDir: opts.srcDir,
importAlias: opts.importAlias,
skipInstall: opts.skipInstall,
empty: opts.empty,
bundler,
disableGit: opts.disableGit,
reactCompiler: opts.reactCompiler,
agentsMd: opts.agentsMd,
})
}
conf.set('preferences', preferences)
}
// Determine the appropriate dist-tag to check for updates.
// For prerelease versions like "16.1.1-canary.32", extract "canary" and check
// against that dist-tag. This ensures canary users are notified about newer
// canary releases, not incorrectly prompted to "update" to stable.
function getDistTag(version: string): string {
const prereleaseMatch = version.match(/-([a-z]+)/)
return prereleaseMatch ? prereleaseMatch[1] : 'latest'
}
const update = updateCheck(packageJson, {
distTag: getDistTag(packageJson.version),
}).catch(() => null)
async function notifyUpdate(): Promise<void> {
try {
if ((await update)?.latest) {
const global = {
npm: 'npm i -g',
yarn: 'yarn global add',
pnpm: 'pnpm add -g',
bun: 'bun add -g',
}
const distTag = getDistTag(packageJson.version)
const pkgTag = distTag === 'latest' ? '' : `@${distTag}`
const updateMessage = `${global[packageManager]} create-next-app${pkgTag}`
console.log(
yellow(bold('A new version of `create-next-app` is available!')) +
'\n' +
'You can update by running: ' +
cyan(updateMessage) +
'\n'
)
}
process.exit(0)
} catch {
// ignore error
}
}
async function exit(reason: { command?: string }) {
console.log()
console.log('Aborting installation.')
if (reason.command) {
console.log(` ${cyan(reason.command)} has failed.`)
} else {
console.log(
red('Unexpected error. Please report it as a bug:') + '\n',
reason
)
}
console.log()
await notifyUpdate()
process.exit(1)
}
run().then(notifyUpdate).catch(exit)