-
Notifications
You must be signed in to change notification settings - Fork 111
feat(init): detect agent/CI environments and skip interactive prompts #1264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
110a396
9244180
0f5863c
fce0445
11bcdb1
23e27c9
4896282
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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: { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently |
||
| type: 'boolean', | ||
| default: true, | ||
| negativeDescription: 'Disable interactive prompts and use defaults', | ||
| }, | ||
| }, | ||
| async run(ctx) { | ||
| if (!ctx.args.offline && !ctx.args.preferOffline && !ctx.args.template) { | ||
|
|
@@ -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…) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this will also be positive if a user is using a shell from within a coding harness. I think it's better to gate this on whether the TTY is interactive or not, rather than whether an agent has been detected
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with this: |
||
| || 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) { | ||
|
|
@@ -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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you want to simplify this, you could just display the help, after that display the template list (I think that's the only value that requires a specific value that isn't already mentioned in the help) |
||
| [ | ||
| 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', | ||
| ) | ||
|
Comment on lines
+200
to
+226
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this will quickly become out of date - can we instead harness citty's own tools? additionally it's hard-coded to be a specific width ( |
||
|
|
||
| // 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be non-zero since the run didn't succeed, probably 2 because it's missing parts of the command but I don't think there is a standard on the actual value (other than being non-zero) |
||
| } | ||
| } | ||
|
|
||
| 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 | ||
|
|
@@ -196,6 +295,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: [ | ||
|
|
@@ -319,7 +426,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 => ({ | ||
|
|
@@ -332,6 +438,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?', | ||
|
|
@@ -350,16 +459,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 | ||
|
|
@@ -433,57 +547,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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should probably be named
yes, it's more conventional (cfnpm init -h)