greybull 0.1.0 → 0.2.0

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 +143 -9
  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.0";
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,6 +308,10 @@ function page(title, body) {
300
308
  }
301
309
 
302
310
  // src/commands/dns.ts
311
+ import fs2 from "fs";
312
+ import os3 from "os";
313
+ import path2 from "path";
314
+ import { spawnSync } from "child_process";
303
315
  var RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
304
316
  function buildListRecords(apiUrl, zone, opts) {
305
317
  return buildRequest({
@@ -368,11 +380,11 @@ async function listCmd(ctx2, zone, opts) {
368
380
  const rows = res.data.map((r) => [
369
381
  r.name,
370
382
  r.type,
371
- r.content,
372
383
  String(r.ttl),
373
- r.priority !== void 0 ? String(r.priority) : ""
384
+ r.priority !== void 0 ? String(r.priority) : "",
385
+ r.content
374
386
  ]);
375
- process.stdout.write(formatTable(["NAME", "TYPE", "CONTENT", "TTL", "PRIO"], rows) + "\n");
387
+ process.stdout.write(formatTable(["NAME", "TYPE", "TTL", "PRIO", "CONTENT"], rows, { max: [48] }) + "\n");
376
388
  }
377
389
  async function addCmd(ctx2, zone, type, name, content, opts) {
378
390
  const upperType = assertType(type, ctx2);
@@ -424,6 +436,125 @@ async function deleteCmd(ctx2, zone, type, name, content, opts) {
424
436
  function runSpec(spec, ctx2) {
425
437
  return performRequest(spec, ctx2.apiKey).catch((err) => fail(err, ctx2));
426
438
  }
439
+ var ERR_PREFIX = "; !! ";
440
+ function renderZoneFile(zone, records) {
441
+ const rows = records.map((r) => {
442
+ const content = r.type === "MX" && r.priority !== void 0 ? `${r.priority} ${r.content}` : r.content;
443
+ return { name: r.name, ttl: String(r.ttl), type: r.type, content };
444
+ }).sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
445
+ const nameW = Math.max(1, ...rows.map((r) => r.name.length));
446
+ const ttlW = Math.max(3, ...rows.map((r) => r.ttl.length));
447
+ const typeW = Math.max(4, ...rows.map((r) => r.type.length));
448
+ const header = [
449
+ `; Zone: ${zone}`,
450
+ "; Edit records below, then save and close your editor to apply.",
451
+ "; Format: NAME TTL TYPE CONTENT (use @ for the zone apex)",
452
+ "; TXT values keep their quotes; MX/SRV keep priority in CONTENT.",
453
+ "; SOA and the apex NS records are managed for you and not shown.",
454
+ ";"
455
+ ];
456
+ const body = rows.map(
457
+ (r) => `${r.name.padEnd(nameW)} ${r.ttl.padEnd(ttlW)} ${r.type.padEnd(typeW)} ${r.content}`
458
+ );
459
+ return [...header, ...body].join("\n") + "\n";
460
+ }
461
+ function parseZoneFile(text) {
462
+ const recs = [];
463
+ const lines = text.split("\n");
464
+ for (let i = 0; i < lines.length; i++) {
465
+ const trimmed = lines[i].trim();
466
+ if (!trimmed || trimmed.startsWith(";")) continue;
467
+ const m = trimmed.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?([A-Za-z]+)\s+(.+)$/i);
468
+ if (!m) {
469
+ throw new Error(`line ${i + 1}: could not parse "${trimmed}" (expected: NAME TTL TYPE CONTENT)`);
470
+ }
471
+ 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) });
476
+ }
477
+ return recs;
478
+ }
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");
481
+ }
482
+ function stripErrorLines(text) {
483
+ return text.split("\n").filter((l) => !l.startsWith(ERR_PREFIX)).join("\n");
484
+ }
485
+ function openInEditor(zone, content) {
486
+ const file = path2.join(os3.tmpdir(), `greybull-${zone}-${process.pid}.zone`);
487
+ fs2.writeFileSync(file, content);
488
+ const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
489
+ const [cmd, ...args] = editor.split(" ");
490
+ try {
491
+ const r = spawnSync(cmd, [...args, file], { stdio: "inherit" });
492
+ if (r.error) return null;
493
+ return fs2.readFileSync(file, "utf8");
494
+ } finally {
495
+ try {
496
+ fs2.unlinkSync(file);
497
+ } catch {
498
+ }
499
+ }
500
+ }
501
+ async function editCmd(ctx2, zone) {
502
+ const res = await apiRequest({
503
+ apiUrl: ctx2.apiUrl,
504
+ apiKey: ctx2.apiKey,
505
+ method: "GET",
506
+ path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`
507
+ }).catch((err) => fail(err, ctx2));
508
+ const original = renderZoneFile(zone, res.data);
509
+ let buffer = original;
510
+ let banner = "";
511
+ let lastTried = null;
512
+ for (; ; ) {
513
+ const edited = openInEditor(zone, banner + buffer);
514
+ 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
+ let records;
524
+ try {
525
+ records = parseZoneFile(buffer);
526
+ } catch (err) {
527
+ banner = errorBanner(err.message);
528
+ lastTried = buffer;
529
+ continue;
530
+ }
531
+ try {
532
+ const put = await apiRequest({
533
+ apiUrl: ctx2.apiUrl,
534
+ apiKey: ctx2.apiKey,
535
+ method: "PUT",
536
+ path: `/api/v1/dns/zones/${encodeURIComponent(zone)}`,
537
+ body: { records }
538
+ });
539
+ process.stdout.write(`Applied ${put.data.applied} record(s) to ${zone}.
540
+ `);
541
+ return;
542
+ } catch (err) {
543
+ if (err instanceof ApiError && (err.status === 422 || err.status === 400)) {
544
+ banner = errorBanner(err.message);
545
+ lastTried = buffer;
546
+ continue;
547
+ }
548
+ fail(err, ctx2);
549
+ }
550
+ }
551
+ }
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
+ }
427
558
 
428
559
  // src/commands/domains.ts
429
560
  async function domainsListCmd(ctx2) {
@@ -464,7 +595,7 @@ async function domainsCheckCmd(ctx2, query, opts) {
464
595
 
465
596
  // src/index.ts
466
597
  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)");
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)");
468
599
  function ctx(requireAuth = true) {
469
600
  const opts = program.opts();
470
601
  const apiUrl = resolveApiUrl(opts.apiUrl);
@@ -506,6 +637,9 @@ dns.command("update <zone> <type> <name> <old-content> <new-content>").descripti
506
637
  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
638
  await deleteCmd(ctx(), zone, type, name, content, opts);
508
639
  });
640
+ dns.command("edit <zone>").description("Edit the whole zone in $EDITOR (like pdnsutil edit-zone)").action(async (zone) => {
641
+ await editCmd(ctx(), zone);
642
+ });
509
643
  var domains = program.command("domains").description("Manage domains");
510
644
  domains.command("list").description("List your registered domains").action(async () => {
511
645
  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.0",
4
4
  "description": "Command-line tool for the Greybull portal — manage DNS records and domains",
5
5
  "type": "module",
6
6
  "bin": {