From 034861f7f78dcfa395184315f27edc0ca50d377e Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 12 Jun 2026 10:33:30 +0200 Subject: [PATCH] feat(init): show help and exit when missing required args on non-interactive tty --- packages/create-nuxt/test/init.spec.ts | 63 ++++++++++++++++++++++++++ packages/nuxi/src/commands/init.ts | 41 +++++++++++++++-- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/create-nuxt/test/init.spec.ts b/packages/create-nuxt/test/init.spec.ts index ff931946e..12a9a098a 100644 --- a/packages/create-nuxt/test/init.spec.ts +++ b/packages/create-nuxt/test/init.spec.ts @@ -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() diff --git a/packages/nuxi/src/commands/init.ts b/packages/nuxi/src/commands/init.ts index a38e77e60..fe492db9e 100644 --- a/packages/nuxi/src/commands/init.ts +++ b/packages/nuxi/src/commands/init.ts @@ -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' @@ -45,6 +45,10 @@ const pms: Record = { // 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 + export default defineCommand({ meta: { name: 'init', @@ -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' ? '' : `--${name}`)) + .join(', ')}`) + process.exit(2) + } + } + let templateName = ctx.args.template if (!templateName) { const result = await select({ @@ -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: [ @@ -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?`,