@sdxc/ip 2026.10.6 → 2026.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries.
4
4
 
5
- An `IP` is one parsed, valid address. A function that takes one cannot be handed `"unknown"`, `"10.0.0"` or a hostname, and the address can say whether it is on the public internet, which network it sits in, and how it is spelled canonically. Nothing performs I/O, so it runs wherever an address arrives as text: a URL host, a DNS answer, a request header.
5
+ An `IP` is one parsed, valid address: it knows whether it is on the public internet, which network it sits in, and how it is spelled canonically. Everything runs without I/O, wherever an address arrives as text: a URL host, a DNS answer, a request header.
6
6
 
7
7
  ## Installation
8
8
 
9
- ```bash
9
+ ```sh
10
10
  npm add @sdxc/ip
11
11
  ```
12
12
 
@@ -14,22 +14,22 @@ Parsing answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@
14
14
 
15
15
  ## Usage
16
16
 
17
- ### Parsing An Address
17
+ ### Parse an address
18
18
 
19
19
  ```typescript
20
20
  import { IP } from "@sdxc/ip";
21
21
  import { isFailure } from "@sdxc/result";
22
22
 
23
23
  let parsed = IP.parse("2001:DB8::0:1");
24
- if (isFailure(parsed)) return parsed; // IP.Error { code: "invalid-address" }
24
+ if (isFailure(parsed)) return parsed.error.code; // "invalid-address"
25
25
 
26
26
  let ip = parsed.data;
27
27
  ip.version; // 6
28
28
  ip.toString(); // "2001:db8::1"
29
- ip.isPublic; // false: documentation range
29
+ ip.equals(ip); // compare two addresses with equals, never ===
30
30
  ```
31
31
 
32
- ### Refusing A URL That Points Inside The Network
32
+ ### Classify it
33
33
 
34
34
  ```typescript
35
35
  import { IP } from "@sdxc/ip";
@@ -40,115 +40,186 @@ function isPublicHost(url: URL): boolean {
40
40
  return isSuccess(ip) && ip.data.isPublic;
41
41
  }
42
42
 
43
- isPublicHost(new URL("http://169.254.169.254/")); // false
44
- isPublicHost(new URL("http://[::ffff:10.0.0.1]/")); // false
43
+ isPublicHost(new URL("http://169.254.169.254/")); // false: link-local
44
+ isPublicHost(new URL("http://[::ffff:10.0.0.1]/")); // false: judged by the IPv4 inside
45
45
  isPublicHost(new URL("http://[::ffff:8.8.8.8]/")); // true
46
46
  ```
47
47
 
48
- ### Testing A Range
48
+ `classification` names what the address is for (`"private"`, `"loopback"`, `"documentation"`, …), and `isPublic` is `classification === "public"`.
49
+
50
+ ### Test a range
49
51
 
50
52
  ```typescript
51
- let office = IP.Range.parse("203.0.113.0/24");
52
- if (isSuccess(office)) office.data.contains(ip); // false across versions
53
+ import { IP } from "@sdxc/ip";
54
+ import { isSuccess } from "@sdxc/result";
55
+
56
+ let range = IP.Range.parse("203.0.113.0/24");
57
+ let ip = IP.parse("203.0.113.7");
58
+ if (isSuccess(range) && isSuccess(ip)) range.data.contains(ip.data); // true
53
59
 
54
60
  IP.Range.parse("10.0.0.1/8"); // failure: IP.Error { code: "host-bits-set" }
55
61
  ```
56
62
 
57
- ### Keying A Rate Limit On The Client's Network
63
+ ## Command line
58
64
 
59
- An IPv6 client is normally assigned a whole `/64`, so a key on the full address counts each address it rotates through separately.
65
+ ```sh
66
+ bunx @sdxc/ip classify 192.168.1.10
67
+ ```
60
68
 
61
- ```typescript
62
- ip.network({ v4: 32, v6: 64 }).toString(); // "2001:db8:1:2::/64" or "203.0.113.7/32"
69
+ With [`@sdxc/kit`](https://www.npmjs.com/package/@sdxc/kit) installed, `sdxc pkg ip …` runs the same command line, using the version your project installs.
70
+
71
+ `classify` prints the address's version, canonical form, classification, whether it is public, and the IPv4 an IPv4-mapped, NAT64 or 6to4 address carries. With no address, or `-`, it reads stdin; `--json` prints the same as an object.
72
+
73
+ ```sh
74
+ bunx @sdxc/ip contains 10.0.0.0/8 10.1.2.3
63
75
  ```
64
76
 
77
+ `contains` exits 0 when the range holds the address and 1 when it does not. Both commands exit 2 for text that is not an address or a range. `bunx @sdxc/ip --help` lists every flag.
78
+
65
79
  ## API
66
80
 
67
- ### `IP.parse(text): Result<IP, IP.Error>`
81
+ ### `IP.parse(text: string): Result<IP, IP.Error>`
68
82
 
69
- Parses an IPv4 or IPv6 address. Brackets around IPv6 are accepted (`[::1]`), the way a URL host spells it. IPv4 must be canonical dotted decimal: `URL` already normalizes octal, hex and bare-integer hosts, and refusing them here keeps any other input from naming a different address than it appears to. Zone identifiers (`fe80::1%eth0`) are refused.
83
+ Parses an IPv4 or IPv6 address, accepting brackets around IPv6 (`[::1]`) the way a URL host spells it. IPv4 must be canonical dotted decimal, and zone identifiers (`fe80::1%eth0`) fail, so every accepted text names exactly the address it appears to.
70
84
 
71
85
  ### `IP`
72
86
 
73
- Immutable, and only obtainable from `IP.parse`.
87
+ An immutable address, obtainable only from `IP.parse`.
74
88
 
75
- - `version` — `4` or `6`.
76
- - `classification` — what the IANA special-purpose registries say the address is for: `"public"`, `"unspecified"`, `"loopback"`, `"private"`, `"shared"`, `"link-local"`, `"documentation"`, `"benchmarking"`, `"multicast"`, `"reserved"`, `"unique-local"`, `"teredo"` or `"discard"`. IPv6 outside `2000::/3` and not otherwise assigned is `"reserved"`.
77
- - `isPublic` — `classification === "public"`.
78
- - `embeddedIPv4` — the IPv4 address inside an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`) address, or `null`. `classification` and `isPublic` judge those addresses by the IPv4 inside, so `::ffff:10.0.0.1` is `"private"`.
79
- - `network({ v4, v6 })` — the `IP.Range` the address sits in at the prefix length for its version. A length outside the version's width is clamped into it.
80
- - `equals(other)` — same version and same address. `===` compares object identity, so compare two `IP`s with this.
81
- - `toString()` — canonical text: dotted decimal, or [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) IPv6 (lowercase, longest zero run compressed, IPv4-mapped ending in a dotted quad), without brackets. Every spelling of one address prints one string, so it is the key for a `Map`, a rate limit, a log field or a database column.
82
- - `toJSON()` — the same string, so `JSON.stringify({ ip })` writes `{"ip":"2001:db8::1"}`.
89
+ - `version`: `4` or `6`.
90
+ - `classification`: what the IANA special-purpose registries say the address is for, one of `"public"`, `"unspecified"`, `"loopback"`, `"private"`, `"shared"`, `"link-local"`, `"documentation"`, `"benchmarking"`, `"multicast"`, `"reserved"`, `"unique-local"`, `"teredo"` or `"discard"`. IPv6 outside `2000::/3` and not otherwise assigned is `"reserved"`.
91
+ - `isPublic`: `classification === "public"`.
92
+ - `embeddedIPv4`: the IPv4 address inside an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`) address, or `null`. `classification` judges those addresses by the IPv4 inside.
93
+ - `network({ v4, v6 })`: the `IP.Range` the address sits in at the prefix length for its version, clamped to the version's width.
94
+ - `equals(other)`: same version and same address.
95
+ - `toString()`: canonical text, dotted decimal or [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) IPv6 without brackets. Every spelling of one address prints one string.
96
+ - `toJSON()`: the same string, so `JSON.stringify({ ip })` writes `{"ip":"2001:db8::1"}`.
83
97
 
84
- A structured clone (Durable Object storage, `postMessage`) drops the methods. Store `toString()` and parse it back on read.
98
+ A structured clone (`postMessage`, Durable Object storage) drops the methods, so store `toString()` and parse it back.
85
99
 
86
- ### `IP.Range.parse(text): Result<IP.Range, IP.Error>`
100
+ ### `IP.Range.parse(text: string): Result<IP.Range, IP.Error>`
87
101
 
88
- Parses `network/prefix` text. A network address with bits set past the prefix fails with `host-bits-set`; anything else malformed fails with `invalid-range`.
102
+ Parses `network/prefix` text. An address with bits set past the prefix fails with `host-bits-set`; other malformed text fails with `invalid-range`.
89
103
 
90
104
  ### `IP.Range`
91
105
 
92
- Immutable, and only obtainable from `IP.Range.parse` or `ip.network`.
106
+ An immutable network, obtainable only from `IP.Range.parse` or `ip.network()`.
93
107
 
94
- - `network` — the first address, as an `IP`.
95
- - `prefix` — the prefix length.
96
- - `version` — `4` or `6`.
97
- - `contains(ip)` — whether the address shares the range's leading bits. An address of the other version is never a member.
98
- - `equals(other)`, `toString()`, `toJSON()` — as on `IP`; the text is `network/prefix`, such as `2001:db8::/32`.
108
+ - `network`: the first address, as an `IP`.
109
+ - `prefix`: the prefix length.
110
+ - `version`: `4` or `6`.
111
+ - `contains(ip)`: whether the address shares the range's leading bits; an address of the other version is never a member.
112
+ - `equals(other)`, `toString()`, `toJSON()`: as on `IP`, with the text written `network/prefix`, such as `2001:db8::/32`.
99
113
 
100
114
  ### `IP.Error`
101
115
 
102
- The failure both parsers answer, never thrown. `code` is `"invalid-address"`, `"invalid-range"` or `"host-bits-set"`, and `input` is the rejected text. It is a class, so `error instanceof IP.Error` narrows.
116
+ The failure both parsers answer inside a `Result`. `code` is `"invalid-address"`, `"invalid-range"` or `"host-bits-set"`, `input` is the rejected text, and `error instanceof IP.Error` narrows it.
103
117
 
104
118
  ### Types
105
119
 
106
120
  `IP.Range`, `IP.Error`, `IP.ErrorCode`, `IP.Classification` and `IP.Prefixes` (`{ v4: number; v6: number }`) are types under the same `IP` name.
107
121
 
122
+ ## Pattern: Refusing A Name That Resolves Inside The Network
123
+
124
+ Checking the URL host stops `http://127.0.0.1`; classifying every DNS answer before fetching also stops a public name whose record points at an internal address.
125
+
126
+ ```typescript
127
+ import { IP } from "@sdxc/ip";
128
+ import { isSuccess } from "@sdxc/result";
129
+
130
+ function allPublic(answers: string[]): boolean {
131
+ return (
132
+ answers.length > 0 &&
133
+ answers.every((answer) => {
134
+ let ip = IP.parse(answer);
135
+ return isSuccess(ip) && ip.data.isPublic;
136
+ })
137
+ );
138
+ }
139
+
140
+ allPublic(["93.184.215.14"]); // true
141
+ allPublic(["93.184.215.14", "10.0.0.5"]); // false
142
+ ```
143
+
108
144
  ## Pattern: An Allowlist Of Networks
109
145
 
146
+ Parse the ranges once and test each request's address against them.
147
+
110
148
  ```typescript
111
149
  import { IP } from "@sdxc/ip";
112
150
  import { isSuccess } from "@sdxc/result";
113
151
 
114
- const OFFICE_RANGES = ["203.0.113.0/24", "2001:db8:42::/48"];
152
+ let allowed = ["203.0.113.0/24", "2001:db8:42::/48"].flatMap((text) => {
153
+ let range = IP.Range.parse(text);
154
+ return isSuccess(range) ? [range.data] : [];
155
+ });
115
156
 
116
- function isOffice(ip: IP): boolean {
117
- return OFFICE_RANGES.some((text) => {
118
- let range = IP.Range.parse(text);
119
- return isSuccess(range) && range.data.contains(ip);
120
- });
157
+ function isAllowed(text: string): boolean {
158
+ let ip = IP.parse(text);
159
+ return isSuccess(ip) && allowed.some((range) => range.contains(ip.data));
121
160
  }
161
+
162
+ isAllowed("203.0.113.9"); // true
163
+ isAllowed("2001:db8:42:7::1"); // true
164
+ isAllowed("198.51.100.1"); // false
122
165
  ```
123
166
 
124
- ## Pattern: Checking Every Address A Name Resolves To
167
+ ## Pattern: Keying A Rate Limit On The Client's Network
125
168
 
126
- A literal check only stops `http://127.0.0.1`. A public name whose `A` record points at an internal address is caught by classifying every answer before fetching:
169
+ An IPv6 client is usually assigned a whole `/64`, so keying on the full address counts each address it rotates through separately. Key on the network instead, and the canonical text keeps every spelling of one client on one key.
127
170
 
128
171
  ```typescript
129
172
  import { IP } from "@sdxc/ip";
130
- import { isSuccess } from "@sdxc/result";
173
+ import { isFailure } from "@sdxc/result";
131
174
 
132
- function allPublic(answers: string[]): boolean {
133
- return (
134
- answers.length > 0 &&
135
- answers.every((answer) => {
136
- let ip = IP.parse(answer);
137
- return isSuccess(ip) && ip.data.isPublic;
138
- })
139
- );
175
+ function rateLimitKey(text: string): string | null {
176
+ let ip = IP.parse(text);
177
+ if (isFailure(ip)) return null;
178
+ return ip.data.network({ v4: 32, v6: 64 }).toString();
140
179
  }
180
+
181
+ rateLimitKey("2001:DB8:1:2::abcd"); // "2001:db8:1:2::/64"
182
+ rateLimitKey("2001:db8:1:2:ffff::1"); // "2001:db8:1:2::/64"
183
+ rateLimitKey("203.0.113.7"); // "203.0.113.7/32"
141
184
  ```
142
185
 
143
- ## Pattern: A Function That Only Accepts A Checked Address
186
+ ## Pattern: Reporting Why Input Was Refused
187
+
188
+ `IP.Error` carries a code to map to your own wording and the text that arrived.
144
189
 
145
190
  ```typescript
146
- import type { IP } from "@sdxc/ip";
191
+ import { IP } from "@sdxc/ip";
192
+ import { isFailure } from "@sdxc/result";
193
+
194
+ let messages: Record<IP.ErrorCode, string> = {
195
+ "invalid-address": "Enter an IPv4 or IPv6 address",
196
+ "invalid-range": "Enter a range such as 10.0.0.0/8",
197
+ "host-bits-set": "Use the network's first address, such as 10.0.0.0/8",
198
+ };
199
+
200
+ function validateRange(text: string): string | null {
201
+ let range = IP.Range.parse(text.trim());
202
+ return isFailure(range) ? messages[range.error.code] : null;
203
+ }
204
+
205
+ validateRange("10.0.0.1/8"); // "Use the network's first address, such as 10.0.0.0/8"
206
+ ```
207
+
208
+ ## Pattern: Functions That Only Accept A Checked Address
209
+
210
+ Taking `IP` instead of `string` moves validation to the edge, and `toString()` stores the canonical form.
211
+
212
+ ```typescript
213
+ import { IP } from "@sdxc/ip";
214
+ import { isSuccess } from "@sdxc/result";
147
215
 
148
216
  function recordProbe(target: IP, at: number) {
149
- return { target: target.toString(), at };
217
+ return { target: target.toString(), version: target.version, at };
150
218
  }
151
219
 
220
+ let target = IP.parse("[2001:db8::0:1]");
221
+ if (isSuccess(target)) recordProbe(target.data, Date.now()); // { target: "2001:db8::1", ... }
222
+
152
223
  recordProbe("10.0.0.1", Date.now()); // type error: a string is not an IP
153
224
  ```
154
225
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The `sdxc-ip classify` handler: parses the address and prints its version, canonical text,
3
+ * IANA special-purpose classification and any IPv4 it carries, as aligned lines or as JSON.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * Classifies the address; `--json` prints `{ address, version, classification, public,
10
+ * embeddedIPv4 }`, with `embeddedIPv4` null outside the IPv4-mapped, NAT64 and 6to4 prefixes.
11
+ * Text that is not an address exits 2.
12
+ */
13
+ declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
14
+ readonly address: import("@sdxc/cli").Argument<string>;
15
+ }, {
16
+ readonly json: import("@sdxc/cli").Flag<boolean>;
17
+ }>, []>;
18
+ export default _default;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `sdxc-ip classify` handler: parses the address and prints its version, canonical text,
3
+ * IANA special-purpose classification and any IPv4 it carries, as aligned lines or as JSON.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import { createCommand, success } from "@sdxc/cli";
9
+ import { isFailure } from "@sdxc/result";
10
+ import { IP_COMMANDS } from "./cli-contract.js";
11
+ import { readAddress } from "./cli-input.js";
12
+ /**
13
+ * Classifies the address; `--json` prints `{ address, version, classification, public,
14
+ * embeddedIPv4 }`, with `embeddedIPv4` null outside the IPv4-mapped, NAT64 and 6to4 prefixes.
15
+ * Text that is not an address exits 2.
16
+ */
17
+ export default createCommand(IP_COMMANDS.classify, async (ctx) => {
18
+ let parsed = await readAddress(ctx.arguments.address, ctx.stdin);
19
+ if (isFailure(parsed))
20
+ return parsed;
21
+ let ip = parsed.data;
22
+ let report = {
23
+ address: ip.toString(),
24
+ version: ip.version,
25
+ classification: ip.classification,
26
+ public: ip.isPublic,
27
+ embeddedIPv4: ip.embeddedIPv4?.toString() ?? null,
28
+ };
29
+ if (ctx.flags.json) {
30
+ ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
31
+ return success();
32
+ }
33
+ let lines = [
34
+ `address ${report.address}`,
35
+ `version ${report.version}`,
36
+ `classification ${report.classification}`,
37
+ `public ${report.public ? "yes" : "no"}`,
38
+ ];
39
+ if (report.embeddedIPv4 !== null)
40
+ lines.push(`embedded IPv4 ${report.embeddedIPv4}`);
41
+ ctx.stdout(`${lines.join("\n")}\n`);
42
+ return success();
43
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The `sdxc-ip contains` handler: parses a range and an address and answers membership in the
3
+ * exit code, so a shell script can branch on it; stdout says the same for a person or as JSON.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * Exits 0 when the range holds the address and 1 when it does not; an address of the other
10
+ * version is always outside. A malformed range, one with host bits set, or text that is not an
11
+ * address exits 2. `--json` prints `{ range, address, contains }` with both in canonical text.
12
+ */
13
+ declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
14
+ readonly range: import("@sdxc/cli").Argument<string>;
15
+ readonly address: import("@sdxc/cli").Argument<string>;
16
+ }, {
17
+ readonly json: import("@sdxc/cli").Flag<boolean>;
18
+ }>, []>;
19
+ export default _default;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The `sdxc-ip contains` handler: parses a range and an address and answers membership in the
3
+ * exit code, so a shell script can branch on it; stdout says the same for a person or as JSON.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import { createCommand, exit, success } from "@sdxc/cli";
9
+ import { isFailure } from "@sdxc/result";
10
+ import { IP_COMMANDS } from "./cli-contract.js";
11
+ import { readAddress } from "./cli-input.js";
12
+ import { IP } from "./ip.js";
13
+ /**
14
+ * Exits 0 when the range holds the address and 1 when it does not; an address of the other
15
+ * version is always outside. A malformed range, one with host bits set, or text that is not an
16
+ * address exits 2. `--json` prints `{ range, address, contains }` with both in canonical text.
17
+ */
18
+ export default createCommand(IP_COMMANDS.contains, async (ctx) => {
19
+ let range = IP.Range.parse(ctx.arguments.range);
20
+ if (isFailure(range)) {
21
+ let reason = range.error.code === "host-bits-set"
22
+ ? `${JSON.stringify(ctx.arguments.range)} has bits set past its prefix; write the network's first address`
23
+ : `Not an IP range: ${JSON.stringify(ctx.arguments.range)}`;
24
+ return exit(2, reason);
25
+ }
26
+ let address = await readAddress(ctx.arguments.address, ctx.stdin);
27
+ if (isFailure(address))
28
+ return address;
29
+ let contains = range.data.contains(address.data);
30
+ if (ctx.flags.json) {
31
+ let report = { range: range.data.toString(), address: address.data.toString(), contains };
32
+ ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
33
+ }
34
+ else {
35
+ ctx.stdout(`${address.data.toString()} is ${contains ? "inside" : "outside"} ${range.data.toString()}\n`);
36
+ }
37
+ return contains ? success() : exit(1);
38
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `sdxc-ip` command line as a contract: `classify` names what an address is for, and
3
+ * `contains` tests whether a range holds an address. Help, usage errors and each handler's
4
+ * typed `ctx.arguments` and `ctx.flags` all come from this one declaration.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Every command `sdxc-ip` takes. */
10
+ export declare const IP_COMMANDS: import("@sdxc/cli").Group<Omit<{
11
+ readonly description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries";
12
+ readonly classify: import("@sdxc/cli").Command<{
13
+ readonly address: import("@sdxc/cli").Argument<string>;
14
+ }, {
15
+ readonly json: import("@sdxc/cli").Flag<boolean>;
16
+ }>;
17
+ readonly contains: import("@sdxc/cli").Command<{
18
+ readonly range: import("@sdxc/cli").Argument<string>;
19
+ readonly address: import("@sdxc/cli").Argument<string>;
20
+ }, {
21
+ readonly json: import("@sdxc/cli").Flag<boolean>;
22
+ }>;
23
+ }, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `sdxc-ip` command line as a contract: `classify` names what an address is for, and
3
+ * `contains` tests whether a range holds an address. Help, usage errors and each handler's
4
+ * typed `ctx.arguments` and `ctx.flags` all come from this one declaration.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { argument, command, commands, flag } from "@sdxc/cli";
10
+ /**
11
+ * Accepts any text, so the package needs no schema library; each handler parses the text
12
+ * itself and answers its own exit 2 naming the rejected value.
13
+ */
14
+ const TEXT = {
15
+ "~standard": {
16
+ version: 1,
17
+ vendor: "@sdxc/ip",
18
+ types: { input: undefined, output: "" },
19
+ validate(value) {
20
+ if (typeof value === "string")
21
+ return { value };
22
+ return { issues: [{ message: `expects text; got ${JSON.stringify(value)}` }] };
23
+ },
24
+ },
25
+ };
26
+ /** The `--json` flag every report-printing command takes. */
27
+ function json() {
28
+ return flag.boolean("json", { description: "Print the result as JSON" });
29
+ }
30
+ /** Every command `sdxc-ip` takes. */
31
+ export const IP_COMMANDS = commands({
32
+ description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries",
33
+ classify: command({
34
+ description: "Print an address's version, canonical form and IANA special-purpose classification",
35
+ arguments: {
36
+ address: argument(TEXT, {
37
+ default: "-",
38
+ description: "The address, or - to read it from stdin",
39
+ }),
40
+ },
41
+ flags: { json: json() },
42
+ examples: ["classify 192.168.1.10", "classify ::ffff:10.0.0.1 --json", "classify - < ip.txt"],
43
+ }),
44
+ contains: command({
45
+ description: "Exit 0 when the range holds the address and 1 when it does not",
46
+ arguments: {
47
+ range: argument(TEXT, { description: "The range as network/prefix" }),
48
+ address: argument(TEXT, {
49
+ default: "-",
50
+ description: "The address, or - to read it from stdin",
51
+ }),
52
+ },
53
+ flags: { json: json() },
54
+ examples: ["contains 10.0.0.0/8 10.1.2.3", "contains 2001:db8::/32 2001:db8::1 --json"],
55
+ }),
56
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What every `sdxc-ip` command shares: reading the address from its argument or stdin and
3
+ * parsing it, so both commands refuse the same input with the same exit 2 and message.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type { ExitError, HandlerContext } from "@sdxc/cli";
9
+ import type { Result } from "@sdxc/result";
10
+ import type { IP_COMMANDS } from "./cli-contract.js";
11
+ import { IP } from "./ip.js";
12
+ /** The stdin every command reads a `-` address from. */
13
+ type Stdin = HandlerContext<typeof IP_COMMANDS.classify>["stdin"];
14
+ /**
15
+ * The address an argument names: the text as typed, or stdin's text trimmed for `-`. A
16
+ * terminal on stdin exits 2, so a command never waits on a person typing.
17
+ *
18
+ * @param value - The argument as typed
19
+ * @param stdin - The run's stdin, read when `value` is `-`
20
+ * @returns The parsed address, or an exit 2 naming the rejected text
21
+ */
22
+ export declare function readAddress(value: string, stdin: Stdin): Promise<Result<IP, ExitError>>;
23
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What every `sdxc-ip` command shares: reading the address from its argument or stdin and
3
+ * parsing it, so both commands refuse the same input with the same exit 2 and message.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import { exit } from "@sdxc/cli";
9
+ import { isFailure, success } from "@sdxc/result";
10
+ import { IP } from "./ip.js";
11
+ /**
12
+ * The address an argument names: the text as typed, or stdin's text trimmed for `-`. A
13
+ * terminal on stdin exits 2, so a command never waits on a person typing.
14
+ *
15
+ * @param value - The argument as typed
16
+ * @param stdin - The run's stdin, read when `value` is `-`
17
+ * @returns The parsed address, or an exit 2 naming the rejected text
18
+ */
19
+ export async function readAddress(value, stdin) {
20
+ let text = value;
21
+ if (text === "-") {
22
+ if (stdin.isTTY)
23
+ return exit(2, "No address: pass one, or pipe it on stdin");
24
+ text = (await stdin.text()).trim();
25
+ }
26
+ let parsed = IP.parse(text);
27
+ if (isFailure(parsed))
28
+ return exit(2, `Not an IP address: ${JSON.stringify(text)}`);
29
+ return success(parsed.data);
30
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `sdxc-ip` command-line program: the contract in `cli-contract.ts` with each command mapped
4
+ * onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the address
5
+ * is outside the range, 2 usage or text that is not an address or a range.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ /** The `sdxc-ip` program, run by the bin entry below and by tests through `invoke()`. */
11
+ export declare const cli: import("@sdxc/cli").CLI<import("@sdxc/cli").Group<Omit<{
12
+ readonly description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries";
13
+ readonly classify: import("@sdxc/cli").Command<{
14
+ readonly address: import("@sdxc/cli").Argument<string>;
15
+ }, {
16
+ readonly json: import("@sdxc/cli").Flag<boolean>;
17
+ }>;
18
+ readonly contains: import("@sdxc/cli").Command<{
19
+ readonly range: import("@sdxc/cli").Argument<string>;
20
+ readonly address: import("@sdxc/cli").Argument<string>;
21
+ }, {
22
+ readonly json: import("@sdxc/cli").Flag<boolean>;
23
+ }>;
24
+ }, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>, []>;
package/dist/cli.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `sdxc-ip` command-line program: the contract in `cli-contract.ts` with each command mapped
4
+ * onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the address
5
+ * is outside the range, 2 usage or text that is not an address or a range.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { createCLI, lazy } from "@sdxc/cli";
11
+ import { IP_COMMANDS } from "./cli-contract.js";
12
+ /** The `sdxc-ip` program, run by the bin entry below and by tests through `invoke()`. */
13
+ export const cli = createCLI({ name: "sdxc-ip", commands: IP_COMMANDS });
14
+ cli.map(IP_COMMANDS.classify, lazy(() => import("./cli-classify.js")));
15
+ cli.map(IP_COMMANDS.contains, lazy(() => import("./cli-contains.js")));
16
+ if (import.meta.main) {
17
+ process.exit(await cli.run(process.argv.slice(2)));
18
+ }
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "@sdxc/ip",
3
- "version": "2026.10.6",
3
+ "version": "2026.11.3",
4
4
  "description": "IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries",
5
5
  "license": "MIT",
6
+ "bin": {
7
+ "sdxc-ip": "dist/cli.js"
8
+ },
6
9
  "type": "module",
7
10
  "exports": {
8
11
  ".": "./dist/index.js"
9
12
  },
10
13
  "dependencies": {
11
- "@sdxc/result": "2026.10.6"
14
+ "@sdxc/cli": "2026.11.3",
15
+ "@sdxc/result": "2026.10.13"
12
16
  },
13
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
17
+ "gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
14
18
  "publishConfig": {
15
19
  "access": "public"
16
20
  },
@@ -19,7 +23,7 @@
19
23
  "url": "git+https://github.com/sergiodxa/monorepo.git",
20
24
  "directory": "packages/ip"
21
25
  },
22
- "homepage": "https://sdxc.sergiodxa.com/api/ip",
26
+ "homepage": "https://sdxc.dev/api/ip",
23
27
  "author": {
24
28
  "name": "Sergio Xalambrí",
25
29
  "url": "https://sergiodxa.com"