Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/nuxt-cli/src/commands/add-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down
3 changes: 1 addition & 2 deletions packages/nuxt-cli/src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/commands/curl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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'
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'
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
17 changes: 14 additions & 3 deletions packages/nuxt-cli/src/dev/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void> {
const { renderUnicodeCompact } = await import('uqr')
const caption = showURL ? `\n${centerBlock(styleText('cyan', url), url.length)}` : ''
Expand Down
15 changes: 10 additions & 5 deletions packages/nuxt-cli/src/dev/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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
Expand Down
66 changes: 57 additions & 9 deletions packages/nuxt-cli/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -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<typeof log, 'info' | 'warn' | 'error' | 'success' | 'step' | 'message'>

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

/**
Expand Down Expand Up @@ -36,16 +63,37 @@ function emit<T>(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')
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt-cli/test/unit/commands/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 36 additions & 4 deletions packages/nuxt-cli/test/unit/listen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('std-env')>(),
isCI: false,
}))

vi.mock('tinyclip', () => ({ writeText }))
vi.mock('../../src/dev/environment', () => ({
detectIsolatedEnvironment: () => isolatedEnvironment.current,
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 () => {
Expand Down
16 changes: 13 additions & 3 deletions packages/nuxt-cli/test/unit/shortcuts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,18 @@ describe('setupShortcuts', () => {
vi.clearAllMocks()
})

function setup(context: Partial<ShortcutContext> = {}, { isTTY = true, isRaw = false } = {}) {
function setup(context: Partial<ShortcutContext> = {}, { 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 })) })

const original = process.stdin
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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/nuxt-cli/test/unit/utils/banner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('std-env')>(),
isCI: false,
}))

const VERSIONS: Record<string, string> = {
'webpack': '5.99.0',
'@rspack/core': '1.3.0',
Expand Down
Loading
Loading