From 96ee7bb9c24acfa6be3947dbcbdee6dab450d01c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 12 Aug 2026 09:03:58 +0000 Subject: [PATCH] refactor: report first-run and lock failures as actionable errors --- packages/nuxt-cli/src/commands/analyze.ts | 7 +++---- packages/nuxt-cli/src/commands/build.ts | 7 +++---- packages/nuxt-cli/src/commands/devtools.ts | 3 ++- packages/nuxt-cli/src/commands/test.ts | 5 +++-- packages/nuxt-cli/src/dev/cert.ts | 3 ++- packages/nuxt-cli/src/dev/listen.ts | 3 ++- packages/nuxt-cli/src/dev/preflight.ts | 3 ++- packages/nuxt-cli/src/dev/takeover.ts | 3 ++- packages/nuxt-cli/src/dev/utils.ts | 4 ++-- packages/nuxt-cli/src/utils/config.ts | 3 ++- packages/nuxt-cli/src/utils/console.ts | 12 ------------ packages/nuxt-cli/src/utils/kit.ts | 3 ++- packages/nuxt-cli/src/utils/lockfile.ts | 19 ++++++++++--------- packages/nuxt-cli/src/utils/stdout.ts | 16 ++++++++++++++++ .../test/unit/commands/analyze.spec.ts | 11 ++++++++--- .../nuxt-cli/test/unit/commands/build.spec.ts | 9 +++++++-- packages/nuxt-cli/test/unit/lockfile.spec.ts | 12 ++++++++++++ 17 files changed, 78 insertions(+), 45 deletions(-) diff --git a/packages/nuxt-cli/src/commands/analyze.ts b/packages/nuxt-cli/src/commands/analyze.ts index a009a8e56..b96336a0d 100644 --- a/packages/nuxt-cli/src/commands/analyze.ts +++ b/packages/nuxt-cli/src/commands/analyze.ts @@ -11,6 +11,7 @@ import { join, relative, resolve } from 'pathe' import { serve } from 'srvx' import { overrideEnv } from '../utils/env' +import { ActionableError } from '../utils/errors' import { clearDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile' @@ -140,15 +141,13 @@ export default defineCommand({ const lockInfo = { command: 'analyze' as const, cwd } const lock = acquireLock(buildDir, lockInfo) if (lock.existing) { - logger.error(formatLockError(lock.existing)) - throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`) + throw new ActionableError(formatLockError(lock.existing)) } const outputLock = acquireOutputLock(nuxt.options.rootDir, outDir, lockInfo) if (outputLock.existing) { lock.release() - logger.error(formatLockError(outputLock.existing)) - throw new Error(`Another Nuxt build is already writing to ${relative(process.cwd(), outDir)} (PID ${outputLock.existing.pid}).`) + throw new ActionableError(formatLockError(outputLock.existing, { outputDir: relative(process.cwd(), outDir) })) } try { diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index d3fe61f2b..1c2c7d480 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -8,6 +8,7 @@ import { relative } from 'pathe' import { showBanner } from '../utils/banner' import { overrideEnv } from '../utils/env' +import { ActionableError } from '../utils/errors' import { formatDuration } from '../utils/formatting' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' @@ -91,8 +92,7 @@ export default defineCommand({ cwd, }) if (lock.existing) { - logger.error(formatLockError(lock.existing)) - throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`) + throw new ActionableError(formatLockError(lock.existing)) } releaseLocks.push(lock.release) @@ -104,8 +104,7 @@ export default defineCommand({ cwd, }) if (outputLock.existing) { - logger.error(formatLockError(outputLock.existing)) - throw new Error(`Another Nuxt build is already writing to ${relative(process.cwd(), nitro.options.output.dir)} (PID ${outputLock.existing.pid}).`) + throw new ActionableError(formatLockError(outputLock.existing, { outputDir: relative(process.cwd(), nitro.options.output.dir) })) } releaseLocks.push(outputLock.release) diff --git a/packages/nuxt-cli/src/commands/devtools.ts b/packages/nuxt-cli/src/commands/devtools.ts index 280f80647..493a6b5a1 100644 --- a/packages/nuxt-cli/src/commands/devtools.ts +++ b/packages/nuxt-cli/src/commands/devtools.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty' import { x } from 'tinyexec' +import { ActionableError } from '../utils/errors' import { resolveRootDir } from '../utils/paths' import { rootDirArgs } from './_shared' @@ -22,7 +23,7 @@ export default defineCommand({ const command = ctx.args.command if (command !== 'enable' && command !== 'disable') { - throw new Error(`Unknown devtools command \`${command}\`. Expected \`enable\` or \`disable\`.`) + throw new ActionableError(`Unknown devtools command \`${command}\`. Expected \`enable\` or \`disable\`.`) } await x( diff --git a/packages/nuxt-cli/src/commands/test.ts b/packages/nuxt-cli/src/commands/test.ts index 4f8452a92..78ddd55b8 100644 --- a/packages/nuxt-cli/src/commands/test.ts +++ b/packages/nuxt-cli/src/commands/test.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url' import { defineCommand } from 'citty' import { resolveModulePath } from 'exsolve' +import { ActionableError } from '../utils/errors' import { resolveRootDir } from '../utils/paths' import { rootDirArgs } from './_shared' @@ -46,10 +47,10 @@ export async function importTestUtils(rootDir: string): Promise 65_535) { - throw new Error(`Invalid port \`${value}\`; expected an integer between 0 and 65535.`) + throw new ActionableError(`Invalid port \`${value}\`; expected an integer between 0 and 65535.`) } return port } diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index 6cdba20c6..b70455a38 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -6,12 +6,13 @@ import { styleText } from 'node:util' import { confirm, isCancel, spinner } from '@clack/prompts' import { dirname, join } from 'pathe' -import { isInteractive, restoreRawMode, withDirectStdout } from '../utils/console' +import { restoreRawMode, withDirectStdout } from '../utils/console' import { ActionableError } from '../utils/errors' import { tryResolveNuxt } from '../utils/kit' import { debug, logger } from '../utils/logger' import { CONFIG_EXTENSIONS } from '../utils/nuxt-config' import { relativeTo } from '../utils/paths' +import { isInteractive } from '../utils/stdout' const NUXT_PACKAGES = ['nuxt', 'nuxt-nightly'] diff --git a/packages/nuxt-cli/src/dev/takeover.ts b/packages/nuxt-cli/src/dev/takeover.ts index e7d17a0d1..f462a08b7 100644 --- a/packages/nuxt-cli/src/dev/takeover.ts +++ b/packages/nuxt-cli/src/dev/takeover.ts @@ -8,8 +8,9 @@ import { checkPort } from 'get-port-please' import { isCI } from 'std-env' import { restoreRawMode, withDirectStdout } from '../utils/console' -import { clearStaleLock, clearTakeover, isInteractiveSession, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile' +import { clearStaleLock, clearTakeover, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile' import { logger } from '../utils/logger' +import { isInteractiveSession } from '../utils/stdout' import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown' /** How long a `SIGKILL`ed process has to disappear before we give up. */ diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 9c773c506..bef99df09 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -24,6 +24,7 @@ import { toNodeHandler } from 'srvx/node' import { provider } from 'std-env' import { showBanner } from '../utils/banner' +import { ActionableError } from '../utils/errors' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../utils/lockfile' @@ -775,8 +776,7 @@ export class NuxtDevServer extends EventEmitter { takeoverFrom: this.options.handoverFrom, }) if (lock.existing) { - console.error(formatLockError(lock.existing)) - throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`) + throw new ActionableError(formatLockError(lock.existing)) } // Swap atomically: install the new release before freeing the old one so // we're never unlocked in between. diff --git a/packages/nuxt-cli/src/utils/config.ts b/packages/nuxt-cli/src/utils/config.ts index 5ed78c297..e424a7ef6 100644 --- a/packages/nuxt-cli/src/utils/config.ts +++ b/packages/nuxt-cli/src/utils/config.ts @@ -6,6 +6,7 @@ import { resolveModulePath } from 'exsolve' import { dirname, extname, join, normalize } from 'pathe' import { CONFIG_KEYS, locateConfig } from './config-parse' +import { ActionableError } from './errors' export interface NuxtConfigFile { /** Absolute path to the config file. */ @@ -65,7 +66,7 @@ export async function readNuxtConfig(cwd: string): Promise report('[uncaughtException]', err)) } -/** - * Whether a question can be asked and answered right now. `hasTTY` is required - * as well as an interactive session, so a prompt cannot be written into a - * redirected stdout where nobody will see it. - */ -export function isInteractive(): boolean { - return isInteractiveSession() && hasTTY && !isTest -} - /** * Take `process.stdin` out of raw mode if something left it there. * diff --git a/packages/nuxt-cli/src/utils/kit.ts b/packages/nuxt-cli/src/utils/kit.ts index fc287e0d3..ca46174d8 100644 --- a/packages/nuxt-cli/src/utils/kit.ts +++ b/packages/nuxt-cli/src/utils/kit.ts @@ -1,5 +1,6 @@ import { pathToFileURL } from 'node:url' import { resolveModulePath } from 'exsolve' +import { ActionableError } from './errors' import { withNodePath } from './paths' // `exsolve` and Node.js word their resolution failures differently @@ -13,7 +14,7 @@ export async function loadKit(rootDir: string): Promise void { } /** - * Format an error message when a Nuxt process is already running. + * Format an error message when a Nuxt process holds a lock this one needs. * Designed to be actionable for both humans and LLM agents. + * + * `outputDir` names the build output being contended, when the lock is over one + * rather than over the project itself. */ -export function formatLockError(info: LockInfo): string { +export function formatLockError(info: LockInfo, options: { outputDir?: string } = {}): string { const isWindows = process.platform === 'win32' const killCmd = isWindows ? `taskkill /PID ${info.pid} /F` : `kill ${info.pid}` const label = info.command === 'dev' ? 'dev server' : 'build' const lines = [ '', - `Another Nuxt ${label} is already running:`, + options.outputDir + ? `Another Nuxt ${label} is already writing to ${options.outputDir}:` + : `Another Nuxt ${label} is already running:`, '', ] diff --git a/packages/nuxt-cli/src/utils/stdout.ts b/packages/nuxt-cli/src/utils/stdout.ts index bf5a920a2..37bb4e112 100644 --- a/packages/nuxt-cli/src/utils/stdout.ts +++ b/packages/nuxt-cli/src/utils/stdout.ts @@ -1,5 +1,21 @@ import process from 'node:process' +import { hasTTY, isCI, isTest } from 'std-env' + +/** Whether this process is attached to a terminal a user can answer prompts on. */ +export function isInteractiveSession(): boolean { + return !!process.stdin.isTTY && !isCI +} + +/** + * Whether a question can be asked and answered right now. `hasTTY` is required + * as well as an interactive session, so a prompt cannot be written into a + * redirected stdout where nobody will see it. + */ +export function isInteractive(): boolean { + return isInteractiveSession() && hasTTY && !isTest +} + /** One blank line needs two newlines: one to end the last line, one to skip a row. */ const BLANK_LINE = 2 diff --git a/packages/nuxt-cli/test/unit/commands/analyze.spec.ts b/packages/nuxt-cli/test/unit/commands/analyze.spec.ts index 7b10fc131..9f2e88a3b 100644 --- a/packages/nuxt-cli/test/unit/commands/analyze.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/analyze.spec.ts @@ -24,8 +24,9 @@ function createHooks() { } } -const { acquireLock, acquireOutputLock, buildNuxt, loadNuxt, releaseBuildLock, releaseOutputLock } = vi.hoisted(() => ({ +const { acquireLock, acquireOutputLock, buildNuxt, formatLockError, loadNuxt, releaseBuildLock, releaseOutputLock } = vi.hoisted(() => ({ acquireLock: vi.fn(), + formatLockError: vi.fn(() => 'locked'), acquireOutputLock: vi.fn(), buildNuxt: vi.fn(), loadNuxt: vi.fn(), @@ -40,7 +41,7 @@ vi.mock('../../../src/utils/kit', () => ({ vi.mock('../../../src/utils/lockfile', () => ({ acquireLock, acquireOutputLock, - formatLockError: vi.fn(() => 'locked'), + formatLockError, })) let cwd: string @@ -154,7 +155,11 @@ describe('nuxt analyze command', () => { existing: { command: 'build', pid: 42 }, }) - await expect(runAnalyze()).rejects.toThrow('Another Nuxt build is already writing') + await expect(runAnalyze()).rejects.toThrow('locked') + expect(formatLockError).toHaveBeenCalledWith( + expect.objectContaining({ pid: 42 }), + { outputDir: expect.stringContaining('.output') }, + ) expect(buildNuxt).not.toHaveBeenCalled() expect(releaseBuildLock).toHaveBeenCalledOnce() }) diff --git a/packages/nuxt-cli/test/unit/commands/build.spec.ts b/packages/nuxt-cli/test/unit/commands/build.spec.ts index 7bfb26ef6..85ada862c 100644 --- a/packages/nuxt-cli/test/unit/commands/build.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/build.spec.ts @@ -5,6 +5,7 @@ import build from '../../../src/commands/build' const mocks = vi.hoisted(() => ({ acquireLock: vi.fn(), + formatLockError: vi.fn(() => 'lock details'), acquireOutputLock: vi.fn(), buildNuxt: vi.fn(), clearBuildDir: vi.fn(), @@ -32,7 +33,7 @@ vi.mock('../../../src/utils/kit', () => ({ vi.mock('../../../src/utils/lockfile', () => ({ acquireLock: mocks.acquireLock, acquireOutputLock: mocks.acquireOutputLock, - formatLockError: vi.fn(() => 'lock details'), + formatLockError: mocks.formatLockError, })) vi.mock('../../../src/utils/logger', () => ({ logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, @@ -124,7 +125,11 @@ describe('build', () => { existing: { pid: 42, command: 'build', cwd: '/other/project', startedAt: Date.now() }, }) - await expect(run()).rejects.toThrow(/Another Nuxt build is already writing to .*\.output \(PID 42\)\./) + await expect(run()).rejects.toThrow('lock details') + expect(mocks.formatLockError).toHaveBeenCalledWith( + expect.objectContaining({ pid: 42 }), + { outputDir: expect.stringContaining('.output') }, + ) expect(mocks.clearBuildDir).not.toHaveBeenCalled() expect(mocks.buildNuxt).not.toHaveBeenCalled() diff --git a/packages/nuxt-cli/test/unit/lockfile.spec.ts b/packages/nuxt-cli/test/unit/lockfile.spec.ts index 95505304b..f786b3601 100644 --- a/packages/nuxt-cli/test/unit/lockfile.spec.ts +++ b/packages/nuxt-cli/test/unit/lockfile.spec.ts @@ -451,6 +451,18 @@ describe('lockfile', () => { expect(message).toContain('connect to') }) + it('names the contended output directory', () => { + const message = formatLockError({ + pid: 12345, + command: 'build', + cwd: '/my/project', + interactive: false, + startedAt: Date.now(), + }, { outputDir: '.output' }) + + expect(message).toContain('already writing to .output') + }) + it('formats build lock without URL', () => { const message = formatLockError({ pid: 12345,