-
Notifications
You must be signed in to change notification settings - Fork 119
feat(curl,task): add nuxt curl, nuxt task list and nuxt task run
#1417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
97862aa
6739421
31a4bc7
4ffc609
ae821d8
6207ea5
9d8304b
86f966c
fe493cf
97926e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } | ||
| return data | ||
| } | ||
|
Comment on lines
+185
to
+207
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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 || trueRepository: 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"
fiRepository: 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"
fiRepository: nuxt/cli Length of output: 25937 Handle a repeated
π€ Prompt for AI Agents |
||
|
|
||
| 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 | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.