Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ catch drift between two copies is a good sign the copies should be one thing.
## CLI

```sh
moshpit-name check (<ending...> | -) [--json]
moshpit-name check (<ending...> | -) [--json | --ndjson]
can endings be claimed; - reads one per line
moshpit-name parse (<name...> | -) [--json]
moshpit-name parse (<name...> | -) [--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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 60 additions & 12 deletions bin/moshpit-name.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,11 +14,11 @@ import {

const USAGE = `moshpit-name — the Moshpit namespace rules

moshpit-name check (<ending...> | -) [--json]
moshpit-name check (<ending...> | -) [--json | --ndjson]
can endings be claimed; - reads one per line
moshpit-name parse (<name...> | -) [--json]
moshpit-name parse (<name...> | -) [--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
Expand All @@ -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;
Expand All @@ -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"';
Expand All @@ -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 = "";
Expand All @@ -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 {
Expand All @@ -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 };
}
Expand All @@ -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);
};
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -202,14 +245,19 @@ 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);
}
if (limitFlags === 1) limit = parsedLimit;

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);
Expand Down
95 changes: 92 additions & 3 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 \(<ending\.\.\.> \| -\) \[--json\]/);
assert.match(result.stdout, /moshpit-name parse \(<name\.\.\.> \| -\) \[--json\]/);
assert.match(result.stdout, /moshpit-name check \(<ending\.\.\.> \| -\) \[--json \| --ndjson\]/);
assert.match(result.stdout, /moshpit-name parse \(<name\.\.\.> \| -\) \[--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", () => {
Expand Down
Loading