From 97862aa9ec4666574c87fbcbc0252140ee44d294 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 2 Aug 2026 09:30:04 +0000 Subject: [PATCH 01/10] feat(curl): add `nuxt curl` for requesting the running dev server --- packages/nuxt-cli/src/commands/curl.ts | 203 ++++++++++++++++++ packages/nuxt-cli/src/commands/index.ts | 1 + packages/nuxt-cli/src/utils/dev-server.ts | 54 +++++ packages/nuxt-cli/test/e2e/commands.spec.ts | 1 + .../nuxt-cli/test/unit/commands/curl.spec.ts | 172 +++++++++++++++ packages/nuxt-cli/test/unit/help.spec.ts | 3 +- 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 packages/nuxt-cli/src/commands/curl.ts create mode 100644 packages/nuxt-cli/src/utils/dev-server.ts create mode 100644 packages/nuxt-cli/test/unit/commands/curl.spec.ts diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts new file mode 100644 index 000000000..318116189 --- /dev/null +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -0,0 +1,203 @@ +import { Buffer } from 'node:buffer' +import { readFile } from 'node:fs/promises' +import process from 'node:process' +import { styleText } from 'node:util' + +import { defineCommand } from 'citty' + +import { findDevServer, noDevServerMessage } from '../utils/dev-server' +import { logger } from '../utils/logger' +import { logNetworkError } from '../utils/network' +import { resolveRootDir } from '../utils/paths' +import { rootDirArgs } from './_shared' + +const HAS_SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//i +const JSON_TOKEN_RE = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g +const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w.+-]+\+)?json\b/i + +/** `curl --fail` uses 22 for an HTTP error response; scripts rely on it. */ +const HTTP_ERROR_EXIT_CODE = 22 + +export default defineCommand({ + meta: { + name: 'curl', + description: 'Send an HTTP request to your running Nuxt dev server', + }, + args: { + // `url` has to precede the `dir` positional supplied by `rootDirArgs` + url: { + type: 'positional', + description: 'Absolute URL, or a path resolved against the running dev server', + valueHint: 'url|path', + }, + ...rootDirArgs, + method: { + type: 'string', + alias: 'X', + description: 'HTTP method (default: GET, or POST when a body is provided)', + valueHint: 'method', + }, + header: { + type: 'string', + alias: 'H', + description: 'Request header in `Name: Value` form. Can be repeated.', + valueHint: 'header', + }, + data: { + type: 'string', + alias: 'd', + description: 'Request body. Use `@-` to read stdin and `@` to read a file.', + valueHint: 'data', + }, + verbose: { + type: 'boolean', + alias: 'v', + description: 'Print request and response headers', + }, + }, + async run(ctx) { + const cwd = resolveRootDir(ctx.args) + const input = ctx.args.url + if (!input) { + logger.error(`Missing URL. Try ${styleText('cyan', 'nuxt curl /api/hello')}.`) + process.exit(1) + } + + const url = await resolveRequestUrl(input, cwd) + + const headers = new Headers() + for (const header of toArray(ctx.args.header)) { + const separator = header.indexOf(':') + if (separator <= 0) { + logger.error(`Invalid header ${styleText('cyan', header)}. Expected ${styleText('cyan', 'Name: Value')}.`) + process.exit(1) + } + headers.append(header.slice(0, separator).trim(), header.slice(separator + 1).trim()) + } + if (!headers.has('user-agent')) { + headers.set('user-agent', 'nuxt-cli') + } + + const body = await readRequestBody(ctx.args.data) + if (body !== undefined && !headers.has('content-type') && isJson(body)) { + headers.set('content-type', 'application/json') + } + + const method = (ctx.args.method || (body === undefined ? 'GET' : 'POST')).toUpperCase() + + if (ctx.args.verbose) { + process.stderr.write(`> ${method} ${url.pathname}${url.search} HTTP/1.1\n`) + process.stderr.write(`> Host: ${url.host}\n`) + for (const [name, value] of headers) { + process.stderr.write(`> ${name}: ${value}\n`) + } + process.stderr.write('>\n') + } + + let response: Response + try { + response = await fetch(url, { method, headers, body, redirect: 'manual' }) + } + catch (error) { + logNetworkError(error, { url: url.href }) + process.exit(1) + } + + if (ctx.args.verbose) { + process.stderr.write(`< HTTP/1.1 ${response.status} ${response.statusText}\n`) + for (const [name, value] of response.headers) { + process.stderr.write(`< ${name}: ${value}\n`) + } + process.stderr.write('<\n') + } + + await writeResponseBody(response) + + if (!response.ok) { + process.exit(HTTP_ERROR_EXIT_CODE) + } + }, +}) + +async function resolveRequestUrl(input: string, cwd: string): Promise { + if (HAS_SCHEME_RE.test(input)) { + return new URL(input) + } + + const server = await findDevServer(cwd) + if (!server) { + logger.error(noDevServerMessage('nuxt curl')) + process.exit(1) + } + + return new URL(input.startsWith('/') ? input : `/${input}`, server.url) +} + +async function readRequestBody(data: string | undefined): Promise { + if (data === undefined) { + return undefined + } + if (data === '@-') { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf-8') + } + if (data.startsWith('@')) { + return await readFile(data.slice(1), 'utf-8') + } + return data +} + +async function writeResponseBody(response: Response): Promise { + const contentType = response.headers.get('content-type') || '' + const text = await response.text() + if (!text) { + return + } + + const pretty = process.stdout.isTTY && JSON_CONTENT_TYPE_RE.test(contentType) + process.stdout.write(pretty ? formatJson(text) : text) + if (process.stdout.isTTY && !text.endsWith('\n')) { + process.stdout.write('\n') + } +} + +function formatJson(text: string): string { + let json: string + try { + json = JSON.stringify(JSON.parse(text), null, 2) + } + catch { + return text + } + + return json.replace(JSON_TOKEN_RE, (match, string: string | undefined, colon: string | undefined) => { + if (string) { + return colon ? styleText('cyan', string) + colon : styleText('green', string) + } + return styleText('yellow', match) + }) +} + +function isJson(value: string): boolean { + const trimmed = value.trimStart() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return false + } + try { + JSON.parse(value) + return true + } + catch { + return false + } +} + +function toArray(value: string | string[] | undefined): string[] { + if (value === undefined) { + return [] + } + return Array.isArray(value) ? value : [value] +} diff --git a/packages/nuxt-cli/src/commands/index.ts b/packages/nuxt-cli/src/commands/index.ts index 706b0c18e..d9c834f6e 100644 --- a/packages/nuxt-cli/src/commands/index.ts +++ b/packages/nuxt-cli/src/commands/index.ts @@ -8,6 +8,7 @@ const commandLoaders = { 'analyze': () => import('./analyze').then(_rDefault), 'build': () => import('./build').then(_rDefault), 'cleanup': () => import('./cleanup').then(_rDefault), + 'curl': () => import('./curl').then(_rDefault), '_dev': () => import('./dev-child').then(_rDefault), 'dev': () => import('./dev').then(_rDefault), 'devtools': () => import('./devtools').then(_rDefault), diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts new file mode 100644 index 000000000..cb35dac06 --- /dev/null +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -0,0 +1,54 @@ +import { styleText } from 'node:util' +import { resolve } from 'pathe' + +import { readActiveLock } from './lockfile' +import { getNuxtConfig } from './nuxt-config' + +const TRAILING_SLASH_RE = /\/$/ + +export interface RunningDevServer { + /** Origin the dev server is listening on, without a trailing slash. */ + url: string + pid: number + cwd: string +} + +/** + * Locate a live `nuxt dev` server for a project, using the metadata its dev + * server records in `nuxt.lock` inside the build directory. + * + * `buildDir` may be passed when it is already known; otherwise the default + * `.nuxt` is tried before falling back to reading `nuxt.config`. + */ +export async function findDevServer(cwd: string, buildDir?: string): Promise { + const candidates = buildDir + ? [resolve(cwd, buildDir)] + : [resolve(cwd, '.nuxt'), await configuredBuildDir(cwd)] + + const seen = new Set() + for (const dir of candidates) { + if (!dir || seen.has(dir)) { + continue + } + seen.add(dir) + + const lock = readActiveLock(dir) + if (lock?.command === 'dev' && lock.url) { + return { url: lock.url.replace(TRAILING_SLASH_RE, ''), pid: lock.pid, cwd: lock.cwd } + } + } +} + +export function noDevServerMessage(what: string): string { + return `No running Nuxt dev server found. Start one with ${styleText('cyan', 'nuxt dev')}, or pass an absolute URL to ${styleText('cyan', what)}.` +} + +async function configuredBuildDir(cwd: string): Promise { + try { + const config = await getNuxtConfig(cwd) + return config.buildDir ? resolve(cwd, config.buildDir) : undefined + } + catch { + return undefined + } +} diff --git a/packages/nuxt-cli/test/e2e/commands.spec.ts b/packages/nuxt-cli/test/e2e/commands.spec.ts index 31e2d20ca..7c05a2cbb 100644 --- a/packages/nuxt-cli/test/e2e/commands.spec.ts +++ b/packages/nuxt-cli/test/e2e/commands.spec.ts @@ -47,6 +47,7 @@ describe('commands', () => { }) expect(res.exitCode).toBe(0) }, + 'curl': 'todo', 'devtools': 'todo', 'module': 'todo', 'prepare': async () => { diff --git a/packages/nuxt-cli/test/unit/commands/curl.spec.ts b/packages/nuxt-cli/test/unit/commands/curl.spec.ts new file mode 100644 index 000000000..76b5fb814 --- /dev/null +++ b/packages/nuxt-cli/test/unit/commands/curl.spec.ts @@ -0,0 +1,172 @@ +import type { AddressInfo } from 'node:net' + +import { Buffer } from 'node:buffer' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { runCommand } from 'citty' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import curl from '../../../src/commands/curl' + +interface ReceivedRequest { + method: string + url: string + headers: Record + body: string +} + +const requests: ReceivedRequest[] = [] + +const server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(chunk as Buffer) + } + requests.push({ + method: req.method!, + url: req.url!, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }) + + if (req.url === '/missing') { + res.statusCode = 404 + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ statusCode: 404, message: 'Page not found' })) + return + } + + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) +}) + +let origin: string +let cwd: string +let stdout: string + +beforeAll(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +beforeEach(async () => { + requests.length = 0 + stdout = '' + cwd = await mkdtemp(join(tmpdir(), 'nuxt-curl-test-')) + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + stdout += String(chunk) + return true + }) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(cwd, { recursive: true, force: true }) +}) + +afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())) +}) + +async function writeLock(url: string) { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt', 'nuxt.lock'), JSON.stringify({ + pid: 424242, + command: 'dev', + cwd, + url, + startedAt: Date.now(), + })) + vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) +} + +/** Resolves with the exit code the command asked for, or `0` if it returned. */ +async function run(args: string[]): Promise { + let code = 0 + vi.spyOn(process, 'exit').mockImplementation(((value?: number) => { + code = value ?? 0 + throw new Error(`exit:${code}`) + }) as never) + + try { + await runCommand(curl, { rawArgs: args }) + } + catch (error) { + if (!(error as Error).message.startsWith('exit:')) { + throw error + } + } + return code +} + +describe('curl', () => { + it('requests an absolute URL and prints the body', async () => { + const code = await run([`${origin}/api/hello`]) + + expect(code).toBe(0) + expect(requests[0]).toMatchObject({ method: 'GET', url: '/api/hello' }) + expect(stdout).toBe('{"hello":"world"}') + }) + + it('resolves a path against the running dev server', async () => { + await writeLock(origin) + const code = await run(['/api/hello', `--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(requests[0]?.url).toBe('/api/hello') + }) + + it('accepts a path without a leading slash', async () => { + await writeLock(`${origin}/`) + const code = await run(['api/hello', `--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(requests[0]?.url).toBe('/api/hello') + }) + + it('exits with 1 when no dev server is running', async () => { + const code = await run(['/api/hello', `--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) + + it('exits with 22 on a non-2xx response but still prints the body', async () => { + const code = await run([`${origin}/missing`]) + + expect(code).toBe(22) + expect(stdout).toContain('Page not found') + }) + + it('sends headers, a body and defaults the method to POST', async () => { + const code = await run([`${origin}/api/hello`, '-H', 'x-test: 1', '-d', '{"a":1}']) + + expect(code).toBe(0) + expect(requests[0]).toMatchObject({ + method: 'POST', + body: '{"a":1}', + }) + expect(requests[0]?.headers['x-test']).toBe('1') + expect(requests[0]?.headers['content-type']).toBe('application/json') + expect(requests[0]?.headers['user-agent']).toBe('nuxt-cli') + }) + + it('honours an explicit method', async () => { + const code = await run([`${origin}/api/hello`, '-X', 'delete']) + + expect(code).toBe(0) + expect(requests[0]?.method).toBe('DELETE') + }) + + it('rejects a malformed header', async () => { + const code = await run([`${origin}/api/hello`, '-H', 'nope']) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) +}) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 5123cc6cb..e1b579401 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -26,7 +26,7 @@ describe('help', () => { expect(await usage(main)).toMatchInlineSnapshot(` "Nuxt CLI (nuxt v0.0.0) - USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|dev|devtools|generate|info|module|prepare|preview|test|typecheck|upgrade + USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|curl|dev|devtools|generate|info|module|prepare|preview|test|typecheck|upgrade ARGUMENTS @@ -43,6 +43,7 @@ describe('help', () => { analyze Build Nuxt and analyze production bundle (experimental) build Build Nuxt for production deployment cleanup Clean up generated Nuxt files and caches + curl Send an HTTP request to your running Nuxt dev server dev Run Nuxt development server devtools Enable or disable devtools in a Nuxt project generate Build Nuxt and prerender all routes From 6739421198c252c39ede1e371430ff303bc6aabb Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 31 Jul 2026 13:53:40 +0000 Subject: [PATCH 02/10] refactor(dev): share one build-directory resolver for the lock --- packages/nuxt-cli/src/commands/dev.ts | 14 +-- packages/nuxt-cli/src/utils/dev-server.ts | 47 +++++---- .../nuxt-cli/test/unit/dev-server.spec.ts | 96 +++++++++++++++++++ 3 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 packages/nuxt-cli/test/unit/dev-server.spec.ts diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 6dc924f67..5566cb57c 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -8,7 +8,6 @@ import type { NuxtDevContext } from '../dev/utils' import process from 'node:process' import { defineCommand } from 'citty' -import { resolve } from 'pathe' import { isBun, isTest } from 'std-env' import { satisfies } from 'verkit' @@ -21,6 +20,7 @@ import { formatRestartReason } from '../dev/reason' import { setupShortcuts } from '../dev/shortcuts' import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover' +import { resolveLockDir } from '../utils/dev-server' import { summariseActiveResources } from '../utils/hang' import { debug, logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' @@ -157,7 +157,7 @@ const command = defineCommand({ const listenOverrides = resolveListenOverrides(ctx.args) - const takeover = await takeOverDevServer(resolveDevBuildDir(cwd), { + const takeover = await takeOverDevServer(await resolveLockDir(cwd), { requestedPort: parsePort(listenOverrides.port), takeover: ctx.args.takeover, }) @@ -398,16 +398,6 @@ function setupSignalHandlers(close: () => Promise): void { } } -/** - * The lock lives in the build directory, which is only known once `nuxt.config` - * has been resolved. Resolving it here would mean loading the config twice, so - * a project with a custom `buildDir` gets the plain lock error instead of the - * cross-terminal takeover. - */ -function resolveDevBuildDir(cwd: string): string { - return resolve(cwd, '.nuxt') -} - function resolveForkPoolSize(): number | undefined { const raw = process.env.NUXT_DEV_FORK_POOL_SIZE if (!raw) { diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts index cb35dac06..492d48514 100644 --- a/packages/nuxt-cli/src/utils/dev-server.ts +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -1,7 +1,9 @@ +import { existsSync } from 'node:fs' import { styleText } from 'node:util' + import { resolve } from 'pathe' -import { readActiveLock } from './lockfile' +import { readActiveLock, readLock } from './lockfile' import { getNuxtConfig } from './nuxt-config' const TRAILING_SLASH_RE = /\/$/ @@ -13,29 +15,38 @@ export interface RunningDevServer { cwd: string } +/** + * Directory a project's `nuxt.lock` lives in. + * + * `.nuxt` answers this for nearly every project, so `nuxt.config` is only + * evaluated when that directory is absent: resolving `buildDir` properly means + * executing the user's config, and `nuxt dev` does not otherwise do that in the + * process that decides whether to take a running server over. A project that + * moved its `buildDir` but kept a stale `.nuxt` therefore reads as the default, + * which loses the takeover but never claims a directory that is in use. + */ +export async function resolveLockDir(cwd: string): Promise { + const defaultDir = resolve(cwd, '.nuxt') + if (readLock(defaultDir) || existsSync(defaultDir)) { + return defaultDir + } + + const configured = await configuredBuildDir(cwd) + return configured || defaultDir +} + /** * Locate a live `nuxt dev` server for a project, using the metadata its dev * server records in `nuxt.lock` inside the build directory. * - * `buildDir` may be passed when it is already known; otherwise the default - * `.nuxt` is tried before falling back to reading `nuxt.config`. + * `buildDir` may be passed when it is already known. */ export async function findDevServer(cwd: string, buildDir?: string): Promise { - const candidates = buildDir - ? [resolve(cwd, buildDir)] - : [resolve(cwd, '.nuxt'), await configuredBuildDir(cwd)] - - const seen = new Set() - for (const dir of candidates) { - if (!dir || seen.has(dir)) { - continue - } - seen.add(dir) - - const lock = readActiveLock(dir) - if (lock?.command === 'dev' && lock.url) { - return { url: lock.url.replace(TRAILING_SLASH_RE, ''), pid: lock.pid, cwd: lock.cwd } - } + const dir = buildDir ? resolve(cwd, buildDir) : await resolveLockDir(cwd) + + const lock = readActiveLock(dir) + if (lock?.command === 'dev' && lock.url) { + return { url: lock.url.replace(TRAILING_SLASH_RE, ''), pid: lock.pid, cwd: lock.cwd } } } diff --git a/packages/nuxt-cli/test/unit/dev-server.spec.ts b/packages/nuxt-cli/test/unit/dev-server.spec.ts new file mode 100644 index 000000000..090d4e851 --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev-server.spec.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { findDevServer, resolveLockDir } from '../../src/utils/dev-server' + +let cwd: string + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'nuxt-dev-server-test-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(cwd, { recursive: true, force: true }) +}) + +async function writeLock(dir: string, info: Record = {}) { + await mkdir(join(cwd, dir), { recursive: true }) + await writeFile(join(cwd, dir, 'nuxt.lock'), JSON.stringify({ + pid: 424242, + command: 'dev', + cwd, + interactive: false, + url: 'http://localhost:3000', + startedAt: Date.now(), + ...info, + })) + vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) +} + +describe('resolveLockDir', () => { + it('defaults to `.nuxt`', async () => { + expect(await resolveLockDir(cwd)).toBe(join(cwd, '.nuxt')) + }) + + it('reads `buildDir` from `nuxt.config` when there is no `.nuxt`', async () => { + await writeFile(join(cwd, 'nuxt.config.mjs'), 'export default { buildDir: ".build" }') + + expect(await resolveLockDir(cwd)).toBe(join(cwd, '.build')) + }) + + it('keeps the default when `.nuxt` exists, without reading the config', async () => { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, 'nuxt.config.mjs'), 'throw new Error("config should not be evaluated")') + + expect(await resolveLockDir(cwd)).toBe(join(cwd, '.nuxt')) + }) +}) + +describe('findDevServer', () => { + it('finds a server recorded in a custom build directory', async () => { + await writeFile(join(cwd, 'nuxt.config.mjs'), 'export default { buildDir: ".build" }') + await writeLock('.build') + + await expect(findDevServer(cwd)).resolves.toMatchObject({ + url: 'http://localhost:3000', + pid: 424242, + }) + }) + + it('strips a trailing slash from the recorded URL', async () => { + await writeLock('.nuxt', { url: 'http://localhost:3000/' }) + + await expect(findDevServer(cwd)).resolves.toMatchObject({ url: 'http://localhost:3000' }) + }) + + it('ignores a build lock', async () => { + await writeLock('.nuxt', { command: 'build', url: undefined }) + + await expect(findDevServer(cwd)).resolves.toBeUndefined() + }) + + it('ignores a lock whose process is gone', async () => { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt', 'nuxt.lock'), JSON.stringify({ + pid: 999999999, + command: 'dev', + cwd, + interactive: false, + url: 'http://localhost:3000', + startedAt: Date.now(), + })) + + await expect(findDevServer(cwd)).resolves.toBeUndefined() + }) + + it('uses an explicit build directory when given one', async () => { + await writeLock('custom') + + await expect(findDevServer(cwd, 'custom')).resolves.toMatchObject({ pid: 424242 }) + }) +}) From 31a4bc7a44462891584c3c390316639079280316 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 31 Jul 2026 14:19:49 +0000 Subject: [PATCH 03/10] fix(curl): dial loopback when the dev server recorded a wildcard host --- packages/nuxt-cli/src/utils/dev-server.ts | 31 ++++++++++++++++++- .../nuxt-cli/test/unit/dev-server.spec.ts | 21 ++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts index 492d48514..ebeb8c008 100644 --- a/packages/nuxt-cli/src/utils/dev-server.ts +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -8,6 +8,12 @@ import { getNuxtConfig } from './nuxt-config' const TRAILING_SLASH_RE = /\/$/ +/** Loopback address to dial for each wildcard a listener can be bound to. */ +const LOOPBACK_FOR_WILDCARD: Record = { + '0.0.0.0': '127.0.0.1', + '[::]': '[::1]', +} + export interface RunningDevServer { /** Origin the dev server is listening on, without a trailing slash. */ url: string @@ -46,10 +52,33 @@ export async function findDevServer(cwd: string, buildDir?: string): Promise { }) }) + it('dials loopback for a server bound to every interface', async () => { + await writeLock('.nuxt', { hostname: '0.0.0.0', url: 'http://0.0.0.0:3597' }) + + await expect(findDevServer(cwd)).resolves.toMatchObject({ url: 'http://127.0.0.1:3597' }) + }) + it('strips a trailing slash from the recorded URL', async () => { await writeLock('.nuxt', { url: 'http://localhost:3000/' }) @@ -94,3 +100,16 @@ describe('findDevServer', () => { await expect(findDevServer(cwd, 'custom')).resolves.toMatchObject({ pid: 424242 }) }) }) + +describe('toLoopback', () => { + it.each([ + ['http://0.0.0.0:3000', 'http://127.0.0.1:3000'], + ['http://[::]:3000', 'http://[::1]:3000'], + ['https://0.0.0.0:3000/', 'https://127.0.0.1:3000'], + ['http://localhost:3000', 'http://localhost:3000'], + ['http://192.168.1.5:3000', 'http://192.168.1.5:3000'], + ['not a url/', 'not a url'], + ])('%s -> %s', (input, expected) => { + expect(toLoopback(input)).toBe(expected) + }) +}) From 4ffc60941388094780928c7433a9a17525cdeccc Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 10:12:01 +0000 Subject: [PATCH 04/10] feat(task): add `nuxt task list` and `nuxt task run` --- packages/nuxt-cli/src/commands/index.ts | 1 + packages/nuxt-cli/src/commands/task/_utils.ts | 214 +++++++++++++++ packages/nuxt-cli/src/commands/task/index.ts | 13 + packages/nuxt-cli/src/commands/task/list.ts | 53 ++++ packages/nuxt-cli/src/commands/task/run.ts | 108 ++++++++ packages/nuxt-cli/src/utils/dev-server.ts | 58 +++- packages/nuxt-cli/test/e2e/commands.spec.ts | 1 + .../nuxt-cli/test/unit/commands/task.spec.ts | 247 ++++++++++++++++++ .../nuxt-cli/test/unit/dev-server.spec.ts | 46 +++- packages/nuxt-cli/test/unit/help.spec.ts | 3 +- 10 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 packages/nuxt-cli/src/commands/task/_utils.ts create mode 100644 packages/nuxt-cli/src/commands/task/index.ts create mode 100644 packages/nuxt-cli/src/commands/task/list.ts create mode 100644 packages/nuxt-cli/src/commands/task/run.ts create mode 100644 packages/nuxt-cli/test/unit/commands/task.spec.ts diff --git a/packages/nuxt-cli/src/commands/index.ts b/packages/nuxt-cli/src/commands/index.ts index d9c834f6e..a21c5f480 100644 --- a/packages/nuxt-cli/src/commands/index.ts +++ b/packages/nuxt-cli/src/commands/index.ts @@ -19,6 +19,7 @@ const commandLoaders = { 'prepare': () => import('./prepare').then(_rDefault), 'preview': () => import('./preview').then(_rDefault), 'start': () => import('./start').then(_rDefault), + 'task': () => import('./task').then(_rDefault), 'test': () => import('./test').then(_rDefault), 'typecheck': () => import('./typecheck').then(_rDefault), 'upgrade': () => import('./upgrade').then(_rDefault), diff --git a/packages/nuxt-cli/src/commands/task/_utils.ts b/packages/nuxt-cli/src/commands/task/_utils.ts new file mode 100644 index 000000000..d7af4355d --- /dev/null +++ b/packages/nuxt-cli/src/commands/task/_utils.ts @@ -0,0 +1,214 @@ +import type { ArgDef } from 'citty' + +import http from 'node:http' +import process from 'node:process' +import { styleText } from 'node:util' + +import { findDevServer, findNitroDevWorker, noDevServerMessage, toLoopback } from '../../utils/dev-server' +import { logger } from '../../utils/logger' +import { logNetworkError } from '../../utils/network' +import { resolveRootDir } from '../../utils/paths' + +const TRAILING_SLASH_RE = /\/$/ + +export const taskArgs = { + url: { + type: 'string', + description: 'URL of the Nuxt server to talk to (default: the running dev server)', + valueHint: 'url', + }, +} as const satisfies Record + +interface ServerError { + statusCode?: number + statusMessage?: string + message?: string + data?: unknown + stack?: string | string[] +} + +interface RequestOptions { + method?: string + headers?: Record + body?: string +} + +/** Where task requests are sent, and how they get there. */ +export interface TaskServer { + /** Origin for HTTP requests, and the label used when one fails. */ + base: string + /** Socket to send the request over, for a Nitro dev worker. */ + socketPath?: string +} + +export interface TaskResponse { + ok: boolean + status: number + data: unknown +} + +/** + * Resolve where the task routes live: an explicit `--url`, the dev server + * recorded in the project's lock file, or the Nitro dev worker behind it. + * + * The worker is a fallback rather than the first choice because its address + * comes from a file Nitro owns; the dev server's public URL is what this CLI + * records itself. + */ +export async function resolveTaskServer(args: { url?: string, cwd?: string, rootDir?: string }): Promise { + if (args.url) { + if (!URL.canParse(args.url)) { + logger.error(`Invalid ${styleText('cyan', '--url')} value ${styleText('cyan', args.url)}.`) + process.exit(1) + } + return { base: args.url.replace(TRAILING_SLASH_RE, '') } + } + + const cwd = resolveRootDir(args) + + const server = await findDevServer(cwd) + if (server) { + return { base: server.url } + } + + const worker = await findNitroDevWorker(cwd) + if (worker?.socketPath) { + return { base: 'http://localhost', socketPath: worker.socketPath } + } + if (worker?.url) { + return { base: toLoopback(worker.url) } + } + + logger.error(noDevServerMessage('nuxt task')) + process.exit(1) +} + +export async function fetchTasks(server: TaskServer): Promise { + return await request(server, '/_nitro/tasks') +} + +/** + * Ask the server to run a task. + * + * Nitro 2 exposes the dev task route for any method and reads the payload from + * a `{ name, payload }` body; Nitro 3 also accepts that body, but earlier + * prereleases registered the route for `GET` only and merged query parameters + * into the payload. `POST` is tried first, falling back to a query-encoded + * `GET` when the route rejects it. + */ +export async function runTask(server: TaskServer, name: string, payload: Record): Promise { + const path = `/_nitro/tasks/${name.split('/').map(encodeURIComponent).join('/')}` + + const posted = await request(server, path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, payload }), + }) + if (posted.ok || (posted.status !== 404 && posted.status !== 405)) { + return posted + } + + const query = new URLSearchParams() + for (const [key, value] of Object.entries(payload)) { + query.set(key, typeof value === 'string' ? value : JSON.stringify(value)) + } + const search = query.size > 0 ? `?${query}` : '' + const queried = await request(server, `${path}${search}`) + + return queried.ok ? queried : posted +} + +/** Print the error a task route returned, with as much detail as it gave us. */ +export function reportTaskError(response: TaskResponse): void { + const error = (typeof response.data === 'object' && response.data ? response.data : {}) as ServerError + const message = error.message || error.statusMessage || `Request failed with status ${response.status}` + + logger.error(message) + + if (error.data !== undefined) { + process.stderr.write(`${format(error.data)}\n`) + } + + // A 4xx describes the request (unknown or unimplemented task) and its stack is + // all framework frames; a 5xx comes from the task itself, where they help. + // Nitro repeats the message at the head of the stack, so only frames are kept. + if (response.status >= 500) { + const stack = (Array.isArray(error.stack) ? error.stack : (error.stack || '').split('\n')) + .map(line => line.trim()) + .filter(line => line.startsWith('at ')) + if (stack.length > 0) { + process.stderr.write(styleText('dim', `${stack.map(line => ` ${line}`).join('\n')}\n`)) + } + } +} + +export function format(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) +} + +async function request(server: TaskServer, path: string, options: RequestOptions = {}): Promise { + const headers = { accept: 'application/json', ...options.headers } + + try { + return server.socketPath + ? await socketRequest(server.socketPath, path, { ...options, headers }) + : await httpRequest(`${server.base}${path}`, { ...options, headers }) + } + catch (error) { + // A socket failure is never a network or proxy problem, so it gets a plain + // message rather than the diagnostics `logNetworkError` would add. + if (server.socketPath) { + logger.error(`Could not reach the Nitro dev worker on ${styleText('cyan', describeSocket(server.socketPath))}: ${(error as Error).message}. Is the dev server still running?`) + process.exit(1) + } + logNetworkError(error, { url: `${server.base}${path}` }) + process.exit(1) + } +} + +async function httpRequest(url: string, options: RequestOptions): Promise { + const response = await fetch(url, options) + return { ok: response.ok, status: response.status, data: parseBody(await response.text()) } +} + +/** + * Nitro's dev worker usually listens on a unix socket rather than a port, which + * `fetch` cannot dial. + */ +function socketRequest(socketPath: string, path: string, options: RequestOptions): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ socketPath, path, method: options.method || 'GET', headers: options.headers }, (res) => { + res.setEncoding('utf-8') + let body = '' + res.on('data', (chunk: string) => { + body += chunk + }) + res.on('end', () => { + const status = res.statusCode || 0 + resolve({ ok: status >= 200 && status < 300, status, data: parseBody(body) }) + }) + }) + req.on('error', reject) + if (options.body) { + req.write(options.body) + } + req.end() + }) +} + +function parseBody(text: string): unknown { + if (!text) { + return undefined + } + try { + return JSON.parse(text) + } + catch { + return text + } +} + +/** Abstract sockets start with a null byte, conventionally shown as `@`. */ +function describeSocket(socketPath: string): string { + return socketPath.replace(/\0/g, '@') +} diff --git a/packages/nuxt-cli/src/commands/task/index.ts b/packages/nuxt-cli/src/commands/task/index.ts new file mode 100644 index 000000000..697d8ef33 --- /dev/null +++ b/packages/nuxt-cli/src/commands/task/index.ts @@ -0,0 +1,13 @@ +import { defineCommand } from 'citty' + +export default defineCommand({ + meta: { + name: 'task', + description: 'List and run Nitro tasks on your dev server', + }, + args: {}, + subCommands: { + list: () => import('./list').then(r => r.default || r), + run: () => import('./run').then(r => r.default || r), + }, +}) diff --git a/packages/nuxt-cli/src/commands/task/list.ts b/packages/nuxt-cli/src/commands/task/list.ts new file mode 100644 index 000000000..e8d1b93dd --- /dev/null +++ b/packages/nuxt-cli/src/commands/task/list.ts @@ -0,0 +1,53 @@ +import process from 'node:process' +import { styleText } from 'node:util' + +import { defineCommand } from 'citty' + +import { logger } from '../../utils/logger' +import { rootDirArgs } from '../_shared' +import { fetchTasks, reportTaskError, resolveTaskServer, taskArgs } from './_utils' + +interface TaskList { + tasks?: Record + scheduledTasks?: { cron: string, tasks: string[] }[] | false +} + +export default defineCommand({ + meta: { + name: 'list', + description: 'List the tasks a Nuxt server exposes', + }, + args: { + ...rootDirArgs, + ...taskArgs, + }, + async run(ctx) { + const server = await resolveTaskServer(ctx.args) + const response = await fetchTasks(server) + + if (!response.ok) { + reportTaskError(response) + process.exit(1) + } + + const { tasks = {}, scheduledTasks } = (response.data || {}) as TaskList + const names = Object.keys(tasks).sort() + + if (names.length === 0) { + logger.info(`No tasks found. Add one in ${styleText('cyan', 'server/tasks/')} and enable ${styleText('cyan', 'nitro.experimental.tasks')}.`) + return + } + + const width = Math.max(...names.map(name => name.length)) + const lines = names.map(name => ` ${styleText('cyan', name.padEnd(width))} ${styleText('dim', tasks[name]?.description || '')}`.trimEnd()) + + if (scheduledTasks && scheduledTasks.length > 0) { + lines.push('', ` ${styleText('bold', 'Scheduled')}`) + for (const { cron, tasks: scheduled } of scheduledTasks) { + lines.push(` ${styleText('cyan', cron)} ${styleText('dim', scheduled.join(', '))}`) + } + } + + process.stdout.write(`${lines.join('\n')}\n`) + }, +}) diff --git a/packages/nuxt-cli/src/commands/task/run.ts b/packages/nuxt-cli/src/commands/task/run.ts new file mode 100644 index 000000000..b51ac8156 --- /dev/null +++ b/packages/nuxt-cli/src/commands/task/run.ts @@ -0,0 +1,108 @@ +import process from 'node:process' +import { styleText } from 'node:util' + +import { defineCommand } from 'citty' + +import { logger } from '../../utils/logger' +import { rootDirArgs } from '../_shared' +import { format, reportTaskError, resolveTaskServer, runTask, taskArgs } from './_utils' + +const PAYLOAD_PREFIX = 'payload.' + +export default defineCommand({ + meta: { + name: 'run', + description: 'Run a task on a Nuxt server and print its result', + }, + args: { + // `name` has to precede the `dir` positional supplied by `rootDirArgs` + name: { + type: 'positional', + description: 'Name of the task to run', + valueHint: 'name', + }, + ...rootDirArgs, + ...taskArgs, + payload: { + type: 'string', + description: 'Task payload, either as a JSON object or as `--payload.key=value` pairs', + valueHint: 'json', + }, + }, + async run(ctx) { + const name = ctx.args.name + if (!name) { + logger.error(`Missing task name. Try ${styleText('cyan', 'nuxt task list')} to see what is available.`) + process.exit(1) + } + + const payload = resolvePayload(ctx.args) + const server = await resolveTaskServer(ctx.args) + const response = await runTask(server, name, payload) + + if (!response.ok) { + reportTaskError(response) + process.exit(1) + } + + const result = unwrapResult(response.data) + if (result !== undefined) { + process.stdout.write(`${format(result)}\n`) + } + }, +}) + +/** + * Nitro answers a task run with `{ result }`. Printing the result on its own is + * what `nitro task run` shows and what a caller can pipe into `jq`, so the + * envelope is dropped when that is all the response holds. + */ +function unwrapResult(data: unknown): unknown { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return data + } + const keys = Object.keys(data) + return keys.length === 1 && keys[0] === 'result' ? (data as { result: unknown }).result : data +} + +function resolvePayload(args: Record): Record { + const payload: Record = {} + + if (typeof args.payload === 'string' && args.payload.trim()) { + let parsed: unknown + try { + parsed = JSON.parse(args.payload) + } + catch { + logger.error(`Could not parse ${styleText('cyan', '--payload')} as JSON. Pass a JSON object, or use ${styleText('cyan', '--payload.key=value')}.`) + process.exit(1) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + logger.error(`${styleText('cyan', '--payload')} must be a JSON object.`) + process.exit(1) + } + Object.assign(payload, parsed) + } + + for (const [key, value] of Object.entries(args)) { + if (key.startsWith(PAYLOAD_PREFIX)) { + assign(payload, key.slice(PAYLOAD_PREFIX.length).split('.'), value) + } + } + + return payload +} + +function assign(target: Record, path: string[], value: unknown): void { + const key = path[0]! + if (path.length === 1) { + target[key] = value + return + } + const existing = target[key] + const child = typeof existing === 'object' && existing !== null && !Array.isArray(existing) + ? existing as Record + : {} + target[key] = child + assign(child, path.slice(1), value) +} diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts index ebeb8c008..5b7097008 100644 --- a/packages/nuxt-cli/src/utils/dev-server.ts +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -1,9 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { styleText } from 'node:util' -import { resolve } from 'pathe' +import { join, resolve } from 'pathe' -import { readActiveLock, readLock } from './lockfile' +import { isProcessAlive, readActiveLock, readLock } from './lockfile' import { getNuxtConfig } from './nuxt-config' const TRAILING_SLASH_RE = /\/$/ @@ -79,10 +79,62 @@ export function toLoopback(url: string): string { return parsed.href.replace(TRAILING_SLASH_RE, '') } +export interface NitroDevWorker { + pid: number + /** Socket the worker listens on, when it was given one. */ + socketPath?: string + /** Origin the worker listens on, when it was given a port instead. */ + url?: string +} + +/** + * Locate the Nitro dev worker behind a dev server, from the build info Nitro + * writes for its own task runner. + * + * The worker answers the dev-only routes directly, so reaching it needs neither + * the public listener nor the dev proxy in front of it. Nitro 2 records this in + * `/nitro.json` and Nitro 3 in `node_modules/.nitro/nitro.dev.json`; + * both are internal to Nitro, so everything here is best-effort. + */ +export async function findNitroDevWorker(cwd: string, buildDir?: string): Promise { + const dir = buildDir ? resolve(cwd, buildDir) : await resolveLockDir(cwd) + + for (const path of [join(dir, 'nitro.json'), join(cwd, 'node_modules/.nitro/nitro.dev.json')]) { + const dev = readBuildInfo(path)?.dev + if (!dev?.pid || !dev.workerAddress || !isProcessAlive(dev.pid)) { + continue + } + + const { socketPath, host, port } = dev.workerAddress + if (socketPath) { + return { pid: dev.pid, socketPath } + } + if (port) { + return { pid: dev.pid, url: `http://${host || 'localhost'}:${port}` } + } + } +} + export function noDevServerMessage(what: string): string { return `No running Nuxt dev server found. Start one with ${styleText('cyan', 'nuxt dev')}, or pass an absolute URL to ${styleText('cyan', what)}.` } +interface NitroBuildInfo { + dev?: { + pid?: number + workerAddress?: { socketPath?: string, host?: string, port?: number } + } +} + +function readBuildInfo(path: string): NitroBuildInfo | undefined { + try { + return JSON.parse(readFileSync(path, 'utf-8')) as NitroBuildInfo + } + catch { + return undefined + } +} + async function configuredBuildDir(cwd: string): Promise { try { const config = await getNuxtConfig(cwd) diff --git a/packages/nuxt-cli/test/e2e/commands.spec.ts b/packages/nuxt-cli/test/e2e/commands.spec.ts index 7c05a2cbb..6e64874fe 100644 --- a/packages/nuxt-cli/test/e2e/commands.spec.ts +++ b/packages/nuxt-cli/test/e2e/commands.spec.ts @@ -48,6 +48,7 @@ describe('commands', () => { expect(res.exitCode).toBe(0) }, 'curl': 'todo', + 'task': 'todo', 'devtools': 'todo', 'module': 'todo', 'prepare': async () => { diff --git a/packages/nuxt-cli/test/unit/commands/task.spec.ts b/packages/nuxt-cli/test/unit/commands/task.spec.ts new file mode 100644 index 000000000..a028f3d63 --- /dev/null +++ b/packages/nuxt-cli/test/unit/commands/task.spec.ts @@ -0,0 +1,247 @@ +import type { CommandDef } from 'citty' +import type { AddressInfo } from 'node:net' + +import { Buffer } from 'node:buffer' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { runCommand } from 'citty' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import list from '../../../src/commands/task/list' +import run from '../../../src/commands/task/run' +import { logger } from '../../../src/utils/logger' + +interface ReceivedRequest { + method: string + url: string + body: string +} + +const requests: ReceivedRequest[] = [] + +/** Set to `true` to emulate a Nitro version that only accepts `GET`. */ +let getOnly = false +let envelope: unknown = { result: { ok: true } } + +const server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(chunk as Buffer) + } + const url = req.url! + const request = { method: req.method!, url, body: Buffer.concat(chunks).toString('utf-8') } + requests.push(request) + + res.setHeader('content-type', 'application/json') + + if (url === '/_nitro/tasks') { + res.end(JSON.stringify({ + tasks: { 'db:migrate': { description: 'Migrate the database' }, 'db:seed': {} }, + scheduledTasks: [{ cron: '0 * * * *', tasks: ['db:seed'] }], + })) + return + } + + if (url.startsWith('/_nitro/tasks/unknown')) { + res.statusCode = 404 + res.end(JSON.stringify({ statusCode: 404, message: 'Task `unknown` is not available!' })) + return + } + + if (getOnly && req.method !== 'GET') { + res.statusCode = 405 + res.end(JSON.stringify({ statusCode: 405, message: 'Method not allowed' })) + return + } + + res.end(JSON.stringify(envelope)) +}) + +let origin: string +let cwd: string +let stdout: string + +beforeAll(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +beforeEach(async () => { + requests.length = 0 + getOnly = false + envelope = { result: { ok: true } } + stdout = '' + cwd = await mkdtemp(join(tmpdir(), 'nuxt-task-test-')) + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + stdout += String(chunk) + return true + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(cwd, { recursive: true, force: true }) +}) + +afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())) +}) + +async function writeBuildInfo(socketPath: string) { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt', 'nitro.json'), JSON.stringify({ + dev: { pid: 424242, workerAddress: { socketPath } }, + })) + vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) +} + +async function writeLock(url: string) { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt', 'nuxt.lock'), JSON.stringify({ + pid: 424242, + command: 'dev', + cwd, + url, + startedAt: Date.now(), + })) + vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) +} + +/** Resolves with the exit code the command asked for, or `0` if it returned. */ +async function runTaskCommand(command: CommandDef, args: string[]): Promise { + let code = 0 + vi.spyOn(process, 'exit').mockImplementation(((value?: number) => { + code = value ?? 0 + throw new Error(`exit:${code}`) + }) as never) + + try { + await runCommand(command, { rawArgs: args }) + } + catch (error) { + if (!(error as Error).message.startsWith('exit:')) { + throw error + } + } + return code +} + +describe('task list', () => { + it('lists tasks with their descriptions', async () => { + const code = await runTaskCommand(list, ['--url', origin]) + + expect(code).toBe(0) + expect(stdout).toContain('db:migrate') + expect(stdout).toContain('Migrate the database') + expect(stdout).toContain('0 * * * *') + }) + + it('finds the dev server from the lock file', async () => { + await writeLock(origin) + const code = await runTaskCommand(list, [`--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(requests[0]?.url).toBe('/_nitro/tasks') + }) + + it('exits with 1 when no dev server is running', async () => { + const code = await runTaskCommand(list, [`--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) +}) + +describe('task over the Nitro dev worker socket', () => { + const socketServer = createServer(async (req, res) => { + requests.push({ method: req.method!, url: req.url!, body: '' }) + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ tasks: { 'db:seed': { description: 'Seed' } } })) + }) + + let socketPath: string + + beforeAll(async () => { + socketPath = join(await mkdtemp(join(tmpdir(), 'nuxt-task-socket-')), 'worker.sock') + await new Promise(resolve => socketServer.listen(socketPath, resolve)) + }) + + afterAll(async () => { + await new Promise(resolve => socketServer.close(() => resolve())) + }) + + // Unix sockets are not available on Windows. + it.skipIf(process.platform === 'win32')('falls back to the worker when no lock records a dev server', async () => { + await writeBuildInfo(socketPath) + const code = await runTaskCommand(list, [`--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(requests[0]?.url).toBe('/_nitro/tasks') + expect(stdout).toContain('db:seed') + }) + + it.skipIf(process.platform === 'win32')('reports a worker that is no longer listening', async () => { + await writeBuildInfo(join(cwd, 'gone.sock')) + const error = vi.spyOn(logger, 'error').mockImplementation(() => {}) + const code = await runTaskCommand(list, [`--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining('gone.sock')) + }) +}) + +describe('task run', () => { + it('posts the payload and prints the result', async () => { + const code = await runTaskCommand(run, ['db:seed', '--url', origin, '--payload.count=3', '--payload.nested.flag=yes']) + + expect(code).toBe(0) + expect(requests[0]).toMatchObject({ method: 'POST', url: '/_nitro/tasks/db%3Aseed' }) + expect(JSON.parse(requests[0]!.body)).toEqual({ + name: 'db:seed', + payload: { count: '3', nested: { flag: 'yes' } }, + }) + expect(stdout).toBe('{\n "ok": true\n}\n') + }) + + it('prints a response that is not a bare result envelope as it came', async () => { + envelope = { result: { ok: true }, duration: 12 } + const code = await runTaskCommand(run, ['db:seed', '--url', origin]) + + expect(code).toBe(0) + expect(JSON.parse(stdout)).toEqual({ result: { ok: true }, duration: 12 }) + }) + + it('merges a JSON payload with dotted arguments', async () => { + const code = await runTaskCommand(run, ['db:seed', '--url', origin, '--payload', '{"a":1}', '--payload.b=2']) + + expect(code).toBe(0) + expect(JSON.parse(requests[0]!.body).payload).toEqual({ a: 1, b: '2' }) + }) + + it('rejects an unparseable payload', async () => { + const code = await runTaskCommand(run, ['db:seed', '--url', origin, '--payload', 'nope']) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) + + it('falls back to a query-encoded GET when POST is not allowed', async () => { + getOnly = true + const code = await runTaskCommand(run, ['db:seed', '--url', origin, '--payload.count=3']) + + expect(code).toBe(0) + expect(requests.map(request => request.method)).toEqual(['POST', 'GET']) + expect(requests[1]?.url).toBe('/_nitro/tasks/db%3Aseed?count=3') + }) + + it('exits non-zero and reports the server error', async () => { + const code = await runTaskCommand(run, ['unknown', '--url', origin]) + + expect(code).toBe(1) + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev-server.spec.ts b/packages/nuxt-cli/test/unit/dev-server.spec.ts index 0db5488e7..199f15aa9 100644 --- a/packages/nuxt-cli/test/unit/dev-server.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-server.spec.ts @@ -5,7 +5,7 @@ import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { findDevServer, resolveLockDir, toLoopback } from '../../src/utils/dev-server' +import { findDevServer, findNitroDevWorker, resolveLockDir, toLoopback } from '../../src/utils/dev-server' let cwd: string @@ -101,6 +101,50 @@ describe('findDevServer', () => { }) }) +describe('findNitroDevWorker', () => { + async function writeBuildInfo(path: string, dev: unknown) { + await mkdir(join(cwd, path.split('/').slice(0, -1).join('/')), { recursive: true }) + await writeFile(join(cwd, path), JSON.stringify({ dev })) + vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) + } + + it('returns nothing when Nitro has not written any build info', async () => { + await expect(findNitroDevWorker(cwd)).resolves.toBeUndefined() + }) + + it('reads the socket a Nitro 2 dev worker recorded', async () => { + await writeBuildInfo('.nuxt/nitro.json', { pid: 424242, workerAddress: { socketPath: '\u0000nitro-worker-1.sock' } }) + + await expect(findNitroDevWorker(cwd)).resolves.toEqual({ pid: 424242, socketPath: '\u0000nitro-worker-1.sock' }) + }) + + it('reads the port a dev worker recorded when it has no socket', async () => { + await writeBuildInfo('.nuxt/nitro.json', { pid: 424242, workerAddress: { host: 'localhost', port: 4321 } }) + + await expect(findNitroDevWorker(cwd)).resolves.toEqual({ pid: 424242, url: 'http://localhost:4321' }) + }) + + it('reads the file Nitro 3 writes', async () => { + await writeBuildInfo('node_modules/.nitro/nitro.dev.json', { pid: 424242, workerAddress: { socketPath: '/tmp/worker.sock' } }) + + await expect(findNitroDevWorker(cwd)).resolves.toMatchObject({ socketPath: '/tmp/worker.sock' }) + }) + + it('ignores build info whose process is gone', async () => { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt/nitro.json'), JSON.stringify({ dev: { pid: 999999999, workerAddress: { socketPath: '/tmp/worker.sock' } } })) + + await expect(findNitroDevWorker(cwd)).resolves.toBeUndefined() + }) + + it('ignores build info from a finished build', async () => { + await mkdir(join(cwd, '.nuxt'), { recursive: true }) + await writeFile(join(cwd, '.nuxt/nitro.json'), JSON.stringify({ preset: 'node-server' })) + + await expect(findNitroDevWorker(cwd)).resolves.toBeUndefined() + }) +}) + describe('toLoopback', () => { it.each([ ['http://0.0.0.0:3000', 'http://127.0.0.1:3000'], diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index e1b579401..fe83ab05d 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -26,7 +26,7 @@ describe('help', () => { expect(await usage(main)).toMatchInlineSnapshot(` "Nuxt CLI (nuxt v0.0.0) - USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|curl|dev|devtools|generate|info|module|prepare|preview|test|typecheck|upgrade + USAGE nuxt [OPTIONS] [COMMAND] add|add-template|analyze|build|cleanup|curl|dev|devtools|generate|info|module|prepare|preview|task|test|typecheck|upgrade ARGUMENTS @@ -51,6 +51,7 @@ describe('help', () => { module Manage Nuxt modules prepare Prepare Nuxt for development/build preview Launches Nitro server for local testing after \`nuxt build\`. + task List and run Nitro tasks on your dev server test Run tests typecheck Runs type-checking throughout your app using \`vue-tsc\` or Golar. upgrade Upgrade Nuxt From ae821d84a9a29dc915bf726fc2877e355c23f4e4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 31 Jul 2026 16:31:22 +0000 Subject: [PATCH 05/10] feat(task): explain how to enable tasks when the server reports none --- packages/nuxt-cli/src/commands/task/_utils.ts | 70 +++++++++++++++ packages/nuxt-cli/src/commands/task/list.ts | 16 ++-- packages/nuxt-cli/src/commands/task/run.ts | 6 +- .../nuxt-cli/test/unit/commands/task.spec.ts | 85 ++++++++++++++++++- 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/packages/nuxt-cli/src/commands/task/_utils.ts b/packages/nuxt-cli/src/commands/task/_utils.ts index d7af4355d..7d269dd47 100644 --- a/packages/nuxt-cli/src/commands/task/_utils.ts +++ b/packages/nuxt-cli/src/commands/task/_utils.ts @@ -1,12 +1,16 @@ import type { ArgDef } from 'citty' +import { readdirSync } from 'node:fs' import http from 'node:http' import process from 'node:process' import { styleText } from 'node:util' +import { join, resolve } from 'pathe' + import { findDevServer, findNitroDevWorker, noDevServerMessage, toLoopback } from '../../utils/dev-server' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' +import { getNuxtConfig } from '../../utils/nuxt-config' import { resolveRootDir } from '../../utils/paths' const TRAILING_SLASH_RE = /\/$/ @@ -47,6 +51,11 @@ export interface TaskResponse { data: unknown } +export interface TaskList { + tasks?: Record + scheduledTasks?: { cron: string, tasks: string[] }[] | false +} + /** * Resolve where the task routes live: an explicit `--url`, the dev server * recorded in the project's lock file, or the Nitro dev worker behind it. @@ -142,6 +151,67 @@ export function reportTaskError(response: TaskResponse): void { } } +/** + * Advice for a server that answered but has no tasks to run. + * + * Nitro only scans the tasks directory when `nitro.experimental.tasks` is on, + * so a project with the flag off is indistinguishable over HTTP from one that + * has no tasks at all. Task files on disk are what tell the two apart. + */ +export async function emptyTaskListHint(cwd: string): Promise { + const enable = styleText('cyan', 'nitro: { experimental: { tasks: true } }') + const dir = await resolveTasksDir(cwd) + const label = styleText('cyan', `${dir}/`) + + return hasFiles(resolve(cwd, dir)) + ? `Found task files in ${label} but the server reports none. Add ${enable} to your Nuxt config and restart the dev server.` + : `Add a task in ${label} and enable tasks with ${enable} in your Nuxt config.` +} + +/** Advice for a server that does not expose the task routes at all. */ +export function missingTaskRoutesHint(): string { + return `Nitro only serves its task routes in development, so this needs a running ${styleText('cyan', 'nuxt dev')} server.` +} + +/** + * Explain a task the server does not have. Only the task list can tell a + * mistyped name from a project whose tasks are not enabled, which is worth one + * more request on a path that has already failed. + */ +export async function reportUnknownTask(server: TaskServer, cwd: string): Promise { + const response = await fetchTasks(server) + if (!response.ok) { + if (response.status === 404) { + logger.info(missingTaskRoutesHint()) + } + return + } + + const names = Object.keys((response.data as TaskList | undefined)?.tasks || {}).sort() + logger.info(names.length === 0 + ? await emptyTaskListHint(cwd) + : `Available tasks: ${names.map(name => styleText('cyan', name)).join(', ')}`) +} + +async function resolveTasksDir(cwd: string): Promise { + try { + const config = await getNuxtConfig(cwd) + return join(config.serverDir || join(config.srcDir || '.', 'server'), 'tasks') + } + catch { + return 'server/tasks' + } +} + +function hasFiles(dir: string): boolean { + try { + return readdirSync(dir).length > 0 + } + catch { + return false + } +} + export function format(value: unknown): string { return typeof value === 'string' ? value : JSON.stringify(value, null, 2) } diff --git a/packages/nuxt-cli/src/commands/task/list.ts b/packages/nuxt-cli/src/commands/task/list.ts index e8d1b93dd..0ee01381d 100644 --- a/packages/nuxt-cli/src/commands/task/list.ts +++ b/packages/nuxt-cli/src/commands/task/list.ts @@ -1,16 +1,14 @@ +import type { TaskList } from './_utils' + import process from 'node:process' import { styleText } from 'node:util' import { defineCommand } from 'citty' import { logger } from '../../utils/logger' +import { resolveRootDir } from '../../utils/paths' import { rootDirArgs } from '../_shared' -import { fetchTasks, reportTaskError, resolveTaskServer, taskArgs } from './_utils' - -interface TaskList { - tasks?: Record - scheduledTasks?: { cron: string, tasks: string[] }[] | false -} +import { emptyTaskListHint, fetchTasks, missingTaskRoutesHint, reportTaskError, resolveTaskServer, taskArgs } from './_utils' export default defineCommand({ meta: { @@ -22,11 +20,15 @@ export default defineCommand({ ...taskArgs, }, async run(ctx) { + const cwd = resolveRootDir(ctx.args) const server = await resolveTaskServer(ctx.args) const response = await fetchTasks(server) if (!response.ok) { reportTaskError(response) + if (response.status === 404) { + logger.info(missingTaskRoutesHint()) + } process.exit(1) } @@ -34,7 +36,7 @@ export default defineCommand({ const names = Object.keys(tasks).sort() if (names.length === 0) { - logger.info(`No tasks found. Add one in ${styleText('cyan', 'server/tasks/')} and enable ${styleText('cyan', 'nitro.experimental.tasks')}.`) + logger.info(`No tasks found. ${await emptyTaskListHint(cwd)}`) return } diff --git a/packages/nuxt-cli/src/commands/task/run.ts b/packages/nuxt-cli/src/commands/task/run.ts index b51ac8156..e5e95ec1c 100644 --- a/packages/nuxt-cli/src/commands/task/run.ts +++ b/packages/nuxt-cli/src/commands/task/run.ts @@ -4,8 +4,9 @@ import { styleText } from 'node:util' import { defineCommand } from 'citty' import { logger } from '../../utils/logger' +import { resolveRootDir } from '../../utils/paths' import { rootDirArgs } from '../_shared' -import { format, reportTaskError, resolveTaskServer, runTask, taskArgs } from './_utils' +import { format, reportTaskError, reportUnknownTask, resolveTaskServer, runTask, taskArgs } from './_utils' const PAYLOAD_PREFIX = 'payload.' @@ -42,6 +43,9 @@ export default defineCommand({ if (!response.ok) { reportTaskError(response) + if (response.status === 404) { + await reportUnknownTask(server, resolveRootDir(ctx.args)) + } process.exit(1) } diff --git a/packages/nuxt-cli/test/unit/commands/task.spec.ts b/packages/nuxt-cli/test/unit/commands/task.spec.ts index a028f3d63..6923c4668 100644 --- a/packages/nuxt-cli/test/unit/commands/task.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/task.spec.ts @@ -26,6 +26,12 @@ const requests: ReceivedRequest[] = [] /** Set to `true` to emulate a Nitro version that only accepts `GET`. */ let getOnly = false let envelope: unknown = { result: { ok: true } } +/** Set to `false` to emulate a server with no dev task routes at all. */ +let tasksRoute = true +let taskList: unknown = { + tasks: { 'db:migrate': { description: 'Migrate the database' }, 'db:seed': {} }, + scheduledTasks: [{ cron: '0 * * * *', tasks: ['db:seed'] }], +} const server = createServer(async (req, res) => { const chunks: Buffer[] = [] @@ -38,11 +44,14 @@ const server = createServer(async (req, res) => { res.setHeader('content-type', 'application/json') + if (!tasksRoute) { + res.statusCode = 404 + res.end(JSON.stringify({ statusCode: 404, message: `Page not found: ${url}` })) + return + } + if (url === '/_nitro/tasks') { - res.end(JSON.stringify({ - tasks: { 'db:migrate': { description: 'Migrate the database' }, 'db:seed': {} }, - scheduledTasks: [{ cron: '0 * * * *', tasks: ['db:seed'] }], - })) + res.end(JSON.stringify(taskList)) return } @@ -73,6 +82,11 @@ beforeAll(async () => { beforeEach(async () => { requests.length = 0 getOnly = false + tasksRoute = true + taskList = { + tasks: { 'db:migrate': { description: 'Migrate the database' }, 'db:seed': {} }, + scheduledTasks: [{ cron: '0 * * * *', tasks: ['db:seed'] }], + } envelope = { result: { ok: true } } stdout = '' cwd = await mkdtemp(join(tmpdir(), 'nuxt-task-test-')) @@ -155,6 +169,50 @@ describe('task list', () => { expect(code).toBe(1) expect(requests).toHaveLength(0) }) + + it('explains how to enable tasks when the server reports none', async () => { + taskList = { tasks: {} } + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const code = await runTaskCommand(list, ['--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(info).toHaveBeenCalledWith(expect.stringContaining('experimental: { tasks: true }')) + expect(info).toHaveBeenCalledWith(expect.stringContaining('Add a task in')) + }) + + it('points at the flag rather than the directory when task files exist', async () => { + taskList = { tasks: {} } + await mkdir(join(cwd, 'server', 'tasks'), { recursive: true }) + await writeFile(join(cwd, 'server', 'tasks', 'hello.ts'), 'export default defineTask({})') + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const code = await runTaskCommand(list, ['--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(info).toHaveBeenCalledWith(expect.stringContaining('Found task files in')) + expect(info).toHaveBeenCalledWith(expect.stringContaining('server/tasks/')) + }) + + it('looks for tasks under a configured `serverDir`', async () => { + taskList = { tasks: {} } + await writeFile(join(cwd, 'nuxt.config.mjs'), 'export default { serverDir: "api" }') + await mkdir(join(cwd, 'api', 'tasks'), { recursive: true }) + await writeFile(join(cwd, 'api', 'tasks', 'hello.ts'), 'export default defineTask({})') + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const code = await runTaskCommand(list, ['--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(0) + expect(info).toHaveBeenCalledWith(expect.stringContaining('api/tasks/')) + }) + + it('explains that task routes only exist in development', async () => { + tasksRoute = false + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + vi.spyOn(logger, 'error').mockImplementation(() => {}) + const code = await runTaskCommand(list, ['--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(info).toHaveBeenCalledWith(expect.stringContaining('only serves its task routes in development')) + }) }) describe('task over the Nitro dev worker socket', () => { @@ -244,4 +302,23 @@ describe('task run', () => { expect(code).toBe(1) }) + + it('lists what is available when the task name is unknown', async () => { + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + vi.spyOn(logger, 'error').mockImplementation(() => {}) + const code = await runTaskCommand(run, ['unknown', '--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(info).toHaveBeenCalledWith(expect.stringContaining('db:migrate')) + }) + + it('explains how to enable tasks when the server has none at all', async () => { + taskList = { tasks: {} } + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}) + vi.spyOn(logger, 'error').mockImplementation(() => {}) + const code = await runTaskCommand(run, ['unknown', '--url', origin, `--cwd=${cwd}`]) + + expect(code).toBe(1) + expect(info).toHaveBeenCalledWith(expect.stringContaining('experimental: { tasks: true }')) + }) }) From 6207ea583fed16e83bbbbc425cc34657c4a5449e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 31 Jul 2026 22:33:51 +0100 Subject: [PATCH 06/10] test: use `pathe` --- packages/nuxt-cli/test/unit/dev-server.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/test/unit/dev-server.spec.ts b/packages/nuxt-cli/test/unit/dev-server.spec.ts index 199f15aa9..fa2c21ede 100644 --- a/packages/nuxt-cli/test/unit/dev-server.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-server.spec.ts @@ -1,8 +1,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' import process from 'node:process' +import { join } from 'pathe' + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { findDevServer, findNitroDevWorker, resolveLockDir, toLoopback } from '../../src/utils/dev-server' From 9d8304b5ae0b737c94ae31d06f5d2d502f605764 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 31 Jul 2026 23:17:01 +0100 Subject: [PATCH 07/10] feat: highlight json results from `task run` --- packages/nuxt-cli/src/commands/curl.ts | 9 ++---- packages/nuxt-cli/src/commands/task/_utils.ts | 3 +- packages/nuxt-cli/src/utils/json-highlight.ts | 24 +++++++++++++++ .../nuxt-cli/test/unit/commands/task.spec.ts | 5 ++-- .../test/unit/utils/json-highlight.spec.ts | 29 +++++++++++++++++++ 5 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/json-highlight.ts create mode 100644 packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 318116189..63b8f31e9 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -6,13 +6,13 @@ import { styleText } from 'node:util' import { defineCommand } from 'citty' import { findDevServer, noDevServerMessage } from '../utils/dev-server' +import { highlightJson } from '../utils/json-highlight' import { logger } from '../utils/logger' import { logNetworkError } from '../utils/network' import { resolveRootDir } from '../utils/paths' import { rootDirArgs } from './_shared' const HAS_SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//i -const JSON_TOKEN_RE = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w.+-]+\+)?json\b/i /** `curl --fail` uses 22 for an HTTP error response; scripts rely on it. */ @@ -173,12 +173,7 @@ function formatJson(text: string): string { return text } - return json.replace(JSON_TOKEN_RE, (match, string: string | undefined, colon: string | undefined) => { - if (string) { - return colon ? styleText('cyan', string) + colon : styleText('green', string) - } - return styleText('yellow', match) - }) + return highlightJson(json) } function isJson(value: string): boolean { diff --git a/packages/nuxt-cli/src/commands/task/_utils.ts b/packages/nuxt-cli/src/commands/task/_utils.ts index 7d269dd47..d0a03d353 100644 --- a/packages/nuxt-cli/src/commands/task/_utils.ts +++ b/packages/nuxt-cli/src/commands/task/_utils.ts @@ -8,6 +8,7 @@ import { styleText } from 'node:util' import { join, resolve } from 'pathe' import { findDevServer, findNitroDevWorker, noDevServerMessage, toLoopback } from '../../utils/dev-server' +import { highlightJson } from '../../utils/json-highlight' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' import { getNuxtConfig } from '../../utils/nuxt-config' @@ -213,7 +214,7 @@ function hasFiles(dir: string): boolean { } export function format(value: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value, null, 2) + return typeof value === 'string' ? value : highlightJson(JSON.stringify(value, null, 2)) } async function request(server: TaskServer, path: string, options: RequestOptions = {}): Promise { diff --git a/packages/nuxt-cli/src/utils/json-highlight.ts b/packages/nuxt-cli/src/utils/json-highlight.ts new file mode 100644 index 000000000..40561f71a --- /dev/null +++ b/packages/nuxt-cli/src/utils/json-highlight.ts @@ -0,0 +1,24 @@ +import { styleText } from 'node:util' + +const TOKEN_RE = /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g + +/** + * Colour the tokens of a JSON document, leaving its text untouched. + * + * `styleText` writes no escapes when stdout cannot show them, so piped output + * stays parseable by `jq` and friends. + */ +export function highlightJson(json: string): string { + return json.replace(TOKEN_RE, (match, string: string | undefined, colon: string | undefined) => { + if (string) { + return colon ? `${styleText('blue', string)}${colon}` : styleText('green', string) + } + if (match === 'null') { + return styleText('dim', match) + } + if (match === 'true' || match === 'false') { + return styleText('yellow', match) + } + return styleText('magenta', match) + }) +} diff --git a/packages/nuxt-cli/test/unit/commands/task.spec.ts b/packages/nuxt-cli/test/unit/commands/task.spec.ts index 6923c4668..526e01560 100644 --- a/packages/nuxt-cli/test/unit/commands/task.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/task.spec.ts @@ -7,6 +7,7 @@ import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' +import { stripVTControlCharacters } from 'node:util' import { runCommand } from 'citty' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -263,7 +264,7 @@ describe('task run', () => { name: 'db:seed', payload: { count: '3', nested: { flag: 'yes' } }, }) - expect(stdout).toBe('{\n "ok": true\n}\n') + expect(stripVTControlCharacters(stdout)).toBe('{\n "ok": true\n}\n') }) it('prints a response that is not a bare result envelope as it came', async () => { @@ -271,7 +272,7 @@ describe('task run', () => { const code = await runTaskCommand(run, ['db:seed', '--url', origin]) expect(code).toBe(0) - expect(JSON.parse(stdout)).toEqual({ result: { ok: true }, duration: 12 }) + expect(JSON.parse(stripVTControlCharacters(stdout))).toEqual({ result: { ok: true }, duration: 12 }) }) it('merges a JSON payload with dotted arguments', async () => { diff --git a/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts b/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts new file mode 100644 index 000000000..3c6d7857f --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/json-highlight.spec.ts @@ -0,0 +1,29 @@ +import process from 'node:process' +import { stripVTControlCharacters, styleText } from 'node:util' + +import { describe, expect, it } from 'vitest' + +process.env.FORCE_COLOR = '3' + +const { highlightJson } = await import('../../../src/utils/json-highlight') + +describe('highlightJson', () => { + const json = JSON.stringify({ name: 'db:seed', count: 3, ok: true, missing: null, list: [1, 'two'] }, null, 2) + + it('colours keys, strings, numbers, booleans and null', () => { + const highlighted = highlightJson(json) + + expect(highlighted).toContain(`${styleText('blue', '"name"')}: ${styleText('green', '"db:seed"')}`) + expect(highlighted).toContain(styleText('magenta', '3')) + expect(highlighted).toContain(styleText('yellow', 'true')) + expect(highlighted).toContain(styleText('dim', 'null')) + }) + + it('leaves the document parseable', () => { + expect(JSON.parse(stripVTControlCharacters(highlightJson(json)))).toEqual(JSON.parse(json)) + }) + + it('does not colour inside strings that look like tokens', () => { + expect(highlightJson('{\n "a": "true 12 null"\n}')).toBe(`{\n ${styleText('blue', '"a"')}: ${styleText('green', '"true 12 null"')}\n}`) + }) +}) From 86f966c1e73877a5ed774fc433322404cda98804 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 2 Aug 2026 13:40:18 +0100 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20address=20comments=20=F0=9F=90=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nuxt-cli/src/commands/curl.ts | 17 +++++++++++++++-- packages/nuxt-cli/src/commands/task/_utils.ts | 1 + packages/nuxt-cli/src/commands/task/run.ts | 5 +++++ .../nuxt-cli/test/unit/commands/curl.spec.ts | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 63b8f31e9..294558f7d 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -78,12 +78,17 @@ export default defineCommand({ headers.set('user-agent', 'nuxt-cli') } - const body = await readRequestBody(ctx.args.data) + const body = await readRequestBody(single(ctx.args.data, 'data', '-d')) if (body !== undefined && !headers.has('content-type') && isJson(body)) { headers.set('content-type', 'application/json') } - const method = (ctx.args.method || (body === undefined ? 'GET' : 'POST')).toUpperCase() + const method = (single(ctx.args.method, 'method', '-X') || (body === undefined ? 'GET' : 'POST')).toUpperCase() + + if (body !== undefined && (method === 'GET' || method === 'HEAD')) { + logger.error(`A ${styleText('cyan', method)} request cannot have a body. Remove ${styleText('cyan', '-d')} or use a different method.`) + process.exit(1) + } if (ctx.args.verbose) { process.stderr.write(`> ${method} ${url.pathname}${url.search} HTTP/1.1\n`) @@ -133,6 +138,14 @@ async function resolveRequestUrl(input: string, cwd: string): Promise { return new URL(input.startsWith('/') ? input : `/${input}`, server.url) } +function single(value: string | string[] | undefined, name: string, alias: string): string | undefined { + if (Array.isArray(value)) { + logger.error(`Expected a single ${styleText('cyan', `--${name}`)} value but received ${value.length}. Pass ${styleText('cyan', alias)} once.`) + process.exit(1) + } + return value +} + async function readRequestBody(data: string | undefined): Promise { if (data === undefined) { return undefined diff --git a/packages/nuxt-cli/src/commands/task/_utils.ts b/packages/nuxt-cli/src/commands/task/_utils.ts index d0a03d353..6fe5eb662 100644 --- a/packages/nuxt-cli/src/commands/task/_utils.ts +++ b/packages/nuxt-cli/src/commands/task/_utils.ts @@ -254,6 +254,7 @@ function socketRequest(socketPath: string, path: string, options: RequestOptions res.on('data', (chunk: string) => { body += chunk }) + res.on('error', reject) res.on('end', () => { const status = res.statusCode || 0 resolve({ ok: status >= 200 && status < 300, status, data: parseBody(body) }) diff --git a/packages/nuxt-cli/src/commands/task/run.ts b/packages/nuxt-cli/src/commands/task/run.ts index e5e95ec1c..19dc14db2 100644 --- a/packages/nuxt-cli/src/commands/task/run.ts +++ b/packages/nuxt-cli/src/commands/task/run.ts @@ -97,8 +97,13 @@ function resolvePayload(args: Record): Record return payload } +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + function assign(target: Record, path: string[], value: unknown): void { const key = path[0]! + if (UNSAFE_KEYS.has(key)) { + return + } if (path.length === 1) { target[key] = value return diff --git a/packages/nuxt-cli/test/unit/commands/curl.spec.ts b/packages/nuxt-cli/test/unit/commands/curl.spec.ts index 76b5fb814..71ed79782 100644 --- a/packages/nuxt-cli/test/unit/commands/curl.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/curl.spec.ts @@ -47,6 +47,7 @@ const server = createServer(async (req, res) => { let origin: string let cwd: string let stdout: string +let isTTY: boolean beforeAll(async () => { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -57,6 +58,8 @@ beforeEach(async () => { requests.length = 0 stdout = '' cwd = await mkdtemp(join(tmpdir(), 'nuxt-curl-test-')) + isTTY = process.stdout.isTTY + process.stdout.isTTY = false vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { stdout += String(chunk) return true @@ -65,6 +68,7 @@ beforeEach(async () => { }) afterEach(async () => { + process.stdout.isTTY = isTTY vi.restoreAllMocks() await rm(cwd, { recursive: true, force: true }) }) From fe493cf1c13f3079e062d83bf3b7c1428ef4bb83 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 2 Aug 2026 13:50:58 +0100 Subject: [PATCH 09/10] feat: support `-i`, `-I` and binary output --- packages/nuxt-cli/src/commands/curl.ts | 110 +++++++++++++----- .../nuxt-cli/test/unit/commands/curl.spec.ts | 61 ++++++++++ 2 files changed, 145 insertions(+), 26 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 294558f7d..a04bbf6b6 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import process from 'node:process' import { styleText } from 'node:util' +import { note } from '@clack/prompts' import { defineCommand } from 'citty' import { findDevServer, noDevServerMessage } from '../utils/dev-server' @@ -14,6 +15,9 @@ import { rootDirArgs } from './_shared' const HAS_SCHEME_RE = /^[a-z][a-z\d+.-]*:\/\//i const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w.+-]+\+)?json\b/i +const TEXT_CONTENT_TYPE_RE = /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml|yaml)\b|application\/(?:javascript|ecmascript|x-www-form-urlencoded|x-ndjson)\b)/i + +const BINARY_SNIFF_BYTES = 4096 /** `curl --fail` uses 22 for an HTTP error response; scripts rely on it. */ const HTTP_ERROR_EXIT_CODE = 22 @@ -49,10 +53,20 @@ export default defineCommand({ description: 'Request body. Use `@-` to read stdin and `@` to read a file.', valueHint: 'data', }, + include: { + type: 'boolean', + alias: 'i', + description: 'Include the response status line and headers in the output', + }, + head: { + type: 'boolean', + alias: 'I', + description: 'Send a `HEAD` request and show only the response headers', + }, verbose: { type: 'boolean', alias: 'v', - description: 'Print request and response headers', + description: 'Print request and response headers to stderr', }, }, async run(ctx) { @@ -66,7 +80,7 @@ export default defineCommand({ const url = await resolveRequestUrl(input, cwd) const headers = new Headers() - for (const header of toArray(ctx.args.header)) { + for (const header of collectRepeated(ctx.rawArgs, 'header', 'H')) { const separator = header.indexOf(':') if (separator <= 0) { logger.error(`Invalid header ${styleText('cyan', header)}. Expected ${styleText('cyan', 'Name: Value')}.`) @@ -78,12 +92,13 @@ export default defineCommand({ headers.set('user-agent', 'nuxt-cli') } - const body = await readRequestBody(single(ctx.args.data, 'data', '-d')) + const body = await readRequestBody(ctx.args.data) if (body !== undefined && !headers.has('content-type') && isJson(body)) { headers.set('content-type', 'application/json') } - const method = (single(ctx.args.method, 'method', '-X') || (body === undefined ? 'GET' : 'POST')).toUpperCase() + const defaultMethod = ctx.args.head ? 'HEAD' : (body === undefined ? 'GET' : 'POST') + const method = (ctx.args.method || defaultMethod).toUpperCase() if (body !== undefined && (method === 'GET' || method === 'HEAD')) { logger.error(`A ${styleText('cyan', method)} request cannot have a body. Remove ${styleText('cyan', '-d')} or use a different method.`) @@ -109,11 +124,11 @@ export default defineCommand({ } if (ctx.args.verbose) { - process.stderr.write(`< HTTP/1.1 ${response.status} ${response.statusText}\n`) - for (const [name, value] of response.headers) { - process.stderr.write(`< ${name}: ${value}\n`) - } - process.stderr.write('<\n') + process.stderr.write(formatResponseHead(response, '< ')) + } + + if (ctx.args.include || ctx.args.head) { + process.stdout.write(formatResponseHead(response, '')) } await writeResponseBody(response) @@ -138,12 +153,33 @@ async function resolveRequestUrl(input: string, cwd: string): Promise { return new URL(input.startsWith('/') ? input : `/${input}`, server.url) } -function single(value: string | string[] | undefined, name: string, alias: string): string | undefined { - if (Array.isArray(value)) { - logger.error(`Expected a single ${styleText('cyan', `--${name}`)} value but received ${value.length}. Pass ${styleText('cyan', alias)} once.`) - process.exit(1) +/** + * citty keeps only the last value of a repeated string flag, so repeatable + * options are read back off the raw argv instead of `ctx.args`. + */ +function collectRepeated(rawArgs: string[], name: string, alias: string): string[] { + const values: string[] = [] + const end = rawArgs.indexOf('--') + const argv = end === -1 ? rawArgs : rawArgs.slice(0, end) + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]! + if (arg === `--${name}` || arg === `-${alias}`) { + const value = argv[++index] + if (value !== undefined) { + values.push(value) + } + continue + } + if (arg.startsWith(`--${name}=`)) { + values.push(arg.slice(name.length + 3)) + } + else if (arg.startsWith(`-${alias}=`)) { + values.push(arg.slice(alias.length + 2)) + } } - return value + + return values } async function readRequestBody(data: string | undefined): Promise { @@ -163,20 +199,49 @@ async function readRequestBody(data: string | undefined): Promise { const contentType = response.headers.get('content-type') || '' - const text = await response.text() - if (!text) { + const buffer = Buffer.from(await response.arrayBuffer()) + if (!buffer.length) { + return + } + + if (!process.stdout.isTTY) { + process.stdout.write(buffer) + return + } + + if (isBinary(buffer, contentType)) { + note('Binary data not shown in terminal. Redirect the output to a file to save it.', 'Response body') return } - const pretty = process.stdout.isTTY && JSON_CONTENT_TYPE_RE.test(contentType) - process.stdout.write(pretty ? formatJson(text) : text) - if (process.stdout.isTTY && !text.endsWith('\n')) { + const text = buffer.toString('utf-8') + process.stdout.write(JSON_CONTENT_TYPE_RE.test(contentType) ? formatJson(text) : text) + if (!text.endsWith('\n')) { process.stdout.write('\n') } } +/** + * A textual content type is trusted outright; anything else is sniffed for a + * NUL byte, which no valid UTF-8 text response contains. + */ +function isBinary(buffer: Buffer, contentType: string): boolean { + if (TEXT_CONTENT_TYPE_RE.test(contentType)) { + return false + } + return buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0) +} + function formatJson(text: string): string { let json: string try { @@ -202,10 +267,3 @@ function isJson(value: string): boolean { return false } } - -function toArray(value: string | string[] | undefined): string[] { - if (value === undefined) { - return [] - } - return Array.isArray(value) ? value : [value] -} diff --git a/packages/nuxt-cli/test/unit/commands/curl.spec.ts b/packages/nuxt-cli/test/unit/commands/curl.spec.ts index 71ed79782..9731d8cef 100644 --- a/packages/nuxt-cli/test/unit/commands/curl.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/curl.spec.ts @@ -21,6 +21,8 @@ interface ReceivedRequest { const requests: ReceivedRequest[] = [] +const BINARY_BODY = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x00, 0x1A, 0x0A, 0x00]) + const server = createServer(async (req, res) => { const chunks: Buffer[] = [] for await (const chunk of req) { @@ -40,6 +42,12 @@ const server = createServer(async (req, res) => { return } + if (req.url === '/binary') { + res.setHeader('content-type', 'application/octet-stream') + res.end(BINARY_BODY) + return + } + res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) @@ -48,6 +56,7 @@ let origin: string let cwd: string let stdout: string let isTTY: boolean +const chunks: Buffer[] = [] beforeAll(async () => { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -60,7 +69,9 @@ beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), 'nuxt-curl-test-')) isTTY = process.stdout.isTTY process.stdout.isTTY = false + chunks.length = 0 vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + chunks.push(Buffer.from(chunk)) stdout += String(chunk) return true }) @@ -173,4 +184,54 @@ describe('curl', () => { expect(code).toBe(1) expect(requests).toHaveLength(0) }) + + it('rejects a body on a GET request', async () => { + const code = await run([`${origin}/api/hello`, '-X', 'GET', '-d', '{}']) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) + + it('sends every value of a repeated -H flag', async () => { + const code = await run([`${origin}/api/hello`, '-H', 'x-one: 1', '--header=x-two: 2']) + + expect(code).toBe(0) + expect(requests[0]?.headers['x-one']).toBe('1') + expect(requests[0]?.headers['x-two']).toBe('2') + }) + + it('sends a HEAD request and prints only the headers with -I', async () => { + const code = await run([`${origin}/api/hello`, '-I']) + + expect(code).toBe(0) + expect(requests[0]?.method).toBe('HEAD') + expect(stdout).toContain('HTTP/1.1 200 OK') + expect(stdout).toContain('content-type: application/json') + expect(stdout).not.toContain('hello') + }) + + it('prints headers before the body with -i', async () => { + const code = await run([`${origin}/api/hello`, '-i']) + + expect(code).toBe(0) + expect(requests[0]?.method).toBe('GET') + expect(stdout).toContain('HTTP/1.1 200 OK') + expect(stdout.endsWith('{"hello":"world"}')).toBe(true) + }) + + it('writes binary responses byte for byte when piped', async () => { + const code = await run([`${origin}/binary`]) + + expect(code).toBe(0) + expect(Buffer.concat(chunks).equals(BINARY_BODY)).toBe(true) + }) + + it('does not write binary responses to a terminal', async () => { + process.stdout.isTTY = true + const code = await run([`${origin}/binary`]) + + expect(code).toBe(0) + expect(stdout).toContain('Binary data not shown in terminal') + expect(stdout).not.toContain('PNG') + }) }) From 97926e085f19ea398a22db949c6b7daa57dda11e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 2 Aug 2026 13:15:52 +0000 Subject: [PATCH 10/10] fix: report unreadable body files and resolve build dir once --- packages/nuxt-cli/src/commands/curl.ts | 9 ++++++++- packages/nuxt-cli/src/commands/task/_utils.ts | 8 +++++--- .../nuxt-cli/test/unit/commands/curl.spec.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index a04bbf6b6..59db81ce6 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -194,7 +194,14 @@ async function readRequestBody(data: string | undefined): Promise { expect(requests).toHaveLength(0) }) + it('reads a request body from a file', async () => { + const path = join(cwd, 'body.json') + await writeFile(path, '{"from":"file"}') + const code = await run([`${origin}/api/hello`, '-d', `@${path}`]) + + expect(code).toBe(0) + expect(requests[0]?.body).toBe('{"from":"file"}') + }) + + it('reports a missing request body file instead of throwing', async () => { + const code = await run([`${origin}/api/hello`, '-d', `@${join(cwd, 'nope.json')}`]) + + expect(code).toBe(1) + expect(requests).toHaveLength(0) + }) + it('sends every value of a repeated -H flag', async () => { const code = await run([`${origin}/api/hello`, '-H', 'x-one: 1', '--header=x-two: 2'])