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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ would only work in one of those places.
## CLI

```sh
moshpit-resolve <name...> [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json]
moshpit-resolve [<name...>] [--stdin] [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict] [--json]
```

```
Expand Down Expand Up @@ -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:

Expand Down
36 changes: 28 additions & 8 deletions bin/moshpit-resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

const USAGE = `moshpit-resolve — where a Moshpit name would send you

moshpit-resolve <name...> [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--strict]
moshpit-resolve [<name...>] [--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
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
150 changes: 146 additions & 4 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand All @@ -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);
}
Expand Down Expand Up @@ -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) => {
Expand Down
Loading