Skip to content
Merged
276 changes: 276 additions & 0 deletions packages/nuxt-cli/src/commands/curl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import { Buffer } from 'node:buffer'
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'
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_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

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 `@<file>` 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 to stderr',
},
},
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 collectRepeated(ctx.rawArgs, 'header', 'H')) {
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 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.`)
process.exit(1)
}

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(formatResponseHead(response, '< '))
}

if (ctx.args.include || ctx.args.head) {
process.stdout.write(formatResponseHead(response, ''))
}

await writeResponseBody(response)

if (!response.ok) {
process.exit(HTTP_ERROR_EXIT_CODE)
}
},
})

async function resolveRequestUrl(input: string, cwd: string): Promise<URL> {
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)
}

/**
* 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 values
}

async function readRequestBody(data: string | undefined): Promise<string | undefined> {
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('@')) {
const path = data.slice(1)
try {
return await readFile(path, 'utf-8')
}
catch (error) {
logger.error(`Could not read ${styleText('cyan', path)}: ${(error as Error).message}`)
process.exit(1)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return data
}
Comment on lines +185 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'package.json' packages/nuxt-cli --exec cat {} \; | rg -n 'citty'
rg -nP --type=ts -C3 'toArray\(' packages/nuxt-cli/src

Repository: nuxt/cli

Length of output: 1218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/nuxt-cli/src/commands/curl.ts relevant sections =="
sed -n '1,225p' packages/nuxt-cli/src/commands/curl.ts

echo
echo "== citty package files =="
fd -t f --hidden '^citty\.' packages/nuxt-cli/node_modules pkg 2>/dev/null || true
fd -t f --hidden 'package.json' packages/nuxt-cli/node_modules 2>/dev/null | head -80

echo
echo "== citty source mentions for args handling =="
rg -n --type=text "string|array|parseArgs|Repeated|command" packages/nuxt-cli/src packages/nuxt-cli 2>/dev/null | head -200 || true

Repository: nuxt/cli

Length of output: 5848


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lockfile/package manager references for citty =="
git ls-files | rg '(^|/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$' || true
for f in pnpm-lock.yaml package-lock.json yarn.lock bun.lock bun.lockb; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n '"?citty"?|\.citty' "$f" || true
  fi
done

echo
echo "== npm metadata for citty@0.2.2 =="
npm view citty@0.2.2 dist.tarball version --json 2>/dev/null || true

echo
echo "== fetch and parse citty source from tarball =="
tmp="$(mktemp -d)"
tarball="$(npm view citty@0.2.2 dist.tarball 2>/dev/null || true)"
if [ -n "${tarball:-}" ]; then
  curl -fsSL "$tarball" -o "$tmp/citty.tgz"
  tar -xzf "$tmp/citty.tgz" -C "$tmp"
  echo "--- files ---"
  find "$tmp/package" -maxdepth 3 -type f | sort | sed 's#^`#-` #'
  echo "--- src/parse.ts ---"
  sed -n '1,260p' "$tmp/package/src/parse.ts" 2>/dev/null || true
  echo "--- src/utils.ts ---"
  sed -n '1,240p' "$tmp/package/src/utils.ts" 2>/dev/null || true
  echo "--- src/index.ts ---"
  sed -n '1,220p' "$tmp/package/src/index.ts" 2>/dev/null || true
  echo "--- repeated arg relevant mentions ---"
  rg -n "split|trim|isArray|repeat|args|positionals|value" "$tmp/package/src" || true
fi

echo
echo "== local citty if installed =="
if [ -d packages/nuxt-cli/node_modules/citty ]; then
  rg -n '"?citty"?|node_modules/citty' package.json packages/nuxt-cli/package.json
  sed -n '1,260p' packages/nuxt-cli/node_modules/citty/src/parse.ts 2>/dev/null || true
else
  echo "packages/nuxt-cli/node_modules/citty not present"
fi

Repository: nuxt/cli

Length of output: 1569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== npm registry mirror availability =="
npm view citty dist.time --json 2>/dev/null || echo FAILED
npm view citty@0.2.2 --json 2>/dev/null | jq -r '"dist.tarball=" + .dist.tarball' || true

echo
echo "== parse citty@0.2.2 source from npm tarball using registry-compatible fetch =="
tmp="$(mktemp -d)"
tarball="$(npm view citty@0.2.2 dist.tarball 2>/dev/null || true)"
if [ -n "${tarball:-}" ]; then
  echo "tarball=$tarball"
  curl -fsSL "$tarball" -o "$tmp/citty.tgz"
  tar -xzf "$tmp/citty.tgz" -C "$tmp"
  echo "--- package files ---"
  find "$tmp/package" -maxdepth 3 -type f | sort | sed 's#^`#-` #'
  echo "--- dist citty source snippets ---"
  rg -n "string|array|split|trim|parseArgs|defineCommand|command" "$tmp/package/dist" -A3 -B3 || true
  echo "--- src citty source snippets, if any ---"
  (test -d "$tmp/package/src" && rg -n "string|array|split|trim|parseArgs|defineCommand|command" "$tmp/package/src" -A3 -B3 || true)
else
  echo "Unable to fetch citty@0.2.2 tarball: $tarball"
fi

Repository: nuxt/cli

Length of output: 25937


Handle a repeated -d flag.

data is declared as type string, but citty can return repeated string args as an array, so data.startsWith can throw a TypeError for -d a -d b. Normalize data before body detection, or reject multiple values with a clear message.

πŸ€– 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/commands/curl.ts` around lines 136 - 151, The
readRequestBody function assumes data is always a string, but repeated -d
arguments may provide an array and cause startsWith to throw. Update
readRequestBody to normalize repeated values before checking for stdin or file
syntax, or explicitly reject arrays with a clear error while preserving existing
single-value behavior.


function formatResponseHead(response: Response, prefix: string): string {
let head = `${prefix}HTTP/1.1 ${response.status} ${response.statusText}\n`
for (const [name, value] of response.headers) {
head += `${prefix}${name}: ${value}\n`
}
return `${head}${prefix.trimEnd()}\n`
}

async function writeResponseBody(response: Response): Promise<void> {
const contentType = response.headers.get('content-type') || ''
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 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 {
json = JSON.stringify(JSON.parse(text), null, 2)
}
catch {
return text
}

return highlightJson(json)
}

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
}
}
14 changes: 2 additions & 12 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -398,16 +398,6 @@ function setupSignalHandlers(close: () => Promise<void>): 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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt-cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -18,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),
Expand Down
Loading
Loading