greybull 0.1.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/index.js +216 -10
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ var ApiError = class extends Error {
94
94
  this.status = status;
95
95
  }
96
96
  };
97
- var VERSION = "0.1.0";
97
+ var VERSION = "0.2.1";
98
98
  function buildRequest(opts) {
99
99
  const url = new URL(opts.apiUrl.replace(/\/+$/, "") + opts.path);
100
100
  if (opts.query) {
@@ -148,13 +148,21 @@ async function apiRequest(opts) {
148
148
  }
149
149
 
150
150
  // src/output.ts
151
- function formatTable(headers, rows) {
151
+ function formatTable(headers, rows, opts = {}) {
152
152
  if (rows.length === 0) return "(none)";
153
+ const max = opts.max ?? [];
154
+ const clip = (s, i) => {
155
+ const w = max[i];
156
+ return w && s.length > w ? s.slice(0, w - 1) + "\u2026" : s;
157
+ };
158
+ const hdr = headers.map((h, i) => clip(h, i));
159
+ const cells = rows.map((r) => headers.map((_, i) => clip(String(r[i] ?? ""), i)));
160
+ const last = headers.length - 1;
153
161
  const widths = headers.map(
154
- (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
162
+ (_, i) => Math.max(hdr[i].length, ...cells.map((r) => r[i].length))
155
163
  );
156
- const line = (cells) => cells.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd();
157
- return [line(headers), ...rows.map(line)].join("\n");
164
+ const line = (row) => row.map((c, i) => i === last ? c : c.padEnd(widths[i])).join(" ").trimEnd();
165
+ return [line(hdr), ...cells.map(line)].join("\n");
158
166
  }
159
167
  function fail(error, opts) {
160
168
  let message = error instanceof Error ? error.message : String(error);
@@ -300,7 +308,38 @@ function page(title, body) {
300
308
  }
301
309
 
302
310
  // src/commands/dns.ts
303
- var RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
311
+ import fs2 from "fs";
312
+ import os3 from "os";
313
+ import path2 from "path";
314
+ import readline2 from "readline/promises";
315
+ import { spawnSync } from "child_process";
316
+ var RECORD_TYPES = [
317
+ "A",
318
+ "AAAA",
319
+ "ALIAS",
320
+ "CAA",
321
+ "CERT",
322
+ "CNAME",
323
+ "DNSKEY",
324
+ "DS",
325
+ "HINFO",
326
+ "HTTPS",
327
+ "LOC",
328
+ "MX",
329
+ "NAPTR",
330
+ "NS",
331
+ "OPENPGPKEY",
332
+ "PTR",
333
+ "RP",
334
+ "SMIMEA",
335
+ "SPF",
336
+ "SRV",
337
+ "SSHFP",
338
+ "SVCB",
339
+ "TLSA",
340
+ "TXT",
341
+ "URI"
342
+ ];
304
343
  function buildListRecords(apiUrl, zone, opts) {
305
344
  return buildRequest({
306
345
  apiUrl,
@@ -368,11 +407,11 @@ async function listCmd(ctx2, zone, opts) {
368
407
  const rows = res.data.map((r) => [
369
408
  r.name,
370
409
  r.type,
371
- r.content,
372
410
  String(r.ttl),
373
- r.priority !== void 0 ? String(r.priority) : ""
411
+ r.priority !== void 0 ? String(r.priority) : "",
412
+ r.content
374
413
  ]);
375
- process.stdout.write(formatTable(["NAME", "TYPE", "CONTENT", "TTL", "PRIO"], rows) + "\n");
414
+ process.stdout.write(formatTable(["NAME", "TYPE", "TTL", "PRIO", "CONTENT"], rows, { max: [48] }) + "\n");
376
415
  }
377
416
  async function addCmd(ctx2, zone, type, name, content, opts) {
378
417
  const upperType = assertType(type, ctx2);
@@ -424,6 +463,170 @@ async function deleteCmd(ctx2, zone, type, name, content, opts) {
424
463
  function runSpec(spec, ctx2) {
425
464
  return performRequest(spec, ctx2.apiKey).catch((err) => fail(err, ctx2));
426
465
  }
466
+ function renderZoneFile(zone, records) {
467
+ const rows = records.map((r) => {
468
+ const content = r.type === "MX" && r.priority !== void 0 ? `${r.priority} ${r.content}` : r.content;
469
+ return { name: r.name, ttl: String(r.ttl), type: r.type, content };
470
+ }).sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
471
+ const nameW = Math.max(1, ...rows.map((r) => r.name.length));
472
+ const ttlW = Math.max(3, ...rows.map((r) => r.ttl.length));
473
+ const typeW = Math.max(4, ...rows.map((r) => r.type.length));
474
+ const header = [
475
+ // Editor mode hints (comments, so the parser ignores them): Emacs dns-mode
476
+ // on line 1, Vim bindzone via the modeline at the end. Together with the
477
+ // .zone temp-file extension these give BIND zone syntax highlighting.
478
+ "; -*- mode: dns -*-",
479
+ `; Zone: ${zone}`,
480
+ "; Edit records below, then save and close your editor to apply.",
481
+ "; Format: NAME TTL TYPE CONTENT (use @ for the zone apex)",
482
+ "; TXT values keep their quotes; MX/SRV keep priority in CONTENT.",
483
+ "; SOA and the apex NS records are managed for you and not shown.",
484
+ ";"
485
+ ];
486
+ const body = rows.map(
487
+ (r) => `${r.name.padEnd(nameW)} ${r.ttl.padEnd(ttlW)} ${r.type.padEnd(typeW)} ${r.content}`
488
+ );
489
+ return [...header, ...body, "; vim: set ft=bindzone:", ""].join("\n");
490
+ }
491
+ function parseZoneFile(text) {
492
+ const recs = [];
493
+ const lines = text.split("\n");
494
+ for (let i = 0; i < lines.length; i++) {
495
+ const trimmed = lines[i].trim();
496
+ if (!trimmed || trimmed.startsWith(";")) continue;
497
+ const m = trimmed.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?([A-Za-z][A-Za-z0-9]*)\s+(.+)$/i);
498
+ if (!m) {
499
+ throw new Error(`line ${i + 1}: could not parse "${trimmed}" (expected: NAME TTL TYPE CONTENT)`);
500
+ }
501
+ const [, name, ttlStr, typeRaw, content] = m;
502
+ recs.push({ name, type: typeRaw.toUpperCase(), content: content.trim(), ttl: parseInt(ttlStr) });
503
+ }
504
+ return recs;
505
+ }
506
+ function zoneDiff(before, after) {
507
+ const norm = (t) => t.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith(";")).map((l) => l.replace(/\s+/g, " "));
508
+ const a = new Set(norm(before));
509
+ const b = new Set(norm(after));
510
+ return {
511
+ removed: [...a].filter((l) => !b.has(l)).sort(),
512
+ added: [...b].filter((l) => !a.has(l)).sort()
513
+ };
514
+ }
515
+ async function prompt(question, valid, def, nonTtyDefault = def) {
516
+ if (!process.stdin.isTTY) return nonTtyDefault;
517
+ const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
518
+ try {
519
+ for (; ; ) {
520
+ const ans = (await rl.question(question)).trim().toLowerCase();
521
+ if (!ans) return def;
522
+ if (valid.includes(ans[0])) return ans[0];
523
+ process.stdout.write(`Please choose one of: ${valid.join(", ")}
524
+ `);
525
+ }
526
+ } finally {
527
+ rl.close();
528
+ }
529
+ }
530
+ function openInEditor(zone, content) {
531
+ const file = path2.join(os3.tmpdir(), `greybull-${zone}-${process.pid}.zone`);
532
+ fs2.writeFileSync(file, content);
533
+ const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
534
+ const [cmd, ...args] = editor.split(" ");
535
+ try {
536
+ const r = spawnSync(cmd, [...args, file], { stdio: "inherit" });
537
+ if (r.error) return null;
538
+ return fs2.readFileSync(file, "utf8");
539
+ } finally {
540
+ try {
541
+ fs2.unlinkSync(file);
542
+ } catch {
543
+ }
544
+ }
545
+ }
546
+ var ABORTED = "Aborted. No changes made.\n";
547
+ async function editCmd(ctx2, zone) {
548
+ const res = await apiRequest({
549
+ apiUrl: ctx2.apiUrl,
550
+ apiKey: ctx2.apiKey,
551
+ method: "GET",
552
+ path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`
553
+ }).catch((err) => fail(err, ctx2));
554
+ const originalText = renderZoneFile(zone, res.data);
555
+ let buffer = originalText;
556
+ for (; ; ) {
557
+ const edited = openInEditor(zone, buffer);
558
+ if (edited === null) fail(new Error("Could not launch editor. Set $EDITOR or $VISUAL."), ctx2);
559
+ let records;
560
+ try {
561
+ records = parseZoneFile(edited);
562
+ } catch (err) {
563
+ process.stdout.write(`
564
+ Error parsing the zone: ${err.message}
565
+ `);
566
+ const c2 = await prompt(
567
+ "Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
568
+ ["e", "r", "a"],
569
+ "e",
570
+ "a"
571
+ );
572
+ if (c2 === "a") return void process.stdout.write(ABORTED);
573
+ buffer = c2 === "r" ? originalText : edited;
574
+ continue;
575
+ }
576
+ const { added, removed } = zoneDiff(originalText, edited);
577
+ if (added.length === 0 && removed.length === 0) {
578
+ return void process.stdout.write("No changes.\n");
579
+ }
580
+ process.stdout.write("\nChanges to apply:\n");
581
+ removed.forEach((l) => process.stdout.write(` - ${l}
582
+ `));
583
+ added.forEach((l) => process.stdout.write(` + ${l}
584
+ `));
585
+ process.stdout.write("\n");
586
+ const c = await prompt(
587
+ "(a)pply these changes, (e)dit again, (r)etry with original zone, (q)uit? [a] ",
588
+ ["a", "e", "r", "q"],
589
+ "a"
590
+ );
591
+ if (c === "q") return void process.stdout.write(ABORTED);
592
+ if (c === "e") {
593
+ buffer = edited;
594
+ continue;
595
+ }
596
+ if (c === "r") {
597
+ buffer = originalText;
598
+ continue;
599
+ }
600
+ try {
601
+ const put = await apiRequest({
602
+ apiUrl: ctx2.apiUrl,
603
+ apiKey: ctx2.apiKey,
604
+ method: "PUT",
605
+ path: `/api/v1/dns/zones/${encodeURIComponent(zone)}`,
606
+ body: { records }
607
+ });
608
+ process.stdout.write(`Applied. ${zone} now has ${put.data.applied} record(s).
609
+ `);
610
+ return;
611
+ } catch (err) {
612
+ if (err instanceof ApiError && (err.status === 422 || err.status === 400)) {
613
+ process.stdout.write(`
614
+ The DNS server rejected the zone: ${err.message}
615
+ `);
616
+ const c2 = await prompt(
617
+ "Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
618
+ ["e", "r", "a"],
619
+ "e",
620
+ "a"
621
+ );
622
+ if (c2 === "a") return void process.stdout.write(ABORTED);
623
+ buffer = c2 === "r" ? originalText : edited;
624
+ continue;
625
+ }
626
+ fail(err, ctx2);
627
+ }
628
+ }
629
+ }
427
630
 
428
631
  // src/commands/domains.ts
429
632
  async function domainsListCmd(ctx2) {
@@ -464,7 +667,7 @@ async function domainsCheckCmd(ctx2, query, opts) {
464
667
 
465
668
  // src/index.ts
466
669
  var program = new Command();
467
- program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.1.0").option("--json", "output raw JSON", false).option("--api-url <url>", "portal base URL (or set GREYBULL_API_URL)");
670
+ program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.2.1").option("--json", "output raw JSON", false).option("--api-url <url>", "portal base URL (or set GREYBULL_API_URL)");
468
671
  function ctx(requireAuth = true) {
469
672
  const opts = program.opts();
470
673
  const apiUrl = resolveApiUrl(opts.apiUrl);
@@ -506,6 +709,9 @@ dns.command("update <zone> <type> <name> <old-content> <new-content>").descripti
506
709
  dns.command("delete <zone> <type> <name> <content>").description("Delete one specific record (content required \u2014 sibling-safe)").option("--priority <n>", "priority (MX/SRV)").action(async (zone, type, name, content, opts) => {
507
710
  await deleteCmd(ctx(), zone, type, name, content, opts);
508
711
  });
712
+ dns.command("edit <zone>").description("Edit the whole zone in $EDITOR (like pdnsutil edit-zone)").action(async (zone) => {
713
+ await editCmd(ctx(), zone);
714
+ });
509
715
  var domains = program.command("domains").description("Manage domains");
510
716
  domains.command("list").description("List your registered domains").action(async () => {
511
717
  await domainsListCmd(ctx());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "greybull",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Command-line tool for the Greybull portal — manage DNS records and domains",
5
5
  "type": "module",
6
6
  "bin": {