From 8bf0aafed43d14c4a541192fbfdadf9f2d314575 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Tue, 4 Aug 2026 13:42:43 +0700 Subject: [PATCH] Support names from standard input --- README.md | 13 +++- bin/moshpit-resolve.mjs | 36 +++++++--- test/cli.test.mjs | 150 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 186 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0d10ea8..7a33482 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ would only work in one of those places. ## CLI ```sh -moshpit-resolve [--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] ``` ``` @@ -101,6 +101,17 @@ moshpit-resolve blue.eggs red.eggs missing.eggs --json moshpit-resolve one.eggs two.eggs three.eggs --concurrency 2 ``` +Use `--stdin` to pipe names from another command or read a whitespace-delimited +file. Command-line names come first, followed by names from standard input; the +same ordering, concurrency, exit-status, and JSON-shape rules apply. Input is +read until EOF. An empty input is a successful empty batch and prints `[]` with +`--json`. + +```sh +printf 'blue.eggs\nred.eggs\n' | moshpit-resolve --stdin --json +cat names.txt | moshpit-resolve pinned.eggs --stdin --strict +``` + Registry lookups use an eight-second deadline by default. Scripts and self-hosted deployments can lower it without changing the resolution policy: diff --git a/bin/moshpit-resolve.mjs b/bin/moshpit-resolve.mjs index 3a4e2ef..9e8fb15 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 [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] + moshpit-resolve [] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json] --moshpit let a registered name beat a clearnet answer --clearnet-resolves pretend the real internet has an answer for this name @@ -20,20 +20,22 @@ const USAGE = `moshpit-resolve — where a Moshpit name would send you --timeout MS registry request deadline (default: ${DEFAULT_LOOKUP_TIMEOUT_MS}) --concurrency N maximum simultaneous batch lookups (default: ${DEFAULT_CONCURRENCY}) --strict fail when any registry lookup is inconclusive + --stdin append whitespace-delimited names from standard input --json print a machine-readable resolution decision Prints the destination and the reason for it. No browser, no navigation.`; const args = process.argv.slice(2); +const flag = (n) => args.includes(`--${n}`); const valueFlags = new Set([ "--registry", "--console", "--parking", "--timeout", "--concurrency", ]); const positional = args.filter((a, i) => !a.startsWith("--") && !valueFlags.has(args[i - 1])); -const names = positional; -const name = names[0]; -if (!name || args.includes("--help")) { console.log(USAGE); process.exit(name ? 0 : 1); } +const names = [...positional]; +let name = names[0]; +if (flag("help")) { console.log(USAGE); process.exit(name ? 0 : 1); } +if (!name && !flag("stdin")) { console.log(USAGE); process.exit(1); } -const flag = (n) => args.includes(`--${n}`); const value = (n, d) => { const i = args.indexOf(`--${n}`); const candidate = i >= 0 ? args[i + 1] : null; @@ -42,9 +44,11 @@ const value = (n, d) => { const raw = flag("json"); const timeoutValue = value("timeout", null); const concurrencyValue = value("concurrency", null); -const jsonError = (error) => names.length === 1 - ? { name, error } - : names.map((requestedName) => ({ name: requestedName, error })); +const jsonError = (error) => { + if (names.length === 0) return { error }; + if (names.length === 1) return { name, error }; + return names.map((requestedName) => ({ name: requestedName, error })); +}; const printJsonError = (error) => new Promise((resolve, reject) => { const output = `${JSON.stringify( jsonError(error), null, names.length > 1 ? 2 : undefined, @@ -86,6 +90,22 @@ if (args.includes("--concurrency") && ( process.exit(1); } +if (flag("stdin")) { + try { + process.stdin.setEncoding("utf8"); + let input = ""; + for await (const chunk of process.stdin) input += chunk; + names.push(...input.split(/\s+/u).filter(Boolean)); + name = names[0]; + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + const error = `failed to read standard input: ${detail}`; + if (raw) await printJsonError(error); + else console.error(`moshpit-resolve: ${error}`); + process.exit(1); + } +} + const config = { mode: flag("moshpit") ? "moshpit" : "clearnet", registryBase: value("registry", undefined), diff --git a/test/cli.test.mjs b/test/cli.test.mjs index b69259f..7595341 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -9,10 +9,12 @@ import { DEFAULT_CONCURRENCY } from "../lib/index.mjs"; const BIN = fileURLToPath(new URL("../bin/moshpit-resolve.mjs", import.meta.url)); -function run(args, { stdoutDelayMs = 0 } = {}) { +function run(args, { + stdoutDelayMs = 0, stdin = null, keepStdinOpen = false, timeoutMs = 5000, +} = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [BIN, ...args], { - stdio: ["ignore", "pipe", "pipe"], + stdio: [stdin === null ? "ignore" : "pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; @@ -21,8 +23,25 @@ function run(args, { stdoutDelayMs = 0 } = {}) { if (stdoutDelayMs > 0) child.stdout.pause(); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.on("error", reject); - child.on("close", (status) => resolve({ status, stdout, stderr })); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`CLI did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + if (stdin !== null) { + child.stdin.on("error", (error) => { + if (error.code !== "EPIPE") reject(error); + }); + child.stdin.write(stdin); + if (!keepStdinOpen) child.stdin.end(); + } + child.on("close", (status) => { + clearTimeout(timer); + resolve({ status, stdout, stderr }); + }); if (stdoutDelayMs > 0) { setTimeout(() => child.stdout.resume(), stdoutDelayMs); } @@ -418,6 +437,129 @@ test("batch resolution bounds concurrency and coalesces normalized duplicates", assert.deepEqual(JSON.parse(defaults.stdout).map(({ name }) => name), defaultNames); }); +test("--stdin appends whitespace-delimited names in input order", async () => { + const result = await run([ + "mosh.eggs", + "--stdin", + "--console", "https://console.example", + "--json", + ], { + stdin: "mosh.oranges\r\n\r\nlocalhost\tmosh.apples\r\n", + }); + const output = JSON.parse(result.stdout); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(output.map(({ name }) => name), [ + "mosh.eggs", "mosh.oranges", "localhost", "mosh.apples", + ]); + assert.equal(output[0].destination, "https://console.example/pit?tld=eggs"); + assert.equal(output[1].destination, "https://console.example/pit?tld=oranges"); + assert.deepEqual(output[2], { + name: "localhost", + error: "not a Moshpit name (one label and one ending)", + }); + assert.equal(output[3].destination, "https://console.example/pit?tld=apples"); +}); + +test("--stdin keeps single-name JSON output as an object", async () => { + const result = await run([ + "--stdin", + "--console", "https://console.example", + "--json", + ], { + stdin: " mosh.eggs \n", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), { + name: "mosh.eggs", + registry: null, + decision: { + use: "register", + reason: "mosh.eggs is the registration console for .eggs", + url: "https://console.example/pit?tld=eggs", + }, + destination: "https://console.example/pit?tld=eggs", + }); +}); + +test("empty stdin is a successful empty batch", async () => { + const human = await run(["--stdin"], { stdin: " \n\t " }); + const json = await run(["--stdin", "--json"], { stdin: "" }); + + assert.deepEqual(human, { status: 0, stdout: "", stderr: "" }); + assert.equal(json.status, 0); + assert.equal(json.stderr, ""); + assert.deepEqual(JSON.parse(json.stdout), []); +}); + +test("argument errors are reported before waiting for stdin", async () => { + const result = await run([ + "--stdin", "--timeout", "0", "--json", + ], { + stdin: "", + keepStdinOpen: true, + timeoutMs: 750, + }); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), { + error: "--timeout must be a positive integer in milliseconds", + }); +}); + +test("stdin is untouched unless --stdin is present", async () => { + const result = await run([ + "mosh.eggs", "--console", "https://console.example", "--json", + ], { + stdin: "ignored.eggs\n", + keepStdinOpen: true, + timeoutMs: 750, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(JSON.parse(result.stdout).name, "mosh.eggs"); +}); + +test("--help preserves its existing exit statuses", async () => { + const withoutName = await run(["--help"]); + const withName = await run(["mosh.eggs", "--help"]); + + assert.equal(withoutName.status, 1); + assert.match(withoutName.stdout, /^moshpit-resolve/); + assert.equal(withName.status, 0); + assert.equal(withName.stdout, withoutName.stdout); +}); + +test("--strict applies to names read from stdin", async (t) => { + const server = createServer((_request, response) => { + response.writeHead(503); + response.end(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const result = await run([ + "--stdin", + "--registry", `http://127.0.0.1:${server.address().port}`, + "--strict", + "--json", + ], { + stdin: "blue.eggs\n", + }); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.equal( + JSON.parse(result.stdout).decision.reason, + "Moshpit registry not consulted or unreachable", + ); +}); + test("--strict reports an unavailable registry through the exit status", async (t) => { let requests = 0; const server = createServer((_request, response) => {