From 4a85083d034ab8f9849dedae7e9412c3618fb252 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 5 Aug 2026 11:17:20 +0700 Subject: [PATCH] Read check and parse inputs from stdin --- README.md | 13 ++++-- bin/moshpit-name.mjs | 56 +++++++++++++++++++---- test/cli.test.mjs | 103 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1fe9000..697f353 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ catch drift between two copies is a good sign the copies should be one thing. ## CLI ```sh -moshpit-name check [--json] - can these endings be claimed, and if not why -moshpit-name parse [--json] - split names into their labels and endings +moshpit-name check ( | -) [--json] + can endings be claimed; - reads one per line +moshpit-name parse ( | -) [--json] + split names into labels; - reads one per line moshpit-name list [-] [--limit N] [--json] parse up to N pasted entries; - reads stdin moshpit-name reserved [--json] list endings that cannot be claimed @@ -113,6 +113,11 @@ their established bare result objects. Passing two or more inputs returns a batch wrapper with counts and a `results` array; results stay in input order and any rejected input makes the command exit non-zero. +Pass `-` as the only input to `check` or `parse` to read up to 1000 non-empty +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. + `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 e83690c..9bb8265 100755 --- a/bin/moshpit-name.mjs +++ b/bin/moshpit-name.mjs @@ -5,6 +5,8 @@ // 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 { CHILD_PRICE_USD, ENDING_PRICE_USD, MAX_BULK_TLDS, RESERVED_TLDS, normalizeTld, parseMoshpitName, parseTldList, tldRejection, @@ -12,10 +14,10 @@ import { const USAGE = `moshpit-name — the Moshpit namespace rules - moshpit-name check [--json] - can these endings be claimed, and if not why - moshpit-name parse [--json] - split names into their labels and endings + moshpit-name check ( | -) [--json] + can endings be claimed; - reads one per line + moshpit-name parse ( | -) [--json] + split names into labels; - reads one per line moshpit-name list [-] [--limit N] [--json] parse up to N pasted entries; - reads stdin moshpit-name reserved [--json] list endings that cannot be claimed @@ -69,6 +71,42 @@ const readStdin = async () => { return data; }; +const readCommandStdin = async () => { + const lines = []; + const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of input) { + if (!line.trim()) continue; + if (lines.length === MAX_BULK_TLDS) { + return { + lines: [], + error: `stdin accepts at most ${MAX_BULK_TLDS} non-empty lines`, + }; + } + lines.push(line); + } + return { lines, error: null }; +}; + +const commandInputs = async (args) => { + const fromStdin = args.length === 1 && args[0] === "-"; + if (!fromStdin) { + return { inputs: args.length ? args : [undefined], fromStdin, error: null }; + } + + const { lines, error } = await readCommandStdin(); + return { + inputs: lines.length ? lines : [undefined], + fromStdin, + error, + }; +}; + +const exitInputError = (error) => { + if (json) outJson({ error }); + else console.error(`moshpit-name: ${error}`); + process.exit(1); +}; + if (!sub || sub === "help" || sub === "--help") { out(USAGE); process.exit(0); @@ -81,7 +119,8 @@ if (optionError) { } if (sub === "check") { - const inputs = rest.length ? rest : [undefined]; + const { inputs, fromStdin, error } = await commandInputs(rest); + if (error) exitInputError(error); const results = inputs.map((input) => { const tld = normalizeTld(input); if (!tld) { @@ -97,7 +136,7 @@ if (sub === "check") { }); if (json) { - if (results.length === 1) outJson(results[0]); + if (!fromStdin && results.length === 1) outJson(results[0]); else { const claimableCount = results.filter((result) => result.claimable).length; outJson({ @@ -118,7 +157,8 @@ if (sub === "check") { } if (sub === "parse") { - const inputs = rest.length ? rest : [undefined]; + const { inputs, fromStdin, error } = await commandInputs(rest); + if (error) exitInputError(error); const reason = "not a Moshpit name (one label and one ending; both numeric reads as an address)"; const results = inputs.map((input) => { const parsed = parseMoshpitName(input); @@ -131,7 +171,7 @@ if (sub === "parse") { }); if (json) { - if (results.length === 1) outJson(results[0]); + if (!fromStdin && results.length === 1) outJson(results[0]); else { const validCount = results.filter((result) => result.valid).length; outJson({ diff --git a/test/cli.test.mjs b/test/cli.test.mjs index fd0be51..84eeca4 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -25,11 +25,12 @@ test("commands stay human-readable unless --json is requested", () => { assert.equal(result.stdout, ".420 — claimable\n"); }); -test("help documents the batch parse syntax", () => { +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 parse \[--json\]/); + assert.match(result.stdout, /moshpit-name check \( \| -\) \[--json\]/); + assert.match(result.stdout, /moshpit-name parse \( \| -\) \[--json\]/); }); test("check --json describes claimable, reserved, and malformed endings", () => { @@ -96,6 +97,60 @@ test("check validates multiple endings in one invocation", () => { ". — not a valid ending (letters, digits and dashes only, no dots)\n"); }); +test("check reads newline-delimited endings from stdin", () => { + const result = run(["check", "-", "--json"], ".420\r\n\r\n.bank\r\ntwo.labels\r\n"); + + assert.equal(result.status, 1); + assert.deepEqual(output(result), { + count: 3, + claimableCount: 1, + rejectedCount: 2, + results: [ + { 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("single-line check stdin keeps the batch JSON shape on success", () => { + const result = run(["check", "-", "--json"], ".420\n"); + + assert.equal(result.status, 0); + assert.deepEqual(output(result), { + count: 1, + claimableCount: 1, + rejectedCount: 0, + results: [ + { input: ".420", tld: "420", claimable: true, reason: null }, + ], + }); +}); + +test("empty stdin is reported like a missing check input", () => { + const missing = run(["check"]); + const empty = run(["check", "-"], "\n\r\n"); + assert.equal(empty.status, 1); + assert.equal(empty.stdout, missing.stdout); + assert.equal(empty.stderr, missing.stderr); +}); + +test("check rejects stdin beyond the bulk ceiling without a stack trace", () => { + const input = Array.from({ length: MAX_BULK_TLDS + 1 }, () => ".eggs").join("\n"); + const result = run(["check", "-", "--json"], input); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(output(result), { + error: `stdin accepts at most ${MAX_BULK_TLDS} non-empty lines`, + }); +}); + test("check rejects unknown options and supports an end-of-options separator", () => { const limit = run(["check", ".eggs", "--limit", ".bank"]); assert.equal(limit.status, 1); @@ -179,6 +234,50 @@ test("parse validates multiple names while preserving input order", () => { ); }); +test("parse reads newline-delimited names from stdin", () => { + const result = run(["parse", "--json", "-"], " Blue.EGGS \n\n1.420\nred.420\n"); + + assert.equal(result.status, 1); + assert.deepEqual(output(result), { + count: 3, + validCount: 2, + invalidCount: 1, + results: [ + { input: " Blue.EGGS ", valid: true, label: "blue", tld: "eggs", reason: null }, + { + input: "1.420", + valid: false, + label: null, + tld: null, + reason: "not a Moshpit name (one label and one ending; both numeric reads as an address)", + }, + { input: "red.420", valid: true, label: "red", tld: "420", reason: null }, + ], + }); +}); + +test("single-line parse stdin keeps the batch JSON shape on success", () => { + const result = run(["parse", "-", "--json"], "Blue.EGGS\n"); + + assert.equal(result.status, 0); + assert.deepEqual(output(result), { + count: 1, + validCount: 1, + invalidCount: 0, + results: [ + { input: "Blue.EGGS", valid: true, label: "blue", tld: "eggs", reason: null }, + ], + }); +}); + +test("empty stdin is reported like a missing parse input", () => { + const missing = run(["parse"]); + const empty = run(["parse", "-"], ""); + assert.equal(empty.status, 1); + assert.equal(empty.stdout, missing.stdout); + assert.equal(empty.stderr, missing.stderr); +}); + test("list --json emits the complete parse result from stdin", () => { const result = run(["list", "--json", "-"], ".Eggs\nblue.eggs\nyeah\n"); assert.equal(result.status, 0);