From 6fc043c45e4c5f1aeabac483ada733d457cd76fa Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 1 Aug 2026 23:45:15 +0100 Subject: [PATCH] fix(init)!: drop `init` command from `@nuxt/cli` --- knip.json | 5 - packages/create-nuxt/package.json | 7 + .../src/completions.ts} | 2 +- packages/create-nuxt/src/init.ts | 907 ++++++++++++++++++ packages/create-nuxt/src/main.ts | 4 +- packages/create-nuxt/src/run.ts | 2 +- .../create-nuxt/test/{ => e2e}/init.spec.ts | 4 +- .../test/unit}/init.spec.ts | 2 +- .../test/unit/network-failures.spec.ts | 83 ++ packages/nuxi/src/launcher.ts | 8 +- packages/nuxi/src/main.ts | 2 +- packages/nuxt-cli/package.json | 2 +- packages/nuxt-cli/src/commands/init.ts | 907 +----------------- packages/nuxt-cli/src/completions.ts | 15 +- packages/nuxt-cli/src/main.ts | 11 +- packages/nuxt-cli/src/utils/update-lazy.ts | 10 - packages/nuxt-cli/test/e2e/commands.spec.ts | 24 +- .../unit/commands/network-failures.spec.ts | 23 - packages/nuxt-cli/test/unit/help.spec.ts | 27 +- pnpm-lock.yaml | 27 +- 20 files changed, 1060 insertions(+), 1012 deletions(-) rename packages/{nuxt-cli/src/completions-init.ts => create-nuxt/src/completions.ts} (92%) create mode 100644 packages/create-nuxt/src/init.ts rename packages/create-nuxt/test/{ => e2e}/init.spec.ts (98%) rename packages/{nuxt-cli/test/unit/commands => create-nuxt/test/unit}/init.spec.ts (98%) create mode 100644 packages/create-nuxt/test/unit/network-failures.spec.ts diff --git a/knip.json b/knip.json index 091908c34..252e416f7 100644 --- a/knip.json +++ b/knip.json @@ -32,11 +32,6 @@ "test/fixtures/*" ] }, - "packages/create-nuxt": { - "ignoreDependencies": [ - "@bomb.sh/tab" - ] - }, "packages/nuxi": { "ignoreDependencies": [ "jiti" diff --git a/packages/create-nuxt/package.json b/packages/create-nuxt/package.json index d65b47e7c..51041a584 100644 --- a/packages/create-nuxt/package.json +++ b/packages/create-nuxt/package.json @@ -31,8 +31,15 @@ }, "devDependencies": { "@bomb.sh/tab": "^0.0.21", + "@clack/prompts": "^1.7.0", "@types/node": "^24.13.3", "citty": "^0.2.2", + "giget": "^3.3.1", + "nypm": "^0.6.8", + "pathe": "^2.0.3", + "pkg-types": "^2.3.1", + "std-env": "^4.2.0", + "tinyexec": "^1.2.4", "tsdown": "^0.22.14", "typescript": "^6.0.3" } diff --git a/packages/nuxt-cli/src/completions-init.ts b/packages/create-nuxt/src/completions.ts similarity index 92% rename from packages/nuxt-cli/src/completions-init.ts rename to packages/create-nuxt/src/completions.ts index a368d4158..3ba785e8f 100644 --- a/packages/nuxt-cli/src/completions-init.ts +++ b/packages/create-nuxt/src/completions.ts @@ -1,6 +1,6 @@ import type { ArgsDef, CommandDef } from 'citty' import tab from '@bomb.sh/tab/citty' -import { templates } from './data/templates' +import { templates } from '../../nuxt-cli/src/data/templates' export async function setupInitCompletions(command: CommandDef) { const completion = await tab(command) diff --git a/packages/create-nuxt/src/init.ts b/packages/create-nuxt/src/init.ts new file mode 100644 index 000000000..a1b1978a0 --- /dev/null +++ b/packages/create-nuxt/src/init.ts @@ -0,0 +1,907 @@ +import type { ArgsDef, CommandDef } from 'citty' +import type { DownloadTemplateResult } from 'giget' +import type { PackageManagerName } from 'nypm' +import type { InstallResult } from '../../nuxt-cli/src/utils/install' +import type { TemplateData } from '../../nuxt-cli/src/utils/starter-templates' + +import { existsSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import process from 'node:process' + +import { styleText } from 'node:util' +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' +import { basename, join, relative, resolve } from 'pathe' +import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types' +import { hasTTY } from 'std-env' +import { x } from 'tinyexec' + +import { cwdArgs, logLevelArgs } from '../../nuxt-cli/src/commands/_shared' +import { selectModulesAutocomplete } from '../../nuxt-cli/src/commands/module/_autocomplete' +import { checkNuxtCompatibility, fetchModules, MODULES_API_URL } from '../../nuxt-cli/src/commands/module/_utils' +import addModuleCommand from '../../nuxt-cli/src/commands/module/add' +import { runCommandDef as runCommand } from '../../nuxt-cli/src/run-command' +import { nuxtIcon, themeColor } from '../../nuxt-cli/src/utils/ascii' +import { fetchJson } from '../../nuxt-cli/src/utils/fetch' +import { formatHeadlessCommand } from '../../nuxt-cli/src/utils/headless' +import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../../nuxt-cli/src/utils/install' +import { debug, logger } from '../../nuxt-cli/src/utils/logger' +import { classifyNetworkError, describeNetworkError, logNetworkError, probeNetworkError } from '../../nuxt-cli/src/utils/network' +import { relativeToProcess } from '../../nuxt-cli/src/utils/paths' +import { getTemplates, TEMPLATES_API_URL } from '../../nuxt-cli/src/utils/starter-templates' +import { getNuxtVersion } from '../../nuxt-cli/src/utils/versions' + +const NON_WORD_RE = /[^\w-]/g +const MULTI_DASH_RE = /-{2,}/g +const LEADING_TRAILING_DASH_RE = /^-|-$/g + +const DEFAULT_REGISTRY = 'https://raw.githubusercontent.com/nuxt/starter/templates/templates' +const DEFAULT_TEMPLATE_NAME = 'minimal' +const NIGHTLY_DIST_TAGS_URL = 'https://registry.npmjs.org/nuxt-nightly' + +const pms: Record = { + npm: undefined, + pnpm: undefined, + yarn: undefined, + bun: undefined, + deno: undefined, + aube: undefined, + nub: 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 + +// Exit code citty uses for argument errors; reuse it for every missing/invalid +// argument so the contract stays consistent regardless of where we detect it. +const ARG_ERROR_EXIT_CODE = 2 + +/** + * Report missing arguments in non-interactive mode. Centralises the message so + * the upfront check and the post-detection package-manager check stay in sync. + * Pass `availableTemplates` to also list the templates the user can choose from + * (when `--template` is missing). Callers are expected to `process.exit` after. + */ +async function reportMissingNonInteractiveArgs( + cmd: CommandDef, + missingArgs: string[], + availableTemplates?: Record, +): Promise { + await showUsage(cmd) + if (availableTemplates) { + logger.info(`Available templates:\n${Object.entries(availableTemplates) + .map(([name, data]) => ` ${styleText('cyan', name)}${data ? ` – ${data.description}` : ''}`) + .join('\n')}`) + } + const label = missingArgs.length === 1 ? 'argument' : 'arguments' + logger.error(`Non-interactive terminal detected. Missing required ${label}: ${missingArgs + .map(name => styleText('cyan', name === 'dir' ? '' : `--${name}`)) + .join(', ')}`) +} + +const YARN_NODE_LINKER = `# Nuxt cannot resolve its modules under Yarn's default Plug'n'Play linker. +# See https://github.com/nuxt/nuxt/issues/26750 +nodeLinker: node-modules +` + +/** + * Opt a scaffolded project out of Yarn's Plug'n'Play linker, under which + * `nuxt prepare` cannot resolve `@nuxt/kit` and the project will not build. + * + * Templates that ship any Yarn configuration of their own are left alone, so a + * template can still choose Plug'n'Play. Yarn 1 ignores `.yarnrc.yml`, so + * writing it there is harmless. + */ +export async function useYarnNodeModulesLinker(dir: string): Promise { + if (existsSync(join(dir, '.yarnrc.yml')) || existsSync(join(dir, '.yarnrc'))) { + return false + } + await writeFile(join(dir, '.yarnrc.yml'), YARN_NODE_LINKER, 'utf8') + return true +} + +/** + * 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. + */ +export function getNextSteps(options: { + dir: string + shell: boolean + installFailure?: unknown + recoveryCommands: string[] + packageManager: PackageManagerName +}): string[] { + const { dir, shell, installFailure, recoveryCommands, packageManager } = options + const runCmd = packageManager === 'deno' ? 'task' : 'run' + return [ + !shell && dir !== '.' && `cd ${dir}`, + installFailure && `${packageManager} install`, + ...recoveryCommands, + `${packageManager} ${runCmd} dev`, + ].filter((step): step is string => typeof step === 'string') +} + +export default defineCommand({ + meta: { + name: 'init', + description: 'Initialize a fresh project', + }, + args: { + ...cwdArgs, + cwd: { + ...cwdArgs.cwd, + description: 'Specify the directory to create the project in', + }, + ...logLevelArgs, + dir: { + type: 'positional', + description: 'Project directory', + default: '', + }, + template: { + type: 'string', + alias: 't', + description: 'Template name', + }, + force: { + type: 'boolean', + alias: 'f', + description: 'Override existing directory', + }, + offline: { + type: 'boolean', + description: 'Force offline mode', + }, + preferOffline: { + type: 'boolean', + description: 'Prefer offline mode', + }, + install: { + type: 'boolean', + default: true, + description: 'Skip installing dependencies', + }, + gitInit: { + type: 'boolean', + description: 'Initialize git repository', + }, + shell: { + type: 'boolean', + description: 'Start shell after installation in project directory', + }, + packageManager: { + type: 'string', + description: `Package manager choice (${packageManagerOptions.join(', ')})`, + }, + modules: { + type: 'string', + required: false, + description: 'Nuxt modules to install (comma separated without spaces)', + negativeDescription: 'Skip module installation prompt', + alias: 'M', + }, + nightly: { + type: 'string', + description: 'Use Nuxt nightly release channel (3x or latest)', + }, + }, + async run(ctx) { + // Validate an explicitly provided `--packageManager` up front (before any + // banner or network work) so a typo fails fast with a clear message instead + // of being silently ignored once a template's own package manager is + // detected. + if (ctx.args.packageManager && !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)) { + logger.error(`Invalid package manager: ${styleText('cyan', ctx.args.packageManager)}. Choose one of ${packageManagerOptions.map(pm => styleText('cyan', pm)).join(', ')}.`) + 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) + } + + if (hasTTY) { + process.stdout.write(`\n${nuxtIcon}\n\n`) + } + + intro(styleText('bold', `Welcome to Nuxt!`.split('').map(m => `${themeColor}${m}`).join(''))) + + let availableTemplates: Record = {} + + // 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('../../nuxt-cli/src/data/templates').then(r => r.templates) + if (ctx.args.offline || ctx.args.preferOffline) { + // In offline mode, use static templates directly + availableTemplates = defaultTemplates + } + else { + const templatesSpinner = spinner() + templatesSpinner.start('Loading available templates') + + try { + availableTemplates = await getTemplates() + templatesSpinner.stop('Templates loaded') + } + catch (err) { + availableTemplates = defaultTemplates + debug(describeNetworkError(err, TEMPLATES_API_URL)) + templatesSpinner.stop('Templates loaded from cache') + } + } + } + + // 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') { + // The package manager can be inferred from a template that pins one, + // so only require it upfront when no template is given (nothing to + // infer from yet). Otherwise it's validated after the template is + // downloaded and its package manager resolved. + if (ctx.args.template) { + return false + } + return !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName) + } + return ctx.args[name] === undefined || ctx.args[name] === '' + }) + + if (missingArgs.length > 0) { + await reportMissingNonInteractiveArgs( + ctx.cmd, + [...missingArgs], + ctx.args.template ? undefined : availableTemplates, + ) + process.exit(ARG_ERROR_EXIT_CODE) + } + } + + 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 ? `${styleText('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 + prompted = true + } + + // Fallback to default if still not set + templateName ||= DEFAULT_TEMPLATE_NAME + + if (typeof templateName !== 'string') { + logger.error('Please specify a template!') + process.exit(1) + } + + let dir = ctx.args.dir + if (dir === '') { + const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app' + const result = await text({ + message: 'Where would you like to create your project?', + placeholder: `./${defaultDir}`, + defaultValue: defaultDir, + }) + + if (isCancel(result)) { + cancel('Operation cancelled.') + process.exit(1) + } + + dir = result + prompted = true + } + + const cwd = resolve(ctx.args.cwd) + let templateDownloadPath = resolve(cwd, dir) + logger.step(`Creating project in ${styleText('cyan', relativeToProcess(templateDownloadPath))}`) + + let shouldForce = Boolean(ctx.args.force) + + // Prompt the user if the template download directory already exists + // when no `--force` flag is provided + const shouldVerify = !shouldForce && existsSync(templateDownloadPath) + if (shouldVerify) { + if (isNonInteractive) { + logger.error(`The directory ${styleText('cyan', relativeToProcess(templateDownloadPath))} already exists. Pass ${styleText('cyan', '--force')} to override it or choose a different directory.`) + process.exit(1) + } + + const selectedAction = await select({ + message: `The directory ${styleText('cyan', relativeToProcess(templateDownloadPath))} already exists. What would you like to do?`, + options: [ + { value: 'override', label: 'Override its contents' }, + { value: 'different', label: 'Select different directory' }, + { value: 'abort', label: 'Abort' }, + ], + }) + + if (isCancel(selectedAction)) { + cancel('Operation cancelled.') + process.exit(1) + } + + switch (selectedAction) { + case 'override': + shouldForce = true + break + + case 'different': { + const result = await text({ + message: 'Please specify a different directory:', + }) + + if (isCancel(result)) { + cancel('Operation cancelled.') + process.exit(1) + } + + templateDownloadPath = resolve(cwd, result) + break + } + + // 'Abort' + case 'abort': + default: + process.exit(1) + } + } + + // Download template + let template: DownloadTemplateResult + + const registry = process.env.NUXI_INIT_REGISTRY || DEFAULT_REGISTRY + + const downloadSpinner = spinner() + downloadSpinner.start(`Downloading ${styleText('cyan', templateName)} template`) + + try { + template = await downloadTemplate(templateName, { + dir: templateDownloadPath, + force: shouldForce, + offline: Boolean(ctx.args.offline), + preferOffline: Boolean(ctx.args.preferOffline), + registry, + }) + + if (dir.length > 0) { + const path = await findFile('package.json', { + startingFrom: join(templateDownloadPath, 'package.json'), + reverse: true, + }) + if (path) { + const pkg = await readPackageJSON(path, { try: true }) + if (pkg && pkg.name) { + const slug = basename(templateDownloadPath) + .replace(NON_WORD_RE, '-') + .replace(MULTI_DASH_RE, '-') + .replace(LEADING_TRAILING_DASH_RE, '') + if (slug) { + pkg.name = slug + await writePackageJSON(path, pkg) + } + } + } + } + + downloadSpinner.stop(`Downloaded ${styleText('cyan', template.name)} template`) + } + catch (err) { + downloadSpinner.error('Template download failed') + if (process.env.DEBUG) { + throw err + } + const diagnosable = classifyNetworkError(err).kind === 'unknown' + ? await probeNetworkError(registry) ?? err + : err + logNetworkError(diagnosable, { + url: registry, + hints: [ + `Retry with ${styleText('cyan', '--offline')} or ${styleText('cyan', '--preferOffline')} to use a cached template, or set ${styleText('cyan', 'NUXI_INIT_REGISTRY')} to a reachable mirror.`, + ], + }) + process.exit(1) + } + + if (ctx.args.nightly !== undefined && !ctx.args.offline && !ctx.args.preferOffline) { + const nightlySpinner = spinner() + nightlySpinner.start('Fetching nightly version info') + + const response = await fetchJson<{ 'dist-tags': Record }>(NIGHTLY_DIST_TAGS_URL).catch((err) => { + nightlySpinner.error('Failed to fetch nightly version info') + logNetworkError(err, { url: NIGHTLY_DIST_TAGS_URL }) + process.exit(1) + }) + const nightlyChannelTag = ctx.args.nightly || 'latest' + + if (!nightlyChannelTag) { + nightlySpinner.error('Failed to get nightly channel tag') + logger.error(`Error getting nightly channel tag.`) + process.exit(1) + } + + const nightlyChannelVersion = response['dist-tags'][nightlyChannelTag] + + if (!nightlyChannelVersion) { + nightlySpinner.error('Nightly version not found') + logger.error(`Nightly channel version for tag ${styleText('cyan', nightlyChannelTag)} not found.`) + process.exit(1) + } + + const nightlyNuxtPackageJsonVersion = `npm:nuxt-nightly@${nightlyChannelVersion}` + const packageJsonPath = resolve(cwd, dir) + + const packageJson = await readPackageJSON(packageJsonPath) + + if (packageJson.dependencies && 'nuxt' in packageJson.dependencies) { + packageJson.dependencies.nuxt = nightlyNuxtPackageJsonVersion + } + else if (packageJson.devDependencies && 'nuxt' in packageJson.devDependencies) { + packageJson.devDependencies.nuxt = nightlyNuxtPackageJsonVersion + } + + await writePackageJSON(join(packageJsonPath, 'package.json'), packageJson) + nightlySpinner.stop(`Updated to nightly version ${styleText('cyan', nightlyChannelVersion)}`) + } + + let installFailure: InstallResult | undefined + let ignoredBuilds: string[] = [] + // Commands the user still has to run before the project works, surfaced in + // the closing "Next steps" section rather than as separate notes. + const recoveryCommands: string[] = [] + + const currentPackageManager = detectCurrentPackageManager() + // Resolve package manager + const packageManagerArg = ctx.args.packageManager as PackageManagerName + const packageManagerSelectOptions = packageManagerOptions.map(pm => ({ + label: pm, + value: pm, + hint: currentPackageManager === pm ? 'current' : undefined, + })) + + // Detect the package manager the template ships with (via a lockfile or its + // `packageManager` field). When the template pins one, we use it instead of + // prompting: switching package managers would leave a stale lockfile or + // workspace config (e.g. `pnpm-workspace.yaml`) behind and silently break + // the project. Shipping a template that works across package managers (i.e. + // without a lockfile) is left to the template author. + const templatePackageManager = await detectTemplatePackageManager(template.dir) + + let selectedPackageManager: PackageManagerName + // Set when an explicit `--packageManager` conflicts with the template's pin: + // installing would run the requested package manager against the template's + // lockfile and workspace config for a different one, leaving a broken + // project. We won't mutate the template, so we scaffold it as-is and skip + // the install, letting the user reconcile the package manager themselves. + let skipInstallOnConflict = false + if (packageManagerOptions.includes(packageManagerArg)) { + selectedPackageManager = packageManagerArg + if (templatePackageManager && templatePackageManager.name !== packageManagerArg) { + skipInstallOnConflict = true + logger.warn(`The ${styleText('cyan', template.name)} template is configured for ${styleText('cyan', templatePackageManager.name)}, but ${styleText('cyan', packageManagerArg)} was requested. Skipping dependency installation to avoid installing against ${styleText('cyan', templatePackageManager.name)}'s lockfile and config. Reconcile the package manager (or use ${styleText('cyan', templatePackageManager.name)}) and install manually.`) + } + } + else if (templatePackageManager) { + selectedPackageManager = templatePackageManager.name + const pinned = templatePackageManager.version + ? `${templatePackageManager.name}@${templatePackageManager.version}` + : templatePackageManager.name + logger.info(`Using ${styleText('cyan', pinned)} as configured by the ${styleText('cyan', template.name)} template.`) + } + else if (isNonInteractive) { + // No explicit `--packageManager`, the template pins none, and we can't + // prompt without a TTY, so there's nothing left to fall back to. + await reportMissingNonInteractiveArgs(ctx.cmd, ['packageManager']) + process.exit(ARG_ERROR_EXIT_CODE) + } + else { + const result = await select({ + message: 'Which package manager would you like to use?', + options: packageManagerSelectOptions, + initialValue: currentPackageManager, + }) + + if (isCancel(result)) { + cancel('Operation cancelled.') + process.exit(1) + } + + selectedPackageManager = result + prompted = true + } + + if (selectedPackageManager === 'yarn' && await useYarnNodeModulesLinker(template.dir)) { + logger.info(`Created ${styleText('cyan', '.yarnrc.yml')} with ${styleText('cyan', 'nodeLinker: node-modules')}, as Nuxt cannot resolve its modules under Yarn's Plug'n'Play linker.`) + } + + // 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) + } + + gitInit = result + prompted = true + } + + // Install project dependencies and initialize git + // or skip installation based on the '--no-install' flag + if (!installRequested || skipInstallOnConflict) { + if (!skipInstallOnConflict) { + logger.info('Skipping install dependencies step.') + } + } + else { + const installController = new AbortController() + const installLog = createInstallLog({ verbose: isVerbose(ctx.args.logLevel) }) + const installSpinner = spinner({ + indicator: 'timer', + onCancel: () => installController.abort(), + }) + + installSpinner.start(`Installing dependencies with ${styleText('cyan', selectedPackageManager)}`) + + const result = await runInstall({ + cwd: template.dir, + packageManager: resolvePackageManagerDescriptor( + selectedPackageManager, + templatePackageManager?.name === selectedPackageManager ? templatePackageManager.version : undefined, + ), + onOutput: installLog.onOutput, + onStatus: message => installSpinner.message(message), + signal: installController.signal, + }) + + if (result.success) { + installSpinner.stop('Dependencies installed') + ignoredBuilds = takeUnreportedIgnoredBuilds(result.ignoredBuilds) + } + else { + installFailure = result + installSpinner.error(result.error ?? 'Dependency installation failed') + } + + installLog.finish(result) + + if (gitInit) { + const gitSpinner = spinner() + gitSpinner.start('Initializing git repository') + + const git = await x('git', ['init', template.dir], { throwOnError: false }) + if (git.exitCode === 0) { + gitSpinner.stop('Git repository initialized') + } + else { + gitSpinner.error('Git initialization failed') + logger.message(git.stderr.trim().split('\n'), { symbol: styleText('gray', S_BAR) }) + } + } + + // `approve-builds` is a pnpm command, so only pnpm gets the offer even if + // another package manager ever prints the same notice. + if (ignoredBuilds.length > 0 && selectedPackageManager === 'pnpm') { + logger.warn(`${styleText('cyan', 'pnpm')} did not run build scripts for ${ignoredBuilds.map(name => styleText('cyan', name)).join(', ')}.`) + + const approve = isNonInteractive + ? false + : await confirm({ message: 'Approve build scripts now?', initialValue: false }) + + if (approve === true) { + await x('pnpm', ['approve-builds'], { + throwOnError: false, + nodeOptions: { cwd: template.dir, stdio: 'inherit' }, + }) + } + else { + recoveryCommands.push('pnpm approve-builds') + } + } + } + + 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 (requestedModules.length) { + logger.warn(`Skipping module installation. Add ${requestedModules.map(mod => styleText('cyan', mod)).join(', ')} with ${styleText('cyan', 'nuxt module add')} once dependencies are installed.`) + } + } + + // Get modules from arg (if provided) + else if (ctx.args.modules !== undefined) { + modulesToAdd.push(...requestedModules) + } + + // ...or offer to browse and install modules (if not offline nor non-interactive) + else if (!ctx.args.offline && !ctx.args.preferOffline && !isNonInteractive) { + // Requested before the prompt so the list is ready if the user says yes, + // but a failure is only reported to users who asked for it (and never + // while the prompt is on screen). + let modulesError: unknown + const modulesPromise = fetchModules().catch((err) => { + modulesError = err + return [] + }) + const wantsUserModules = await confirm({ + message: `Would you like to browse and install modules?`, + initialValue: false, + }) + + if (isCancel(wantsUserModules)) { + cancel('Operation cancelled.') + process.exit(1) + } + + prompted = true + + if (wantsUserModules) { + const modulesSpinner = spinner() + modulesSpinner.start('Fetching available modules') + + const [response, templateDeps, nuxtVersion] = await Promise.all([ + modulesPromise, + getTemplateDependencies(template.dir), + getNuxtVersion(template.dir), + ]) + + if (modulesError) { + modulesSpinner.error('Failed to fetch available modules') + logNetworkError(modulesError, { url: MODULES_API_URL, level: 'warn', prefix: 'Could not load the Nuxt Modules database.' }) + } + else { + modulesSpinner.stop('Modules loaded') + } + + 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 (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 { 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 => styleText('cyan', m)).join(', ')}`) + } + modulesToAdd.push(...toInstall) + } + } + } + } + + // Add modules + if (modulesToAdd.length > 0) { + const args: string[] = [ + ...modulesToAdd, + `--cwd=${templateDownloadPath}`, + installRequested && !skipInstallOnConflict ? '' : '--skipInstall', + `--packageManager=${selectedPackageManager}`, + ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '', + ].filter(Boolean) + + await runCommand(addModuleCommand, args) + } + + if (installFailure) { + logger.warn(`Created your project from the ${styleText('cyan', template.name)} template, but its dependencies are not installed.`) + } + else { + logger.step(`Created your project from the ${styleText('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, + }) + logger.info([ + 'to scaffold this project again without prompts:', + ...headlessCommand.map(line => styleText('dim', line)), + ].join('\n')) + } + + const nextSteps = getNextSteps({ + dir: projectDir, + shell: !!ctx.args.shell, + installFailure, + recoveryCommands, + packageManager: selectedPackageManager, + }) + + logger.message([ + 'Next steps:', + ...nextSteps.map(step => `› ${styleText('cyan', step)}`), + ], { symbol: styleText('gray', S_BAR) }) + + outro('✨ Happy building!') + + if (installFailure) { + process.exitCode = 1 + } + + if (ctx.args.shell) { + startShell(template.dir) + } + }, +}) + +async function getModuleDependencies(moduleName: string) { + const url = `https://registry.npmjs.org/${moduleName}/latest` + try { + const response = await fetchJson<{ dependencies?: Record }>(url) + const dependencies = response.dependencies || {} + return Object.keys(dependencies) + } + catch (err) { + logNetworkError(err, { url, level: 'warn', prefix: `Could not get dependencies for ${styleText('cyan', moduleName)}.` }) + return [] + } +} + +function filterModules(modules: string[], allDependencies: Record) { + const result = { + toInstall: [] as string[], + skipped: [] as string[], + } + + for (const module of modules) { + const isDependency = modules.some((otherModule) => { + if (otherModule === module) + return false + const deps = allDependencies[otherModule] || [] + return deps.includes(module) + }) + + if (isDependency) { + result.skipped.push(module) + } + else { + result.toInstall.push(module) + } + } + + return result +} + +async function getTemplateDependencies(templateDir: string) { + try { + const packageJsonPath = join(templateDir, 'package.json') + if (!existsSync(packageJsonPath)) { + return [] + } + const packageJson = await readPackageJSON(packageJsonPath) + const directDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + } + const directDepNames = Object.keys(directDeps) + const allDeps = new Set(directDepNames) + + const transitiveDepsResults = await Promise.all( + directDepNames.map(dep => getModuleDependencies(dep)), + ) + + transitiveDepsResults.forEach((deps) => { + deps.forEach(dep => allDeps.add(dep)) + }) + + return [...allDeps] + } + catch (err) { + logger.warn(`Could not read template dependencies: ${err}`) + return [] + } +} + +export interface TemplatePackageManager { + name: PackageManagerName + version?: string +} + +/** + * Detect the package manager a template pins, scoped to the template directory + * (so we don't pick up the parent project's setup) via its lockfile, marker + * files or `packageManager` field. Returns `undefined` when the template pins + * none, in which case it is package-manager agnostic and the user is free to + * pick any. Detection errors are treated as "no pin". + */ +export async function detectTemplatePackageManager(templateDir: string): Promise { + const detected = await detectPackageManager(templateDir, { + includeParentDirs: false, + ignoreArgv: true, + }).catch(() => undefined) + + if (!detected) { + return + } + + return { name: detected.name, version: detected.version } +} + +function isVerbose(logLevel?: string) { + return logLevel === 'verbose' || Boolean(process.env.DEBUG) +} + +function detectCurrentPackageManager() { + const userAgent = process.env.npm_config_user_agent + if (!userAgent) { + return + } + const [name] = userAgent.split('/') + if (packageManagerOptions.includes(name as PackageManagerName)) { + return name as PackageManagerName + } +} diff --git a/packages/create-nuxt/src/main.ts b/packages/create-nuxt/src/main.ts index d88e1238e..b7c99f4ac 100644 --- a/packages/create-nuxt/src/main.ts +++ b/packages/create-nuxt/src/main.ts @@ -3,13 +3,13 @@ import process from 'node:process' import { defineCommand } from 'citty' 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 { 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' +import { setupInitCompletions } from './completions' +import init from './init' const _main = defineCommand({ meta: { diff --git a/packages/create-nuxt/src/run.ts b/packages/create-nuxt/src/run.ts index 62a2df839..d15713dec 100644 --- a/packages/create-nuxt/src/run.ts +++ b/packages/create-nuxt/src/run.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url' import { runCommand as _runCommand, runMain as _runMain } from 'citty' -import init from '../../nuxt-cli/src/commands/init' +import init from './init' import { main } from './main' globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { diff --git a/packages/create-nuxt/test/init.spec.ts b/packages/create-nuxt/test/e2e/init.spec.ts similarity index 98% rename from packages/create-nuxt/test/init.spec.ts rename to packages/create-nuxt/test/e2e/init.spec.ts index da37fb21f..e33828675 100644 --- a/packages/create-nuxt/test/init.spec.ts +++ b/packages/create-nuxt/test/e2e/init.spec.ts @@ -8,8 +8,8 @@ import { isWindows } from 'std-env' import { x } from 'tinyexec' 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)) +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 () => { diff --git a/packages/nuxt-cli/test/unit/commands/init.spec.ts b/packages/create-nuxt/test/unit/init.spec.ts similarity index 98% rename from packages/nuxt-cli/test/unit/commands/init.spec.ts rename to packages/create-nuxt/test/unit/init.spec.ts index be1261930..2fe1b1dca 100644 --- a/packages/nuxt-cli/test/unit/commands/init.spec.ts +++ b/packages/create-nuxt/test/unit/init.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { detectTemplatePackageManager, getNextSteps, useYarnNodeModulesLinker } from '../../../src/commands/init' +import { detectTemplatePackageManager, getNextSteps, useYarnNodeModulesLinker } from '../../src/init' describe('useYarnNodeModulesLinker', () => { let dir: string diff --git a/packages/create-nuxt/test/unit/network-failures.spec.ts b/packages/create-nuxt/test/unit/network-failures.spec.ts new file mode 100644 index 000000000..93d667154 --- /dev/null +++ b/packages/create-nuxt/test/unit/network-failures.spec.ts @@ -0,0 +1,83 @@ +import type { AddressInfo } from 'node:net' + +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { runCommandDef } from '../../../nuxt-cli/src/run-command' +import { render, screen } from '../../../nuxt-cli/test/utils/terminal' +import initCommand from '../../src/init' + +/** A port nothing listens on, so connections to it are refused immediately. */ +async function closedPort(): Promise { + const server = createServer() + const port = await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))) + await new Promise(resolve => server.close(() => resolve())) + return port +} + +class ExitError extends Error { + constructor(readonly code: number | undefined) { + super(`process.exit(${code})`) + } +} + +/** Run a command that is expected to bail out, returning the screen and exit code. */ +async function runFailing(argv: string[]) { + let exitCode: number | undefined + const exit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code as number | undefined + throw new ExitError(exitCode) + }) + + const renderer = await render(async () => { + await runCommandDef(initCommand, argv).catch((err) => { + if (!(err instanceof ExitError)) { + throw err + } + }) + }) + + exit.mockRestore() + return { output: screen(renderer), exitCode } +} + +describe('network failures reported when scaffolding', () => { + let dir: string + let port: number + + beforeAll(async () => { + port = await closedPort() + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + delete process.env.NUXI_INIT_REGISTRY + }) + + it('names the unreachable registry when a template cannot be downloaded', async () => { + dir = await mkdtemp(join(tmpdir(), 'nuxt-init-network-')) + process.env.NUXI_INIT_REGISTRY = `http://127.0.0.1:${port}/templates` + + const { output, exitCode } = await runFailing([ + `--cwd=${dir}`, + 'app', + '--template=minimal', + '--packageManager=npm', + '--gitInit=false', + '--install=false', + '--no-modules', + ]) + + expect(output).toContain('Template download failed') + expect(output).toContain(`Connection to 127.0.0.1:${port} was refused.`) + expect(output).toContain('--offline') + expect(output).toContain('NUXI_INIT_REGISTRY') + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/nuxi/src/launcher.ts b/packages/nuxi/src/launcher.ts index 6b6dfec73..909062a8d 100644 --- a/packages/nuxi/src/launcher.ts +++ b/packages/nuxi/src/launcher.ts @@ -34,8 +34,12 @@ const MIN_PROJECT_CLI: [major: number, minor: number] = [3, 26] export async function runMain(): Promise { const rawArgs = process.argv.slice(2) - const cli = loadProjectCli(rawArgs) - const delegate = cli && await loadDelegate(cli, commandName(rawArgs)) + const command = commandName(rawArgs) + + // `@nuxt/cli` only redirects `init` to `create-nuxt`, so scaffolding is served + // from the copy bundled here rather than handed off to the project. + const cli = command === 'init' ? null : loadProjectCli(rawArgs) + const delegate = cli && await loadDelegate(cli, command) if (delegate) { return delegate() } diff --git a/packages/nuxi/src/main.ts b/packages/nuxi/src/main.ts index 8e71464a5..9dd0ff4b1 100644 --- a/packages/nuxi/src/main.ts +++ b/packages/nuxi/src/main.ts @@ -33,7 +33,7 @@ if (!process.getBuiltinModule) { } const commands = { - init: () => import('../../nuxt-cli/src/commands/init').then(m => m.default || m), + init: () => import('../../create-nuxt/src/init').then(m => m.default || m), } as const const _main = defineCommand({ diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 8e2b92a86..44b4dca97 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -68,7 +68,6 @@ "exsolve": "^1.1.1", "fzf": "^0.5.2", "get-port-please": "^3.2.0", - "giget": "^3.3.1", "nypm": "^0.6.8", "obug": "^2.1.4", "ohash": "^2.0.11", @@ -92,6 +91,7 @@ "@nuxt/schema": "^4.5.1", "@nuxt/test-utils": "^4.1.0", "@types/node": "^24.13.3", + "giget": "^3.3.1", "h3": "^1.15.11", "h3-next": "npm:h3@^2.0.1-rc.26", "jiti": "^2.7.0", diff --git a/packages/nuxt-cli/src/commands/init.ts b/packages/nuxt-cli/src/commands/init.ts index da823b28b..75ad52b0c 100644 --- a/packages/nuxt-cli/src/commands/init.ts +++ b/packages/nuxt-cli/src/commands/init.ts @@ -1,907 +1,20 @@ -import type { ArgsDef, CommandDef } from 'citty' -import type { DownloadTemplateResult } from 'giget' -import type { PackageManagerName } from 'nypm' -import type { InstallResult } from '../utils/install' -import type { TemplateData } from '../utils/starter-templates' - -import { existsSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' import process from 'node:process' - import { styleText } from 'node:util' -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' -import { basename, join, relative, resolve } from 'pathe' -import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types' -import { hasTTY } from 'std-env' -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' -import { relativeToProcess } from '../utils/paths' -import { getTemplates, TEMPLATES_API_URL } from '../utils/starter-templates' -import { getNuxtVersion } from '../utils/versions' -import { cwdArgs, logLevelArgs } from './_shared' -import { selectModulesAutocomplete } from './module/_autocomplete' -import { checkNuxtCompatibility, fetchModules, MODULES_API_URL } from './module/_utils' -import addModuleCommand from './module/add' - -const NON_WORD_RE = /[^\w-]/g -const MULTI_DASH_RE = /-{2,}/g -const LEADING_TRAILING_DASH_RE = /^-|-$/g - -const DEFAULT_REGISTRY = 'https://raw.githubusercontent.com/nuxt/starter/templates/templates' -const DEFAULT_TEMPLATE_NAME = 'minimal' -const NIGHTLY_DIST_TAGS_URL = 'https://registry.npmjs.org/nuxt-nightly' - -const pms: Record = { - npm: undefined, - pnpm: undefined, - yarn: undefined, - bun: undefined, - deno: undefined, - aube: undefined, - nub: 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 +import { defineCommand } from 'citty' -// Exit code citty uses for argument errors; reuse it for every missing/invalid -// argument so the contract stays consistent regardless of where we detect it. -const ARG_ERROR_EXIT_CODE = 2 - -/** - * Report missing arguments in non-interactive mode. Centralises the message so - * the upfront check and the post-detection package-manager check stay in sync. - * Pass `availableTemplates` to also list the templates the user can choose from - * (when `--template` is missing). Callers are expected to `process.exit` after. - */ -async function reportMissingNonInteractiveArgs( - cmd: CommandDef, - missingArgs: string[], - availableTemplates?: Record, -): Promise { - await showUsage(cmd) - if (availableTemplates) { - logger.info(`Available templates:\n${Object.entries(availableTemplates) - .map(([name, data]) => ` ${styleText('cyan', name)}${data ? ` – ${data.description}` : ''}`) - .join('\n')}`) - } - const label = missingArgs.length === 1 ? 'argument' : 'arguments' - logger.error(`Non-interactive terminal detected. Missing required ${label}: ${missingArgs - .map(name => styleText('cyan', name === 'dir' ? '' : `--${name}`)) - .join(', ')}`) -} - -const YARN_NODE_LINKER = `# Nuxt cannot resolve its modules under Yarn's default Plug'n'Play linker. -# See https://github.com/nuxt/nuxt/issues/26750 -nodeLinker: node-modules -` - -/** - * Opt a scaffolded project out of Yarn's Plug'n'Play linker, under which - * `nuxt prepare` cannot resolve `@nuxt/kit` and the project will not build. - * - * Templates that ship any Yarn configuration of their own are left alone, so a - * template can still choose Plug'n'Play. Yarn 1 ignores `.yarnrc.yml`, so - * writing it there is harmless. - */ -export async function useYarnNodeModulesLinker(dir: string): Promise { - if (existsSync(join(dir, '.yarnrc.yml')) || existsSync(join(dir, '.yarnrc'))) { - return false - } - await writeFile(join(dir, '.yarnrc.yml'), YARN_NODE_LINKER, 'utf8') - return true -} - -/** - * 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. - */ -export function getNextSteps(options: { - dir: string - shell: boolean - installFailure?: unknown - recoveryCommands: string[] - packageManager: PackageManagerName -}): string[] { - const { dir, shell, installFailure, recoveryCommands, packageManager } = options - const runCmd = packageManager === 'deno' ? 'task' : 'run' - return [ - !shell && dir !== '.' && `cd ${dir}`, - installFailure && `${packageManager} install`, - ...recoveryCommands, - `${packageManager} ${runCmd} dev`, - ].filter((step): step is string => typeof step === 'string') -} +import { getCreateCommand } from '../utils/headless' +import { logger } from '../utils/logger' export default defineCommand({ meta: { name: 'init', - description: 'Initialize a fresh project', + description: 'Scaffold a fresh project (moved to create-nuxt)', + hidden: true, }, - args: { - ...cwdArgs, - cwd: { - ...cwdArgs.cwd, - description: 'Specify the directory to create the project in', - }, - ...logLevelArgs, - dir: { - type: 'positional', - description: 'Project directory', - default: '', - }, - template: { - type: 'string', - alias: 't', - description: 'Template name', - }, - force: { - type: 'boolean', - alias: 'f', - description: 'Override existing directory', - }, - offline: { - type: 'boolean', - description: 'Force offline mode', - }, - preferOffline: { - type: 'boolean', - description: 'Prefer offline mode', - }, - install: { - type: 'boolean', - default: true, - description: 'Skip installing dependencies', - }, - gitInit: { - type: 'boolean', - description: 'Initialize git repository', - }, - shell: { - type: 'boolean', - description: 'Start shell after installation in project directory', - }, - packageManager: { - type: 'string', - description: `Package manager choice (${packageManagerOptions.join(', ')})`, - }, - modules: { - type: 'string', - required: false, - description: 'Nuxt modules to install (comma separated without spaces)', - negativeDescription: 'Skip module installation prompt', - alias: 'M', - }, - nightly: { - type: 'string', - description: 'Use Nuxt nightly release channel (3x or latest)', - }, - }, - async run(ctx) { - // Validate an explicitly provided `--packageManager` up front (before any - // banner or network work) so a typo fails fast with a clear message instead - // of being silently ignored once a template's own package manager is - // detected. - if (ctx.args.packageManager && !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)) { - logger.error(`Invalid package manager: ${styleText('cyan', ctx.args.packageManager)}. Choose one of ${packageManagerOptions.map(pm => styleText('cyan', pm)).join(', ')}.`) - 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) - } - - if (hasTTY) { - process.stdout.write(`\n${nuxtIcon}\n\n`) - } - - intro(styleText('bold', `Welcome to Nuxt!`.split('').map(m => `${themeColor}${m}`).join(''))) - - let availableTemplates: Record = {} - - // 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) { - // In offline mode, use static templates directly - availableTemplates = defaultTemplates - } - else { - const templatesSpinner = spinner() - templatesSpinner.start('Loading available templates') - - try { - availableTemplates = await getTemplates() - templatesSpinner.stop('Templates loaded') - } - catch (err) { - availableTemplates = defaultTemplates - debug(describeNetworkError(err, TEMPLATES_API_URL)) - templatesSpinner.stop('Templates loaded from cache') - } - } - } - - // 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') { - // The package manager can be inferred from a template that pins one, - // so only require it upfront when no template is given (nothing to - // infer from yet). Otherwise it's validated after the template is - // downloaded and its package manager resolved. - if (ctx.args.template) { - return false - } - return !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName) - } - return ctx.args[name] === undefined || ctx.args[name] === '' - }) - - if (missingArgs.length > 0) { - await reportMissingNonInteractiveArgs( - ctx.cmd, - [...missingArgs], - ctx.args.template ? undefined : availableTemplates, - ) - process.exit(ARG_ERROR_EXIT_CODE) - } - } - - 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 ? `${styleText('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 - prompted = true - } - - // Fallback to default if still not set - templateName ||= DEFAULT_TEMPLATE_NAME - - if (typeof templateName !== 'string') { - logger.error('Please specify a template!') - process.exit(1) - } - - let dir = ctx.args.dir - if (dir === '') { - const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app' - const result = await text({ - message: 'Where would you like to create your project?', - placeholder: `./${defaultDir}`, - defaultValue: defaultDir, - }) - - if (isCancel(result)) { - cancel('Operation cancelled.') - process.exit(1) - } - - dir = result - prompted = true - } - - const cwd = resolve(ctx.args.cwd) - let templateDownloadPath = resolve(cwd, dir) - logger.step(`Creating project in ${styleText('cyan', relativeToProcess(templateDownloadPath))}`) - - let shouldForce = Boolean(ctx.args.force) - - // Prompt the user if the template download directory already exists - // when no `--force` flag is provided - const shouldVerify = !shouldForce && existsSync(templateDownloadPath) - if (shouldVerify) { - if (isNonInteractive) { - logger.error(`The directory ${styleText('cyan', relativeToProcess(templateDownloadPath))} already exists. Pass ${styleText('cyan', '--force')} to override it or choose a different directory.`) - process.exit(1) - } - - const selectedAction = await select({ - message: `The directory ${styleText('cyan', relativeToProcess(templateDownloadPath))} already exists. What would you like to do?`, - options: [ - { value: 'override', label: 'Override its contents' }, - { value: 'different', label: 'Select different directory' }, - { value: 'abort', label: 'Abort' }, - ], - }) - - if (isCancel(selectedAction)) { - cancel('Operation cancelled.') - process.exit(1) - } - - switch (selectedAction) { - case 'override': - shouldForce = true - break - - case 'different': { - const result = await text({ - message: 'Please specify a different directory:', - }) - - if (isCancel(result)) { - cancel('Operation cancelled.') - process.exit(1) - } - - templateDownloadPath = resolve(cwd, result) - break - } - - // 'Abort' - case 'abort': - default: - process.exit(1) - } - } - - // Download template - let template: DownloadTemplateResult - - const registry = process.env.NUXI_INIT_REGISTRY || DEFAULT_REGISTRY - - const downloadSpinner = spinner() - downloadSpinner.start(`Downloading ${styleText('cyan', templateName)} template`) - - try { - template = await downloadTemplate(templateName, { - dir: templateDownloadPath, - force: shouldForce, - offline: Boolean(ctx.args.offline), - preferOffline: Boolean(ctx.args.preferOffline), - registry, - }) - - if (dir.length > 0) { - const path = await findFile('package.json', { - startingFrom: join(templateDownloadPath, 'package.json'), - reverse: true, - }) - if (path) { - const pkg = await readPackageJSON(path, { try: true }) - if (pkg && pkg.name) { - const slug = basename(templateDownloadPath) - .replace(NON_WORD_RE, '-') - .replace(MULTI_DASH_RE, '-') - .replace(LEADING_TRAILING_DASH_RE, '') - if (slug) { - pkg.name = slug - await writePackageJSON(path, pkg) - } - } - } - } - - downloadSpinner.stop(`Downloaded ${styleText('cyan', template.name)} template`) - } - catch (err) { - downloadSpinner.error('Template download failed') - if (process.env.DEBUG) { - throw err - } - const diagnosable = classifyNetworkError(err).kind === 'unknown' - ? await probeNetworkError(registry) ?? err - : err - logNetworkError(diagnosable, { - url: registry, - hints: [ - `Retry with ${styleText('cyan', '--offline')} or ${styleText('cyan', '--preferOffline')} to use a cached template, or set ${styleText('cyan', 'NUXI_INIT_REGISTRY')} to a reachable mirror.`, - ], - }) - process.exit(1) - } - - if (ctx.args.nightly !== undefined && !ctx.args.offline && !ctx.args.preferOffline) { - const nightlySpinner = spinner() - nightlySpinner.start('Fetching nightly version info') - - const response = await fetchJson<{ 'dist-tags': Record }>(NIGHTLY_DIST_TAGS_URL).catch((err) => { - nightlySpinner.error('Failed to fetch nightly version info') - logNetworkError(err, { url: NIGHTLY_DIST_TAGS_URL }) - process.exit(1) - }) - const nightlyChannelTag = ctx.args.nightly || 'latest' - - if (!nightlyChannelTag) { - nightlySpinner.error('Failed to get nightly channel tag') - logger.error(`Error getting nightly channel tag.`) - process.exit(1) - } - - const nightlyChannelVersion = response['dist-tags'][nightlyChannelTag] - - if (!nightlyChannelVersion) { - nightlySpinner.error('Nightly version not found') - logger.error(`Nightly channel version for tag ${styleText('cyan', nightlyChannelTag)} not found.`) - process.exit(1) - } - - const nightlyNuxtPackageJsonVersion = `npm:nuxt-nightly@${nightlyChannelVersion}` - const packageJsonPath = resolve(cwd, dir) - - const packageJson = await readPackageJSON(packageJsonPath) - - if (packageJson.dependencies && 'nuxt' in packageJson.dependencies) { - packageJson.dependencies.nuxt = nightlyNuxtPackageJsonVersion - } - else if (packageJson.devDependencies && 'nuxt' in packageJson.devDependencies) { - packageJson.devDependencies.nuxt = nightlyNuxtPackageJsonVersion - } - - await writePackageJSON(join(packageJsonPath, 'package.json'), packageJson) - nightlySpinner.stop(`Updated to nightly version ${styleText('cyan', nightlyChannelVersion)}`) - } - - let installFailure: InstallResult | undefined - let ignoredBuilds: string[] = [] - // Commands the user still has to run before the project works, surfaced in - // the closing "Next steps" section rather than as separate notes. - const recoveryCommands: string[] = [] - - const currentPackageManager = detectCurrentPackageManager() - // Resolve package manager - const packageManagerArg = ctx.args.packageManager as PackageManagerName - const packageManagerSelectOptions = packageManagerOptions.map(pm => ({ - label: pm, - value: pm, - hint: currentPackageManager === pm ? 'current' : undefined, - })) - - // Detect the package manager the template ships with (via a lockfile or its - // `packageManager` field). When the template pins one, we use it instead of - // prompting: switching package managers would leave a stale lockfile or - // workspace config (e.g. `pnpm-workspace.yaml`) behind and silently break - // the project. Shipping a template that works across package managers (i.e. - // without a lockfile) is left to the template author. - const templatePackageManager = await detectTemplatePackageManager(template.dir) - - let selectedPackageManager: PackageManagerName - // Set when an explicit `--packageManager` conflicts with the template's pin: - // installing would run the requested package manager against the template's - // lockfile and workspace config for a different one, leaving a broken - // project. We won't mutate the template, so we scaffold it as-is and skip - // the install, letting the user reconcile the package manager themselves. - let skipInstallOnConflict = false - if (packageManagerOptions.includes(packageManagerArg)) { - selectedPackageManager = packageManagerArg - if (templatePackageManager && templatePackageManager.name !== packageManagerArg) { - skipInstallOnConflict = true - logger.warn(`The ${styleText('cyan', template.name)} template is configured for ${styleText('cyan', templatePackageManager.name)}, but ${styleText('cyan', packageManagerArg)} was requested. Skipping dependency installation to avoid installing against ${styleText('cyan', templatePackageManager.name)}'s lockfile and config. Reconcile the package manager (or use ${styleText('cyan', templatePackageManager.name)}) and install manually.`) - } - } - else if (templatePackageManager) { - selectedPackageManager = templatePackageManager.name - const pinned = templatePackageManager.version - ? `${templatePackageManager.name}@${templatePackageManager.version}` - : templatePackageManager.name - logger.info(`Using ${styleText('cyan', pinned)} as configured by the ${styleText('cyan', template.name)} template.`) - } - else if (isNonInteractive) { - // No explicit `--packageManager`, the template pins none, and we can't - // prompt without a TTY, so there's nothing left to fall back to. - await reportMissingNonInteractiveArgs(ctx.cmd, ['packageManager']) - process.exit(ARG_ERROR_EXIT_CODE) - } - else { - const result = await select({ - message: 'Which package manager would you like to use?', - options: packageManagerSelectOptions, - initialValue: currentPackageManager, - }) - - if (isCancel(result)) { - cancel('Operation cancelled.') - process.exit(1) - } - - selectedPackageManager = result - prompted = true - } - - if (selectedPackageManager === 'yarn' && await useYarnNodeModulesLinker(template.dir)) { - logger.info(`Created ${styleText('cyan', '.yarnrc.yml')} with ${styleText('cyan', 'nodeLinker: node-modules')}, as Nuxt cannot resolve its modules under Yarn's Plug'n'Play linker.`) - } - - // 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) - } - - gitInit = result - prompted = true - } - - // Install project dependencies and initialize git - // or skip installation based on the '--no-install' flag - if (!installRequested || skipInstallOnConflict) { - if (!skipInstallOnConflict) { - logger.info('Skipping install dependencies step.') - } - } - else { - const installController = new AbortController() - const installLog = createInstallLog({ verbose: isVerbose(ctx.args.logLevel) }) - const installSpinner = spinner({ - indicator: 'timer', - onCancel: () => installController.abort(), - }) - - installSpinner.start(`Installing dependencies with ${styleText('cyan', selectedPackageManager)}`) - - const result = await runInstall({ - cwd: template.dir, - packageManager: resolvePackageManagerDescriptor( - selectedPackageManager, - templatePackageManager?.name === selectedPackageManager ? templatePackageManager.version : undefined, - ), - onOutput: installLog.onOutput, - onStatus: message => installSpinner.message(message), - signal: installController.signal, - }) - - if (result.success) { - installSpinner.stop('Dependencies installed') - ignoredBuilds = takeUnreportedIgnoredBuilds(result.ignoredBuilds) - } - else { - installFailure = result - installSpinner.error(result.error ?? 'Dependency installation failed') - } - - installLog.finish(result) - - if (gitInit) { - const gitSpinner = spinner() - gitSpinner.start('Initializing git repository') - - const git = await x('git', ['init', template.dir], { throwOnError: false }) - if (git.exitCode === 0) { - gitSpinner.stop('Git repository initialized') - } - else { - gitSpinner.error('Git initialization failed') - logger.message(git.stderr.trim().split('\n'), { symbol: styleText('gray', S_BAR) }) - } - } - - // `approve-builds` is a pnpm command, so only pnpm gets the offer even if - // another package manager ever prints the same notice. - if (ignoredBuilds.length > 0 && selectedPackageManager === 'pnpm') { - logger.warn(`${styleText('cyan', 'pnpm')} did not run build scripts for ${ignoredBuilds.map(name => styleText('cyan', name)).join(', ')}.`) - - const approve = isNonInteractive - ? false - : await confirm({ message: 'Approve build scripts now?', initialValue: false }) - - if (approve === true) { - await x('pnpm', ['approve-builds'], { - throwOnError: false, - nodeOptions: { cwd: template.dir, stdio: 'inherit' }, - }) - } - else { - recoveryCommands.push('pnpm approve-builds') - } - } - } - - 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 (requestedModules.length) { - logger.warn(`Skipping module installation. Add ${requestedModules.map(mod => styleText('cyan', mod)).join(', ')} with ${styleText('cyan', 'nuxt module add')} once dependencies are installed.`) - } - } - - // Get modules from arg (if provided) - else if (ctx.args.modules !== undefined) { - modulesToAdd.push(...requestedModules) - } - - // ...or offer to browse and install modules (if not offline nor non-interactive) - else if (!ctx.args.offline && !ctx.args.preferOffline && !isNonInteractive) { - // Requested before the prompt so the list is ready if the user says yes, - // but a failure is only reported to users who asked for it (and never - // while the prompt is on screen). - let modulesError: unknown - const modulesPromise = fetchModules().catch((err) => { - modulesError = err - return [] - }) - const wantsUserModules = await confirm({ - message: `Would you like to browse and install modules?`, - initialValue: false, - }) - - if (isCancel(wantsUserModules)) { - cancel('Operation cancelled.') - process.exit(1) - } - - prompted = true - - if (wantsUserModules) { - const modulesSpinner = spinner() - modulesSpinner.start('Fetching available modules') - - const [response, templateDeps, nuxtVersion] = await Promise.all([ - modulesPromise, - getTemplateDependencies(template.dir), - getNuxtVersion(template.dir), - ]) - - if (modulesError) { - modulesSpinner.error('Failed to fetch available modules') - logNetworkError(modulesError, { url: MODULES_API_URL, level: 'warn', prefix: 'Could not load the Nuxt Modules database.' }) - } - else { - modulesSpinner.stop('Modules loaded') - } - - 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 (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 { 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 => styleText('cyan', m)).join(', ')}`) - } - modulesToAdd.push(...toInstall) - } - } - } - } - - // Add modules - if (modulesToAdd.length > 0) { - const args: string[] = [ - ...modulesToAdd, - `--cwd=${templateDownloadPath}`, - installRequested && !skipInstallOnConflict ? '' : '--skipInstall', - `--packageManager=${selectedPackageManager}`, - ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '', - ].filter(Boolean) - - await runCommand(addModuleCommand, args) - } - - if (installFailure) { - logger.warn(`Created your project from the ${styleText('cyan', template.name)} template, but its dependencies are not installed.`) - } - else { - logger.step(`Created your project from the ${styleText('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, - }) - logger.info([ - 'to scaffold this project again without prompts:', - ...headlessCommand.map(line => styleText('dim', line)), - ].join('\n')) - } - - const nextSteps = getNextSteps({ - dir: projectDir, - shell: !!ctx.args.shell, - installFailure, - recoveryCommands, - packageManager: selectedPackageManager, - }) - - logger.message([ - 'Next steps:', - ...nextSteps.map(step => `› ${styleText('cyan', step)}`), - ], { symbol: styleText('gray', S_BAR) }) - - outro('✨ Happy building!') - - if (installFailure) { - process.exitCode = 1 - } - - if (ctx.args.shell) { - startShell(template.dir) - } + run(ctx) { + const command = [getCreateCommand(), ...ctx.rawArgs].join(' ') + logger.info(`Project scaffolding now lives in ${styleText('cyan', 'create-nuxt')}.`) + logger.info(`Run ${styleText('cyan', command)} instead.`) + process.exit(1) }, }) - -async function getModuleDependencies(moduleName: string) { - const url = `https://registry.npmjs.org/${moduleName}/latest` - try { - const response = await fetchJson<{ dependencies?: Record }>(url) - const dependencies = response.dependencies || {} - return Object.keys(dependencies) - } - catch (err) { - logNetworkError(err, { url, level: 'warn', prefix: `Could not get dependencies for ${styleText('cyan', moduleName)}.` }) - return [] - } -} - -function filterModules(modules: string[], allDependencies: Record) { - const result = { - toInstall: [] as string[], - skipped: [] as string[], - } - - for (const module of modules) { - const isDependency = modules.some((otherModule) => { - if (otherModule === module) - return false - const deps = allDependencies[otherModule] || [] - return deps.includes(module) - }) - - if (isDependency) { - result.skipped.push(module) - } - else { - result.toInstall.push(module) - } - } - - return result -} - -async function getTemplateDependencies(templateDir: string) { - try { - const packageJsonPath = join(templateDir, 'package.json') - if (!existsSync(packageJsonPath)) { - return [] - } - const packageJson = await readPackageJSON(packageJsonPath) - const directDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - } - const directDepNames = Object.keys(directDeps) - const allDeps = new Set(directDepNames) - - const transitiveDepsResults = await Promise.all( - directDepNames.map(dep => getModuleDependencies(dep)), - ) - - transitiveDepsResults.forEach((deps) => { - deps.forEach(dep => allDeps.add(dep)) - }) - - return [...allDeps] - } - catch (err) { - logger.warn(`Could not read template dependencies: ${err}`) - return [] - } -} - -export interface TemplatePackageManager { - name: PackageManagerName - version?: string -} - -/** - * Detect the package manager a template pins, scoped to the template directory - * (so we don't pick up the parent project's setup) via its lockfile, marker - * files or `packageManager` field. Returns `undefined` when the template pins - * none, in which case it is package-manager agnostic and the user is free to - * pick any. Detection errors are treated as "no pin". - */ -export async function detectTemplatePackageManager(templateDir: string): Promise { - const detected = await detectPackageManager(templateDir, { - includeParentDirs: false, - ignoreArgv: true, - }).catch(() => undefined) - - if (!detected) { - return - } - - return { name: detected.name, version: detected.version } -} - -function isVerbose(logLevel?: string) { - return logLevel === 'verbose' || Boolean(process.env.DEBUG) -} - -function detectCurrentPackageManager() { - const userAgent = process.env.npm_config_user_agent - if (!userAgent) { - return - } - const [name] = userAgent.split('/') - if (packageManagerOptions.includes(name as PackageManagerName)) { - return name as PackageManagerName - } -} diff --git a/packages/nuxt-cli/src/completions.ts b/packages/nuxt-cli/src/completions.ts index 9c6dc4d35..2ac0e9397 100644 --- a/packages/nuxt-cli/src/completions.ts +++ b/packages/nuxt-cli/src/completions.ts @@ -1,7 +1,6 @@ import type { ArgsDef, CommandDef } from 'citty' import tab from '@bomb.sh/tab/citty' import { nitroPresets } from './data/nitro-presets' -import { templates } from './data/templates' export async function initCompletions(command: CommandDef) { const completion = await tab(command) @@ -39,18 +38,6 @@ export async function initCompletions(command: Comm } } - const initCommand = completion.commands.get('init') - if (initCommand) { - const templateOption = initCommand.options.get('template') - if (templateOption) { - templateOption.handler = (complete) => { - for (const template in templates) { - complete(template, templates[template as 'content']?.description || '') - } - } - } - } - const addCommand = completion.commands.get('add') if (addCommand) { const cwdOption = addCommand.options.get('cwd') @@ -61,7 +48,7 @@ export async function initCompletions(command: Comm } } - const logLevelCommands = ['dev', 'build', 'generate', 'preview', 'prepare', 'init'] + const logLevelCommands = ['dev', 'build', 'generate', 'preview', 'prepare'] for (const cmdName of logLevelCommands) { const cmd = completion.commands.get(cmdName) if (cmd) { diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index a1e67f47e..5fcb7c613 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -15,12 +15,11 @@ import { runCommand } from './run' import { normaliseCwdArg } from './utils/args' import { setupGlobalConsole } from './utils/console' import { checkEngines } from './utils/engines' -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 { scheduleSelfUpdateNudge, scheduleUpdateNudge } from './utils/update-lazy' +import { scheduleUpdateNudge } from './utils/update-lazy' // 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 @@ -53,12 +52,8 @@ const _main = defineCommand({ // 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)) + void scheduleUpdateNudge(resolveProjectDir(ctx.args), command) + .catch(err => debug('Failed to check for updates:', err)) } if (command === 'add' && ctx.rawArgs[1] && templateNames.includes(ctx.rawArgs[1] as TemplateName)) { diff --git a/packages/nuxt-cli/src/utils/update-lazy.ts b/packages/nuxt-cli/src/utils/update-lazy.ts index 582987d8b..a5cf8c6da 100644 --- a/packages/nuxt-cli/src/utils/update-lazy.ts +++ b/packages/nuxt-cli/src/utils/update-lazy.ts @@ -1,5 +1,3 @@ -import type { SelfUpdateNudgeOptions } from './update-check' - /** * The update check pulls in `rc9`, `@clack/prompts`, `verkit` and the registry * client, and then reads `.nuxtrc` and queries the registry. Its only output is @@ -12,14 +10,6 @@ export function scheduleUpdateNudge(cwd: string, command?: string): Promise import('./update').then(({ scheduleUpdateNudge }) => scheduleUpdateNudge(cwd, command))) } -/** - * Unlike the project check this one runs straight away, because `init` can - * finish well inside the grace period and the nudge would be dropped with it. - */ -export function scheduleSelfUpdateNudge(name: string, current: string, options: SelfUpdateNudgeOptions): Promise { - return import('./update-check').then(({ scheduleSelfUpdateNudge }) => scheduleSelfUpdateNudge(name, current, options)) -} - function afterStartup(run: () => Promise): Promise { return new Promise((resolve, reject) => { // Unref'd so a pending check never keeps a short command alive. diff --git a/packages/nuxt-cli/test/e2e/commands.spec.ts b/packages/nuxt-cli/test/e2e/commands.spec.ts index 287a25189..520ef5c33 100644 --- a/packages/nuxt-cli/test/e2e/commands.spec.ts +++ b/packages/nuxt-cli/test/e2e/commands.spec.ts @@ -3,8 +3,7 @@ import type { commands } from '../../src/commands' import { existsSync } from 'node:fs' -import { readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { rm } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { getPort } from 'get-port-please' @@ -115,22 +114,13 @@ describe('commands', () => { expect(existsSync(join(fixtureDir, 'dist/index.html'))).toBeTruthy() }, 'init': async () => { - const dir = tmpdir() - const pm = 'pnpm' - const installPath = join(dir, pm) + const res = await x(nuxi, ['init', 'my-app'], { + throwOnError: false, + nodeOptions: { stdio: 'pipe', cwd: fixtureDir }, + }) - await rm(installPath, { recursive: true, force: true }) - try { - await x(nuxi, ['init', installPath, `--packageManager=${pm}`, '--template=minimal', '--gitInit=false', '--preferOffline', '--install=false'], { - throwOnError: true, - nodeOptions: { stdio: 'inherit', cwd: fixtureDir }, - }) - const files = await readdir(installPath).catch(() => []) - expect(files).toContain('nuxt.config.ts') - } - finally { - await rm(installPath, { recursive: true, force: true }) - } + expect(res.exitCode).toBe(1) + expect(res.stdout + res.stderr).toContain('create nuxt@latest my-app') }, 'info': 'todo', } diff --git a/packages/nuxt-cli/test/unit/commands/network-failures.spec.ts b/packages/nuxt-cli/test/unit/commands/network-failures.spec.ts index d522fdceb..12f849229 100644 --- a/packages/nuxt-cli/test/unit/commands/network-failures.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/network-failures.spec.ts @@ -9,7 +9,6 @@ import process from 'node:process' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import initCommand from '../../../src/commands/init' import moduleCommand from '../../../src/commands/module' import * as moduleUtils from '../../../src/commands/module/_utils' import addCommand from '../../../src/commands/module/add' @@ -62,31 +61,9 @@ describe('network failures reported by commands', () => { afterEach(async () => { await rm(dir, { recursive: true, force: true }) vi.restoreAllMocks() - delete process.env.NUXI_INIT_REGISTRY delete process.env.COREPACK_NPM_REGISTRY }) - it('names the unreachable registry when a template cannot be downloaded', async () => { - dir = await mkdtemp(join(tmpdir(), 'nuxt-init-network-')) - process.env.NUXI_INIT_REGISTRY = `http://127.0.0.1:${port}/templates` - - const { output, exitCode } = await runFailing(initCommand, [ - `--cwd=${dir}`, - 'app', - '--template=minimal', - '--packageManager=npm', - '--gitInit=false', - '--install=false', - '--no-modules', - ]) - - expect(output).toContain('Template download failed') - expect(output).toContain(`Connection to 127.0.0.1:${port} was refused.`) - expect(output).toContain('--offline') - expect(output).toContain('NUXI_INIT_REGISTRY') - expect(exitCode).toBe(1) - }) - it('names the unreachable npm registry when module metadata cannot be fetched', async () => { dir = await mkdtemp(join(tmpdir(), 'nuxt-add-network-')) await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'app', devDependencies: { nuxt: '4.0.0' } })) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index d5e0c5f98..553d4409b 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -26,7 +26,7 @@ describe('help', () => { expect(await usage(main)).toMatchInlineSnapshot(` "Nuxt CLI (nuxt v0.0.0) - USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|dev|devtools|generate|info|init|module|prepare|preview|test|typecheck|upgrade + USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|dev|devtools|generate|info|module|prepare|preview|test|typecheck|upgrade ARGUMENTS @@ -47,7 +47,6 @@ describe('help', () => { devtools Enable or disable devtools in a Nuxt project generate Build Nuxt and prerender all routes info Get information about Nuxt project - init Initialize a fresh project module Manage Nuxt modules prepare Prepare Nuxt for development/build preview Launches Nitro server for local testing after \`nuxt build\`. @@ -277,29 +276,9 @@ describe('help', () => { it('nuxt init', async () => { expect(await usage(commands.init, main)).toMatchInlineSnapshot(` - "Initialize a fresh project (nuxt init v0.0.0) + "Scaffold a fresh project (moved to create-nuxt) (nuxt init v0.0.0) - USAGE nuxt init [OPTIONS] [DIR] - - ARGUMENTS - - DIR Project directory (Default: ) - - OPTIONS - - --cwd= Specify the directory to create the project in (Default: .) - --logLevel= Specify build-time log level - -t, --template=