diff --git a/packages/create-nuxt/src/main.ts b/packages/create-nuxt/src/main.ts index d1a87b6ee..d88e1238e 100644 --- a/packages/create-nuxt/src/main.ts +++ b/packages/create-nuxt/src/main.ts @@ -6,7 +6,9 @@ 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 { logger } from '../../nuxt-cli/src/utils/logger' +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' const _main = defineCommand({ @@ -22,9 +24,17 @@ const _main = defineCommand({ return } - // Check Node.js version and CLI updates in background if (provider !== 'stackblitz') { + // The engine check is awaited so its warning cannot land in the middle of + // a prompt, but the update check is left running: it reaches the user + // through a `process.exit` handler, and a slow or unreachable registry + // must never hold up scaffolding. await checkEngines().catch(err => logger.error(String(err))) + void scheduleSelfUpdateNudge(name, version, { + name, + command: getCreateCommand(), + shouldNudge: () => !isPinnedCreateInvocation(), + }).catch(err => debug('Failed to check for updates:', err)) } await init.run?.(ctx) diff --git a/packages/nuxt-cli/src/commands/init.ts b/packages/nuxt-cli/src/commands/init.ts index 08822d8d3..c688c3522 100644 --- a/packages/nuxt-cli/src/commands/init.ts +++ b/packages/nuxt-cli/src/commands/init.ts @@ -7,9 +7,8 @@ import type { TemplateData } from '../utils/starter-templates' import { existsSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import process from 'node:process' -import { Writable } from 'node:stream' -import { box, cancel, confirm, intro, isCancel, outro, S_BAR, select, spinner, text } from '@clack/prompts' +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' @@ -22,6 +21,7 @@ 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' @@ -34,7 +34,6 @@ import { checkNuxtCompatibility, fetchModules, MODULES_API_URL } from './module/ import addModuleCommand from './module/add' const NON_WORD_RE = /[^\w-]/g -const TRAILING_NEWLINE_RE = /\n$/ const MULTI_DASH_RE = /-{2,}/g const LEADING_TRAILING_DASH_RE = /^-|-$/g @@ -108,7 +107,7 @@ export async function useYarnNodeModulesLinker(dir: string): Promise { } /** - * Commands to print in the closing 'Next steps' box, in the order to run them. + * 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. @@ -204,6 +203,9 @@ export default defineCommand({ 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) } @@ -216,6 +218,10 @@ export default defineCommand({ 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) { @@ -287,6 +293,7 @@ export default defineCommand({ } templateName = result + prompted = true } // Fallback to default if still not set @@ -312,6 +319,7 @@ export default defineCommand({ } dir = result + prompted = true } const cwd = resolve(ctx.args.cwd) @@ -469,7 +477,7 @@ export default defineCommand({ let installFailure: InstallResult | undefined let ignoredBuilds: string[] = [] // Commands the user still has to run before the project works, surfaced in - // the closing "Next steps" box rather than as separate notes. + // the closing "Next steps" section rather than as separate notes. const recoveryCommands: string[] = [] const currentPackageManager = detectCurrentPackageManager() @@ -529,6 +537,7 @@ export default defineCommand({ } selectedPackageManager = result + prompted = true } if (selectedPackageManager === 'yarn' && await useYarnNodeModulesLinker(template.dir)) { @@ -548,12 +557,12 @@ export default defineCommand({ } gitInit = result + prompted = true } // Install project dependencies and initialize git // or skip installation based on the '--no-install' flag - // citty v0.2.0 with node:util.parseArgs returns 'false' string for --install=false - if (ctx.args.install === false || (ctx.args.install as unknown) === 'false' || skipInstallOnConflict) { + if (!installRequested || skipInstallOnConflict) { if (!skipInstallOnConflict) { logger.info('Skipping install dependencies step.') } @@ -626,24 +635,23 @@ export default defineCommand({ } 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 (ctx.args.modules) { - logger.warn(`Skipping module installation. Add ${ctx.args.modules.split(',').map(m => colors.cyan(m.trim())).join(', ')} with ${colors.cyan('nuxt module add')} once dependencies are installed.`) + if (requestedModules.length) { + logger.warn(`Skipping module installation. Add ${requestedModules.map(mod => colors.cyan(mod)).join(', ')} with ${colors.cyan('nuxt module add')} once dependencies are installed.`) } } // Get modules from arg (if provided) else if (ctx.args.modules !== undefined) { - // ctx.args.modules is false when --no-modules is used - for (const segment of (ctx.args.modules || '').split(',')) { - const mod = segment.trim() - if (mod) { - modulesToAdd.push(mod) - } - } + modulesToAdd.push(...requestedModules) } // ...or offer to browse and install modules (if not offline nor non-interactive) @@ -666,6 +674,8 @@ export default defineCommand({ process.exit(1) } + prompted = true + if (wantsUserModules) { const modulesSpinner = spinner() modulesSpinner.start('Fetching available modules') @@ -722,7 +732,7 @@ export default defineCommand({ const args: string[] = [ ...modulesToAdd, `--cwd=${templateDownloadPath}`, - ctx.args.install && !skipInstallOnConflict ? '' : '--skipInstall', + installRequested && !skipInstallOnConflict ? '' : '--skipInstall', `--packageManager=${selectedPackageManager}`, ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '', ].filter(Boolean) @@ -730,34 +740,53 @@ export default defineCommand({ await runCommand(addModuleCommand, args) } - // Display next steps + if (installFailure) { + logger.warn(`Created your project from the ${colors.cyan(template.name)} template, but its dependencies are not installed.`) + } + else { + logger.step(`Created your project from the ${colors.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 => colors.dim(line)), + ].join('\n')) + } + const nextSteps = getNextSteps({ - dir: relative(process.cwd(), template.dir) || '.', + dir: projectDir, shell: !!ctx.args.shell, installFailure, recoveryCommands, packageManager: selectedPackageManager, - }).map(step => colors.cyan(step)) - - logger.message() - writeWithGuide(output => box(`\n${nextSteps.map(step => ` › ${step}`).join('\n')}\n`, ` 👉 Next steps `, { - contentAlign: 'left', - titleAlign: 'left', - width: 'auto', - titlePadding: 2, - contentPadding: 2, - rounded: true, - withGuide: false, - formatBorder: (text: string) => `${themeColor + text}\x1B[0m`, - output, - })) + }) - if (installFailure) { - outro(`Created the project from the ${colors.cyan(template.name)} template, but its dependencies are not installed.`) - } - else { - outro(`✨ Nuxt project has been created with the ${colors.cyan(template.name)} template.`) - } + logger.message([ + 'Next steps:', + ...nextSteps.map(step => `› ${colors.cyan(step)}`), + ], { symbol: colors.gray(S_BAR) }) + + outro('✨ Happy building!') if (installFailure) { process.exitCode = 1 @@ -862,26 +891,6 @@ export async function detectTemplatePackageManager(templateDir: string): Promise return { name: detected.name, version: detected.version } } -/** - * Print a clack block behind the prompt gutter. `withGuide` draws the bar in the - * terminal's default colour rather than the grey clack uses everywhere else, so - * the block is rendered to a buffer and re-emitted with a matching gutter. - */ -function writeWithGuide(render: (output: Writable) => void) { - const chunks: string[] = [] - render(new Writable({ - write(chunk, _encoding, callback) { - chunks.push(String(chunk)) - callback() - }, - })) - - const gutter = colors.gray(S_BAR) - for (const line of chunks.join('').replace(TRAILING_NEWLINE_RE, '').split('\n')) { - process.stdout.write(`${gutter} ${line}\n`) - } -} - function isVerbose(logLevel?: string) { return logLevel === 'verbose' || Boolean(process.env.DEBUG) } diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index ff4e56552..d652cfea0 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -15,11 +15,13 @@ import { runCommand } from './run' import { normaliseCwdArg } from './utils/args' import { setupGlobalConsole } from './utils/console' import { checkEngines } from './utils/engines' - -import { logger } from './utils/logger' +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 { scheduleUpdateNudge } from './utils/update' +import { scheduleSelfUpdateNudge } from './utils/update-check' // 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 @@ -46,18 +48,18 @@ const _main = defineCommand({ const command = ctx.args._[0] setupGlobalConsole({ dev: command === 'dev' }) - // Check Node.js version and Nuxt updates in background - let backgroundTasks: Promise | undefined if (command !== '_dev' && provider !== 'stackblitz') { - backgroundTasks = Promise.all([ - checkEngines(), - scheduleUpdateNudge(resolve(ctx.args.cwd), command), - ]).catch(err => logger.error(String(err))) - } - - // Avoid background check to fix prompt issues - if (command === 'init') { - await backgroundTasks + // The engine check is awaited so its warning cannot land in the middle of + // a prompt, but the update checks are left running: they reach the user + // 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)) } if (command === 'add' && ctx.rawArgs[1] && templateNames.includes(ctx.rawArgs[1] as TemplateName)) { diff --git a/packages/nuxt-cli/src/utils/headless.ts b/packages/nuxt-cli/src/utils/headless.ts new file mode 100644 index 000000000..860aa767a --- /dev/null +++ b/packages/nuxt-cli/src/utils/headless.ts @@ -0,0 +1,223 @@ +import type { PackageManagerName } from 'nypm' + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import process from 'node:process' + +import { basename } from 'pathe' +import { isWindows } from 'std-env' + +import { debug } from './logger' + +const BIN_EXTENSION_RE = /\.[cm]?js$/ +const NEEDS_QUOTING_RE = /[\s"'$`\\]/ +const SINGLE_QUOTE_RE = /'/g +const BACKSLASHES_BEFORE_QUOTE_RE = /(\\*)"/g +const TRAILING_BACKSLASHES_RE = /(\\*)$/ +const CREATE_BIN_RE = /^create-nuxt(?:-app)?$/ + +// `@latest` everywhere: package managers happily reuse a cached `create-nuxt`, +// so an unpinned invocation can keep scaffolding from a stale version. +const createCommands: Partial> = { + npm: 'npm create nuxt@latest', + pnpm: 'pnpm create nuxt@latest', + yarn: 'yarn create nuxt@latest', + bun: 'bun create nuxt@latest', + deno: 'deno run -A npm:create-nuxt@latest', +} + +function currentPackageManager(userAgent: string | undefined): PackageManagerName | undefined { + const name = userAgent?.split('/')[0] + return name && name in createCommands ? name as PackageManagerName : undefined +} + +/** + * The command a user would type to reach the current invocation, used as the + * prefix of the non-interactive command we suggest. `create-nuxt` is reached + * through a package manager, so it is echoed back in that form rather than as + * the bin name, which is not on the user's `PATH`. + */ +export function getInvocationPrefix(argv: string[] = process.argv, userAgent = process.env.npm_config_user_agent): string { + return isCreateInvocation(argv) ? getCreateCommand(userAgent) : 'nuxt init' +} + +/** Whether the CLI was reached through `create-nuxt` rather than `nuxt init`. */ +function isCreateInvocation(argv: string[]): boolean { + return CREATE_BIN_RE.test(basename(argv[1] || '').replace(BIN_EXTENSION_RE, '')) +} + +/** How to scaffold a project with the latest release, in the user's package manager. */ +export function getCreateCommand(userAgent = process.env.npm_config_user_agent): string { + return createCommands[currentPackageManager(userAgent) ?? 'npm']! +} + +const PINNED_CREATE_RE = /\bcreate[ -]nuxt(?:-app)?@/ +const PROC_PPID_RE = /^\d+ \S+ \S+ (\d+)/ + +/** + * The command lines of this process' ancestors, nearest first. The invoking + * package manager is not our direct parent in every setup, so a few generations + * are walked. Returns an empty list when the platform gives us no way to look. + */ +function getAncestorCommands(depth = 4): string[] { + const commands: string[] = [] + try { + if (process.platform === 'linux') { + let pid = process.ppid + for (let level = 0; level < depth && pid > 1; level++) { + commands.push(readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ').trim()) + // `comm` may contain spaces, so ppid is read past the parenthesised name. + pid = Number(readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/\(.*\)/, 'x').match(PROC_PPID_RE)?.[1]) + } + return commands + } + + const listing = execFileSync('ps', ['-Ao', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 1000 }) + const parents = new Map() + for (const line of listing.split('\n')) { + const [pid, ppid, ...command] = line.trim().split(/ +/) + if (pid && /^\d+$/.test(pid)) { + parents.set(Number(pid), { ppid: Number(ppid), command: command.join(' ') }) + } + } + for (let pid = process.ppid, level = 0; level < depth && pid > 1; level++) { + const parent = parents.get(pid) + if (!parent) { + break + } + commands.push(parent.command) + pid = parent.ppid + } + } + catch (error) { + debug('Failed to inspect parent processes:', error) + } + return commands +} + +/** + * Whether the user pinned a version or tag when invoking the scaffolder, e.g. + * `pnpm create nuxt@latest`. Package managers do not pass the requested spec + * down to the package they run, so it is recovered from the invoking command. + * Defaults to `false` when nothing can be determined. + */ +export function isPinnedCreateInvocation(commands: string[] = getAncestorCommands()): boolean { + return commands.some(command => PINNED_CREATE_RE.test(command)) +} + +/** + * The line-continuation marker for the shell the user is most likely in, so a + * wrapped command can still be pasted as a single invocation. + */ +function getContinuation(windows: boolean, env: NodeJS.ProcessEnv): string { + // Git Bash and other MSYS environments report as Windows but run a POSIX shell. + if (!windows || env.MSYSTEM) { + return '\\' + } + // Neither marker can be detected reliably, so this is a guess: `PSModulePath` + // is set in PowerShell, which needs a backtick, and absent in a bare cmd.exe, + // which needs `^`. A cmd.exe launched from PowerShell inherits it and loses. + return env.PSModulePath ? '`' : '^' +} + +/** + * Quote a value so the shell passes it through unchanged. POSIX shells still + * expand `$` and backticks inside double quotes, so single quotes are used + * there; cmd.exe and PowerShell have no single-quoted form in common. + * + * Windows argument parsing only treats a backslash as an escape when a quote + * follows it, so each run of backslashes is doubled in exactly those two places + * it would otherwise escape the quote we are adding. + */ +function quoteArgument(value: string, windows: boolean): string { + if (!NEEDS_QUOTING_RE.test(value)) { + return value + } + if (!windows) { + return `'${value.replace(SINGLE_QUOTE_RE, `'\\''`)}'` + } + const escaped = value + .replace(BACKSLASHES_BEFORE_QUOTE_RE, '$1$1\\"') + .replace(TRAILING_BACKSLASHES_RE, '$1$1') + return `"${escaped}"` +} + +export interface HeadlessCommandOptions { + prefix?: string + dir: string + template: string + packageManager: PackageManagerName + gitInit: boolean + install: boolean + force?: boolean + modules?: string[] + nightly?: string + windows?: boolean +} + +/** + * The arguments that reproduce a scaffold without any prompts, as separate + * tokens so they can be wrapped for display. + */ +export function getHeadlessCommand(options: HeadlessCommandOptions): string[] { + const { prefix = getInvocationPrefix(), dir, template, packageManager, gitInit, install, force, modules, nightly, windows = isWindows } = options + return [ + prefix, + quoteArgument(dir, windows), + `--template=${template}`, + `--packageManager=${packageManager}`, + gitInit ? '--gitInit' : '--no-gitInit', + modules?.length ? `--modules=${modules.join(',')}` : '--no-modules', + install ? '' : '--no-install', + force ? '--force' : '', + nightly ? `--nightly=${nightly}` : '', + ].filter(Boolean) +} + +export interface WrapOptions { + /** Columns available for the command itself, excluding any surrounding gutter. */ + width?: number + /** Spaces to indent continuation lines by. */ + indent?: number + windows?: boolean + env?: NodeJS.ProcessEnv +} + +/** + * Break a command into lines that fit `width`, joined with the shell's + * line-continuation marker so the result stays runnable when pasted. Tokens are + * never split, so a single token longer than `width` overflows its line. + */ +export function wrapCommand(tokens: string[], options: WrapOptions = {}): string[] { + const { width = 80, indent = 2, windows = isWindows, env = process.env } = options + const continuation = getContinuation(windows, env) + const lines: string[] = [] + let current = '' + + for (const token of tokens) { + if (!current) { + current = token + continue + } + if (current.length + token.length + 1 + continuation.length + 1 > width) { + lines.push(`${current} ${continuation}`) + current = ' '.repeat(indent) + token + continue + } + current += ` ${token}` + } + + if (current) { + lines.push(current) + } + + return lines +} + +/** + * A copy-pasteable command that scaffolds the same project again without any + * prompts, so the invocation can be scripted or handed to an agent. + */ +export function formatHeadlessCommand(options: HeadlessCommandOptions & WrapOptions): string[] { + return wrapCommand(getHeadlessCommand(options), options) +} diff --git a/packages/nuxt-cli/src/utils/paths.ts b/packages/nuxt-cli/src/utils/paths.ts index db1aec5ae..c2357b265 100644 --- a/packages/nuxt-cli/src/utils/paths.ts +++ b/packages/nuxt-cli/src/utils/paths.ts @@ -1,3 +1,4 @@ +import { existsSync, statSync } from 'node:fs' import { delimiter, relative } from 'node:path' import process from 'node:process' import { link } from 'clickable-path' @@ -28,6 +29,27 @@ export function resolveRootDir(args: { cwd?: string, rootDir?: string }): string return resolved } +/** + * The directory whose Nuxt version a command will actually use, given the root + * command's arguments. Commands taking a ROOTDIR positional (`nuxt dev + * playground`) run against it rather than against `--cwd`, and a version read + * from the wrong directory is worse than no version at all. Positionals that + * are not directories belong to some other command shape (`nuxt add page foo`). + * + * `args` are the root command's, so `_[0]` is the subcommand and `_[1]` is the + * first argument to it. + */ +export function resolveProjectDir(args: { cwd: string, _: string[] }): string { + const [, rootDir] = args._ + if (args.cwd === '.' && rootDir && !rootDir.startsWith('-')) { + const candidate = resolve(rootDir) + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + return candidate + } + } + return resolve(args.cwd) +} + export function relativeToProcess(path: string) { return relativeTo(cwd, path) } diff --git a/packages/nuxt-cli/src/utils/stdout.ts b/packages/nuxt-cli/src/utils/stdout.ts new file mode 100644 index 000000000..f2b41968a --- /dev/null +++ b/packages/nuxt-cli/src/utils/stdout.ts @@ -0,0 +1,60 @@ +import process from 'node:process' + +/** One blank line needs two newlines: one to end the last line, one to skip a row. */ +const BLANK_LINE = 2 + +/** + * How many newlines everything written so far ends with, capped at the most a + * caller can ask about. Starts at one because a shell hands us a cursor at the + * start of an empty line and nothing has been written to move it. + */ +let trailingNewlines = 1 +let tracking = false + +function isNewline(chunk: string | Uint8Array, index: number): boolean { + return typeof chunk === 'string' ? chunk[index] === '\n' : chunk[index] === 0x0A +} + +/** + * Record `chunk` as written to stdout. Exported for the tracker's tests; every + * other caller should go through {@link trackOutputSpacing}. + */ +export function observeOutput(chunk: string | Uint8Array): void { + if (!chunk.length) { + return + } + let newlines = 0 + while (newlines < chunk.length && isNewline(chunk, chunk.length - 1 - newlines)) { + newlines++ + } + // A chunk of nothing but newlines continues the run the last one left off at. + const total = newlines === chunk.length ? trailingNewlines + newlines : newlines + trailingNewlines = Math.min(total, BLANK_LINE) +} + +/** + * The newlines to write before a block of output for it to be separated from + * whatever came before by exactly one blank line, wherever the cursor is now. + */ +export function blankLineBefore(): string { + return '\n'.repeat(Math.max(0, BLANK_LINE - trailingNewlines)) +} + +/** + * Watch stdout so {@link blankLineBefore} knows how much vertical space is + * already on screen. Every writer has to be seen for the count to be right, so + * this is installed as soon as something knows it will print later, and stays. + */ +export function trackOutputSpacing(): void { + if (tracking) { + return + } + tracking = true + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: unknown, encoding?: unknown, callback?: unknown) => { + if (typeof chunk === 'string' || chunk instanceof Uint8Array) { + observeOutput(chunk) + } + return (write as (...args: unknown[]) => boolean)(chunk, encoding, callback) + }) as typeof process.stdout.write +} diff --git a/packages/nuxt-cli/src/utils/update-check.ts b/packages/nuxt-cli/src/utils/update-check.ts new file mode 100644 index 000000000..baf416cf2 --- /dev/null +++ b/packages/nuxt-cli/src/utils/update-check.ts @@ -0,0 +1,217 @@ +import process from 'node:process' + +import { S_INFO } from '@clack/prompts' +import colors from 'picocolors' +import { readUser, updateUser } from 'rc9' +import { isCI, isTest, provider } from 'std-env' +import { joinURL } from 'ufo' +import { isGreater, tryParse } from 'verkit' + +import { fetchJson } from './fetch' +import { debug } from './logger' +import { detectNpmRegistry } from './registry' +import { blankLineBefore, trackOutputSpacing } from './stdout' + +const RC_FILE = '.nuxtrc' +const CACHE_KEY = 'updateCheck' +const CACHE_TTL = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT = 3000 + +/** + * Patch releases within the same minor are only worth interrupting for once the + * user is meaningfully behind; a single patch is noise for anyone who upgrades + * regularly. + */ +const MIN_PATCH_DISTANCE = 5 + +export interface NuxtUpdate { + current: string + latest: string +} + +interface PackageCache { + latest?: string + checkedAt?: number +} + +interface UpdateCache extends PackageCache { + enabled?: boolean + /** Keyed by package name; `nuxt` lives at the top level for backwards compatibility. */ + packages?: Record +} + +function readCache(): UpdateCache { + try { + return (readUser(RC_FILE)[CACHE_KEY] as UpdateCache | undefined) || {} + } + catch (error) { + debug('Failed to read update check cache:', error) + return {} + } +} + +function writeCache(cache: UpdateCache) { + try { + updateUser({ [CACHE_KEY]: cache }, RC_FILE) + } + catch (error) { + debug('Failed to persist update check cache:', error) + } +} + +/** + * `NUXT_IGNORE_UPDATE_CHECK=1` (or the cross-tool `NO_UPDATE_NOTIFIER`) opts out + * for a single run and `updateCheck.enabled=false` in the user `.nuxtrc` opts + * out permanently. We also stay quiet where the nudge cannot be acted on + * interactively (CI, tests, StackBlitz, no TTY). + */ +export function isUpdateCheckEnabled(): boolean { + if (process.env.NUXT_IGNORE_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) { + return false + } + if (readCache().enabled === false) { + return false + } + return !isCI && !isTest && provider !== 'stackblitz' && Boolean(process.stdout.isTTY) +} + +async function resolveLatestVersion(name: string): Promise { + const cache = readCache() + const entry = name === 'nuxt' ? cache : cache.packages?.[name] || {} + if (entry.checkedAt && Date.now() - Number(entry.checkedAt) < CACHE_TTL) { + return entry.latest + } + + let latest: string | undefined + try { + const { registry, authToken } = await detectNpmRegistry(null) + latest = (await fetchJson<{ latest?: string }>(joinURL(registry, `-/package/${name}/dist-tags`), { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, + timeout: FETCH_TIMEOUT, + retry: 0, + })).latest + } + catch (error) { + debug(`Failed to resolve the latest ${name} version:`, error) + } + + // an unreachable or unauthenticated registry is recorded as a completed check + // so an offline user does not pay the request timeout on every command + const checked = { latest, checkedAt: Date.now() } + writeCache(name === 'nuxt' ? checked : { packages: { [name]: checked } }) + return latest +} + +/** + * Compare an installed version against the registry's `latest`, returning + * nothing when there is no nudge worth showing. + */ +export async function checkForUpdate(name: string, current: string | undefined): Promise { + const latest = await resolveLatestVersion(name) + if (!current || !latest) { + return undefined + } + + const installed = tryParse(current) + const published = tryParse(latest) + if (!installed || !published) { + return undefined + } + + // prereleases are compared against `latest`, which is never the right + // baseline for someone tracking nightlies or release candidates + if (installed.prerelease) { + return undefined + } + + if (!isGreater(latest, current)) { + return undefined + } + + if ( + published.major === installed.major + && published.minor === installed.minor + && published.patch - installed.patch < MIN_PATCH_DISTANCE + ) { + return undefined + } + + return { current, latest } +} + +export interface UpdateNudgeOptions { + /** Display name of the package, e.g. `Nuxt`. */ + name?: string + /** Command that gets the user onto the new version. */ + command?: string +} + +/** + * Both nudges are written as a labelled line plus an indented instruction, so + * whatever the user needs to type is on a line of its own that they can select. + * + * Commands leave the cursor in different places (`init` ends on the blank line + * after clack's outro, `dev` and `build` on the line after their last log), so + * the leading gap is measured rather than assumed. + */ +function writeNudge(headline: string, instruction: string): void { + process.stdout.write(`${blankLineBefore()}${colors.blue(S_INFO)} ${headline}\n ${instruction}\n`) +} + +function describeUpdate({ current, latest }: NuxtUpdate, name: string): string { + return `a new version of ${name} is available: ${colors.green(latest)} ${colors.gray(`(you are on ${current})`)}` +} + +export function renderUpdateNudge(update: NuxtUpdate, options: UpdateNudgeOptions = {}): void { + const { name = 'Nuxt', command = 'nuxt upgrade' } = options + writeNudge(describeUpdate(update, name), `run ${colors.cyan(command)} to update`) +} + +/** + * A nudge for a stale scaffolder, where the user has already got what they came + * for and only needs to know what to type next time. + */ +export function renderSelfUpdateNudge(update: NuxtUpdate, options: UpdateNudgeOptions = {}): void { + const { name = 'the Nuxt CLI', command = 'nuxt upgrade' } = options + writeNudge(describeUpdate(update, name), `next time, run ${colors.cyan(command)} to use the latest version`) +} + +export interface SelfUpdateNudgeOptions extends UpdateNudgeOptions { + /** + * Last word on whether to nudge, called only once a newer version is found so + * an expensive check is not paid for by an up-to-date user. + */ + shouldNudge?: () => boolean +} + +/** + * Nudge about a stale version of the CLI package itself. Unlike a project + * dependency there is nothing to upgrade in place: the user re-runs the + * published package, so `command` should point at that invocation. + */ +export async function scheduleSelfUpdateNudge(name: string, current: string, options: SelfUpdateNudgeOptions): Promise { + if (!isUpdateCheckEnabled()) { + return + } + + const update = await checkForUpdate(name, current).catch((error) => { + debug(`Failed to check for ${name} updates:`, error) + return undefined + }) + + if (update && (options.shouldNudge?.() ?? true)) { + deferNudge(update, options, renderSelfUpdateNudge) + } +} + +export function deferNudge(update: NuxtUpdate, options?: UpdateNudgeOptions, render = renderUpdateNudge) { + trackOutputSpacing() + process.once('exit', () => { + try { + render(update, options) + } + catch (error) { + debug('Failed to render update nudge:', error) + } + }) +} diff --git a/packages/nuxt-cli/src/utils/update.ts b/packages/nuxt-cli/src/utils/update.ts index 41080f090..640e9bb4f 100644 --- a/packages/nuxt-cli/src/utils/update.ts +++ b/packages/nuxt-cli/src/utils/update.ts @@ -1,98 +1,7 @@ -import process from 'node:process' +import type { NuxtUpdate } from './update-check' -import { box } from '@clack/prompts' -import colors from 'picocolors' -import { readUser, updateUser } from 'rc9' -import { isCI, isTest, provider } from 'std-env' -import { joinURL } from 'ufo' -import { isGreater, tryParse } from 'verkit' - -import { fetchJson } from './fetch' import { debug } from './logger' -import { detectNpmRegistry } from './registry' - -const RC_FILE = '.nuxtrc' -const CACHE_KEY = 'updateCheck' -const CACHE_TTL = 24 * 60 * 60 * 1000 -const FETCH_TIMEOUT = 3000 - -/** - * Patch releases within the same minor are only worth interrupting for once the - * user is meaningfully behind; a single patch is noise for anyone who upgrades - * regularly. - */ -const MIN_PATCH_DISTANCE = 5 - -export interface NuxtUpdate { - current: string - latest: string -} - -interface UpdateCache { - enabled?: boolean - latest?: string - checkedAt?: number -} - -function readCache(): UpdateCache { - try { - return (readUser(RC_FILE)[CACHE_KEY] as UpdateCache | undefined) || {} - } - catch (error) { - debug('Failed to read update check cache:', error) - return {} - } -} - -function writeCache(cache: UpdateCache) { - try { - updateUser({ [CACHE_KEY]: cache }, RC_FILE) - } - catch (error) { - debug('Failed to persist update check cache:', error) - } -} - -/** - * `NUXT_IGNORE_UPDATE_CHECK=1` (or the cross-tool `NO_UPDATE_NOTIFIER`) opts out - * for a single run and `updateCheck.enabled=false` in the user `.nuxtrc` opts - * out permanently. We also stay quiet where the nudge cannot be acted on - * interactively (CI, tests, StackBlitz, no TTY). - */ -export function isUpdateCheckEnabled(): boolean { - if (process.env.NUXT_IGNORE_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) { - return false - } - if (readCache().enabled === false) { - return false - } - return !isCI && !isTest && provider !== 'stackblitz' && Boolean(process.stdout.isTTY) -} - -async function resolveLatestVersion(): Promise { - const cache = readCache() - if (cache.checkedAt && Date.now() - Number(cache.checkedAt) < CACHE_TTL) { - return cache.latest - } - - let latest: string | undefined - try { - const { registry, authToken } = await detectNpmRegistry(null) - latest = (await fetchJson<{ latest?: string }>(joinURL(registry, '-/package/nuxt/dist-tags'), { - headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, - timeout: FETCH_TIMEOUT, - retry: 0, - })).latest - } - catch (error) { - debug('Failed to resolve the latest Nuxt version:', error) - } - - // an unreachable or unauthenticated registry is recorded as a completed check - // so an offline user does not pay the request timeout on every command - writeCache({ latest, checkedAt: Date.now() }) - return latest -} +import { checkForUpdate, deferNudge, isUpdateCheckEnabled } from './update-check' /** * Resolve the installed and latest Nuxt versions, returning nothing when the @@ -102,40 +11,8 @@ async function resolveLatestVersion(): Promise { export async function checkForNuxtUpdate(cwd: string): Promise { try { // imported lazily to keep package resolution off the CLI startup path - const [current, latest] = await Promise.all([ - import('./versions').then(({ getNuxtVersion }) => getNuxtVersion(cwd)).catch(() => undefined), - resolveLatestVersion(), - ]) - - if (!current || !latest) { - return undefined - } - - const installed = tryParse(current) - const published = tryParse(latest) - if (!installed || !published) { - return undefined - } - - // prereleases are compared against `latest`, which is never the right - // baseline for someone tracking nightlies or release candidates - if (installed.prerelease) { - return undefined - } - - if (!isGreater(latest, current)) { - return undefined - } - - if ( - published.major === installed.major - && published.minor === installed.minor - && published.patch - installed.patch < MIN_PATCH_DISTANCE - ) { - return undefined - } - - return { current, latest } + const current = await import('./versions').then(({ getNuxtVersion }) => getNuxtVersion(cwd)).catch(() => undefined) + return await checkForUpdate('nuxt', current) } catch (error) { debug('Failed to check for Nuxt updates:', error) @@ -143,37 +20,7 @@ export async function checkForNuxtUpdate(cwd: string): Promise { - if (command === 'upgrade' || !isUpdateCheckEnabled()) { + if ((command && UNNUDGED_COMMANDS.has(command)) || !isUpdateCheckEnabled()) { return } const update = await checkForNuxtUpdate(cwd) - if (!update) { - return + if (update) { + deferNudge(update) } - - process.once('exit', () => { - try { - renderUpdateNudge(update) - } - catch (error) { - debug('Failed to render update nudge:', error) - } - }) } diff --git a/packages/nuxt-cli/test/unit/utils/headless.spec.ts b/packages/nuxt-cli/test/unit/utils/headless.spec.ts new file mode 100644 index 000000000..bc536b5e9 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/headless.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { getHeadlessCommand, getInvocationPrefix, isPinnedCreateInvocation, wrapCommand } from '../../../src/utils/headless' + +describe('getInvocationPrefix', () => { + it('should suggest the package manager create command', () => { + expect(getInvocationPrefix(['node', '/tmp/x/create-nuxt.mjs'], 'pnpm/9.0.0 npm/? node/v22')).toBe('pnpm create nuxt@latest') + }) + + it('should fall back to npm for an unknown package manager', () => { + expect(getInvocationPrefix(['node', '/tmp/x/create-nuxt-app.mjs'], 'ni/1.0.0')).toBe('npm create nuxt@latest') + }) + + it('should use the nuxt subcommand for the nuxt cli', () => { + expect(getInvocationPrefix(['node', '/tmp/x/nuxi.mjs'], 'npm/10.0.0')).toBe('nuxt init') + }) +}) + +describe('getHeadlessCommand', () => { + const base = { prefix: 'nuxt init', dir: 'my-app', template: 'minimal', packageManager: 'pnpm' as const, gitInit: true, install: true, windows: false } + + it('should include every prompted argument', () => { + expect(getHeadlessCommand(base).join(' ')).toBe('nuxt init my-app --template=minimal --packageManager=pnpm --gitInit --no-modules') + }) + + it('should negate declined options', () => { + expect(getHeadlessCommand({ ...base, gitInit: false, install: false }).join(' ')).toBe('nuxt init my-app --template=minimal --packageManager=pnpm --no-gitInit --no-modules --no-install') + }) + + it('should list selected modules and quote a directory with spaces', () => { + expect(getHeadlessCommand({ ...base, dir: 'my app', modules: ['@nuxt/ui', '@nuxt/image'], force: true }).join(' ')).toBe('nuxt init \'my app\' --template=minimal --packageManager=pnpm --gitInit --modules=@nuxt/ui,@nuxt/image --force') + }) + + it('should carry the nightly channel through', () => { + expect(getHeadlessCommand({ ...base, nightly: 'latest' }).join(' ')).toContain('--nightly=latest') + }) + + it('should quote so the shell cannot expand the directory', () => { + expect(getHeadlessCommand({ ...base, dir: 'my $app' })[1]).toBe(`'my $app'`) + expect(getHeadlessCommand({ ...base, dir: `it's mine` })[1]).toBe(`'it'\\''s mine'`) + expect(getHeadlessCommand({ ...base, dir: 'my $app', windows: true })[1]).toBe('"my $app"') + }) + + it('should double the backslashes windows would read as escaping a quote', () => { + const quote = (dir: string) => getHeadlessCommand({ ...base, dir, windows: true })[1] + expect(quote('my dir\\')).toBe('"my dir\\\\"') + expect(quote('a\\"b')).toBe('"a\\\\\\"b"') + // Backslashes away from a quote are literal and must be left alone. + expect(quote('a\\b c')).toBe('"a\\b c"') + }) +}) + +describe('wrapCommand', () => { + const tokens = ['pnpm create nuxt', 'my-app', '--template=minimal', '--packageManager=pnpm', '--no-gitInit', '--no-modules'] + + it('should leave a command that fits on one line', () => { + expect(wrapCommand(tokens, { width: 120, windows: false })).toEqual([tokens.join(' ')]) + }) + + it('should wrap on argument boundaries within the given width', () => { + const lines = wrapCommand(tokens, { width: 50, windows: false }) + expect(lines).toEqual([ + 'pnpm create nuxt my-app --template=minimal \\', + ' --packageManager=pnpm --no-gitInit \\', + ' --no-modules', + ]) + expect(Math.max(...lines.map(line => line.length))).toBeLessThanOrEqual(50) + }) + + it('should use the continuation marker of the likely shell', () => { + expect(wrapCommand(tokens, { width: 40, windows: true, env: { PSModulePath: 'C:\\Modules' } })[0]).toMatch(/`$/) + expect(wrapCommand(tokens, { width: 40, windows: true, env: {} })[0]).toMatch(/\^$/) + expect(wrapCommand(tokens, { width: 40, windows: true, env: { MSYSTEM: 'MINGW64' } })[0]).toMatch(/\\$/) + }) + + it('should not split a token longer than the width', () => { + expect(wrapCommand(['nuxt init', '--modules=@nuxt/ui,@nuxt/image,@nuxt/content'], { width: 20, windows: false })).toEqual([ + 'nuxt init \\', + ' --modules=@nuxt/ui,@nuxt/image,@nuxt/content', + ]) + }) +}) + +describe('isPinnedCreateInvocation', () => { + it.each([ + 'pnpm create nuxt@latest my-app', + 'npm create nuxt@latest', + 'npx create-nuxt@4.2.0', + 'bun create nuxt-app@latest', + ])('should detect a pinned invocation (%s)', (command) => { + expect(isPinnedCreateInvocation([command])).toBe(true) + }) + + it.each([ + 'pnpm create nuxt my-app', + 'npx create-nuxt', + 'node /path/to/create-nuxt.mjs', + ])('should not treat an unpinned invocation as pinned (%s)', (command) => { + expect(isPinnedCreateInvocation([command])).toBe(false) + }) + + it('should find the invocation further up the process tree', () => { + expect(isPinnedCreateInvocation(['node /npx/create-nuxt', 'npm exec create-nuxt@latest', '-zsh'])).toBe(true) + }) + + it('should assume nothing was pinned when the process tree is unavailable', () => { + expect(isPinnedCreateInvocation([])).toBe(false) + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/paths.spec.ts b/packages/nuxt-cli/test/unit/utils/paths.spec.ts index 96343186c..cd40227cb 100644 --- a/packages/nuxt-cli/test/unit/utils/paths.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/paths.spec.ts @@ -5,7 +5,7 @@ import { resolve } from 'pathe' import { afterEach, describe, expect, it, vi } from 'vitest' import { logger } from '../../../src/utils/logger' -import { relativeToProcess, resolveRootDir } from '../../../src/utils/paths' +import { relativeToProcess, resolveProjectDir, resolveRootDir } from '../../../src/utils/paths' describe('relativeToProcess', () => { it('should label paths relative to the working directory', () => { @@ -55,3 +55,25 @@ describe('resolveRootDir', () => { expect(warn).not.toHaveBeenCalled() }) }) + +describe('resolveProjectDir', () => { + it('should follow the ROOTDIR positional a command will run against', () => { + expect(resolveProjectDir({ cwd: '.', _: ['dev', 'packages'] })).toBe(resolve('packages')) + }) + + it('should fall back to the working directory without a positional', () => { + expect(resolveProjectDir({ cwd: '.', _: ['dev'] })).toBe(resolve('.')) + }) + + it.each([ + ['a subcommand', ['module', 'add', '@nuxt/ui']], + ['a template name', ['add', 'page', 'foo']], + ['a passthrough flag', ['test', '--watch']], + ])('should ignore %s', (_label, args) => { + expect(resolveProjectDir({ cwd: '.', _: args })).toBe(resolve('.')) + }) + + it('should let an explicit --cwd win', () => { + expect(resolveProjectDir({ cwd: 'packages', _: ['dev', 'scripts'] })).toBe(resolve('packages')) + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/stdout.spec.ts b/packages/nuxt-cli/test/unit/utils/stdout.spec.ts new file mode 100644 index 000000000..baa54a38f --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/stdout.spec.ts @@ -0,0 +1,82 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { Writable } from 'node:stream' + +import { outro } from '@clack/prompts' +import { describe, expect, it } from 'vitest' + +import { blankLineBefore, observeOutput, trackOutputSpacing } from '../../../src/utils/stdout' + +/** The gap a nudge would leave after `previous` was written. */ +function gapAfter(previous: string): string { + observeOutput(previous) + return blankLineBefore() +} + +describe('blankLineBefore', () => { + it.each([ + ['a command that ended its last line', 'Build complete\n', '\n'], + ['clack\'s outro, which already leaves a blank line', '└ Happy building!\n\n', ''], + ['output that stopped mid-line', 'Building...', '\n\n'], + ['more blank lines than are needed', 'done\n\n\n\n', ''], + ])('leaves one blank line after %s', (_label, previous, expected) => { + expect(gapAfter(previous)).toBe(expected) + }) + + it('should count newlines written on their own', () => { + observeOutput('Build complete\n') + observeOutput('\n') + expect(blankLineBefore()).toBe('') + }) + + it('should ignore an empty write', () => { + observeOutput('Building...') + observeOutput('') + expect(blankLineBefore()).toBe('\n\n') + }) + + it('should read the tail of a buffer as it would a string', () => { + expect(gapAfter('reset\n')).toBe('\n') + observeOutput(Buffer.from('café\n\n', 'utf8')) + expect(blankLineBefore()).toBe('') + }) + + // `init` signs off with clack's outro, which is the one caller that already + // leaves a blank line behind it. + it('should add nothing after a real clack outro', () => { + let written = '' + const output = new Writable({ + write(chunk, _encoding, callback) { + written += String(chunk) + callback() + }, + }) + outro('Happy building!', { output }) + + gapAfter('reset\n') + observeOutput(written) + expect(blankLineBefore()).toBe('') + }) +}) + +describe('trackOutputSpacing', () => { + it('should observe what other code writes to stdout', () => { + const original = process.stdout.write + const written: string[] = [] + process.stdout.write = ((chunk: string) => { + written.push(chunk) + return true + }) as typeof process.stdout.write + + try { + trackOutputSpacing() + gapAfter('reset\n') + process.stdout.write('a line of output\n\n') + expect(blankLineBefore()).toBe('') + expect(written).toEqual(['a line of output\n\n']) + } + finally { + process.stdout.write = original + } + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/update.spec.ts b/packages/nuxt-cli/test/unit/utils/update.spec.ts index 61c18cb8a..087826814 100644 --- a/packages/nuxt-cli/test/unit/utils/update.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/update.spec.ts @@ -1,6 +1,8 @@ import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '../../utils/terminal' + const stdEnv = vi.hoisted(() => ({ isCI: false, isTest: false, provider: 'unknown' as string })) const rcStore = vi.hoisted(() => ({ current: {} as Record })) const project = vi.hoisted(() => ({ nuxtVersion: undefined as string | undefined })) @@ -48,7 +50,8 @@ vi.mock('pkg-types', () => ({ }, })) -const { checkForNuxtUpdate, isUpdateCheckEnabled, renderUpdateNudge, scheduleUpdateNudge } = await import('../../../src/utils/update') +const { isUpdateCheckEnabled, renderSelfUpdateNudge, renderUpdateNudge, scheduleSelfUpdateNudge } = await import('../../../src/utils/update-check') +const { checkForNuxtUpdate, scheduleUpdateNudge } = await import('../../../src/utils/update') describe('update check', () => { const originalIsTTY = process.stdout.isTTY @@ -221,8 +224,8 @@ describe('update check', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('does not check when running the upgrade command', async () => { - await scheduleUpdateNudge('/project', 'upgrade') + it.each(['upgrade', 'init'])('does not check when running the %s command', async (command) => { + await scheduleUpdateNudge('/project', command) expect(fetchMock).not.toHaveBeenCalled() }) @@ -246,32 +249,85 @@ describe('update check', () => { }) }) + describe('scheduleSelfUpdateNudge', () => { + it('checks the given package rather than the project', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + const once = vi.spyOn(process, 'once') + await scheduleSelfUpdateNudge('create-nuxt', '4.0.0', { name: 'create-nuxt', command: 'pnpm create nuxt@latest' }) + expect(fetchMock.mock.calls[0]![0]).toContain('/-/package/create-nuxt/dist-tags') + expect(once).toHaveBeenCalledWith('exit', expect.any(Function)) + for (const [event, listener] of once.mock.calls) { + process.off(event as string, listener as () => void) + } + once.mockRestore() + }) + + it('does not nudge when the caller declines', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + const once = vi.spyOn(process, 'once') + await scheduleSelfUpdateNudge('create-nuxt', '4.0.0', { shouldNudge: () => false }) + expect(once).not.toHaveBeenCalled() + once.mockRestore() + }) + + it('does not ask the caller when already up to date', async () => { + fetchMock.mockResolvedValue({ latest: '4.0.0' }) + const shouldNudge = vi.fn(() => true) + await scheduleSelfUpdateNudge('create-nuxt', '4.0.0', { shouldNudge }) + expect(shouldNudge).not.toHaveBeenCalled() + }) + + it('caches a package check separately from the nuxt one', async () => { + rcStore.current = { updateCheck: { latest: '4.9.0', checkedAt: Date.now() } } + fetchMock.mockResolvedValue({ latest: '4.0.0' }) + await scheduleSelfUpdateNudge('create-nuxt', '4.0.0', {}) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect((rcStore.current.updateCheck as any).packages['create-nuxt'].latest).toBe('4.0.0') + }) + }) + + // Rendered through a virtual terminal so the assertions describe what the + // user sees, whether or not the environment running them supports colour. + describe('renderSelfUpdateNudge', () => { + it('tells the user what to run next time, without a box', async () => { + const output = screen(await render(() => { + renderSelfUpdateNudge({ current: '4.4.6', latest: '4.5.1' }, { name: 'create-nuxt', command: 'pnpm create nuxt@latest' }) + })) + expect(output).toContain('a new version of create-nuxt is available: 4.5.1 (you are on 4.4.6)') + expect(output).toContain('next time, run pnpm create nuxt@latest to use the latest version') + expect(output).not.toContain('╭') + }) + }) + describe('renderUpdateNudge', () => { const originalColumns = process.stdout.columns - function captureNudge(columns: number) { - const chunks: string[] = [] - process.stdout.columns = columns - const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { - chunks.push(String(chunk)) - return true - }) - renderUpdateNudge({ current: '4.0.0', latest: '4.1.0' }) - write.mockRestore() + afterEach(() => { process.stdout.columns = originalColumns - return chunks.join('') + }) + + async function captureNudge(columns: number, options?: { name?: string, command?: string }) { + process.stdout.columns = columns + return screen(await render(() => { + renderUpdateNudge({ current: '4.0.0', latest: '4.1.0' }, options) + })) } - it('shows both versions and the upgrade command in a box', () => { - const output = captureNudge(100) - expect(output).toContain('4.1.0') - expect(output).toContain('4.0.0') - expect(output).toContain('nuxt upgrade') - expect(output).toContain('╭') + it('shows both versions and the upgrade command without a box', async () => { + const output = await captureNudge(100) + expect(output).toContain('a new version of Nuxt is available: 4.1.0 (you are on 4.0.0)') + expect(output).toContain('run nuxt upgrade to update') + expect(output).not.toContain('╭') + }) + + it('shows a custom package name and command', async () => { + const output = await captureNudge(100, { name: 'create-nuxt', command: 'pnpm create nuxt@latest' }) + expect(output).toContain('create-nuxt') + expect(output).toContain('pnpm create nuxt@latest') }) - it('falls back to plain lines in a narrow terminal', () => { - const output = captureNudge(0) + it('does not depend on the terminal width', async () => { + const output = await captureNudge(0) expect(output).toContain('4.1.0') expect(output).toContain('nuxt upgrade') expect(output).not.toContain('╭')