← 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
A scripts/cncp-refresh.mjs
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 →