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
14 changes: 12 additions & 2 deletions packages/create-nuxt/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { provider } from 'std-env'
import init from '../../nuxt-cli/src/commands/init'
import { setupInitCompletions } from '../../nuxt-cli/src/completions-init'
import { checkEngines } from '../../nuxt-cli/src/utils/engines'
import { logger } from '../../nuxt-cli/src/utils/logger'
import { getCreateCommand, isPinnedCreateInvocation } from '../../nuxt-cli/src/utils/headless'
import { debug, logger } from '../../nuxt-cli/src/utils/logger'
import { scheduleSelfUpdateNudge } from '../../nuxt-cli/src/utils/update-check'
import { description, name, version } from '../package.json'

const _main = defineCommand({
Expand All @@ -22,9 +24,17 @@ const _main = defineCommand({
return
}

// Check Node.js version and CLI updates in background
if (provider !== 'stackblitz') {
// The engine check is awaited so its warning cannot land in the middle of
// a prompt, but the update check is left running: it reaches the user
// through a `process.exit` handler, and a slow or unreachable registry
// must never hold up scaffolding.
await checkEngines().catch(err => logger.error(String(err)))
void scheduleSelfUpdateNudge(name, version, {
name,
command: getCreateCommand(),
shouldNudge: () => !isPinnedCreateInvocation(),
}).catch(err => debug('Failed to check for updates:', err))
}

await init.run?.(ctx)
Expand Down
127 changes: 68 additions & 59 deletions packages/nuxt-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import process from 'node:process'
import { Writable } from 'node:stream'

import { box, cancel, confirm, intro, isCancel, outro, S_BAR, select, spinner, text } from '@clack/prompts'
import { cancel, confirm, intro, isCancel, outro, S_BAR, select, spinner, text } from '@clack/prompts'
import { defineCommand, showUsage } from 'citty'
import { downloadTemplate, startShell } from 'giget'
import { detectPackageManager } from 'nypm'
Expand All @@ -22,6 +21,7 @@ import { x } from 'tinyexec'
import { runCommandDef as runCommand } from '../run-command'
import { nuxtIcon, themeColor } from '../utils/ascii'
import { fetchJson } from '../utils/fetch'
import { formatHeadlessCommand } from '../utils/headless'
import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../utils/install'
import { debug, logger } from '../utils/logger'
import { classifyNetworkError, describeNetworkError, logNetworkError, probeNetworkError } from '../utils/network'
Expand All @@ -34,7 +34,6 @@ import { checkNuxtCompatibility, fetchModules, MODULES_API_URL } from './module/
import addModuleCommand from './module/add'

const NON_WORD_RE = /[^\w-]/g
const TRAILING_NEWLINE_RE = /\n$/
const MULTI_DASH_RE = /-{2,}/g
const LEADING_TRAILING_DASH_RE = /^-|-$/g

Expand Down Expand Up @@ -108,7 +107,7 @@ export async function useYarnNodeModulesLinker(dir: string): Promise<boolean> {
}

/**
* Commands to print in the closing 'Next steps' box, in the order to run them.
* Commands to print in the closing 'Next steps' section, in the order to run them.
*
* `dir` is relative to the current working directory, so `.` means the project
* was created in place and there is nowhere to `cd` to.
Expand Down Expand Up @@ -204,6 +203,9 @@ export default defineCommand({
process.exit(ARG_ERROR_EXIT_CODE)
}

// citty v0.2.0 with node:util.parseArgs returns the string 'false' for --install=false
const installRequested = ctx.args.install !== false && (ctx.args.install as unknown) !== 'false'

if (!ctx.args.offline && !ctx.args.preferOffline && !ctx.args.template) {
getTemplates().catch(() => null)
}
Expand All @@ -216,6 +218,10 @@ export default defineCommand({

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

// Whether any of the project's shape came from a prompt. With every answer
// already given as an argument there is nothing to teach the user.
let prompted = false

if (!ctx.args.template || !ctx.args.dir) {
const defaultTemplates = await import('../data/templates').then(r => r.templates)
if (ctx.args.offline || ctx.args.preferOffline) {
Expand Down Expand Up @@ -287,6 +293,7 @@ export default defineCommand({
}

templateName = result
prompted = true
}

// Fallback to default if still not set
Expand All @@ -312,6 +319,7 @@ export default defineCommand({
}

dir = result
prompted = true
}

const cwd = resolve(ctx.args.cwd)
Expand Down Expand Up @@ -469,7 +477,7 @@ export default defineCommand({
let installFailure: InstallResult | undefined
let ignoredBuilds: string[] = []
// Commands the user still has to run before the project works, surfaced in
// the closing "Next steps" box rather than as separate notes.
// the closing "Next steps" section rather than as separate notes.
const recoveryCommands: string[] = []

const currentPackageManager = detectCurrentPackageManager()
Expand Down Expand Up @@ -529,6 +537,7 @@ export default defineCommand({
}

selectedPackageManager = result
prompted = true
}

if (selectedPackageManager === 'yarn' && await useYarnNodeModulesLinker(template.dir)) {
Expand All @@ -548,12 +557,12 @@ export default defineCommand({
}

gitInit = result
prompted = true
}

// Install project dependencies and initialize git
// or skip installation based on the '--no-install' flag
// citty v0.2.0 with node:util.parseArgs returns 'false' string for --install=false
if (ctx.args.install === false || (ctx.args.install as unknown) === 'false' || skipInstallOnConflict) {
if (!installRequested || skipInstallOnConflict) {
if (!skipInstallOnConflict) {
logger.info('Skipping install dependencies step.')
}
Expand Down Expand Up @@ -626,24 +635,23 @@ export default defineCommand({
}

const modulesToAdd: string[] = []
// `ctx.args.modules` is `false` when --no-modules is used and `undefined`
// when the user has not decided either way.
const requestedModules = typeof ctx.args.modules === 'string'
? ctx.args.modules.split(',').map(segment => segment.trim()).filter(Boolean)
: []

// A project whose dependencies are missing cannot resolve modules, and
// adding them to `nuxt.config` anyway would leave it unable to boot.
if (installFailure) {
if (ctx.args.modules) {
logger.warn(`Skipping module installation. Add ${ctx.args.modules.split(',').map(m => colors.cyan(m.trim())).join(', ')} with ${colors.cyan('nuxt module add')} once dependencies are installed.`)
if (requestedModules.length) {
logger.warn(`Skipping module installation. Add ${requestedModules.map(mod => colors.cyan(mod)).join(', ')} with ${colors.cyan('nuxt module add')} once dependencies are installed.`)
}
}

// Get modules from arg (if provided)
else if (ctx.args.modules !== undefined) {
// ctx.args.modules is false when --no-modules is used
for (const segment of (ctx.args.modules || '').split(',')) {
const mod = segment.trim()
if (mod) {
modulesToAdd.push(mod)
}
}
modulesToAdd.push(...requestedModules)
}

// ...or offer to browse and install modules (if not offline nor non-interactive)
Expand All @@ -666,6 +674,8 @@ export default defineCommand({
process.exit(1)
}

prompted = true

if (wantsUserModules) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')
Expand Down Expand Up @@ -722,42 +732,61 @@ export default defineCommand({
const args: string[] = [
...modulesToAdd,
`--cwd=${templateDownloadPath}`,
ctx.args.install && !skipInstallOnConflict ? '' : '--skipInstall',
installRequested && !skipInstallOnConflict ? '' : '--skipInstall',
`--packageManager=${selectedPackageManager}`,
ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '',
].filter(Boolean)

await runCommand(addModuleCommand, args)
}

// Display next steps
if (installFailure) {
logger.warn(`Created your project from the ${colors.cyan(template.name)} template, but its dependencies are not installed.`)
}
else {
logger.step(`Created your project from the ${colors.cyan(template.name)} template`)
}

// The command carries no `--cwd`, so both it and the next steps are
// written to be run from the directory the user is already in.
const projectDir = relative(process.cwd(), template.dir) || '.'

if (hasTTY && prompted) {
const headlessCommand = formatHeadlessCommand({
// Two columns for the gutter clack puts in front of each line, and one
// of slack so a full line never wraps in the terminal itself.
width: Math.max((process.stdout.columns || 80) - 3, 40),
dir: projectDir,
template: templateName,
packageManager: selectedPackageManager,
gitInit: Boolean(gitInit),
install: installRequested,
force: shouldForce,
// `modulesToAdd` is empty when a failed install stopped us adding the
// modules that were asked for, which the command should still request.
modules: modulesToAdd.length ? modulesToAdd : requestedModules,
nightly: ctx.args.nightly,
})
Comment on lines +754 to +769

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Headless "scaffold again" command should always force-overwrite.

The printed reproduction command is meant to let the project be scaffolded again non-interactively (e.g. by an agent/script), but it will land in the same, now-existing directory. Passing force: shouldForce means the command omits --force in the common case (matching the PR's own example screenshot, which shows --force in an otherwise fresh scaffold). Without it, re-running the printed command against an existing directory hits the "already exists" prompt and, in a non-interactive context, exits with an error — defeating the stated goal of a headless, scriptable command.

🐛 Proposed fix
       const headlessCommand = formatHeadlessCommand({
         // Two columns for the gutter clack puts in front of each line, and one
         // of slack so a full line never wraps in the terminal itself.
         width: Math.max((process.stdout.columns || 80) - 3, 40),
         dir: projectDir,
         template: templateName,
         packageManager: selectedPackageManager,
         gitInit: Boolean(gitInit),
         install: installRequested,
-        force: shouldForce,
+        // The reproduction command targets a directory that already exists
+        // (the one just scaffolded), so it always needs to force-overwrite.
+        force: true,
         // `modulesToAdd` is empty when a failed install stopped us adding the
         // modules that were asked for, which the command should still request.
         modules: modulesToAdd.length ? modulesToAdd : requestedModules,
         nightly: ctx.args.nightly,
       })
📝 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 (hasTTY && prompted) {
const headlessCommand = formatHeadlessCommand({
// Two columns for the gutter clack puts in front of each line, and one
// of slack so a full line never wraps in the terminal itself.
width: Math.max((process.stdout.columns || 80) - 3, 40),
dir: projectDir,
template: templateName,
packageManager: selectedPackageManager,
gitInit: Boolean(gitInit),
install: installRequested,
force: shouldForce,
// `modulesToAdd` is empty when a failed install stopped us adding the
// modules that were asked for, which the command should still request.
modules: modulesToAdd.length ? modulesToAdd : requestedModules,
nightly: ctx.args.nightly,
})
if (hasTTY && prompted) {
const headlessCommand = formatHeadlessCommand({
// Two columns for the gutter clack puts in front of each line, and one
// of slack so a full line never wraps in the terminal itself.
width: Math.max((process.stdout.columns || 80) - 3, 40),
dir: projectDir,
template: templateName,
packageManager: selectedPackageManager,
gitInit: Boolean(gitInit),
install: installRequested,
// The reproduction command targets a directory that already exists
// (the one just scaffolded), so it always needs to force-overwrite.
force: true,
// `modulesToAdd` is empty when a failed install stopped us adding the
// modules that were asked for, which the command should still request.
modules: modulesToAdd.length ? modulesToAdd : requestedModules,
nightly: ctx.args.nightly,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/commands/init.ts` around lines 754 - 769, Update the
formatHeadlessCommand call in the hasTTY && prompted block so its force option
is always enabled, rather than inheriting shouldForce. Keep the generated
command’s other options unchanged and ensure it includes --force for reliable
non-interactive reruns in the existing project directory.

logger.info([
'to scaffold this project again without prompts:',
...headlessCommand.map(line => colors.dim(line)),
].join('\n'))
}

const nextSteps = getNextSteps({
dir: relative(process.cwd(), template.dir) || '.',
dir: projectDir,
shell: !!ctx.args.shell,
installFailure,
recoveryCommands,
packageManager: selectedPackageManager,
}).map(step => colors.cyan(step))

logger.message()
writeWithGuide(output => box(`\n${nextSteps.map(step => ` › ${step}`).join('\n')}\n`, ` 👉 Next steps `, {
contentAlign: 'left',
titleAlign: 'left',
width: 'auto',
titlePadding: 2,
contentPadding: 2,
rounded: true,
withGuide: false,
formatBorder: (text: string) => `${themeColor + text}\x1B[0m`,
output,
}))
})

if (installFailure) {
outro(`Created the project from the ${colors.cyan(template.name)} template, but its dependencies are not installed.`)
}
else {
outro(`✨ Nuxt project has been created with the ${colors.cyan(template.name)} template.`)
}
logger.message([
'Next steps:',
...nextSteps.map(step => `› ${colors.cyan(step)}`),
], { symbol: colors.gray(S_BAR) })

outro('✨ Happy building!')

if (installFailure) {
process.exitCode = 1
Expand Down Expand Up @@ -862,26 +891,6 @@ export async function detectTemplatePackageManager(templateDir: string): Promise
return { name: detected.name, version: detected.version }
}

/**
* Print a clack block behind the prompt gutter. `withGuide` draws the bar in the
* terminal's default colour rather than the grey clack uses everywhere else, so
* the block is rendered to a buffer and re-emitted with a matching gutter.
*/
function writeWithGuide(render: (output: Writable) => void) {
const chunks: string[] = []
render(new Writable({
write(chunk, _encoding, callback) {
chunks.push(String(chunk))
callback()
},
}))

const gutter = colors.gray(S_BAR)
for (const line of chunks.join('').replace(TRAILING_NEWLINE_RE, '').split('\n')) {
process.stdout.write(`${gutter} ${line}\n`)
}
}

function isVerbose(logLevel?: string) {
return logLevel === 'verbose' || Boolean(process.env.DEBUG)
}
Expand Down
28 changes: 15 additions & 13 deletions packages/nuxt-cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import { runCommand } from './run'
import { normaliseCwdArg } from './utils/args'
import { setupGlobalConsole } from './utils/console'
import { checkEngines } from './utils/engines'

import { logger } from './utils/logger'
import { getCreateCommand } from './utils/headless'
import { debug, logger } from './utils/logger'
import { setupProxySupport } from './utils/network'
import { resolveProjectDir } from './utils/paths'
import { templateNames } from './utils/templates/names'
import { scheduleUpdateNudge } from './utils/update'
import { scheduleSelfUpdateNudge } from './utils/update-check'

// Node.js only reads `NODE_USE_ENV_PROXY` during bootstrap, so this cannot make
// the current process proxy-aware; it propagates the setting to child processes
Expand All @@ -46,18 +48,18 @@ const _main = defineCommand({
const command = ctx.args._[0]
setupGlobalConsole({ dev: command === 'dev' })

// Check Node.js version and Nuxt updates in background
let backgroundTasks: Promise<any> | undefined
if (command !== '_dev' && provider !== 'stackblitz') {
backgroundTasks = Promise.all([
checkEngines(),
scheduleUpdateNudge(resolve(ctx.args.cwd), command),
]).catch(err => logger.error(String(err)))
}

// Avoid background check to fix prompt issues
if (command === 'init') {
await backgroundTasks
// The engine check is awaited so its warning cannot land in the middle of
// a prompt, but the update checks are left running: they reach the user
// through a `process.exit` handler, and a slow or unreachable registry
// must never hold up the command.
await checkEngines().catch(err => logger.error(String(err)))
void Promise.all([
scheduleUpdateNudge(resolveProjectDir(ctx.args), command),
...(command === 'init'
? [scheduleSelfUpdateNudge(name, version, { name, command: getCreateCommand() })]
: []),
]).catch(err => debug('Failed to check for updates:', err))
}

if (command === 'add' && ctx.rawArgs[1] && templateNames.includes(ctx.rawArgs[1] as TemplateName)) {
Expand Down
Loading
Loading