greybull 0.2.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 +111 -39
  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.2.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) {
@@ -311,8 +311,35 @@ function page(title, body) {
311
311
  import fs2 from "fs";
312
312
  import os3 from "os";
313
313
  import path2 from "path";
314
+ import readline2 from "readline/promises";
314
315
  import { spawnSync } from "child_process";
315
- var RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
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
+ ];
316
343
  function buildListRecords(apiUrl, zone, opts) {
317
344
  return buildRequest({
318
345
  apiUrl,
@@ -436,7 +463,6 @@ async function deleteCmd(ctx2, zone, type, name, content, opts) {
436
463
  function runSpec(spec, ctx2) {
437
464
  return performRequest(spec, ctx2.apiKey).catch((err) => fail(err, ctx2));
438
465
  }
439
- var ERR_PREFIX = "; !! ";
440
466
  function renderZoneFile(zone, records) {
441
467
  const rows = records.map((r) => {
442
468
  const content = r.type === "MX" && r.priority !== void 0 ? `${r.priority} ${r.content}` : r.content;
@@ -446,6 +472,10 @@ function renderZoneFile(zone, records) {
446
472
  const ttlW = Math.max(3, ...rows.map((r) => r.ttl.length));
447
473
  const typeW = Math.max(4, ...rows.map((r) => r.type.length));
448
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 -*-",
449
479
  `; Zone: ${zone}`,
450
480
  "; Edit records below, then save and close your editor to apply.",
451
481
  "; Format: NAME TTL TYPE CONTENT (use @ for the zone apex)",
@@ -456,7 +486,7 @@ function renderZoneFile(zone, records) {
456
486
  const body = rows.map(
457
487
  (r) => `${r.name.padEnd(nameW)} ${r.ttl.padEnd(ttlW)} ${r.type.padEnd(typeW)} ${r.content}`
458
488
  );
459
- return [...header, ...body].join("\n") + "\n";
489
+ return [...header, ...body, "; vim: set ft=bindzone:", ""].join("\n");
460
490
  }
461
491
  function parseZoneFile(text) {
462
492
  const recs = [];
@@ -464,23 +494,38 @@ function parseZoneFile(text) {
464
494
  for (let i = 0; i < lines.length; i++) {
465
495
  const trimmed = lines[i].trim();
466
496
  if (!trimmed || trimmed.startsWith(";")) continue;
467
- const m = trimmed.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?([A-Za-z]+)\s+(.+)$/i);
497
+ const m = trimmed.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?([A-Za-z][A-Za-z0-9]*)\s+(.+)$/i);
468
498
  if (!m) {
469
499
  throw new Error(`line ${i + 1}: could not parse "${trimmed}" (expected: NAME TTL TYPE CONTENT)`);
470
500
  }
471
501
  const [, name, ttlStr, typeRaw, content] = m;
472
- const type = typeRaw.toUpperCase();
473
- if (type === "SOA") throw new Error(`line ${i + 1}: SOA is managed automatically \u2014 remove it`);
474
- if (!RECORD_TYPES.includes(type)) throw new Error(`line ${i + 1}: unknown record type "${typeRaw}"`);
475
- recs.push({ name, type, content: content.trim(), ttl: parseInt(ttlStr) });
502
+ recs.push({ name, type: typeRaw.toUpperCase(), content: content.trim(), ttl: parseInt(ttlStr) });
476
503
  }
477
504
  return recs;
478
505
  }
479
- function normalizeBody(text) {
480
- return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith(";")).map((l) => l.replace(/\s+/g, " ")).sort().join("\n");
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
+ };
481
514
  }
482
- function stripErrorLines(text) {
483
- return text.split("\n").filter((l) => !l.startsWith(ERR_PREFIX)).join("\n");
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
+ }
484
529
  }
485
530
  function openInEditor(zone, content) {
486
531
  const file = path2.join(os3.tmpdir(), `greybull-${zone}-${process.pid}.zone`);
@@ -498,6 +543,7 @@ function openInEditor(zone, content) {
498
543
  }
499
544
  }
500
545
  }
546
+ var ABORTED = "Aborted. No changes made.\n";
501
547
  async function editCmd(ctx2, zone) {
502
548
  const res = await apiRequest({
503
549
  apiUrl: ctx2.apiUrl,
@@ -505,27 +551,50 @@ async function editCmd(ctx2, zone) {
505
551
  method: "GET",
506
552
  path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`
507
553
  }).catch((err) => fail(err, ctx2));
508
- const original = renderZoneFile(zone, res.data);
509
- let buffer = original;
510
- let banner = "";
511
- let lastTried = null;
554
+ const originalText = renderZoneFile(zone, res.data);
555
+ let buffer = originalText;
512
556
  for (; ; ) {
513
- const edited = openInEditor(zone, banner + buffer);
557
+ const edited = openInEditor(zone, buffer);
514
558
  if (edited === null) fail(new Error("Could not launch editor. Set $EDITOR or $VISUAL."), ctx2);
515
- buffer = stripErrorLines(edited);
516
- if (normalizeBody(buffer) === normalizeBody(original)) {
517
- process.stdout.write("No changes.\n");
518
- return;
519
- }
520
- if (lastTried !== null && normalizeBody(buffer) === normalizeBody(lastTried)) {
521
- fail(new Error("Aborted \u2014 the reported problem was not fixed."), ctx2);
522
- }
523
559
  let records;
524
560
  try {
525
- records = parseZoneFile(buffer);
561
+ records = parseZoneFile(edited);
526
562
  } catch (err) {
527
- banner = errorBanner(err.message);
528
- lastTried = buffer;
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;
529
598
  continue;
530
599
  }
531
600
  try {
@@ -536,25 +605,28 @@ async function editCmd(ctx2, zone) {
536
605
  path: `/api/v1/dns/zones/${encodeURIComponent(zone)}`,
537
606
  body: { records }
538
607
  });
539
- process.stdout.write(`Applied ${put.data.applied} record(s) to ${zone}.
608
+ process.stdout.write(`Applied. ${zone} now has ${put.data.applied} record(s).
540
609
  `);
541
610
  return;
542
611
  } catch (err) {
543
612
  if (err instanceof ApiError && (err.status === 422 || err.status === 400)) {
544
- banner = errorBanner(err.message);
545
- lastTried = buffer;
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;
546
624
  continue;
547
625
  }
548
626
  fail(err, ctx2);
549
627
  }
550
628
  }
551
629
  }
552
- function errorBanner(message) {
553
- return `${ERR_PREFIX}${message}
554
- ${ERR_PREFIX}Fix and save again, or exit without changes to abort.
555
- ;
556
- `;
557
- }
558
630
 
559
631
  // src/commands/domains.ts
560
632
  async function domainsListCmd(ctx2) {
@@ -595,7 +667,7 @@ async function domainsCheckCmd(ctx2, query, opts) {
595
667
 
596
668
  // src/index.ts
597
669
  var program = new Command();
598
- program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.2.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)");
599
671
  function ctx(requireAuth = true) {
600
672
  const opts = program.opts();
601
673
  const apiUrl = resolveApiUrl(opts.apiUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "greybull",
3
- "version": "0.2.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": {