Skip to content
39 changes: 35 additions & 4 deletions packages/create-nuxt/test/init.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('../../../playground', import.meta.url))
const createNuxt = fileURLToPath(new URL('../bin/create-nuxt.mjs', import.meta.url))

describe('non-interactive mode', () => {
it('should exit with code 2 when no dir is provided and no TTY', { timeout: isWindows ? 200000 : 50000 }, async () => {
const result = await x(createNuxt, ['--yes', '--preferOffline'], {
throwOnError: false,
nodeOptions: { stdio: 'pipe', cwd: fixtureDir },
})

expect(result.exitCode).toBe(2)
expect(result.stdout).toContain('Non-interactive mode')
expect(result.stdout).toContain('create-nuxt <dir>')
})

it('should proceed without prompts when dir is provided with --yes', { timeout: isWindows ? 200000 : 50000 }, async () => {
const dir = tmpdir()
const installPath = join(dir, 'non-interactive-test')

await rm(installPath, { recursive: true, force: true })
try {
await x(createNuxt, [installPath, '--yes', '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'pipe', cwd: fixtureDir },
})

expect(existsSync(join(installPath, 'package.json'))).toBeTruthy()
}
finally {
await rm(installPath, { recursive: true, force: true })
}
})
})

describe('init command package name slugification', () => {
it('should slugify directory names with special characters', { timeout: isWindows ? 200000 : 50000 }, async () => {
const dir = tmpdir()
Expand All @@ -19,7 +50,7 @@ describe('init command package name slugification', () => {

await rm(installPath, { recursive: true, force: true })
try {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit', cwd: fixtureDir },
})
Expand Down Expand Up @@ -47,7 +78,7 @@ describe('init command package name slugification', () => {

await rm(installPath, { recursive: true, force: true })
try {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit', cwd: fixtureDir },
})
Expand All @@ -74,7 +105,7 @@ describe('init command package name slugification', () => {

await rm(installPath, { recursive: true, force: true })
try {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit', cwd: fixtureDir },
})
Expand All @@ -100,7 +131,7 @@ describe('init command package name slugification', () => {

await rm(installPath, { recursive: true, force: true })
try {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], {
await x(createNuxt, [installPath, '--packageManager=pnpm', '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit', cwd: fixtureDir },
})
Expand Down
199 changes: 127 additions & 72 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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'
Expand Down Expand Up @@ -104,6 +104,11 @@ export default defineCommand({
type: 'string',
description: 'Use Nuxt nightly release channel (3x or latest)',
},
yes: {
type: 'boolean',
alias: 'y',
description: 'Use default values for all prompts',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
description: 'Use default values for all prompts',
description: 'Use default values for and skip interactive prompts',

},
},
async run(ctx) {
if (!ctx.args.offline && !ctx.args.preferOffline && !ctx.args.template) {
Expand All @@ -116,6 +121,14 @@ export default defineCommand({

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

const isNonInteractive = ctx.args.yes === true || !hasTTY
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rather than defaulting immediately to --yes in non-TTY mode, if the option --yes isn't set, show the help so it can be invoked with the proper arguments.
Actually, it's a bit more complex, ideally the CLI should detect if it has all the information it needs to run without interactivity, if not, show the help and exit with non-zero (like 2)


if (isNonInteractive) {
logger.info('Running in non-interactive mode. Prompts will use defaults.')
}

const currentPackageManager = detectCurrentPackageManager()

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

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

if (isNonInteractive && !ctx.args.dir) {
const binName = basename(process.argv[1] || 'nuxi').replace(/\.[cm]?js$/, '') || 'nuxi'
// `create-nuxt` runs init as the root command, `nuxi`/`nuxt` run it as the `init` subcommand
const usesInitSubcommand = process.argv.slice(2).find(arg => !arg.startsWith('-')) === 'init'
const initCmd = usesInitSubcommand ? `${binName} init` : binName
const templateLines = Object.entries(availableTemplates).map(([name, data]) =>
` ${colors.cyan(name)} ${data?.description ?? ''}${name === DEFAULT_TEMPLATE_NAME ? colors.dim(' (default)') : ''}`,
)

note(
Comment thread
atinux marked this conversation as resolved.
[
'Available templates:',
'',
...templateLines,
'',
`Run ${colors.cyan(`${initCmd} --help`)} for all options.`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if it should just display the help before the list of templates. I think this could be simplified by merging this message with my suggestion above: if options are missing show the help and the list of templates because it's needed to provide a value

].join('\n'),
'Non-interactive mode',
)
Comment thread
atinux marked this conversation as resolved.

outro(`Provide a project directory to proceed, e.g: ${initCmd} <dir>`)
process.exit(2)
}

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,
})

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

templateName = result
}
}

// Fallback to default if still not set
Expand Down Expand Up @@ -196,6 +238,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 +369,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 +381,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 @@ -347,25 +399,26 @@ export default defineCommand({
selectedPackageManager = result
}

// Determine if we should init git
let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit
let gitInit: boolean | undefined = 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
// 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') {
if (!ctx.args.install) {
logger.info('Skipping install dependencies step.')
}
else {
Expand Down Expand Up @@ -433,57 +486,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
4 changes: 2 additions & 2 deletions packages/nuxt-cli/test/e2e/commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe('commands', () => {
})

// Test that server responds
const response = await fetchWithPolling(`http://127.0.0.1:${port}`)
const response = await fetchWithPolling(`http://127.0.0.1:${port}`, {}, 30, 300)
expect.soft(response?.status).toBe(200)

previewProcess.kill()
Expand Down Expand Up @@ -121,7 +121,7 @@ describe('commands', () => {

await rm(installPath, { recursive: true, force: true })
try {
await x(nuxi, ['init', installPath, `--packageManager=${pm}`, '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], {
await x(nuxi, ['init', installPath, `--packageManager=${pm}`, '--template=minimal', '--no-gitInit', '--preferOffline', '--no-install'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit', cwd: fixtureDir },
})
Expand Down
Loading