diff --git a/README.md b/README.md index 7a33482..a2e8652 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ would only work in one of those places. ## CLI ```sh -moshpit-resolve [] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json] +moshpit-resolve [] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json | --ndjson] ``` ``` @@ -83,6 +83,13 @@ every navigation, and the thing you need when a name goes somewhere unexpected. Use `--json` when another tool needs the registry answer, decision, reason, and destination without parsing the human-readable summary. +Use `--ndjson` for batch pipelines that consume one compact JSON object per +line. Input order and exit-status rules are identical to `--json`; an empty +stdin batch emits no records. The two output flags are mutually exclusive. + +```sh +moshpit-resolve blue.eggs red.eggs --ndjson | jq -c 'select(.destination)' +``` By default, an unavailable registry still exits successfully after reporting the clearnet fallback. Scripts can add `--strict` to exit non-zero when any diff --git a/bin/moshpit-resolve.mjs b/bin/moshpit-resolve.mjs index 9e8fb15..c602007 100755 --- a/bin/moshpit-resolve.mjs +++ b/bin/moshpit-resolve.mjs @@ -10,7 +10,7 @@ import { const USAGE = `moshpit-resolve — where a Moshpit name would send you - moshpit-resolve [] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json] + moshpit-resolve [] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json | --ndjson] --moshpit let a registered name beat a clearnet answer --clearnet-resolves pretend the real internet has an answer for this name @@ -22,6 +22,7 @@ const USAGE = `moshpit-resolve — where a Moshpit name would send you --strict fail when any registry lookup is inconclusive --stdin append whitespace-delimited names from standard input --json print a machine-readable resolution decision + --ndjson print one compact JSON decision per line Prints the destination and the reason for it. No browser, no navigation.`; @@ -41,7 +42,8 @@ const value = (n, d) => { const candidate = i >= 0 ? args[i + 1] : null; return candidate && !candidate.startsWith("-") ? candidate : d; }; -const raw = flag("json"); +const ndjson = flag("ndjson"); +const raw = flag("json") || ndjson; const timeoutValue = value("timeout", null); const concurrencyValue = value("concurrency", null); const jsonError = (error) => { @@ -49,16 +51,30 @@ const jsonError = (error) => { if (names.length === 1) return { name, error }; return names.map((requestedName) => ({ name: requestedName, error })); }; +const machineText = (value, space) => { + if (ndjson) { + const records = Array.isArray(value) ? value : [value]; + return records.map((record) => JSON.stringify(record)).join("\n"); + } + return JSON.stringify(value, null, space); +}; +const printMachine = (value) => { + const output = machineText(value, 2); + if (output) console.log(output); +}; const printJsonError = (error) => new Promise((resolve, reject) => { - const output = `${JSON.stringify( - jsonError(error), null, names.length > 1 ? 2 : undefined, - )}\n`; + const output = `${machineText(jsonError(error), names.length > 1 ? 2 : undefined)}\n`; process.stdout.write(output, (writeError) => { if (writeError) reject(writeError); else resolve(); }); }); +if (flag("json") && ndjson) { + await printJsonError("--json and --ndjson cannot be used together"); + process.exit(1); +} + for (const option of ["registry", "console", "parking"]) { if (flag(option) && value(option, null) === null) { const error = `--${option} requires a URL`; @@ -186,12 +202,12 @@ if (names.length === 1) { process.exit(1); } - if (raw) console.log(JSON.stringify(result, null, 2)); + if (raw) printMachine(result); else printHuman(result); if (strictFailure(result)) process.exitCode = 1; } else { if (raw) { - console.log(JSON.stringify(results, null, 2)); + printMachine(results); } else { results.forEach((result, index) => { if (index > 0) console.log(""); diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 7595341..a0c6c0d 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -48,6 +48,70 @@ function run(args, { }); } +test("--ndjson prints one compact decision per input in order", async () => { + const result = await run([ + "mosh.eggs", "localhost", "mosh.apples", + "--console", "https://console.example", "--ndjson", + ]); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + const lines = result.stdout.trimEnd().split("\n"); + assert.equal(lines.length, 3); + assert.ok(lines.every((line) => !line.startsWith(" "))); + const records = lines.map((line) => JSON.parse(line)); + assert.deepEqual(records.map(({ name }) => name), [ + "mosh.eggs", "localhost", "mosh.apples", + ]); + assert.equal(records[0].destination, "https://console.example/pit?tld=eggs"); + assert.equal(records[1].error, "not a Moshpit name (one label and one ending)"); + assert.equal(records[2].destination, "https://console.example/pit?tld=apples"); +}); + +test("--ndjson keeps a single result on one compact line", async () => { + const result = await run([ + "mosh.eggs", "--console", "https://console.example", "--ndjson", + ]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stderr, ""); + assert.equal(result.stdout.trimEnd().split("\n").length, 1); + assert.equal(JSON.parse(result.stdout).name, "mosh.eggs"); +}); + +test("--ndjson emits no records for an empty stdin batch", async () => { + const result = await run(["--stdin", "--ndjson"], { stdin: " \n\t " }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); +}); + +test("machine-readable output flags are mutually exclusive", async () => { + const result = await run(["blue.eggs", "--json", "--ndjson"]); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), { + name: "blue.eggs", + error: "--json and --ndjson cannot be used together", + }); +}); + +test("batch option errors use one NDJSON record per requested name", async () => { + const result = await run([ + "blue.eggs", "red.eggs", "--registry", "--ndjson", + ]); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + const records = result.stdout.trimEnd().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(records, [ + { name: "blue.eggs", error: "--registry requires a URL" }, + { name: "red.eggs", error: "--registry requires a URL" }, + ]); +}); + test("--json prints the complete machine-readable resolution", async () => { let requests = 0; const server = createServer((_request, response) => { @@ -88,6 +152,11 @@ test("--json prints the complete machine-readable resolution", async () => { }, destination: `http://127.0.0.1:${port}/n/blue.eggs`, }); + assert.equal( + result.stdout, + `${JSON.stringify(JSON.parse(result.stdout), null, 2)}\n`, + "--json should preserve its indented output", + ); assert.equal(requests, 1, "the CLI should make one registry request per resolution"); } finally { await new Promise((resolve) => server.close(resolve));