From 8dd7faa9b9e5bf549133c2fdc671bb11ec4673bd Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 28 Sep 2026 20:23:17 +0000 Subject: [PATCH 1/2] fix: avoid spinner frames + ansi help when not a tty --- packages/create-nuxt/src/init.ts | 21 +++--- packages/nuxt-cli/src/commands/typecheck.ts | 5 +- packages/nuxt-cli/src/commands/upgrade.ts | 15 +++-- packages/nuxt-cli/src/dev/preflight.ts | 5 +- packages/nuxt-cli/src/run.ts | 14 +++- packages/nuxt-cli/src/utils/spinner.ts | 64 +++++++++++-------- packages/nuxt-cli/test/unit/help.spec.ts | 20 +++++- .../nuxt-cli/test/unit/utils/spinner.spec.ts | 32 +++++++--- 8 files changed, 115 insertions(+), 61 deletions(-) diff --git a/packages/create-nuxt/src/init.ts b/packages/create-nuxt/src/init.ts index 67b029e83..ba22bf953 100644 --- a/packages/create-nuxt/src/init.ts +++ b/packages/create-nuxt/src/init.ts @@ -3,13 +3,13 @@ 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 { cancel, confirm, intro, isCancel, outro, S_BAR, select, text } from '@clack/prompts' import { defineCommand, showUsage } from 'citty' import { downloadTemplate, startShell } from 'giget' import { detectPackageManager } from 'nypm' @@ -17,8 +17,8 @@ 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' @@ -30,6 +30,7 @@ import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnre 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 { createSpinner } from '../../nuxt-cli/src/utils/spinner' import { getTemplates, TEMPLATES_API_URL } from '../../nuxt-cli/src/utils/starter-templates' import { paint } from '../../nuxt-cli/src/utils/terminal-theme' import { getNuxtVersion } from '../../nuxt-cli/src/utils/versions' @@ -236,7 +237,7 @@ export default defineCommand({ availableTemplates = defaultTemplates } else { - const templatesSpinner = spinner() + const templatesSpinner = createSpinner() templatesSpinner.start('Loading available templates') try { @@ -384,7 +385,7 @@ export default defineCommand({ const registry = process.env.NUXI_INIT_REGISTRY || DEFAULT_REGISTRY - const downloadSpinner = spinner() + const downloadSpinner = createSpinner() downloadSpinner.start(`Downloading ${styleText('cyan', templateName)} template`) try { @@ -436,7 +437,7 @@ export default defineCommand({ } if (ctx.args.nightly !== undefined && !ctx.args.offline && !ctx.args.preferOffline) { - const nightlySpinner = spinner() + const nightlySpinner = createSpinner() nightlySpinner.start('Fetching nightly version info') const response = await fetchJson<{ 'dist-tags': Record }>(NIGHTLY_DIST_TAGS_URL).catch((err) => { @@ -568,7 +569,7 @@ export default defineCommand({ else { const installController = new AbortController() const installLog = createInstallLog({ verbose: isVerbose(ctx.args.logLevel) }) - const installSpinner = spinner({ + const installSpinner = createSpinner({ indicator: 'timer', onCancel: () => installController.abort(), }) @@ -619,7 +620,7 @@ export default defineCommand({ } if (gitInit) { - const gitSpinner = spinner() + const gitSpinner = createSpinner() gitSpinner.start('Initializing git repository') const git = await x('git', ['init'], { @@ -674,7 +675,7 @@ export default defineCommand({ prompted = true if (wantsUserModules) { - const modulesSpinner = spinner() + const modulesSpinner = createSpinner() modulesSpinner.start('Fetching available modules') const [response, templateDeps, nuxtVersion] = await Promise.all([ diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 15c8645ff..dbd44ec30 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -4,7 +4,7 @@ import { writeFile } from 'node:fs/promises' import process from 'node:process' import { styleText } from 'node:util' -import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts' +import { cancel, confirm, isCancel, select } from '@clack/prompts' import { defineCommand } from 'citty' import { resolveModulePath } from 'exsolve' import { addDevDependency, detectPackageManager } from 'nypm' @@ -18,6 +18,7 @@ import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { withNodePath } from '../utils/resolve-nuxt' +import { createSpinner } from '../utils/spinner' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' type TypeChecker = 'vue-tsc' | 'golar' @@ -355,7 +356,7 @@ async function installMissingPackages(options: { return false } - const spin = spinner() + const spin = createSpinner() spin.start(`Installing ${list} with ${styleText('cyan', pmName)}`) try { await addDevDependency(packages, { cwd, packageManager, silent: true }) diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index db58f0959..3d1316574 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -2,24 +2,25 @@ import type { PackageJson } from 'pkg-types' import type { UpdateCatalogEntriesResult } from '../utils/catalog' import type { InstallResult } from '../utils/install' - import { existsSync } from 'node:fs' -import process from 'node:process' +import process from 'node:process' import { styleText } from 'node:util' -import { cancel, isCancel, note, select, spinner } from '@clack/prompts' + +import { cancel, isCancel, note, select } from '@clack/prompts' import { defineCommand } from 'citty' import { detectPackageManager } from 'nypm' import { dirname, relative, resolve } from 'pathe' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' - import { resolveCatalogEntry, updateCatalogEntries } from '../utils/catalog' + import { createInstallLog, runDedupe, runInstall, takeUnreportedIgnoredBuilds } from '../utils/install' import { loadKit } from '../utils/kit' import { intro, logger, outro } from '../utils/logger' import { cleanupNuxtDirs, nuxtVersionToGitIdentifier } from '../utils/nuxt' import { getPackageManagerVersion } from '../utils/packageManagers' import { relativeToProcess, resolveRootDir } from '../utils/paths' +import { createSpinner } from '../utils/spinner' import { getNuxtVersion, resolveRegistryVersion } from '../utils/versions' import { logLevelArgs, rootDirArgs } from './_shared' @@ -269,7 +270,7 @@ export default defineCommand({ let catalogResult: UpdateCatalogEntriesResult | 'skipped' = 'skipped' if (catalogUpdates.length > 0) { - const catalogSpinner = spinner() + const catalogSpinner = createSpinner() catalogSpinner.start('Updating catalog entries') const resolved: Array<{ catalog: string, pkg: string, specifier: string }> = [] @@ -352,7 +353,7 @@ export default defineCommand({ } } - const cleanupSpinner = spinner() + const cleanupSpinner = createSpinner() cleanupSpinner.start('Cleaning up build directories') let buildDir: string = '.nuxt' try { @@ -423,7 +424,7 @@ async function withInstallSpinner( ): Promise { const controller = new AbortController() const installLog = createInstallLog({ verbose: options.verbose }) - const spin = spinner({ + const spin = createSpinner({ indicator: 'timer', onCancel: () => controller.abort(), }) diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index 8ab242295..b13383205 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -3,7 +3,7 @@ import type { PackageManager } from 'nypm' import { accessSync, constants, existsSync, readdirSync, readFileSync } from 'node:fs' import { styleText } from 'node:util' -import { confirm, isCancel, spinner } from '@clack/prompts' +import { confirm, isCancel } from '@clack/prompts' import { dirname, join } from 'pathe' import { restoreRawMode, withDirectStdout } from '../utils/console' @@ -12,6 +12,7 @@ import { debug, logger } from '../utils/logger' import { CONFIG_EXTENSIONS } from '../utils/nuxt-config' import { relativeTo } from '../utils/paths' import { tryResolveNuxt } from '../utils/resolve-nuxt' +import { createSpinner } from '../utils/spinner' import { withUserAttention } from '../utils/startup-clock' import { isInteractive } from '../utils/stdout' @@ -253,7 +254,7 @@ async function offerInstall(cwd: string, interactive: boolean): Promise { const controller = new AbortController() const installLog = createInstallLog() - const installSpinner = spinner({ indicator: 'timer', onCancel: () => controller.abort() }) + const installSpinner = createSpinner({ indicator: 'timer', onCancel: () => controller.abort() }) installSpinner.start(`Installing with ${styleText('cyan', packageManager.name)}`) const result = await runInstall({ diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index 9d0b2c7c0..e7de0268c 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -1,7 +1,10 @@ +import type { CommandDef } from 'citty' + import process from 'node:process' import { fileURLToPath } from 'node:url' +import { stripVTControlCharacters } from 'node:util' -import { runCommand as _runCommand, runMain as _runMain } from 'citty' +import { runCommand as _runCommand, runMain as _runMain, renderUsage } from 'citty' import { commands } from './commands' import { globalCwdArgs } from './commands/_shared' @@ -34,7 +37,7 @@ export async function runMain(): Promise { const { initCompletions } = await import('./completions') await initCompletions(main) } - await _runMain(main) + await _runMain(main, { showUsage }) if (!currentCommand || !LONG_RUNNING_COMMANDS.has(currentCommand)) { warnOnHang({ action: currentCommand ? `\`nuxt ${currentCommand}\`` : 'command' }) @@ -58,3 +61,10 @@ export async function runCommand( }, }) } + +/** Print usage, unstyled when stdout lacks colour support. */ +export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise { + const usage = await renderUsage(cmd, parent) + const plain = process.env.NO_COLOR || !process.stdout.hasColors?.() + process.stdout.write(`${plain ? stripVTControlCharacters(usage) : usage}\n\n`) +} diff --git a/packages/nuxt-cli/src/utils/spinner.ts b/packages/nuxt-cli/src/utils/spinner.ts index 6f8ba3b52..4abad3aaa 100644 --- a/packages/nuxt-cli/src/utils/spinner.ts +++ b/packages/nuxt-cli/src/utils/spinner.ts @@ -21,51 +21,32 @@ export interface Spinner { * says what it is doing. Stopped with `options.done`, or silently, once `fn` * settles. * - * Anywhere the frames would be noise rather than animation (CI, an agent, a piped - * log) each message is logged as a plain line instead. + * Without a TTY or in CI, each message is logged once as a plain line. */ export async function withSpinner(message: string, fn: (spinner: Spinner) => Promise, options: { done?: string } = {}): Promise { let done = options.done const setDone = (text: string) => { done = text } - - const host = useTerminalHost() - if (host) { - const task = host.startTask(message) + const indicator = createSpinner() + const run = async () => { + indicator.start(message) try { - const result = await fn({ update: text => task.update(text), done: setDone }) - task.stop(done, 'success') + const result = await fn({ update: text => indicator.message(text), done: setDone }) + indicator.stop(done) return result } catch (error) { // The message the work chose describes it succeeding; whoever threw // reports the failure itself. - task.stop(undefined, 'failure') + indicator.error() throw error } - } - - if (!process.stdout.isTTY || isCI) { - logger.info(`${message}...`) - const result = await fn({ update: text => logger.info(`${text}...`), done: setDone }) - if (done) { - logger.info(done) - } - return result - } - - return withDirectStdout(async () => { - const indicator = spinner() - indicator.start(message) - try { - return await fn({ update: text => indicator.message(text), done: setDone }) - } finally { - indicator.stop(done) restoreRawMode() } - }) + } + return useTerminalHost() ? run() : withDirectStdout(run) } export interface CliSpinner { @@ -84,12 +65,17 @@ export interface CliSpinner { * history one frame at a time. With a terminal host published, the work is * reported as a task on the host's own status line instead. * + * Without a TTY or in CI, each distinct message is logged once as a plain line. + * * A host implies an interactive terminal, so cancellation stays with its key * handling: `onCancel` only fires on the clack path. */ export function createSpinner(options: { indicator?: 'dots' | 'timer', onCancel?: () => void } = {}): CliSpinner { const host = useTerminalHost() if (!host) { + if (!process.stdout.isTTY || isCI) { + return createPlainSpinner() + } return spinner(options) } let task: TerminalTask | undefined @@ -106,3 +92,25 @@ export function createSpinner(options: { indicator?: 'dots' | 'timer', onCancel? }, } } + +function createPlainSpinner(): CliSpinner { + let last: string | undefined + const log = (text: string) => { + if (text !== last) { + last = text + logger.info(`${text}...`) + } + } + const finish = (report: (message: string) => void) => (message?: string) => { + if (message) { + report(message) + } + last = undefined + } + return { + start: log, + message: log, + stop: finish(message => logger.success(message)), + error: finish(message => logger.error(message)), + } +} diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 9cf69747a..f82312f51 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -1,10 +1,13 @@ import type { CommandDef, Resolvable } from 'citty' +import { styleText } from 'node:util' + import { renderUsage } from 'citty' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { commands } from '../../src/commands' import { main } from '../../src/main' +import { showUsage } from '../../src/run' async function resolve(def: Resolvable): Promise { return typeof def === 'function' ? await def() : def @@ -528,3 +531,18 @@ describe('help', () => { `) }) }) + +describe('showUsage', () => { + it('should drop ANSI styling when stdout does not support colour', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + await showUsage({ meta: { name: 'nuxt', description: styleText('bold', 'Nuxt CLI', { validateStream: false }) } }) + const output = String(write.mock.calls[0]![0]) + expect(output).toContain('Nuxt CLI') + expect(output).not.toContain('\u001B[') + } + finally { + write.mockRestore() + } + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/spinner.spec.ts b/packages/nuxt-cli/test/unit/utils/spinner.spec.ts index 061ae1095..d1f5b74ab 100644 --- a/packages/nuxt-cli/test/unit/utils/spinner.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/spinner.spec.ts @@ -10,12 +10,14 @@ const realIsTTY = process.stdout.isTTY afterEach(() => { process.stdout.isTTY = realIsTTY + vi.restoreAllMocks() }) describe('withSpinner', () => { it('should log each stage as a line without a terminal to animate', async () => { process.stdout.isTTY = false const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const success = vi.spyOn(logger, 'success').mockImplementation(() => {}) const result = await withSpinner('Searching', async (spinner) => { spinner.update('Downloading') @@ -24,7 +26,27 @@ describe('withSpinner', () => { }, { done: 'Searched' }) expect(result).toBe('done') - expect(info.mock.calls.map(call => call[0])).toEqual(['Searching...', 'Downloading...', 'Searched 3 pages']) + expect(info.mock.calls.map(call => call[0])).toEqual(['Searching...', 'Downloading...']) + expect(success.mock.calls.map(call => call[0])).toEqual(['Searched 3 pages']) + }) +}) + +describe('createSpinner', () => { + it('should log each distinct message once without a terminal to animate', () => { + process.stdout.isTTY = false + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const success = vi.spyOn(logger, 'success').mockImplementation(() => {}) + + const spinner = createSpinner({ indicator: 'timer' }) + spinner.start('Installing with pnpm') + spinner.message('`pnpm add` may be stuck') + spinner.message('`pnpm add` may be stuck') + spinner.stop('Dependencies installed') + + expect(info.mock.calls.map(call => call[0])).toEqual(['Installing with pnpm...', '`pnpm add` may be stuck...']) + expect(success.mock.calls.map(call => call[0])).toEqual(['Dependencies installed']) + expect(write).not.toHaveBeenCalled() }) }) @@ -106,12 +128,4 @@ describe('spinners with a terminal host', () => { release() } }) - - it('should hand out a clack spinner when nothing owns the terminal', () => { - const spinner = createSpinner() - - expect(typeof spinner.start).toBe('function') - expect(typeof spinner.message).toBe('function') - expect(typeof spinner.stop).toBe('function') - }) }) From 502af9c3414ff7aa74568e2b60d7fd83045a8f7c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 29 Sep 2026 03:30:23 +0000 Subject: [PATCH 2/2] fix: report cleanup failure as error + force no-colour in test --- packages/nuxt-cli/src/commands/upgrade.ts | 2 +- packages/nuxt-cli/test/unit/help.spec.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index 3d1316574..cd5e31768 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -369,7 +369,7 @@ export default defineCommand({ cleanupSpinner.stop('Build directories cleaned') } catch (err) { - cleanupSpinner.stop('Could not clean build directories') + cleanupSpinner.error('Could not clean build directories') logger.warn(`Nuxt was upgraded but build directories could not be removed: ${err instanceof Error ? err.message : err}`) } diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index f82312f51..ad5200d96 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -535,6 +535,8 @@ describe('help', () => { describe('showUsage', () => { it('should drop ANSI styling when stdout does not support colour', async () => { const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const hasColors = process.stdout.hasColors + process.stdout.hasColors = () => false try { await showUsage({ meta: { name: 'nuxt', description: styleText('bold', 'Nuxt CLI', { validateStream: false }) } }) const output = String(write.mock.calls[0]![0]) @@ -543,6 +545,7 @@ describe('showUsage', () => { } finally { write.mockRestore() + process.stdout.hasColors = hasColors } }) })