@sdxc/zone-file 2026.10.13 → 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
@@ -95,6 +95,25 @@ formatRecordData({ type: "MX", preference: 10, exchange: "mail.example.com" });
95
95
  // "10 mail.example.com."
96
96
  ```
97
97
 
98
+ ## Command line
99
+
100
+ ```sh
101
+ bunx @sdxc/zone-file --help
102
+ ```
103
+
104
+ With [`@sdxc/kit`](https://www.npmjs.com/package/@sdxc/kit) installed, `sdxc pkg zone-file …` runs the same command line, using the version your project installs.
105
+
106
+ Each command reads a path, or stdin when it is `-` or absent. `--origin` (default the root) and
107
+ `--ttl` (seconds) set the defaults a file without `$ORIGIN` or `$TTL` reads with, and `$INCLUDE`
108
+ paths resolve beside the zone file. Exit codes: `0` done, `1` the zone has entries that could
109
+ not be read, `2` usage or unreadable input.
110
+
111
+ ```sh
112
+ bunx @sdxc/zone-file validate example.com.zone --origin example.com # file:line: message (reason)
113
+ bunx @sdxc/zone-file parse example.com.zone --origin example.com --json # { origin, records, rejected }
114
+ bunx @sdxc/zone-file format records.json --relative > example.com.zone # JSON records or a zone file in
115
+ ```
116
+
98
117
  ## API
99
118
 
100
119
  ### `parse(text: string, options: ParseOptions): Result<Zone, ZoneFileError>`
@@ -140,8 +159,9 @@ standalone comments and parentheses are not.
140
159
  ### `parseRecordData<Type extends RecordType>(type: Type, data: string): Result<RecordData<Type>, RecordDataError>`
141
160
 
142
161
  Reads RDATA in presentation format, or RFC 3597 generic form, into the typed fields of `A`,
143
- `AAAA`, `CNAME`, `NS`, `PTR`, `DNAME`, `MX`, `TXT`, `CAA`, `SRV` and `SOA` (SOA timers accept
144
- TTL units). Any other type returns `{ type, data }` as written. Names come back lowercased
162
+ `AAAA`, `CNAME`, `NS`, `PTR`, `DNAME`, `MX`, `TXT`, `CAA`, `SRV`, `SOA` (SOA timers accept TTL
163
+ units), `SVCB` and `HTTPS` (their SvcParams as `params`), `TLSA`, `DS`, `CDS`, `DNSKEY` and
164
+ `CDNSKEY` (with the key's `keyTag`). Any other type returns `{ type, data }` as written. Names come back lowercased
145
165
  without the trailing dot, and a relative name stays relative.
146
166
 
147
167
  ```typescript
@@ -152,8 +172,8 @@ parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
152
172
  ### `formatRecordData(data: RecordData<RecordType>): string`
153
173
 
154
174
  Prints record data in canonical presentation format, the inverse of `parseRecordData`: names
155
- absolute with the trailing dot, TXT strings and the CAA value quoted with non-printable octets as
156
- `\DDD`, untyped data as is. Two spellings of one record, such as `0 ISSUE "ca.example"` and its
175
+ absolute with the trailing dot, TXT strings, the CAA value and SvcParam text quoted with
176
+ non-printable octets as `\DDD`, hex lowercase, SvcParams in key order, untyped data as is. Two spellings of one record, such as `0 ISSUE "ca.example"` and its
157
177
  generic form, print to one string.
158
178
 
159
179
  ### `canonicalType(type: string): string`
@@ -165,6 +185,10 @@ is `"TXT"`.
165
185
 
166
186
  The mnemonic for a type code, or `TYPEnnn` without one: `typeName(16)` is `"TXT"`.
167
187
 
188
+ ### `typeCode(type: string): number | null`
189
+
190
+ The code for a mnemonic or `TYPEnnn`, `null` for an unknown mnemonic: `typeCode("HTTPS")` is `65`.
191
+
168
192
  ### `ZoneFileError`
169
193
 
170
194
  The failure `parse` returns when the input, with its includes, passes `maxBytes`. `code` is
@@ -189,7 +213,9 @@ Every type is exported top-level and under the `ZoneFile` namespace (`ZoneFile.Z
189
213
  (no `include` option, a file it returned `null` for, or nesting past 8) or
190
214
  `"unsupported-directive"` (`$GENERATE` or any other `$` word).
191
215
  - `RecordData<Type>`: the typed fields of a type: `AData`, `AAAAData`, `CNAMEData`, `NSData`,
192
- `PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`, or `UnknownData`.
216
+ `PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`, `SVCBData`,
217
+ `HTTPSData` (with `SvcParams`), `TLSAData`, `DSData`, `CDSData`, `DNSKEYData`, `CDNSKEYData`,
218
+ or `UnknownData`.
193
219
  - `RecordType`, `TypedRecordType`, `RecordClass`: type and class names.
194
220
  - `ParseOptions`, `StringifyOptions`: the options above.
195
221
  - `ZoneInput`, `RecordInput`, `RecordInputFields`: what `stringify` accepts; a record needs only
@@ -0,0 +1,17 @@
1
+ /**
2
+ * IP address codecs for A and AAAA data and the SVCB address hints: the strict dotted-quad
3
+ * reading, RFC 5952 canonical IPv6, and the octets each spells on the wire.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** Whether text is an IPv4 address in the dotted-quad form A data takes. */
9
+ export declare function isIpv4(value: string): boolean;
10
+ /** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
11
+ export declare function canonicalIpv6(value: string): string | null;
12
+ /** The four octets of a dotted-quad IPv4 address, or `null` when the text is not one. */
13
+ export declare function ipv4Octets(value: string): Uint8Array | null;
14
+ /** The sixteen octets of an IPv6 address, or `null` when the text is not one. */
15
+ export declare function ipv6Octets(value: string): Uint8Array | null;
16
+ /** Prints sixteen octets as an RFC 5952 canonical IPv6 address. */
17
+ export declare function printIpv6(octets: Uint8Array): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * IP address codecs for A and AAAA data and the SVCB address hints: the strict dotted-quad
3
+ * reading, RFC 5952 canonical IPv6, and the octets each spells on the wire.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * A dotted-quad IPv4 address without leading zeros, which `inet_aton` would read as octal
10
+ * and browsers as decimal, so an address carrying them has no single meaning.
11
+ */
12
+ const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
13
+ /** Whether text is an IPv4 address in the dotted-quad form A data takes. */
14
+ export function isIpv4(value) {
15
+ return IPV4_PATTERN.test(value);
16
+ }
17
+ /** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
18
+ export function canonicalIpv6(value) {
19
+ if (!value.includes(":"))
20
+ return null;
21
+ let url = URL.parse(`http://[${value}]/`);
22
+ return url ? url.hostname.slice(1, -1) : null;
23
+ }
24
+ /** The four octets of a dotted-quad IPv4 address, or `null` when the text is not one. */
25
+ export function ipv4Octets(value) {
26
+ return isIpv4(value) ? Uint8Array.from(value.split("."), Number) : null;
27
+ }
28
+ /** The sixteen octets of an IPv6 address, or `null` when the text is not one. */
29
+ export function ipv6Octets(value) {
30
+ let canonical = canonicalIpv6(value);
31
+ if (canonical === null)
32
+ return null;
33
+ let [head = "", tail] = canonical.split("::");
34
+ let leading = head === "" ? [] : head.split(":");
35
+ let trailing = tail === undefined || tail === "" ? [] : tail.split(":");
36
+ let zeros = Array.from({ length: 8 - leading.length - trailing.length }, () => "0");
37
+ let groups = tail === undefined ? leading : [...leading, ...zeros, ...trailing];
38
+ let octets = new Uint8Array(16);
39
+ groups.forEach((group, index) => {
40
+ let word = Number.parseInt(group, 16);
41
+ octets[index * 2] = word >> 8;
42
+ octets[index * 2 + 1] = word & 0xff;
43
+ });
44
+ return octets;
45
+ }
46
+ /** Prints sixteen octets as an RFC 5952 canonical IPv6 address. */
47
+ export function printIpv6(octets) {
48
+ let groups = Array.from({ length: 8 }, (_, index) => (((octets[index * 2] ?? 0) << 8) | (octets[index * 2 + 1] ?? 0)).toString(16));
49
+ return canonicalIpv6(groups.join(":")) ?? groups.join(":");
50
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The `sdxc-zone-file` command line as a contract: `validate` reports every entry a zone file
3
+ * could not use, `parse` prints its records, and `format` writes records back as a normalized
4
+ * zone file. Help, usage errors and each handler's typed flags come from this one declaration.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Every command `sdxc-zone-file` takes. */
10
+ export declare const ZONE_FILE_COMMANDS: import("@sdxc/cli").Group<Omit<{
11
+ readonly description: "sdxc-zone-file — read, check and write RFC 1035 DNS zone files";
12
+ readonly validate: import("@sdxc/cli").Command<{
13
+ readonly zone: import("@sdxc/cli").Argument<string>;
14
+ }, {
15
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
16
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
17
+ readonly json: import("@sdxc/cli").Flag<boolean>;
18
+ }>;
19
+ readonly parse: import("@sdxc/cli").Command<{
20
+ readonly zone: import("@sdxc/cli").Argument<string>;
21
+ }, {
22
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
23
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
24
+ readonly json: import("@sdxc/cli").Flag<boolean>;
25
+ }>;
26
+ readonly format: import("@sdxc/cli").Command<{
27
+ readonly input: import("@sdxc/cli").Argument<string>;
28
+ }, {
29
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
30
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
31
+ readonly relative: import("@sdxc/cli").Flag<boolean>;
32
+ }>;
33
+ }, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The `sdxc-zone-file` command line as a contract: `validate` reports every entry a zone file
3
+ * could not use, `parse` prints its records, and `format` writes records back as a normalized
4
+ * zone file. Help, usage errors and each handler's typed flags 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
+ import * as s from "remix/data-schema";
11
+ /** A TTL in seconds, written as digits only, so `1h` and `-5` are usage errors. */
12
+ function seconds() {
13
+ return s.createSchema(function validate(value, context) {
14
+ if (typeof value === "string" && /^\d+$/.test(value))
15
+ return { value: Number(value) };
16
+ return s.fail(`expects a TTL in seconds; got ${JSON.stringify(value)}`, context.path);
17
+ });
18
+ }
19
+ /** The input argument every command takes: a path, or stdin when it is `-` or absent. */
20
+ function input(description) {
21
+ return argument(s.string(), { default: "-", description });
22
+ }
23
+ /** The `--origin` flag: the name `@` and relative names resolve against until a `$ORIGIN`. */
24
+ function origin() {
25
+ return flag.string("origin", {
26
+ valueName: "name",
27
+ default: ".",
28
+ description: "The initial $ORIGIN; relative names resolve against it",
29
+ });
30
+ }
31
+ /** The `--ttl` flag: the TTL a record gets when neither it, a `$TTL` nor an earlier record states one. */
32
+ function ttl() {
33
+ return flag.string("ttl", {
34
+ valueName: "seconds",
35
+ schema: seconds(),
36
+ description: "The TTL a record without one gets",
37
+ });
38
+ }
39
+ /** Every command `sdxc-zone-file` takes. */
40
+ export const ZONE_FILE_COMMANDS = commands({
41
+ description: "sdxc-zone-file — read, check and write RFC 1035 DNS zone files",
42
+ validate: command({
43
+ description: "Read a zone file and report every entry it could not use, with its line. Exits 1 when there is any.",
44
+ arguments: { zone: input("The zone file; - or absent reads stdin") },
45
+ flags: {
46
+ origin: origin(),
47
+ ttl: ttl(),
48
+ json: flag.boolean("json", {
49
+ description: "Print the result as JSON: { valid, records, rejected }",
50
+ }),
51
+ },
52
+ examples: ["validate example.com.zone --origin example.com", "validate db.zone --json"],
53
+ }),
54
+ parse: command({
55
+ description: "Print the records a zone file declares, one per line. Exits 1 when an entry could not be read.",
56
+ arguments: { zone: input("The zone file; - or absent reads stdin") },
57
+ flags: {
58
+ origin: origin(),
59
+ ttl: ttl(),
60
+ json: flag.boolean("json", {
61
+ description: "Print the zone as JSON: { origin, records, rejected }",
62
+ }),
63
+ },
64
+ examples: ["parse example.com.zone --origin example.com --json"],
65
+ }),
66
+ format: command({
67
+ description: "Write records as a normalized zone file. Reads JSON ({ origin?, ttl?, records } or an array of records) or a zone file.",
68
+ arguments: { input: input("JSON records or a zone file; - or absent reads stdin") },
69
+ flags: {
70
+ origin: origin(),
71
+ ttl: ttl(),
72
+ relative: flag.boolean("relative", {
73
+ description: "Write names under the origin relative to it, the apex as @",
74
+ }),
75
+ },
76
+ examples: [
77
+ "format records.json --relative",
78
+ "format example.com.zone --origin example.com --ttl 3600",
79
+ ],
80
+ }),
81
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The `sdxc-zone-file format` handler: writes records as a zone file with one spelling per
3
+ * record — canonical names, addresses and quoting — so two exports of one zone diff only where
4
+ * their records differ. It reads JSON records, `parse --json` output included, or a zone file.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Writes the zone to stdout. JSON that is not records exits 2; a zone file with entries it could
11
+ * not read, or a record whose fields do not fit its type, prints each problem on stderr, writes
12
+ * nothing and exits 1, so a normalized file never silently drops a record.
13
+ */
14
+ declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
15
+ readonly input: import("@sdxc/cli").Argument<string>;
16
+ }, {
17
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
18
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
19
+ readonly relative: import("@sdxc/cli").Flag<boolean>;
20
+ }>, []>;
21
+ export default _default;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The `sdxc-zone-file format` handler: writes records as a zone file with one spelling per
3
+ * record — canonical names, addresses and quoting — so two exports of one zone diff only where
4
+ * their records differ. It reads JSON records, `parse --json` output included, or a zone file.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { createCommand, exit, success } from "@sdxc/cli";
10
+ import { failure, isFailure } from "@sdxc/result";
11
+ import * as s from "remix/data-schema";
12
+ import { ZONE_FILE_COMMANDS } from "./cli-contract.js";
13
+ import { describeRejection, parseInput, readInput } from "./cli-input.js";
14
+ import { parse } from "./parse.js";
15
+ import { stringify } from "./stringify.js";
16
+ /** What every record in JSON input needs; its type's fields pass through and are checked by writing it. */
17
+ const RECORD = s.object({
18
+ name: s.string(),
19
+ type: s.string(),
20
+ ttl: s.optional(s.nullable(s.number())),
21
+ class: s.optional(s.string()),
22
+ comment: s.optional(s.nullable(s.string())),
23
+ origin: s.optional(s.string()),
24
+ }, { unknownKeys: "passthrough" });
25
+ /** JSON input: a bare array of records, or a zone with an optional origin and TTL. */
26
+ const JSON_INPUT = s.union([
27
+ s.array(RECORD),
28
+ s.object({
29
+ origin: s.optional(s.string()),
30
+ ttl: s.optional(s.nullable(s.number())),
31
+ records: s.array(RECORD),
32
+ }),
33
+ ]);
34
+ /**
35
+ * Writes the zone to stdout. JSON that is not records exits 2; a zone file with entries it could
36
+ * not read, or a record whose fields do not fit its type, prints each problem on stderr, writes
37
+ * nothing and exits 1, so a normalized file never silently drops a record.
38
+ */
39
+ export default createCommand(ZONE_FILE_COMMANDS.format, async (ctx) => {
40
+ let input = await readInput(ctx, ctx.arguments.input);
41
+ if (isFailure(input))
42
+ return input;
43
+ let { name, text } = input.data;
44
+ let flagOrigin = ctx.flags.origin === "." ? undefined : ctx.flags.origin;
45
+ let trimmed = text.trimStart();
46
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
47
+ let zone = parseInput(input.data, ctx.flags);
48
+ if (isFailure(zone))
49
+ return zone;
50
+ if (zone.data.rejected.length > 0) {
51
+ for (let rejection of zone.data.rejected) {
52
+ ctx.stderr(`${describeRejection(name, rejection)}\n`);
53
+ }
54
+ return exit(1, `${name} has entries that could not be read; nothing was written`);
55
+ }
56
+ let origin = zone.data.origin === "." ? undefined : zone.data.origin;
57
+ let zoneInput = { origin, ttl: ctx.flags.ttl, records: zone.data.records };
58
+ ctx.stdout(stringify(zoneInput, { relative: ctx.flags.relative }));
59
+ return success();
60
+ }
61
+ let zone = readJson(name, trimmed);
62
+ if (isFailure(zone))
63
+ return zone;
64
+ let problems = zone.data.records.flatMap((record, index) => {
65
+ let checked = checkRecord(record);
66
+ return isFailure(checked)
67
+ ? [`${name}: records[${index}] (${record.type}): ${checked.error.message}`]
68
+ : [];
69
+ });
70
+ if (problems.length > 0) {
71
+ for (let problem of problems)
72
+ ctx.stderr(`${problem}\n`);
73
+ return exit(1, `${name} has records that could not be written; nothing was written`);
74
+ }
75
+ let zoneInput = {
76
+ origin: zone.data.origin ?? flagOrigin,
77
+ ttl: ctx.flags.ttl ?? zone.data.ttl,
78
+ records: zone.data.records,
79
+ };
80
+ ctx.stdout(stringify(zoneInput, { relative: ctx.flags.relative }));
81
+ return success();
82
+ });
83
+ /**
84
+ * Reads JSON input into a zone, accepting a bare array of records.
85
+ *
86
+ * @param name - What the input is called, for the error
87
+ * @param text - The JSON text
88
+ * @returns The zone, or an exit 2 naming every field that does not fit
89
+ */
90
+ function readJson(name, text) {
91
+ let value;
92
+ try {
93
+ value = JSON.parse(text);
94
+ }
95
+ catch (error) {
96
+ let reason = error instanceof Error ? error.message : String(error);
97
+ return exit(2, `${name} is not valid JSON: ${reason}`);
98
+ }
99
+ let parsed = s.parseSafe(JSON_INPUT, value);
100
+ if (!parsed.success) {
101
+ let issues = parsed.issues.map((issue) => ` ${issuePath(issue.path)}: ${issue.message}`);
102
+ return exit(2, `${name} is not a list of records:\n${issues.join("\n")}`);
103
+ }
104
+ return success(Array.isArray(parsed.value) ? { records: parsed.value } : parsed.value);
105
+ }
106
+ /** A Standard Schema issue path as `records[0].name`, or `(root)` for the value itself. */
107
+ function issuePath(path) {
108
+ if (path === undefined || path.length === 0)
109
+ return "(root)";
110
+ return path
111
+ .map((segment) => (typeof segment === "object" ? segment.key : segment))
112
+ .map((key, index) => typeof key === "number" ? `[${key}]` : `${index === 0 ? "" : "."}${String(key)}`)
113
+ .join("");
114
+ }
115
+ /**
116
+ * Writes one record alone and reads it back, so a field missing or invalid for its type is
117
+ * reported against the record rather than printed into the zone.
118
+ *
119
+ * @param record - A record whose envelope already passed `RECORD`
120
+ * @returns Nothing, or the reason the record cannot be written
121
+ */
122
+ function checkRecord(record) {
123
+ let line;
124
+ try {
125
+ line = stringify({ records: [record] });
126
+ }
127
+ catch (error) {
128
+ let reason = error instanceof Error ? error.message : String(error);
129
+ return failure(new Error(`missing fields for its type (${reason})`));
130
+ }
131
+ let read = parse(line, { origin: "." });
132
+ if (isFailure(read))
133
+ return read;
134
+ let [rejection] = read.data.rejected;
135
+ if (rejection !== undefined)
136
+ return failure(new Error(rejection.message));
137
+ return success();
138
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the `sdxc-zone-file` commands share: reading a zone from a file or stdin, parsing it with
3
+ * `$INCLUDE` files read beside it, and printing a rejection with the file and lines it came from,
4
+ * so every command accepts input and reports problems the same way.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { ChainContext, ExitError } from "@sdxc/cli";
10
+ import type { Result } from "@sdxc/result";
11
+ import type * as ZoneFile from "./types.js";
12
+ /** The text read and the name diagnostics call it by: the path as typed, or `<stdin>`. */
13
+ export interface ZoneInputText {
14
+ name: string;
15
+ text: string;
16
+ /** Where `$INCLUDE` paths resolve: the file's directory, or the run's directory for stdin. */
17
+ directory: string;
18
+ }
19
+ /** The parse defaults the `--origin` and `--ttl` flags set. */
20
+ export interface ZoneDefaults {
21
+ origin: string;
22
+ ttl?: number;
23
+ }
24
+ /**
25
+ * The text of `file`, resolved against the run's directory; `-` reads stdin to its end.
26
+ *
27
+ * @param ctx - The running command, for its directory and stdin
28
+ * @param file - The path argument as typed
29
+ * @returns The input, or an exit 2 naming the file that could not be read
30
+ */
31
+ export declare function readInput(ctx: Pick<ChainContext, "cwd" | "stdin">, file: string): Promise<Result<ZoneInputText, ExitError>>;
32
+ /**
33
+ * Parses the input with `$INCLUDE` paths read relative to it; an include that cannot be read
34
+ * becomes an `include` rejection on its line. A zone over the size limit exits 2.
35
+ *
36
+ * @param input - The text and where it came from
37
+ * @param defaults - The origin and TTL the flags set
38
+ */
39
+ export declare function parseInput(input: ZoneInputText, defaults: ZoneDefaults): Result<ZoneFile.Zone, ExitError>;
40
+ /**
41
+ * One problem as `file:line: message (reason)`, with `line-endLine` for a parenthesized entry
42
+ * and the included file's name for an entry read from one.
43
+ *
44
+ * @param name - What the input is called, for entries of the main file
45
+ * @param rejection - The entry the parser could not use
46
+ */
47
+ export declare function describeRejection(name: string, rejection: ZoneFile.Rejection): string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What the `sdxc-zone-file` commands share: reading a zone from a file or stdin, parsing it with
3
+ * `$INCLUDE` files read beside it, and printing a rejection with the file and lines it came from,
4
+ * so every command accepts input and reports problems the same way.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { dirname, resolve } from "node:path";
12
+ import { exit } from "@sdxc/cli";
13
+ import { isFailure, success } from "@sdxc/result";
14
+ import { parse } from "./parse.js";
15
+ /**
16
+ * The text of `file`, resolved against the run's directory; `-` reads stdin to its end.
17
+ *
18
+ * @param ctx - The running command, for its directory and stdin
19
+ * @param file - The path argument as typed
20
+ * @returns The input, or an exit 2 naming the file that could not be read
21
+ */
22
+ export async function readInput(ctx, file) {
23
+ if (file === "-") {
24
+ return success({ name: "<stdin>", text: await ctx.stdin.text(), directory: ctx.cwd });
25
+ }
26
+ let path = resolve(ctx.cwd, file);
27
+ try {
28
+ return success({ name: file, text: await readFile(path, "utf8"), directory: dirname(path) });
29
+ }
30
+ catch (error) {
31
+ let reason = error instanceof Error ? error.message : String(error);
32
+ return exit(2, `Cannot read ${file}: ${reason}`);
33
+ }
34
+ }
35
+ /**
36
+ * Parses the input with `$INCLUDE` paths read relative to it; an include that cannot be read
37
+ * becomes an `include` rejection on its line. A zone over the size limit exits 2.
38
+ *
39
+ * @param input - The text and where it came from
40
+ * @param defaults - The origin and TTL the flags set
41
+ */
42
+ export function parseInput(input, defaults) {
43
+ let parsed = parse(input.text, {
44
+ origin: defaults.origin,
45
+ ttl: defaults.ttl ?? null,
46
+ include: (fileName) => readInclude(resolve(input.directory, fileName)),
47
+ });
48
+ if (isFailure(parsed))
49
+ return exit(2, `${input.name}: ${parsed.error.message}`);
50
+ return parsed;
51
+ }
52
+ /** An included file's text, or `null` so the parser rejects the `$INCLUDE` that named it. */
53
+ function readInclude(path) {
54
+ try {
55
+ return readFileSync(path, "utf8");
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * One problem as `file:line: message (reason)`, with `line-endLine` for a parenthesized entry
63
+ * and the included file's name for an entry read from one.
64
+ *
65
+ * @param name - What the input is called, for entries of the main file
66
+ * @param rejection - The entry the parser could not use
67
+ */
68
+ export function describeRejection(name, rejection) {
69
+ let lines = rejection.endLine === rejection.line
70
+ ? String(rejection.line)
71
+ : `${rejection.line}-${rejection.endLine}`;
72
+ return `${rejection.file ?? name}:${lines}: ${rejection.message} (${rejection.reason})`;
73
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The `sdxc-zone-file parse` handler: prints the records a zone file declares, one
3
+ * `name ttl class type data` line each with the data in canonical presentation format, or the
4
+ * whole zone as JSON for a script to read.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Prints every record it read, tab-separated, with `-` for a record without a TTL; `--json`
11
+ * prints `{ origin, records, rejected }`. Entries it could not read go to stderr and exit 1,
12
+ * after the records that did parse are printed.
13
+ */
14
+ declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
15
+ readonly zone: import("@sdxc/cli").Argument<string>;
16
+ }, {
17
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
18
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
19
+ readonly json: import("@sdxc/cli").Flag<boolean>;
20
+ }>, []>;
21
+ export default _default;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `sdxc-zone-file parse` handler: prints the records a zone file declares, one
3
+ * `name ttl class type data` line each with the data in canonical presentation format, or the
4
+ * whole zone as JSON for a script to read.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { createCommand, exit, success } from "@sdxc/cli";
10
+ import { isFailure } from "@sdxc/result";
11
+ import { ZONE_FILE_COMMANDS } from "./cli-contract.js";
12
+ import { describeRejection, parseInput, readInput } from "./cli-input.js";
13
+ import { formatRecordData } from "./format-record-data.js";
14
+ /**
15
+ * Prints every record it read, tab-separated, with `-` for a record without a TTL; `--json`
16
+ * prints `{ origin, records, rejected }`. Entries it could not read go to stderr and exit 1,
17
+ * after the records that did parse are printed.
18
+ */
19
+ export default createCommand(ZONE_FILE_COMMANDS.parse, async (ctx) => {
20
+ let input = await readInput(ctx, ctx.arguments.zone);
21
+ if (isFailure(input))
22
+ return input;
23
+ let zone = parseInput(input.data, ctx.flags);
24
+ if (isFailure(zone))
25
+ return zone;
26
+ let { records, rejected } = zone.data;
27
+ if (ctx.flags.json) {
28
+ ctx.stdout(`${JSON.stringify(zone.data, null, 2)}\n`);
29
+ }
30
+ else {
31
+ for (let record of records) {
32
+ let fields = [record.name, record.ttl ?? "-", record.class, record.type];
33
+ ctx.stdout(`${[...fields, formatRecordData(record)].join("\t")}\n`);
34
+ }
35
+ for (let rejection of rejected) {
36
+ ctx.stderr(`${describeRejection(input.data.name, rejection)}\n`);
37
+ }
38
+ }
39
+ if (rejected.length === 0)
40
+ return success();
41
+ let noun = rejected.length === 1 ? "entry" : "entries";
42
+ return exit(1, ctx.flags.json ? undefined : `${rejected.length} ${noun} could not be read`);
43
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The `sdxc-zone-file validate` handler: reads a zone file and reports every entry the parser
3
+ * could not use with its line. A rejected entry is the finding the check exists for, so it exits
4
+ * 1; only unreadable or oversized input exits 2.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Prints `file: N records, no problems`, or one `file:line: message (reason)` line per problem
11
+ * on stderr; `--json` prints `{ valid, records, rejected }` on stdout in both cases, `records`
12
+ * being the count read.
13
+ */
14
+ declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
15
+ readonly zone: import("@sdxc/cli").Argument<string>;
16
+ }, {
17
+ readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
18
+ readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
19
+ readonly json: import("@sdxc/cli").Flag<boolean>;
20
+ }>, []>;
21
+ export default _default;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The `sdxc-zone-file validate` handler: reads a zone file and reports every entry the parser
3
+ * could not use with its line. A rejected entry is the finding the check exists for, so it exits
4
+ * 1; only unreadable or oversized input exits 2.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { createCommand, exit, success } from "@sdxc/cli";
10
+ import { isFailure } from "@sdxc/result";
11
+ import { ZONE_FILE_COMMANDS } from "./cli-contract.js";
12
+ import { describeRejection, parseInput, readInput } from "./cli-input.js";
13
+ /**
14
+ * Prints `file: N records, no problems`, or one `file:line: message (reason)` line per problem
15
+ * on stderr; `--json` prints `{ valid, records, rejected }` on stdout in both cases, `records`
16
+ * being the count read.
17
+ */
18
+ export default createCommand(ZONE_FILE_COMMANDS.validate, async (ctx) => {
19
+ let input = await readInput(ctx, ctx.arguments.zone);
20
+ if (isFailure(input))
21
+ return input;
22
+ let zone = parseInput(input.data, ctx.flags);
23
+ if (isFailure(zone))
24
+ return zone;
25
+ let { records, rejected } = zone.data;
26
+ if (ctx.flags.json) {
27
+ let report = { valid: rejected.length === 0, records: records.length, rejected };
28
+ ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
29
+ return rejected.length === 0 ? success() : exit(1);
30
+ }
31
+ if (rejected.length === 0) {
32
+ let noun = records.length === 1 ? "record" : "records";
33
+ ctx.stdout(`${input.data.name}: ${records.length} ${noun}, no problems\n`);
34
+ return success();
35
+ }
36
+ for (let rejection of rejected)
37
+ ctx.stderr(`${describeRejection(input.data.name, rejection)}\n`);
38
+ let noun = rejected.length === 1 ? "problem" : "problems";
39
+ return exit(1, `${rejected.length} ${noun} in ${input.data.name}`);
40
+ });