@sdxc/zone-file 2026.10.25 → 2026.11.10
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 +19 -0
- package/dist/cli-contract.d.ts +33 -0
- package/dist/cli-contract.js +81 -0
- package/dist/cli-format.d.ts +21 -0
- package/dist/cli-format.js +138 -0
- package/dist/cli-input.d.ts +47 -0
- package/dist/cli-input.js +73 -0
- package/dist/cli-parse.d.ts +21 -0
- package/dist/cli-parse.js +43 -0
- package/dist/cli-validate.d.ts +21 -0
- package/dist/cli-validate.js +40 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +19 -0
- package/package.json +8 -3
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>`
|
|
@@ -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
|
+
});
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-zone-file` command-line program: the contract in `cli-contract.ts` with each command
|
|
4
|
+
* mapped onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the
|
|
5
|
+
* zone file has entries it could not use, 2 usage or unreadable input.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
/** The `sdxc-zone-file` 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-zone-file — read, check and write RFC 1035 DNS zone files";
|
|
13
|
+
readonly validate: import("@sdxc/cli").Command<{
|
|
14
|
+
readonly zone: import("@sdxc/cli").Argument<string>;
|
|
15
|
+
}, {
|
|
16
|
+
readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
|
|
17
|
+
readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
|
|
18
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
19
|
+
}>;
|
|
20
|
+
readonly parse: import("@sdxc/cli").Command<{
|
|
21
|
+
readonly zone: import("@sdxc/cli").Argument<string>;
|
|
22
|
+
}, {
|
|
23
|
+
readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
|
|
24
|
+
readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
|
|
25
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
26
|
+
}>;
|
|
27
|
+
readonly format: import("@sdxc/cli").Command<{
|
|
28
|
+
readonly input: import("@sdxc/cli").Argument<string>;
|
|
29
|
+
}, {
|
|
30
|
+
readonly origin: import("@sdxc/cli").ValueFlag<string, true, false, false>;
|
|
31
|
+
readonly ttl: import("@sdxc/cli").ValueFlag<number, false, false, false>;
|
|
32
|
+
readonly relative: import("@sdxc/cli").Flag<boolean>;
|
|
33
|
+
}>;
|
|
34
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>, []>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-zone-file` command-line program: the contract in `cli-contract.ts` with each command
|
|
4
|
+
* mapped onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the
|
|
5
|
+
* zone file has entries it could not use, 2 usage or unreadable input.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import { createCLI, lazy } from "@sdxc/cli";
|
|
11
|
+
import { ZONE_FILE_COMMANDS } from "./cli-contract.js";
|
|
12
|
+
/** The `sdxc-zone-file` program, run by the bin entry below and by tests through `invoke()`. */
|
|
13
|
+
export const cli = createCLI({ name: "sdxc-zone-file", commands: ZONE_FILE_COMMANDS });
|
|
14
|
+
cli.map(ZONE_FILE_COMMANDS.validate, lazy(() => import("./cli-validate.js")));
|
|
15
|
+
cli.map(ZONE_FILE_COMMANDS.parse, lazy(() => import("./cli-parse.js")));
|
|
16
|
+
cli.map(ZONE_FILE_COMMANDS.format, lazy(() => import("./cli-format.js")));
|
|
17
|
+
if (import.meta.main) {
|
|
18
|
+
process.exit(await cli.run(process.argv.slice(2)));
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/zone-file",
|
|
3
|
-
"version": "2026.10
|
|
3
|
+
"version": "2026.11.10",
|
|
4
4
|
"description": "Read and write RFC 1035 DNS zone files, with the record data codec for every typed record",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sdxc-zone-file": "dist/cli.js"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./dist/index.js"
|
|
9
12
|
},
|
|
10
13
|
"dependencies": {
|
|
11
|
-
"@sdxc/
|
|
14
|
+
"@sdxc/cli": "2026.11.10",
|
|
15
|
+
"@sdxc/result": "2026.10.13",
|
|
16
|
+
"remix": "3.2.0"
|
|
12
17
|
},
|
|
13
|
-
"gitHead": "
|
|
18
|
+
"gitHead": "6191b60a13885b1d826884d574c4a393f9b8eeee",
|
|
14
19
|
"publishConfig": {
|
|
15
20
|
"access": "public"
|
|
16
21
|
},
|