diff --git a/packages/nuxt-cli/src/commands/add-template.ts b/packages/nuxt-cli/src/commands/add-template.ts index cc45cb458..4620aadb7 100644 --- a/packages/nuxt-cli/src/commands/add-template.ts +++ b/packages/nuxt-cli/src/commands/add-template.ts @@ -4,12 +4,11 @@ import { promises as fsp } from 'node:fs' import process from 'node:process' import { styleText } from 'node:util' -import { intro, outro } from '@clack/prompts' import { defineCommand } from 'citty' import { dirname, isAbsolute, relative, resolve } from 'pathe' import { loadKit } from '../utils/kit' -import { logger } from '../utils/logger' +import { intro, logger, outro } from '../utils/logger' import { relativeToProcess } from '../utils/paths' import { templates } from '../utils/templates/index' import { httpMethods, modes } from '../utils/templates/modifiers' diff --git a/packages/nuxt-cli/src/commands/analyze.ts b/packages/nuxt-cli/src/commands/analyze.ts index c5703e175..cde732da3 100644 --- a/packages/nuxt-cli/src/commands/analyze.ts +++ b/packages/nuxt-cli/src/commands/analyze.ts @@ -4,7 +4,7 @@ import { promises as fsp } from 'node:fs' import process from 'node:process' import { styleText } from 'node:util' -import { intro, note, outro, taskLog } from '@clack/prompts' +import { note, taskLog } from '@clack/prompts' import { defineCommand } from 'citty' import { defu } from 'defu' import { join, relative, resolve } from 'pathe' @@ -16,7 +16,7 @@ import { ActionableError } from '../utils/errors' import { clearDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile' -import { logger } from '../utils/logger' +import { intro, logger, outro } from '../utils/logger' import { relativeToProcess, resolveRootDir } from '../utils/paths' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index 3599733c9..c3b9dfcc9 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -1,7 +1,6 @@ import process from 'node:process' import { styleText } from 'node:util' -import { intro, outro } from '@clack/prompts' import { defineCommand } from 'citty' import { relative } from 'pathe' @@ -14,7 +13,7 @@ import { formatDuration } from '../utils/formatting' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile' -import { logger } from '../utils/logger' +import { intro, logger, outro } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { startCpuProfile, stopCpuProfile } from '../utils/profile' import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDirArgs } from './_shared' diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 1ffdcdc88..d55027e07 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -100,7 +100,7 @@ export default defineCommand({ const cwd = resolveRootDir(ctx.args) const input = ctx.args.url if (!input) { - logger.error(`Missing URL. Try ${styleText('cyan', 'nuxt curl /api/hello')}.`) + logger.error(`Missing URL. Try ${styleText('cyan', 'nuxt curl /')}.`) process.exit(1) } diff --git a/packages/nuxt-cli/src/commands/preview.ts b/packages/nuxt-cli/src/commands/preview.ts index 3fb703b63..93b754cf4 100644 --- a/packages/nuxt-cli/src/commands/preview.ts +++ b/packages/nuxt-cli/src/commands/preview.ts @@ -3,7 +3,7 @@ import { dirname } from 'node:path' import process from 'node:process' import { styleText } from 'node:util' -import { box, outro } from '@clack/prompts' +import { box } from '@clack/prompts' import { tokenizeArgs } from 'args-tokenizer' import { defineCommand } from 'citty' import { resolve } from 'pathe' @@ -11,7 +11,7 @@ import { x } from 'tinyexec' import { resolveDotenvFileNames } from '../utils/args' import { loadKit } from '../utils/kit' -import { logger } from '../utils/logger' +import { logger, outro } from '../utils/logger' import { withPrependedPath } from '../utils/path-env' import { relativeToProcess, resolveRootDir } from '../utils/paths' import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index a5c75cbd2..db58f0959 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -7,7 +7,7 @@ import { existsSync } from 'node:fs' import process from 'node:process' import { styleText } from 'node:util' -import { cancel, intro, isCancel, note, outro, select, spinner } from '@clack/prompts' +import { cancel, isCancel, note, select, spinner } from '@clack/prompts' import { defineCommand } from 'citty' import { detectPackageManager } from 'nypm' import { dirname, relative, resolve } from 'pathe' @@ -16,7 +16,7 @@ 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 { logger } from '../utils/logger' +import { intro, logger, outro } from '../utils/logger' import { cleanupNuxtDirs, nuxtVersionToGitIdentifier } from '../utils/nuxt' import { getPackageManagerVersion } from '../utils/packageManagers' import { relativeToProcess, resolveRootDir } from '../utils/paths' diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index 8fc1c2cee..8f09cdd6b 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -11,6 +11,7 @@ import process from 'node:process' import { styleText } from 'node:util' import { getPort } from 'get-port-please' +import { isCI } from 'std-env' import { ActionableError } from '../utils/errors' import { debug, logger } from '../utils/logger' @@ -340,10 +341,11 @@ export async function createListener(bound: BoundServer, options: ListenOptions if (announce) { if (options.showURL !== false) { - if (qrURL) { - await printQRCode(qrURL) + const showQR = !!qrURL && isQRCodeVisible() + if (showQR) { + await printQRCode(qrURL!) } - showURLs({ qr: !!qrURL }) + showURLs({ qr: showQR }) } if (options.clipboard) { @@ -529,6 +531,15 @@ function describeBindError(error: NodeJS.ErrnoException, port: number, hostname: return error } +/** + * Whether block art is worth printing. A QR code is a couple of dozen lines of + * Unicode blocks that nothing downstream of a pipe can scan, so it is only + * drawn where someone is looking at a terminal. + */ +function isQRCodeVisible(): boolean { + return !!process.stdout.isTTY && !isCI +} + export async function printQRCode(url: string, { showURL = false }: { showURL?: boolean } = {}): Promise { const { renderUnicodeCompact } = await import('uqr') const caption = showURL ? `\n${centerBlock(styleText('cyan', url), url.length)}` : '' diff --git a/packages/nuxt-cli/src/dev/shortcuts.ts b/packages/nuxt-cli/src/dev/shortcuts.ts index b224cd70d..46d1cd3de 100644 --- a/packages/nuxt-cli/src/dev/shortcuts.ts +++ b/packages/nuxt-cli/src/dev/shortcuts.ts @@ -106,13 +106,16 @@ function printHelp(context: ActionContext): void { } /** - * Without a TTY there are no shortcuts to offer, so point at the way to talk to - * the server instead: non-interactive callers (scripts, agents) otherwise have - * no indication that one exists. + * Without a readable stdin there are no shortcuts to offer, so point at the way + * to talk to the server instead: a caller driving the CLI without a keyboard + * (an agent, a wrapper script) otherwise has no indication that one exists. + * + * `/` is suggested rather than an API route because it is the one path every + * project serves. */ function printRequestHint(): void { // eslint-disable-next-line no-console - console.log(`\n ${styleText('dim', 'run')} ${styleText('bold', 'nuxt curl /api/hello')} ${styleText('dim', 'to send a request to this server')}\n`) + console.log(`\n ${styleText('dim', 'run')} ${styleText('bold', 'nuxt curl /')} ${styleText('dim', 'to send a request to this server')}\n`) } function availableShortcuts(context: ShortcutContext): Shortcut[] { @@ -127,7 +130,9 @@ function availableShortcuts(context: ShortcutContext): Shortcut[] { */ export function setupShortcuts(context: ShortcutContext): void { if (!process.stdin.isTTY || isCI || isTest) { - if (!isCI && !isTest) { + // A hint written into a redirected log is read by nobody and answered by + // nobody, so it is only offered while stdout is still a terminal. + if (process.stdout.isTTY && !isCI && !isTest) { context.onReady(() => printRequestHint()) } return diff --git a/packages/nuxt-cli/src/utils/logger.ts b/packages/nuxt-cli/src/utils/logger.ts index bfcfbdf0e..c81cb21eb 100644 --- a/packages/nuxt-cli/src/utils/logger.ts +++ b/packages/nuxt-cli/src/utils/logger.ts @@ -1,12 +1,39 @@ +import process from 'node:process' import { styleText } from 'node:util' -import { log, S_INFO } from '@clack/prompts' +import { intro as clackIntro, outro as clackOutro, log, S_ERROR, S_INFO, S_STEP_SUBMIT, S_SUCCESS, S_WARN } from '@clack/prompts' import { createDebug } from 'obug' +import { isCI } from 'std-env' import { blankLineBefore, writeDirect } from './stdout' type LoggerImpl = Pick -let impl: LoggerImpl = log +type LineColor = 'blue' | 'yellow' | 'red' | 'green' + +/** + * A log line with no clack framing: the `│` gutter connects one prompt to the + * next on screen, and in a redirected log it is a bare column of punctuation + * between every line of real output. + */ +function plainLine(symbol: string, color: LineColor, message?: string): void { + const body = String(message ?? '').replace(/\n/g, '\n ') + writeDirect(`${styleText(color, symbol)} ${body}\n`) +} + +const plain: LoggerImpl = { + info: message => plainLine(S_INFO, 'blue', message), + warn: message => plainLine(S_WARN, 'yellow', message), + error: message => plainLine(S_ERROR, 'red', message), + success: message => plainLine(S_SUCCESS, 'green', message), + step: message => plainLine(S_STEP_SUBMIT, 'green', message), + message: message => writeDirect(`${String(message ?? '')}\n`), +} + +function defaultImpl(): LoggerImpl { + return !process.stdout.isTTY || isCI ? plain : log +} + +let impl: LoggerImpl | undefined let depth = 0 /** @@ -36,16 +63,37 @@ function emit(write: () => T): T { * guideline, which reads as a stray artefact next to a persistent footer. */ export function setLoggerImpl(next?: LoggerImpl): void { - impl = next ?? log + impl = next } export const logger: LoggerImpl = { - info: message => emit(() => impl.info(message)), - warn: message => emit(() => impl.warn(message)), - error: message => emit(() => impl.error(message)), - success: message => emit(() => impl.success(message)), - step: message => emit(() => impl.step(message)), - message: (message, options) => emit(() => impl.message(message, options)), + info: message => emit(() => (impl ?? defaultImpl()).info(message)), + warn: message => emit(() => (impl ?? defaultImpl()).warn(message)), + error: message => emit(() => (impl ?? defaultImpl()).error(message)), + success: message => emit(() => (impl ?? defaultImpl()).success(message)), + step: message => emit(() => (impl ?? defaultImpl()).step(message)), + message: (message, options) => emit(() => (impl ?? defaultImpl()).message(message, options)), +} + +/** + * Open a command's output. Falls back to a bare headline where clack's opening + * corner would only introduce a gutter nothing draws against. + */ +export function intro(message: string): void { + if (!process.stdout.isTTY || isCI) { + writeDirect(`${message}\n`) + return + } + clackIntro(message) +} + +/** {@link intro}, for the line a command finishes on. */ +export function outro(message: string): void { + if (!process.stdout.isTTY || isCI) { + writeDirect(`${styleText('green', S_SUCCESS)} ${message}\n`) + return + } + clackOutro(message) } export const debug = createDebug('nuxi') diff --git a/packages/nuxt-cli/test/unit/commands/build.spec.ts b/packages/nuxt-cli/test/unit/commands/build.spec.ts index e9227918a..4ae864c22 100644 --- a/packages/nuxt-cli/test/unit/commands/build.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/build.spec.ts @@ -37,6 +37,8 @@ vi.mock('../../../src/utils/lockfile', () => ({ })) vi.mock('../../../src/utils/logger', () => ({ logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + intro: vi.fn(), + outro: vi.fn(), })) vi.mock('../../../src/utils/profile', () => ({ startCpuProfile: mocks.startCpuProfile, diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index cde3e288c..66342cfd7 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -10,6 +10,11 @@ import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, l const writeText = vi.hoisted(() => vi.fn()) const isolatedEnvironment = vi.hoisted(() => ({ current: undefined as string | undefined })) +vi.mock('std-env', async importOriginal => ({ + ...await importOriginal(), + isCI: false, +})) + vi.mock('tinyclip', () => ({ writeText })) vi.mock('../../src/dev/environment', () => ({ detectIsolatedEnvironment: () => isolatedEnvironment.current, @@ -234,6 +239,26 @@ describe('listen', () => { return listener } + it('should not draw a QR code into output nothing can scan it from', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const originalIsTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }) + + try { + const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', qr: true }) + listeners.push(listener) + const printed = log.mock.calls.map(([line]) => String(line)).join('\n') + + expect(printed).toContain('Local:') + expect(printed).not.toContain('[QR code]') + expect(printed).not.toMatch(/[\u2580-\u259F]/) + } + finally { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) + log.mockRestore() + } + }) + it('should accept connections on both loopback addresses in an isolated environment', async () => { isolatedEnvironment.current = 'the container' const listener = await start({ port: 0 }) @@ -356,12 +381,19 @@ describe('listener.close', () => { throw new Error('qr unavailable') }, })) + const originalIsTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) - await expect(listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', tunnel: true, qr: true })).rejects.toThrow('qr unavailable') + try { + await expect(listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', tunnel: true, qr: true })).rejects.toThrow('qr unavailable') - expect(closeTunnel).toHaveBeenCalledTimes(1) - vi.doUnmock('../../src/dev/tunnel') - vi.doUnmock('uqr') + expect(closeTunnel).toHaveBeenCalledTimes(1) + } + finally { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) + vi.doUnmock('../../src/dev/tunnel') + vi.doUnmock('uqr') + } }) it('should release the port when setup fails after binding', async () => { diff --git a/packages/nuxt-cli/test/unit/shortcuts.spec.ts b/packages/nuxt-cli/test/unit/shortcuts.spec.ts index e9ef0c4b2..02ce39df4 100644 --- a/packages/nuxt-cli/test/unit/shortcuts.spec.ts +++ b/packages/nuxt-cli/test/unit/shortcuts.spec.ts @@ -38,7 +38,7 @@ describe('setupShortcuts', () => { vi.clearAllMocks() }) - function setup(context: Partial = {}, { isTTY = true, isRaw = false } = {}) { + function setup(context: Partial = {}, { isTTY = true, isRaw = false, stdoutIsTTY = true } = {}) { const stdin = new PassThrough() as unknown as typeof process.stdin Object.assign(stdin, { isTTY, isRaw, setRawMode: vi.fn((raw: boolean) => Object.assign(stdin, { isRaw: raw })) }) @@ -46,6 +46,10 @@ describe('setupShortcuts', () => { Object.defineProperty(process, 'stdin', { value: stdin, configurable: true }) restores.push(() => Object.defineProperty(process, 'stdin', { value: original, configurable: true })) + const originalStdoutIsTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: stdoutIsTTY, configurable: true }) + restores.push(() => Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true })) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const listener = { @@ -89,10 +93,16 @@ describe('setupShortcuts', () => { expect(setup().stdin.listenerCount('data')).toBe(0) }) - it('should suggest `nuxt curl` when there is no TTY', () => { + it('should suggest `nuxt curl` when stdin cannot be read', () => { const { log } = setup({}, { isTTY: false }) - expect(log.mock.calls.join('\n')).toContain('nuxt curl /api/hello') + expect(log.mock.calls.join('\n')).toContain('nuxt curl /') + }) + + it('should stay silent when the output is redirected', () => { + const { log } = setup({}, { isTTY: false, stdoutIsTTY: false }) + + expect(log.mock.calls.join('\n')).not.toContain('nuxt curl') }) it('should stay silent in CI', () => { diff --git a/packages/nuxt-cli/test/unit/utils/banner.spec.ts b/packages/nuxt-cli/test/unit/utils/banner.spec.ts index 6a43919a4..9e6a8e84a 100644 --- a/packages/nuxt-cli/test/unit/utils/banner.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/banner.spec.ts @@ -4,6 +4,11 @@ import { describe, expect, it, vi } from 'vitest' import { render, screen } from '../../utils/terminal' +vi.mock('std-env', async importOriginal => ({ + ...await importOriginal(), + isCI: false, +})) + const VERSIONS: Record = { 'webpack': '5.99.0', '@rspack/core': '1.3.0', diff --git a/packages/nuxt-cli/test/unit/utils/logger.spec.ts b/packages/nuxt-cli/test/unit/utils/logger.spec.ts new file mode 100644 index 000000000..131ca699b --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/logger.spec.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { stripAnsi } from '../../../src/dev/tui/width' +import { logger } from '../../../src/utils/logger' + +const environment = vi.hoisted(() => ({ isCI: false })) + +vi.mock('std-env', async importOriginal => ({ + ...await importOriginal(), + get isCI() { + return environment.isCI + }, +})) + +describe('logger', () => { + const restores: Array<() => void> = [] + + afterEach(() => { + restores.splice(0).forEach(restore => restore()) + environment.isCI = false + vi.restoreAllMocks() + }) + + function capture(isTTY: boolean): { written: () => string } { + const original = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: isTTY, configurable: true }) + restores.push(() => Object.defineProperty(process.stdout, 'isTTY', { value: original, configurable: true })) + + const chunks: string[] = [] + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)) + return true + }) + restores.push(() => write.mockRestore()) + + return { written: () => stripAnsi(chunks.join('')) } + } + + it('should leave no clack gutter in redirected output', () => { + const { written } = capture(false) + + logger.info('Bundling app') + logger.warn('Slow') + + expect(written()).toBe('● Bundling app\n▲ Slow\n') + }) + + it('should keep clack framing in a terminal', () => { + const { written } = capture(true) + + logger.info('Bundling app') + + expect(written()).toContain('│\n') + }) + + it('should leave no clack gutter in CI, where nothing draws against it', () => { + environment.isCI = true + const { written } = capture(true) + + logger.info('Bundling app') + + expect(written()).toBe('● Bundling app\n') + }) + + it('should indent a continuation line under its symbol', () => { + const { written } = capture(false) + + logger.info('Ready in 2.4s\nconfig 320ms') + + expect(written()).toBe('● Ready in 2.4s\n config 320ms\n') + }) +})