Skip to content
Open
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
63 changes: 63 additions & 0 deletions packages/create-nuxt/test/init.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,69 @@ 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 (no TTY)', () => {
it('shows help and exits with code 2 when required arguments are missing', { timeout: isWindows ? 200000 : 50000 }, async () => {
const result = await x(createNuxt, ['--preferOffline'], {
throwOnError: false,
nodeOptions: { stdio: 'pipe', cwd: fixtureDir },
})

const output = result.stdout + result.stderr

expect(result.exitCode).toBe(2)
// citty help output
expect(output).toContain('USAGE')
// which arguments are required
expect(output).toContain('Missing required arguments')
expect(output).toContain('--template')
expect(output).toContain('--packageManager')
expect(output).toContain('--gitInit')
// list of available templates since one must be picked
expect(output).toContain('minimal')
})

it('creates a project without prompting when all required arguments are provided', { timeout: isWindows ? 200000 : 50000 }, async () => {
const installPath = join(tmpdir(), 'create-nuxt-non-interactive-test')

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

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

it('fails fast when the target directory already exists', { timeout: isWindows ? 200000 : 50000 }, async () => {
const installPath = join(tmpdir(), 'create-nuxt-existing-dir-test')

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

const result = await x(createNuxt, args, {
throwOnError: false,
nodeOptions: { stdio: 'pipe', cwd: fixtureDir },
})

expect(result.exitCode).not.toBe(0)
expect(result.stdout + result.stderr).toContain('--force')
}
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 Down
41 changes: 38 additions & 3 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 { defineCommand } from 'citty'
import { defineCommand, showUsage } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
import { installDependencies } from 'nypm'
Expand Down Expand Up @@ -45,6 +45,10 @@ const pms: Record<PackageManagerName, undefined> = {
// this is for type safety to prompt updating code in nuxi when nypm adds a new package manager
const packageManagerOptions = Object.keys(pms) as PackageManagerName[]

// Arguments that would otherwise be gathered through interactive prompts,
// so they must be explicitly provided when no TTY is available
const nonInteractiveRequiredArgs = ['dir', 'template', 'packageManager', 'gitInit'] as const
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default defineCommand({
meta: {
name: 'init',
Expand Down Expand Up @@ -139,6 +143,32 @@ export default defineCommand({
}
}

// When no interactive terminal is available (e.g. agents, CI, piped input),
// all arguments normally gathered through prompts must be provided up front.
// Otherwise, show the help so the command can be re-run with proper arguments.
const isNonInteractive = !hasTTY
if (isNonInteractive) {
const missingArgs = nonInteractiveRequiredArgs.filter((name) => {
if (name === 'packageManager') {
return !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)
}
return ctx.args[name] === undefined || ctx.args[name] === ''
})

if (missingArgs.length > 0) {
await showUsage(ctx.cmd)
if (!ctx.args.template) {
logger.info(`Available templates:\n${Object.entries(availableTemplates)
.map(([name, data]) => ` ${colors.cyan(name)}${data ? ` – ${data.description}` : ''}`)
.join('\n')}`)
}
logger.error(`Non-interactive terminal detected. Missing required arguments: ${missingArgs
.map(name => colors.cyan(name === 'dir' ? '<dir>' : `--${name}`))
.join(', ')}`)
process.exit(2)
}
}

let templateName = ctx.args.template
if (!templateName) {
const result = await select({
Expand Down Expand Up @@ -196,6 +226,11 @@ export default defineCommand({
// when no `--force` flag is provided
const shouldVerify = !shouldForce && existsSync(templateDownloadPath)
if (shouldVerify) {
if (isNonInteractive) {
logger.error(`The directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. Pass ${colors.cyan('--force')} to override it or choose 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 @@ -431,8 +466,8 @@ export default defineCommand({
}
}

// ...or offer to browse and install modules (if not offline)
else if (!ctx.args.offline && !ctx.args.preferOffline) {
// ...or offer to browse and install modules (if not offline nor non-interactive)
else if (!ctx.args.offline && !ctx.args.preferOffline && !isNonInteractive) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to browse and install modules?`,
Expand Down
Loading