Skip to content
Merged
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
7 changes: 3 additions & 4 deletions packages/nuxt-cli/src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 3 additions & 4 deletions packages/nuxt-cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/commands/devtools.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/nuxt-cli/src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -46,10 +47,10 @@ export async function importTestUtils(rootDir: string): Promise<typeof import('@

const exports = await import(pathToFileURL(entry).href)
if (typeof exports.runTests !== 'function') {
throw new TypeError(`The installed version of \`${pkg}\` does not support \`nuxt test\`.`)
throw new ActionableError(`The installed version of \`${pkg}\` does not support \`nuxt test\`.`)
}
return exports
}

throw new Error('`@nuxt/test-utils` is not installed in this project. Install it as a development dependency to use `nuxt test`.')
throw new ActionableError('`@nuxt/test-utils` is not installed in this project. Install it as a development dependency to use `nuxt test`.')
}
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/cert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import process from 'node:process'

import { join } from 'pathe'

import { ActionableError } from '../utils/errors'
import { debug, logger } from '../utils/logger'
import { findInPath, getCacheDir, resolveTool } from './binaries'

Expand Down Expand Up @@ -58,7 +59,7 @@ async function generateCertificate(options: HTTPSOptions): Promise<ResolvedCerti
const generated = await generateWithMkcert(certPath, keyPath, domains)
|| generateWithOpenssl(certPath, keyPath, domains, options.validityDays)
if (!generated) {
throw new Error('Could not generate a development certificate. Install `mkcert` (https://github.com/FiloSottile/mkcert) or provide `--https.cert` and `--https.key`.')
throw new ActionableError('Could not generate a development certificate. Install `mkcert` (https://github.com/FiloSottile/mkcert) or provide `--https.cert` and `--https.key`.')
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import process from 'node:process'
import { styleText } from 'node:util'
import { getPort } from 'get-port-please'

import { ActionableError } from '../utils/errors'
import { debug, logger } from '../utils/logger'
import { detectIsolatedEnvironment, isWsl } from './environment'
import { resolvePortlessURLs } from './portless'
Expand Down Expand Up @@ -383,7 +384,7 @@ export function parsePort(value: string | number | undefined): number | undefine
}
const port = Number(value)
if (!Number.isInteger(port) || port < 0 || port > 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
}
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/dev/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -775,8 +776,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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.
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -65,7 +66,7 @@ export async function readNuxtConfig(cwd: string): Promise<NuxtConfigFile | unde

const ext = extname(file)
if (!EDITABLE_EXTENSIONS.includes(ext)) {
throw new Error(`Unsupported config file extension: ${ext} (${file}) (supported: ${EDITABLE_EXTENSIONS.join(', ')})`)
throw new ActionableError(`Unsupported config file extension: ${ext} (${file}) (supported: ${EDITABLE_EXTENSIONS.join(', ')})`)
}

const source = await readFile(file, 'utf8')
Expand Down
12 changes: 0 additions & 12 deletions packages/nuxt-cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import process from 'node:process'

import { consola } from 'consola'

import { hasTTY, isTest } from 'std-env'

import { isRemotePeerError } from './errors'
import { isInteractiveSession } from './lockfile'
import { debug } from './logger'
import { trackOutputSpacing } from './stdout'

Expand Down Expand Up @@ -50,15 +47,6 @@ export function setupGlobalConsole(opts: { dev?: boolean } = {}) {
process.on('uncaughtException', err => 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.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/utils/kit.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +14,7 @@ export async function loadKit(rootDir: string): Promise<typeof import('@nuxt/kit
}
catch (e: any) {
if (KIT_NOT_FOUND_RE.test(String(e))) {
throw new Error(
throw new ActionableError(
'nuxi requires `@nuxt/kit` to be installed in your project. Try installing `nuxt` v3+ first.',
)
}
Expand Down
19 changes: 10 additions & 9 deletions packages/nuxt-cli/src/utils/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from '
import process from 'node:process'

import { join } from 'pathe'
import { isCI } from 'std-env'

import { isInteractiveSession } from './stdout'

export interface LockInfo {
pid: number
Expand Down Expand Up @@ -36,11 +37,6 @@ const OUTPUT_LOCK_DIRNAME = 'node_modules/.cache/nuxt'
// recycled PID could match a dead build's record.
const MAX_LOCK_AGE_MS = 24 * 60 * 60 * 1000

/** Whether this process is attached to a terminal a user can answer prompts on. */
export function isInteractiveSession(): boolean {
return !!process.stdin.isTTY && !isCI
}

export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
Expand Down Expand Up @@ -330,17 +326,22 @@ function makeRelease(lockPath: string): () => 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:`,
Comment on lines +335 to +344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve output context when outputDir is empty.

If a caller passes '', this truthiness check emits the generic “already running” message. Treat undefined as the absent-value case and render an empty relative path as ..

Proposed fix
-    options.outputDir
-      ? `Another Nuxt ${label} is already writing to ${options.outputDir}:`
+    options.outputDir !== undefined
+      ? `Another Nuxt ${label} is already writing to ${options.outputDir || '.'}:`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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:`,
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 = [
'',
options.outputDir !== undefined
? `Another Nuxt ${label} is already writing to ${options.outputDir || '.'}:`
: `Another Nuxt ${label} is already running:`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/utils/lockfile.ts` around lines 335 - 344, Update
formatLockError to distinguish an omitted outputDir from an explicitly empty
string: treat only undefined as absent, and render an empty outputDir as "." in
the writing-message path while preserving existing non-empty paths.

'',
]

Expand Down
16 changes: 16 additions & 0 deletions packages/nuxt-cli/src/utils/stdout.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
11 changes: 8 additions & 3 deletions packages/nuxt-cli/test/unit/commands/analyze.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -40,7 +41,7 @@ vi.mock('../../../src/utils/kit', () => ({
vi.mock('../../../src/utils/lockfile', () => ({
acquireLock,
acquireOutputLock,
formatLockError: vi.fn(() => 'locked'),
formatLockError,
}))

let cwd: string
Expand Down Expand Up @@ -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()
})
Expand Down
9 changes: 7 additions & 2 deletions packages/nuxt-cli/test/unit/commands/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt-cli/test/unit/lockfile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading