diff --git a/README.md b/README.md index 697f353..efde49e 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,11 @@ catch drift between two copies is a good sign the copies should be one thing. ## CLI ```sh -moshpit-name check ( | -) [--json] +moshpit-name check ( | -) [--json | --ndjson] can endings be claimed; - reads one per line -moshpit-name parse ( | -) [--json] +moshpit-name parse ( | -) [--json | --ndjson] split names into labels; - reads one per line -moshpit-name list [-] [--limit N] [--json] +moshpit-name list [-] [--limit N] [--json | --ndjson] parse up to N pasted entries; - reads stdin moshpit-name reserved [--json] list endings that cannot be claimed moshpit-name prices [--json] what an ending and a name cost @@ -118,6 +118,18 @@ lines from stdin. Stdin JSON always uses the batch wrapper, even for one line, so a pipeline receives a stable shape. More than 1000 lines is an error. An empty stream is treated like a missing input and exits non-zero. +Use `--ndjson` with `check`, `parse`, or `list` when each result should be a +compact JSON object on its own line. This keeps input order, writes even a +single result as one record, and preserves the command's normal exit status. +For `list`, the records are the parsed entries; an empty list produces no +records. `--json` and `--ndjson` cannot be combined. + +```sh +$ printf '.eggs\n.bank\n' | moshpit-name check - --ndjson +{"input":".eggs","tld":"eggs","claimable":true,"reason":null} +{"input":".bank","tld":"bank","claimable":false,"reason":"that name is reserved"} +``` + `list --limit N` stops after `N` unique entries and reports the remainder in `skipped`. `N` must be an integer from 1 through 1000, the package's bulk ceiling. The option works with inline arguments or stdin and can appear before diff --git a/bin/moshpit-name.mjs b/bin/moshpit-name.mjs index 9bb8265..27e4523 100755 --- a/bin/moshpit-name.mjs +++ b/bin/moshpit-name.mjs @@ -5,7 +5,7 @@ // asked over the network — and the answers are pure, so they are the same // offline, in CI, and inside a browser extension. -import { createInterface } from "node:readline"; +import { writeFileSync } from "node:fs"; import { CHILD_PRICE_USD, ENDING_PRICE_USD, MAX_BULK_TLDS, RESERVED_TLDS, @@ -14,11 +14,11 @@ import { const USAGE = `moshpit-name — the Moshpit namespace rules - moshpit-name check ( | -) [--json] + moshpit-name check ( | -) [--json | --ndjson] can endings be claimed; - reads one per line - moshpit-name parse ( | -) [--json] + moshpit-name parse ( | -) [--json | --ndjson] split names into labels; - reads one per line - moshpit-name list [-] [--limit N] [--json] + moshpit-name list [-] [--limit N] [--json | --ndjson] parse up to N pasted entries; - reads stdin moshpit-name reserved [--json] list endings that cannot be claimed moshpit-name prices [--json] what an ending and a name cost @@ -27,6 +27,7 @@ Pure rules, no network. The same answers the registry gives, without asking it.` const [sub, ...rawRest] = process.argv.slice(2); let json = false; +let ndjson = false; const rest = []; let limit = MAX_BULK_TLDS; let limitValue; @@ -43,6 +44,14 @@ for (let index = 0; index < rawRest.length; index++) { json = true; continue; } + if (parsingOptions && arg === "--ndjson") { + if (!["check", "parse", "list"].includes(sub)) { + optionError ??= 'unknown option "--ndjson"'; + } else { + ndjson = true; + } + continue; + } if (parsingOptions && arg === "--limit") { if (sub !== "list") { optionError ??= 'unknown option "--limit"'; @@ -62,8 +71,21 @@ for (let index = 0; index < rawRest.length; index++) { } rest.push(arg); } +if (json && ndjson) optionError ??= "--json and --ndjson cannot be used together"; const out = console.log; -const outJson = (value) => out(JSON.stringify(value, null, 2)); +const writeStdout = (value) => { + try { + writeFileSync(1, value); + } catch (error) { + if (error?.code === "EPIPE") process.exit(0); + throw error; + } +}; +const outJson = (value) => writeStdout(`${JSON.stringify(value, null, 2)}\n`); +const outNdjson = (values) => { + if (values.length) writeStdout(`${values.map((value) => JSON.stringify(value)).join("\n")}\n`); +}; +const MAX_STDIN_BYTES = 1024 * 1024; const readStdin = async () => { let data = ""; @@ -72,9 +94,19 @@ const readStdin = async () => { }; const readCommandStdin = async () => { + const chunks = []; + let bytes = 0; + for await (const chunk of process.stdin) { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += data.length; + if (bytes > MAX_STDIN_BYTES) { + return { lines: [], error: "stdin accepts at most 1 MiB" }; + } + chunks.push(data); + } + const lines = []; - const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); - for await (const line of input) { + for (const line of Buffer.concat(chunks).toString("utf8").split(/\r?\n/)) { if (!line.trim()) continue; if (lines.length === MAX_BULK_TLDS) { return { @@ -89,6 +121,13 @@ const readCommandStdin = async () => { const commandInputs = async (args) => { const fromStdin = args.length === 1 && args[0] === "-"; + if (!fromStdin && args.includes("-")) { + return { + inputs: [], + fromStdin: false, + error: '"-" must be the only input when reading from stdin', + }; + } if (!fromStdin) { return { inputs: args.length ? args : [undefined], fromStdin, error: null }; } @@ -102,7 +141,8 @@ const commandInputs = async (args) => { }; const exitInputError = (error) => { - if (json) outJson({ error }); + if (ndjson) outNdjson([{ error }]); + else if (json) outJson({ error }); else console.error(`moshpit-name: ${error}`); process.exit(1); }; @@ -113,7 +153,8 @@ if (!sub || sub === "help" || sub === "--help") { } if (optionError) { - if (json) outJson({ error: optionError }); + if (ndjson) outNdjson([{ error: optionError }]); + else if (json) outJson({ error: optionError }); else console.error(`moshpit-name: ${optionError}`); process.exit(1); } @@ -135,7 +176,8 @@ if (sub === "check") { return { input, tld, claimable: !reason, reason }; }); - if (json) { + if (ndjson) outNdjson(results); + else if (json) { if (!fromStdin && results.length === 1) outJson(results[0]); else { const claimableCount = results.filter((result) => result.claimable).length; @@ -170,7 +212,8 @@ if (sub === "parse") { return { input, valid: true, ...parsed, reason: null }; }); - if (json) { + if (ndjson) outNdjson(results); + else if (json) { if (!fromStdin && results.length === 1) outJson(results[0]); else { const validCount = results.filter((result) => result.valid).length; @@ -202,7 +245,8 @@ if (sub === "list") { ); if (!validLimit) { const error = `--limit must be an integer from 1 to ${MAX_BULK_TLDS}`; - if (json) outJson({ error }); + if (ndjson) outNdjson([{ error }]); + else if (json) outJson({ error }); else console.error(`moshpit-name: ${error}`); process.exit(1); } @@ -210,6 +254,10 @@ if (sub === "list") { const input = rest[0] === "-" || !rest.length ? await readStdin() : rest.join("\n"); const parsed = parseTldList(input, limit); + if (ndjson) { + outNdjson(parsed.entries); + process.exit(0); + } if (json) { outJson(parsed); process.exit(0); diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 84eeca4..2adb0f3 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -29,8 +29,97 @@ test("help documents batch arguments and stdin", () => { const result = run(["--help"]); assert.equal(result.status, 0); assert.equal(result.stderr, ""); - assert.match(result.stdout, /moshpit-name check \( \| -\) \[--json\]/); - assert.match(result.stdout, /moshpit-name parse \( \| -\) \[--json\]/); + assert.match(result.stdout, /moshpit-name check \( \| -\) \[--json \| --ndjson\]/); + assert.match(result.stdout, /moshpit-name parse \( \| -\) \[--json \| --ndjson\]/); +}); + +test("check --ndjson emits one ordered result per line", () => { + const result = run(["check", ".420", ".bank", "two.labels", "--ndjson"]); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(result.stdout.trimEnd().split("\n").map(JSON.parse), [ + { input: ".420", tld: "420", claimable: true, reason: null }, + { input: ".bank", tld: "bank", claimable: false, reason: "that name is reserved" }, + { + input: "two.labels", + tld: null, + claimable: false, + reason: "not a valid ending (letters, digits and dashes only, no dots)", + }, + ]); +}); + +test("parse --ndjson keeps a single result unwrapped", () => { + const result = run(["parse", "Blue.EGGS", "--ndjson"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, + '{"input":"Blue.EGGS","valid":true,"label":"blue","tld":"eggs","reason":null}\n'); +}); + +test("list --ndjson emits entries and stays empty for empty input", () => { + const result = run(["list", "-", "--limit", "2", "--ndjson"], "eggs\nblue.eggs\noranges\n"); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.deepEqual(result.stdout.trimEnd().split("\n").map(JSON.parse), [ + { tld: "eggs", label: null, aliasOf: null, priceUsd: null }, + { tld: "eggs", label: "blue", aliasOf: null, priceUsd: null }, + ]); + + const empty = run(["list", "-", "--ndjson"], "\n"); + assert.equal(empty.status, 0); + assert.equal(empty.stdout, ""); + assert.equal(empty.stderr, ""); +}); + +test("NDJSON validation errors stay machine-readable", () => { + const formats = run(["check", ".eggs", "--json", "--ndjson"]); + assert.equal(formats.status, 1); + assert.equal(formats.stderr, ""); + assert.deepEqual(JSON.parse(formats.stdout), { + error: "--json and --ndjson cannot be used together", + }); + + const marker = run(["check", ".eggs", "-", "--ndjson"]); + assert.equal(marker.status, 1); + assert.equal(marker.stderr, ""); + assert.deepEqual(JSON.parse(marker.stdout), { + error: '"-" must be the only input when reading from stdin', + }); + + const limit = run(["list", "-", "--limit", "0", "--ndjson"], "eggs\n"); + assert.equal(limit.status, 1); + assert.equal(limit.stderr, ""); + assert.deepEqual(JSON.parse(limit.stdout), { + error: "--limit must be an integer from 1 to " + MAX_BULK_TLDS, + }); +}); + +test("check bounds stdin bytes before emitting NDJSON", () => { + const result = run(["check", "-", "--ndjson"], "." + "a".repeat(1024 * 1024) + "\n"); + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), { error: "stdin accepts at most 1 MiB" }); +}); + +test("NDJSON output handles a downstream reader closing the pipe", async () => { + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN, "check", ".eggs", "--ndjson"], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (status, signal) => resolve({ status, signal, stderr })); + child.stdout.destroy(); + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.signal, null); + assert.equal(result.stderr, ""); }); test("check --json describes claimable, reserved, and malformed endings", () => {