← back to Cncp Mcp

scripts/cncp-refresh.mjs

279 lines

#!/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, dirname, basename } 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);
  // Retention: keep newest 5 backups, delete rest. Backups can contain real
  // API keys (e.g. ELEVENLABS_API_KEY in cncp-config.json), so unbounded
  // rotation is a slow-growing security liability.
  try {
    const dir = dirname(CONFIG);
    const base = basename(CONFIG);
    const entries = await fs.readdir(dir);
    const baks = entries
      .filter(n => n.startsWith(`${base}.bak.`))
      .map(n => ({ n, ts: Number(n.split(".bak.")[1]) || 0 }))
      .sort((a, b) => b.ts - a.ts)
      .slice(5);
    for (const { n } of baks) {
      try { await fs.unlink(join(dir, n)); } catch {}
    }
  } catch {}
}

// ─── --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;
  let pat = process.env.GODADDY_PAT;
  if (!key || !secret || !pat) {
    // 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) {
          const val = m[2].replace(/^["']|["']$/g, "");
          if (m[1] === "KEY") key = key || val;
          else secret = secret || val;
          continue;
        }
        const p = line.match(/^GODADDY_PAT=(.+)$/);
        if (p && !pat) pat = p[1].replace(/^["']|["']$/g, "");
      }
    } catch {}
  }
  if (!pat && (!key || !secret)) {
    throw new Error(
      "GoDaddy API creds not found. Set GODADDY_PAT (preferred) or " +
      "GODADDY_API_KEY and GODADDY_API_SECRET, " +
      "or place them in ~/Projects/secrets-manager/.env."
    );
  }
  // Prefer a Bearer PAT when present (TK-10 key rotation); else legacy sso-key.
  const GD_AUTH = pat ? `Bearer ${pat}` : `sso-key ${key}:${secret}`;

  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: GD_AUTH,
        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);
}