← 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);
}