Skip to content
Open
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
275 changes: 198 additions & 77 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import process from 'node:process'

import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts'
import { box, cancel, confirm, intro, isCancel, note, outro, select, spinner, tasks, text } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
import { installDependencies } from 'nypm'
import { $fetch } from 'ofetch'
import { basename, join, relative, resolve } from 'pathe'
import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types'
import { hasTTY } from 'std-env'
import { agent, hasTTY, isAgent, isCI } from 'std-env'
import { x } from 'tinyexec'

import { runCommand } from '../run'
Expand Down Expand Up @@ -104,6 +104,16 @@ export default defineCommand({
type: 'string',
description: 'Use Nuxt nightly release channel (3x or latest)',
},
defaults: {
type: 'boolean',
alias: 'y',
description: 'Use defaults for all prompts (useful for CI/agent environments)',
},
interactive: {
type: 'boolean',
default: true,
negativeDescription: 'Disable interactive prompts and use defaults',
},
},
async run(ctx) {
if (!ctx.args.offline && !ctx.args.preferOffline && !ctx.args.template) {
Expand All @@ -116,6 +126,28 @@ export default defineCommand({

intro(colors.bold(`Welcome to Nuxt!`.split('').map(m => `${themeColor}${m}`).join('')))

// Detect non-interactive environments: agent, CI, no TTY, or explicit flags
const isNonInteractive
= ctx.args.defaults === true
|| ctx.args.interactive === false
|| isAgent // AI coding agent (Claude, Cursor, Copilot, Devin, Gemini…)
|| isCI // CI/CD pipeline
|| !hasTTY // no terminal attached (piped, subprocess, etc.)

if (isNonInteractive) {
const reason = ctx.args.defaults || ctx.args.interactive === false
? 'flag'
: isAgent
? `agent (${agent})`
: isCI
? 'CI environment'
: 'no TTY'
logger.info(`Running in non-interactive mode (${reason}). Prompts will use defaults.`)
}

// Detect current package manager early so it can be shown in the options note
const currentPackageManager = detectCurrentPackageManager()

let availableTemplates: Record<string, TemplateData> = {}

if (!ctx.args.template || !ctx.args.dir) {
Expand All @@ -139,26 +171,93 @@ export default defineCommand({
}
}

// In non-interactive mode, print all available options and the exact values
// that will be used this run, so agents can discover flags and re-run with
// custom settings.
if (isNonInteractive) {
// Compute every effective value upfront so the agent sees the full picture
// before any action is taken.
const effectiveTemplate = ctx.args.template || DEFAULT_TEMPLATE_NAME
const effectiveDir = ctx.args.dir || availableTemplates[effectiveTemplate]?.defaultDir || 'nuxt-app'
const effectivePM: PackageManagerName
= (packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)
? ctx.args.packageManager as PackageManagerName
: undefined)
?? currentPackageManager
?? 'npm'
const effectiveGitInit = (ctx.args.gitInit as unknown) === 'false' ? false : (ctx.args.gitInit ?? false)
const effectiveInstall = ctx.args.install !== false && (ctx.args.install as unknown) !== 'false'
const effectiveModules = ctx.args.modules === undefined
? '(none)'
: !ctx.args.modules
? '(skipped)'
: ctx.args.modules as string

const templateLines = Object.entries(availableTemplates).map(([name, data]) =>
` ${colors.cyan(name.padEnd(18))} ${data?.description ?? ''}${name === DEFAULT_TEMPLATE_NAME ? colors.dim(' ← default') : ''}`,
)

note(
[
colors.bold('Re-run with any of these flags to customise:'),
'',
`${colors.cyan('--template')} <name>`,
...templateLines,
'',
`${colors.cyan('<dir>')} Project directory`,
`${colors.cyan('--packageManager')} <pm> npm | pnpm | yarn | bun | deno`,
`${colors.cyan('--gitInit')} / ${colors.cyan('--no-gitInit')} Initialise git repo`,
`${colors.cyan('--install')} / ${colors.cyan('--no-install')} Install dependencies`,
`${colors.cyan('--modules')} <m1,m2,...> e.g. ${colors.dim('@nuxt/content,@nuxt/ui,@nuxt/image')}`,
`${colors.dim(' Full list: https://nuxt.com/modules')}`,
`${colors.cyan('--no-modules')} Skip module prompt`,
`${colors.cyan('--force')} Override existing directory`,
`${colors.cyan('--offline')} Use cached templates`,
'',
colors.bold('Proceeding with:'),
` template: ${colors.cyan(effectiveTemplate)}`,
` directory: ${colors.cyan(effectiveDir)}`,
` packageManager: ${colors.cyan(effectivePM)}`,
` gitInit: ${colors.cyan(String(effectiveGitInit))}`,
` install: ${colors.cyan(String(effectiveInstall))}`,
` modules: ${colors.cyan(effectiveModules)}`,
].join('\n'),
'Available options',
)

// No project directory was given — the agent now has all the information
// it needs to re-run with explicit flags. Exit without creating anything.
if (!ctx.args.dir) {
outro('Re-run with a project directory to proceed, e.g: nuxi init <dir>')
process.exit(0)
}
}

let templateName = ctx.args.template
if (!templateName) {
const result = await select({
message: 'Which template would you like to use?',
options: Object.entries(availableTemplates).map(([name, data]) => {
return {
value: name,
label: data ? `${colors.whiteBright(name)} – ${data.description}` : name,
hint: name === DEFAULT_TEMPLATE_NAME ? 'recommended' : undefined,
}
}),
initialValue: DEFAULT_TEMPLATE_NAME,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
if (isNonInteractive) {
templateName = DEFAULT_TEMPLATE_NAME
}
else {
const result = await select({
message: 'Which template would you like to use?',
options: Object.entries(availableTemplates).map(([name, data]) => {
return {
value: name,
label: data ? `${colors.whiteBright(name)} – ${data.description}` : name,
hint: name === DEFAULT_TEMPLATE_NAME ? 'recommended' : undefined,
}
}),
initialValue: DEFAULT_TEMPLATE_NAME,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}

templateName = result
templateName = result
}
}

// Fallback to default if still not set
Expand All @@ -172,18 +271,23 @@ export default defineCommand({
let dir = ctx.args.dir
if (dir === '') {
const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app'
const result = await text({
message: 'Where would you like to create your project?',
placeholder: `./${defaultDir}`,
defaultValue: defaultDir,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
if (isNonInteractive) {
dir = defaultDir
}
Comment on lines +274 to 276
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unreachable code: this branch can never execute.

In non-interactive mode, if ctx.args.dir is empty (the '' default), the code exits early at lines 230-233. Therefore, the isNonInteractive branch here for dir === '' is dead code since we'd never reach this point with an empty dir in non-interactive mode.

This doesn't break anything but adds confusion. Consider removing this branch or adjusting the early exit logic if you want non-interactive mode to auto-select a directory.

🧹 Proposed fix: Remove unreachable branch
     let dir = ctx.args.dir
     if (dir === '') {
       const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app'
-      if (isNonInteractive) {
-        dir = defaultDir
-      }
-      else {
-        const result = await text({
-          message: 'Where would you like to create your project?',
-          placeholder: `./${defaultDir}`,
-          defaultValue: defaultDir,
-        })
-
-        if (isCancel(result)) {
-          cancel('Operation cancelled.')
-          process.exit(1)
-        }
-
-        dir = result
-      }
+      // In non-interactive mode, we exit early at line 230-233 if no dir provided
+      const result = await text({
+        message: 'Where would you like to create your project?',
+        placeholder: `./${defaultDir}`,
+        defaultValue: defaultDir,
+      })
+
+      if (isCancel(result)) {
+        cancel('Operation cancelled.')
+        process.exit(1)
+      }
+
+      dir = result
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isNonInteractive) {
dir = defaultDir
}
let dir = ctx.args.dir
if (dir === '') {
const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app'
// In non-interactive mode, we exit early at line 230-233 if no dir provided
const result = await text({
message: 'Where would you like to create your project?',
placeholder: `./${defaultDir}`,
defaultValue: defaultDir,
})
if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}
dir = result
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/init.ts` around lines 274 - 276, The if-branch
that sets dir = defaultDir when isNonInteractive is unreachable and should be
removed; locate the conditional using isNonInteractive and dir (the block "if
(isNonInteractive) { dir = defaultDir }") in the init command and delete that
branch so the code flow relies on the existing early-exit behavior around
ctx.args.dir, or if you intend non-interactive to auto-select a directory
instead, adjust the earlier early-exit logic that checks ctx.args.dir rather
than keeping this unreachable fallback.

else {
const result = await text({
message: 'Where would you like to create your project?',
placeholder: `./${defaultDir}`,
defaultValue: defaultDir,
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}

dir = result
dir = result
}
}

const cwd = resolve(ctx.args.cwd)
Expand All @@ -196,6 +300,14 @@ export default defineCommand({
// when no `--force` flag is provided
const shouldVerify = !shouldForce && existsSync(templateDownloadPath)
if (shouldVerify) {
if (isNonInteractive) {
logger.error(
`Directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. `
+ `Pass ${colors.cyan('--force')} to override or specify a different directory.`,
)
process.exit(1)
}

const selectedAction = await select({
message: `The directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. What would you like to do?`,
options: [
Expand Down Expand Up @@ -319,7 +431,6 @@ export default defineCommand({
nightlySpinner.stop(`Updated to nightly version ${colors.cyan(nightlyChannelVersion)}`)
}

const currentPackageManager = detectCurrentPackageManager()
// Resolve package manager
const packageManagerArg = ctx.args.packageManager as PackageManagerName
const packageManagerSelectOptions = packageManagerOptions.map(pm => ({
Expand All @@ -332,6 +443,9 @@ export default defineCommand({
if (packageManagerOptions.includes(packageManagerArg)) {
selectedPackageManager = packageManagerArg
}
else if (isNonInteractive) {
selectedPackageManager = currentPackageManager ?? 'npm'
}
else {
const result = await select({
message: 'Which package manager would you like to use?',
Expand All @@ -350,16 +464,21 @@ export default defineCommand({
// Determine if we should init git
let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit
if (gitInit === undefined) {
const result = await confirm({
message: 'Initialize git repository?',
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
if (isNonInteractive) {
gitInit = false
}
else {
const result = await confirm({
message: 'Initialize git repository?',
})

if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(1)
}

gitInit = result
gitInit = result
}
}

// Install project dependencies and initialize git
Expand Down Expand Up @@ -433,57 +552,59 @@ export default defineCommand({

// ...or offer to browse and install modules (if not offline)
else if (!ctx.args.offline && !ctx.args.preferOffline) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to browse and install modules?`,
initialValue: false,
})
if (!isNonInteractive) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to browse and install modules?`,
initialValue: false,
})

if (isCancel(wantsUserModules)) {
cancel('Operation cancelled.')
process.exit(1)
}
if (isCancel(wantsUserModules)) {
cancel('Operation cancelled.')
process.exit(1)
}

if (wantsUserModules) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')
if (wantsUserModules) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')

const [response, templateDeps, nuxtVersion] = await Promise.all([
modulesPromise,
getTemplateDependencies(template.dir),
getNuxtVersion(template.dir),
])
const [response, templateDeps, nuxtVersion] = await Promise.all([
modulesPromise,
getTemplateDependencies(template.dir),
getNuxtVersion(template.dir),
])

modulesSpinner.stop('Modules loaded')
modulesSpinner.stop('Modules loaded')

const allModules = response
.filter(module =>
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)
const allModules = response
.filter(module =>
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)

if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const result = await selectModulesAutocomplete({ modules: allModules })
if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const result = await selectModulesAutocomplete({ modules: allModules })

if (result.selected.length > 0) {
const modules = result.selected
if (result.selected.length > 0) {
const modules = result.selected

const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
[module, await getModuleDependencies(module)] as const,
)),
)
const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
[module, await getModuleDependencies(module)] as const,
)),
)

const { toInstall, skipped } = filterModules(modules, allDependencies)
const { toInstall, skipped } = filterModules(modules, allDependencies)

if (skipped.length) {
logger.info(`The following modules are already included as dependencies of another module and will not be installed: ${skipped.map(m => colors.cyan(m)).join(', ')}`)
if (skipped.length) {
logger.info(`The following modules are already included as dependencies of another module and will not be installed: ${skipped.map(m => colors.cyan(m)).join(', ')}`)
}
modulesToAdd.push(...toInstall)
}
modulesToAdd.push(...toInstall)
}
}
}
Expand Down
Loading