[object Object]

← back to Cncp Mcp

add 5-min cncp-refresh + 1-hr GoDaddy --full sync (update-only, no bloat)

f7b88e58cdb27a767a7282f1066a50b75306b01e · 2026-05-10 22:11:47 -0700 · Steve Abrams

Files touched

Diff

commit f7b88e58cdb27a767a7282f1066a50b75306b01e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 22:11:47 2026 -0700

    add 5-min cncp-refresh + 1-hr GoDaddy --full sync (update-only, no bloat)
---
 scripts/cncp-refresh.mjs | 254 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 254 insertions(+)

diff --git a/scripts/cncp-refresh.mjs b/scripts/cncp-refresh.mjs
new file mode 100755
index 0000000..071427b
--- /dev/null
+++ b/scripts/cncp-refresh.mjs
@@ -0,0 +1,254 @@
+#!/usr/bin/env node
+/**
+ * cncp-refresh.mjs — keep ~/cncp-starter/cncp-config.json domains[] honest.
+ *
+ * Two modes:
+ *   --status-only  (default) → no API calls. Recompute `status` from `expires`
+ *                              for all 281 domains. Atomic write. < 100ms.
+ *   --full                   → fetch ALL domains from GoDaddy /v1/domains,
+ *                              merge into cncp-config.json (preserving manual
+ *                              fields: note, suggestion, suggestionReason, dnsHost),
+ *                              then run status recompute.
+ *
+ * Why two modes:
+ *   - status-only is safe to run every 5 min (no rate-limit risk, no network)
+ *   - --full hits GoDaddy ~once per 5min the user has 281 active domains; their
+ *     rate limit is generous, but running it every 5 min × 288/day is overkill.
+ *     Recommended: --full every hour, --status-only every 5 min.
+ *
+ * Manual fields PRESERVED on merge (--full):
+ *   note, suggestion, suggestionReason, dnsHost, statusColor, statusLabel
+ *
+ * Fields OVERWRITTEN from GoDaddy:
+ *   expires, status (then recomputed), ns, reg
+ *
+ * Atomic write: writes cncp-config.json.tmp then renames. Backs up to
+ * cncp-config.json.bak.<ts> on every change.
+ */
+
+import { promises as fs } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+const CNCP_DIR = process.env.CNCP_DIR || join(homedir(), "cncp-starter");
+const CONFIG = join(CNCP_DIR, "cncp-config.json");
+
+// Recompute `status` from `expires` (canonical mapping).
+function statusFor(expiresStr) {
+  if (!expiresStr) return { status: "unknown", statusColor: "#6b7280", statusLabel: "Unknown" };
+  const exp = new Date(expiresStr + "T00:00:00Z");
+  if (Number.isNaN(exp.getTime())) {
+    return { status: "unknown", statusColor: "#6b7280", statusLabel: "Unknown" };
+  }
+  const today = new Date();
+  today.setUTCHours(0, 0, 0, 0);
+  const days = Math.round((exp - today) / 86400000);
+  if (days < 0)      return { status: "expired",       statusColor: "#dc2626", statusLabel: "Expired" };
+  if (days <= 7)     return { status: "expiring-soon", statusColor: "#ea580c", statusLabel: "Expiring soon" };
+  if (days <= 30)    return { status: "expiring",      statusColor: "#f59e0b", statusLabel: "Expiring" };
+  if (days <= 90)    return { status: "watch",         statusColor: "#facc15", statusLabel: "Watch" };
+  return                    { status: "live",          statusColor: "#34d399", statusLabel: "Live" };
+}
+
+async function loadConfig() {
+  return JSON.parse(await fs.readFile(CONFIG, "utf-8"));
+}
+
+async function saveConfig(cfg) {
+  // Backup
+  try {
+    const current = await fs.readFile(CONFIG, "utf-8");
+    await fs.writeFile(`${CONFIG}.bak.${Date.now()}`, current, "utf-8");
+  } catch {}
+  // Atomic
+  const tmp = `${CONFIG}.tmp.${process.pid}.${Date.now()}`;
+  await fs.writeFile(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
+  await fs.rename(tmp, CONFIG);
+}
+
+// ─── --status-only mode ──────────────────────────────────────────────────
+
+async function recomputeStatus() {
+  const cfg = await loadConfig();
+  const domains = cfg.domains || [];
+  let changed = 0;
+  const transitions = { toExpired: 0, toExpiringSoon: 0, toExpiring: 0, toWatch: 0, toLive: 0 };
+
+  for (const d of domains) {
+    const next = statusFor(d.expires);
+    if (d.status !== next.status) {
+      changed++;
+      const key = "to" + next.status.replace(/(^|-)([a-z])/g, (_,a,b)=>b.toUpperCase());
+      if (transitions[key] !== undefined) transitions[key]++;
+      d.status = next.status;
+      d.statusColor = next.statusColor;
+      d.statusLabel = next.statusLabel;
+    }
+  }
+
+  if (changed > 0) {
+    await saveConfig(cfg);
+  }
+
+  return { mode: "status-only", total: domains.length, changed, transitions };
+}
+
+// ─── --full mode (GoDaddy fetch) ─────────────────────────────────────────
+
+async function fetchGoDaddyDomains() {
+  // Load API creds from secrets manager .env
+  let key = process.env.GODADDY_API_KEY;
+  let secret = process.env.GODADDY_API_SECRET;
+  if (!key || !secret) {
+    // Try secrets-manager .env file
+    try {
+      const envText = await fs.readFile(
+        join(homedir(), "Projects/secrets-manager/.env"),
+        "utf-8"
+      );
+      for (const line of envText.split("\n")) {
+        const m = line.match(/^GODADDY_API_(KEY|SECRET)=(.+)$/);
+        if (!m) continue;
+        const val = m[2].replace(/^["']|["']$/g, "");
+        if (m[1] === "KEY") key = val;
+        else secret = val;
+      }
+    } catch {}
+  }
+  if (!key || !secret) {
+    throw new Error(
+      "GoDaddy API creds not found. Set GODADDY_API_KEY and GODADDY_API_SECRET, " +
+      "or place them in ~/Projects/secrets-manager/.env."
+    );
+  }
+
+  const all = [];
+  let marker = null;
+  for (let page = 0; page < 20; page++) {  // safety cap
+    const url = new URL("https://api.godaddy.com/v1/domains");
+    url.searchParams.set("limit", "1000");
+    if (marker) url.searchParams.set("marker", marker);
+    const res = await fetch(url, {
+      headers: {
+        Authorization: `sso-key ${key}:${secret}`,
+        Accept: "application/json",
+      },
+    });
+    if (!res.ok) {
+      const body = await res.text();
+      throw new Error(
+        `GoDaddy /v1/domains → ${res.status} ${res.statusText}: ${body.slice(0, 300)}`
+      );
+    }
+    const page_data = await res.json();
+    if (!Array.isArray(page_data) || page_data.length === 0) break;
+    all.push(...page_data);
+    // Marker pagination: marker is the last domain name returned
+    if (page_data.length < 1000) break;
+    marker = page_data[page_data.length - 1].domain;
+  }
+  return all;
+}
+
+async function fullRefresh({ includeNew = false } = {}) {
+  const cfg = await loadConfig();
+  const existing = cfg.domains || [];
+  const byName = new Map(existing.map(d => [d.name.toLowerCase(), d]));
+
+  const fresh = await fetchGoDaddyDomains();
+  const freshByName = new Map(fresh.map(g => [g.domain.toLowerCase(), g]));
+  const seenInBoth = new Set();
+  let updated = 0;
+  let skippedNotInCNCP = 0;
+
+  // UPDATE existing CNCP entries from GoDaddy. Never add new ones unless --include-new.
+  for (const entry of existing) {
+    const name = entry.name.toLowerCase();
+    const gd = freshByName.get(name);
+    if (!gd) continue;
+    seenInBoth.add(name);
+    updated++;
+    entry.reg = "GoDaddy";
+    entry.expires = gd.expires ? gd.expires.slice(0, 10) : entry.expires;
+    if (gd.nameServers && gd.nameServers.length) {
+      entry.ns = gd.nameServers.join(",");
+    }
+    if (gd.locked != null) entry.locked = gd.locked;
+    if (gd.privacy != null) entry.privacy = gd.privacy;
+    if (gd.renewAuto != null) {
+      entry.note = (entry.note || "").replace(/\s*Auto-renew:\s*(ON|OFF)/g, "").trim();
+      entry.note = (entry.note ? entry.note + " " : "") + `Auto-renew: ${gd.renewAuto ? "ON" : "OFF"}`;
+    }
+    // PRESERVE: suggestion, suggestionReason, dnsHost, ip
+  }
+
+  // Flag CNCP-tracked GoDaddy entries that GoDaddy no longer returns (transferred / dropped).
+  let orphaned = 0;
+  for (const d of existing) {
+    if (d.reg === "GoDaddy" && !seenInBoth.has(d.name.toLowerCase())) {
+      orphaned++;
+      if (!(d.note || "").includes("[GD-orphan")) {
+        d.note = (d.note || "") + " [GD-orphan: not in /v1/domains as of refresh]";
+      }
+    }
+  }
+
+  // Domains in GoDaddy but NOT in CNCP: by default, skip (CNCP is curated).
+  // Optional `--include-new` (e.g., for first-time backfill): add them.
+  let added = 0;
+  if (includeNew) {
+    for (const gd of fresh) {
+      const name = gd.domain.toLowerCase();
+      if (byName.has(name)) continue;
+      const newEntry = {
+        name: gd.domain,
+        reg: "GoDaddy",
+        expires: gd.expires ? gd.expires.slice(0, 10) : null,
+        ns: (gd.nameServers || []).join(","),
+        note: `Auto-renew: ${gd.renewAuto ? "ON" : "OFF"} [auto-imported]`,
+      };
+      existing.push(newEntry);
+      added++;
+    }
+  } else {
+    skippedNotInCNCP = fresh.length - seenInBoth.size;
+  }
+
+  // Recompute status for everyone
+  for (const d of existing) {
+    const s = statusFor(d.expires);
+    d.status = s.status;
+    d.statusColor = s.statusColor;
+    d.statusLabel = s.statusLabel;
+  }
+
+  cfg.domains = existing;
+  await saveConfig(cfg);
+
+  return {
+    mode: includeNew ? "full+include-new" : "full",
+    godaddyReturned: fresh.length,
+    updated,
+    orphaned,
+    added,
+    skippedNotInCNCP,
+    totalInCNCP: existing.length,
+  };
+}
+
+// ─── main ────────────────────────────────────────────────────────────────
+
+const args = process.argv.slice(2);
+const mode = args.includes("--full") ? "full" : "status-only";
+const includeNew = args.includes("--include-new");
+
+try {
+  const result = mode === "full"
+    ? await fullRefresh({ includeNew })
+    : await recomputeStatus();
+  console.log(new Date().toISOString(), JSON.stringify(result));
+  process.exit(0);
+} catch (e) {
+  console.error(new Date().toISOString(), "ERROR:", e.message);
+  process.exit(1);
+}

← 51e1232 gitignore: drop logs/ from tracking (watchdog stdout/stderr/  ·  back to Cncp Mcp  ·  load-spreading: run-on-mac1 wrapper + pm2-cap audit script 0239c2e →