greybull 0.2.0 → 0.2.2

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 +119 -40
  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.2";
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,45 @@ 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
+ }
529
+ }
530
+ function editorArgv(cmd, args, file) {
531
+ const base = path2.basename(cmd).replace(/\.exe$/i, "").toLowerCase();
532
+ if (["vim", "nvim", "gvim", "mvim"].includes(base)) {
533
+ return [...args, "-c", "syntax enable", "-c", "set ft=bindzone", file];
534
+ }
535
+ return [...args, file];
484
536
  }
485
537
  function openInEditor(zone, content) {
486
538
  const file = path2.join(os3.tmpdir(), `greybull-${zone}-${process.pid}.zone`);
@@ -488,7 +540,7 @@ function openInEditor(zone, content) {
488
540
  const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
489
541
  const [cmd, ...args] = editor.split(" ");
490
542
  try {
491
- const r = spawnSync(cmd, [...args, file], { stdio: "inherit" });
543
+ const r = spawnSync(cmd, editorArgv(cmd, args, file), { stdio: "inherit" });
492
544
  if (r.error) return null;
493
545
  return fs2.readFileSync(file, "utf8");
494
546
  } finally {
@@ -498,6 +550,7 @@ function openInEditor(zone, content) {
498
550
  }
499
551
  }
500
552
  }
553
+ var ABORTED = "Aborted. No changes made.\n";
501
554
  async function editCmd(ctx2, zone) {
502
555
  const res = await apiRequest({
503
556
  apiUrl: ctx2.apiUrl,
@@ -505,27 +558,50 @@ async function editCmd(ctx2, zone) {
505
558
  method: "GET",
506
559
  path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`
507
560
  }).catch((err) => fail(err, ctx2));
508
- const original = renderZoneFile(zone, res.data);
509
- let buffer = original;
510
- let banner = "";
511
- let lastTried = null;
561
+ const originalText = renderZoneFile(zone, res.data);
562
+ let buffer = originalText;
512
563
  for (; ; ) {
513
- const edited = openInEditor(zone, banner + buffer);
564
+ const edited = openInEditor(zone, buffer);
514
565
  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
566
  let records;
524
567
  try {
525
- records = parseZoneFile(buffer);
568
+ records = parseZoneFile(edited);
526
569
  } catch (err) {
527
- banner = errorBanner(err.message);
528
- lastTried = buffer;
570
+ process.stdout.write(`
571
+ Error parsing the zone: ${err.message}
572
+ `);
573
+ const c2 = await prompt(
574
+ "Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
575
+ ["e", "r", "a"],
576
+ "e",
577
+ "a"
578
+ );
579
+ if (c2 === "a") return void process.stdout.write(ABORTED);
580
+ buffer = c2 === "r" ? originalText : edited;
581
+ continue;
582
+ }
583
+ const { added, removed } = zoneDiff(originalText, edited);
584
+ if (added.length === 0 && removed.length === 0) {
585
+ return void process.stdout.write("No changes.\n");
586
+ }
587
+ process.stdout.write("\nChanges to apply:\n");
588
+ removed.forEach((l) => process.stdout.write(` - ${l}
589
+ `));
590
+ added.forEach((l) => process.stdout.write(` + ${l}
591
+ `));
592
+ process.stdout.write("\n");
593
+ const c = await prompt(
594
+ "(a)pply these changes, (e)dit again, (r)etry with original zone, (q)uit? [a] ",
595
+ ["a", "e", "r", "q"],
596
+ "a"
597
+ );
598
+ if (c === "q") return void process.stdout.write(ABORTED);
599
+ if (c === "e") {
600
+ buffer = edited;
601
+ continue;
602
+ }
603
+ if (c === "r") {
604
+ buffer = originalText;
529
605
  continue;
530
606
  }
531
607
  try {
@@ -536,25 +612,28 @@ async function editCmd(ctx2, zone) {
536
612
  path: `/api/v1/dns/zones/${encodeURIComponent(zone)}`,
537
613
  body: { records }
538
614
  });
539
- process.stdout.write(`Applied ${put.data.applied} record(s) to ${zone}.
615
+ process.stdout.write(`Applied. ${zone} now has ${put.data.applied} record(s).
540
616
  `);
541
617
  return;
542
618
  } catch (err) {
543
619
  if (err instanceof ApiError && (err.status === 422 || err.status === 400)) {
544
- banner = errorBanner(err.message);
545
- lastTried = buffer;
620
+ process.stdout.write(`
621
+ The DNS server rejected the zone: ${err.message}
622
+ `);
623
+ const c2 = await prompt(
624
+ "Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
625
+ ["e", "r", "a"],
626
+ "e",
627
+ "a"
628
+ );
629
+ if (c2 === "a") return void process.stdout.write(ABORTED);
630
+ buffer = c2 === "r" ? originalText : edited;
546
631
  continue;
547
632
  }
548
633
  fail(err, ctx2);
549
634
  }
550
635
  }
551
636
  }
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
637
 
559
638
  // src/commands/domains.ts
560
639
  async function domainsListCmd(ctx2) {
@@ -595,7 +674,7 @@ async function domainsCheckCmd(ctx2, query, opts) {
595
674
 
596
675
  // src/index.ts
597
676
  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)");
677
+ program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.2.2").option("--json", "output raw JSON", false).option("--api-url <url>", "portal base URL (or set GREYBULL_API_URL)");
599
678
  function ctx(requireAuth = true) {
600
679
  const opts = program.opts();
601
680
  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.2",
4
4
  "description": "Command-line tool for the Greybull portal — manage DNS records and domains",
5
5
  "type": "module",
6
6
  "bin": {