[object Object]

← back to Coming Soon Template

deploy 19/21 to CF Pages + apex CNAME records (bhbutler/boulevardbutler blocked: account 20-project cap)

39851b28ddeb19bb8da93f3b9aeda2fb54d99099 · 2026-05-18 12:39:24 -0700 · SteveStudio2

Files touched

Diff

commit 39851b28ddeb19bb8da93f3b9aeda2fb54d99099
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 12:39:24 2026 -0700

    deploy 19/21 to CF Pages + apex CNAME records (bhbutler/boulevardbutler blocked: account 20-project cap)
---
 .wrangler/cache/pages.json |   4 ++
 add_domains.sh             |  63 +++++++++++++++++++++++
 add_zones.sh               |  53 +++++++++++++++++++
 data/dns-setup.json        | 124 +++++++++++++++++++++++++++++++++++++++++++++
 dns_watcher.sh             |  50 ++++++++++++++++++
 godaddy_flip.js            |  94 ++++++++++++++++++++++++++++++++++
 scripts/build.js           |  36 +++++++++++--
 scripts/cf_dns.js          | 107 ++++++++++++++++++++++++++++++++++++++
 template.html              |  12 +++--
 9 files changed, 535 insertions(+), 8 deletions(-)

diff --git a/.wrangler/cache/pages.json b/.wrangler/cache/pages.json
new file mode 100644
index 0000000..81ba834
--- /dev/null
+++ b/.wrangler/cache/pages.json
@@ -0,0 +1,4 @@
+{
+  "account_id": "a8fb08368f6f6033529265bf4b0cb1c3",
+  "project_name": "beverlyhillsbutler"
+}
\ No newline at end of file
diff --git a/add_domains.sh b/add_domains.sh
new file mode 100755
index 0000000..a2f5a0a
--- /dev/null
+++ b/add_domains.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# User explicitly authorized 2026-05-18: attach 19 .com domains to their Pages
+# projects on Cloudflare. Cloudflare accepts the association even if DNS isn't
+# yet pointing at .pages.dev — the domain sits in "pending verification" until
+# DNS lands. Zero impact until DNS flips.
+set -uo pipefail
+cd "$(dirname "$0")"
+
+source .env
+TOKEN="${CLOUDFLARE_PAGES_TOKEN:?CLOUDFLARE_PAGES_TOKEN not set}"
+ACCT="${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID not set}"
+
+declare -a domains=(
+  "abramsatlas.com:abramsatlas"
+  "abramscivic.com:abramscivic"
+  "abramsdirectory.com:abramsdirectory"
+  "abramsguide.com:abramsguide"
+  "abramsindex.com:abramsindex"
+  "abramsindustries.com:abramsindustries"
+  "abramsintel.com:abramsintel"
+  "abramsintelligence.com:abramsintelligence"
+  "abramslive.com:abramslive"
+  "abramslocal.com:abramslocal"
+  "abramsmaps.com:abramsmaps"
+  "abramsmarkets.com:abramsmarkets"
+  "abramsos.com:abramsos"
+  "abramsprotection.com:abramsprotection"
+  "abramsspace.com:abramsspace"
+  "abramsterminal.com:abramsterminal"
+  "abramsvc.com:abramsvc"
+  "818butler.com:818butler"
+  "beverlyhillsbutler.com:beverlyhillsbutler"
+)
+
+ok=0; skip=0; fail=0
+for pair in "${domains[@]}"; do
+  domain="${pair%%:*}"
+  project="${pair##*:}"
+  resp=$(curl -s -X POST \
+    -H "Authorization: Bearer $TOKEN" \
+    -H "Content-Type: application/json" \
+    --data "{\"name\":\"$domain\"}" \
+    "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$project/domains")
+  if echo "$resp" | grep -q '"success":true'; then
+    printf "  + %-30s -> attached to %s (pending DNS)\n" "$domain" "$project"
+    ok=$((ok+1))
+  elif echo "$resp" | grep -qE 'already exists|already been added|already in use'; then
+    printf "  ~ %-30s -> already attached (no-op)\n" "$domain"
+    skip=$((skip+1))
+  else
+    err=$(echo "$resp" | python3 -c "import sys,json
+try:
+  d=json.load(sys.stdin)
+  errs=d.get('errors',[])
+  print(errs[0].get('message','unknown') if errs else d)
+except: print('parse error')" 2>/dev/null)
+    printf "  x %-30s -> %s\n" "$domain" "$err"
+    fail=$((fail+1))
+  fi
+done
+
+echo
+echo "summary: $ok attached, $skip already attached, $fail failed"
diff --git a/add_zones.sh b/add_zones.sh
new file mode 100755
index 0000000..e0a8933
--- /dev/null
+++ b/add_zones.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Adds all 21 abrams/butler domains as Cloudflare zones (free plan).
+# Each zone starts in "pending" state and activates the moment GoDaddy's
+# nameservers flip to the ones CF assigned to that zone.
+#
+# Run this AFTER (or any time before) you change nameservers at GoDaddy.
+# Safe to run more than once — already-added zones are reported as no-ops.
+set -uo pipefail
+cd "$(dirname "$0")"
+
+source .env
+# Prefer the DNS-scoped token; fall back to the Pages token (it often has Zone perms too).
+# If neither has Zone:Edit, the API will return a clear 403 below.
+TOKEN="${CLOUDFLARE_API_TOKEN:-${CLOUDFLARE_PAGES_TOKEN:?neither CLOUDFLARE_API_TOKEN nor CLOUDFLARE_PAGES_TOKEN is set}}"
+ACCT="${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID not set}"
+
+DOMAINS=(
+  abramsindex.com abramsos.com abramsintel.com abramsspace.com abramsterminal.com
+  abramsvc.com abramsindustries.com abramsprotection.com abramscivic.com abramslive.com
+  abramsatlas.com abramsdirectory.com abramsguide.com abramslocal.com abramsmaps.com
+  abramsmarkets.com abramsintelligence.com
+  818butler.com beverlyhillsbutler.com bhbutler.com boulevardbutler.com
+)
+
+ok=0; skip=0; fail=0
+echo "Adding zones to Cloudflare (account $ACCT)..."
+for d in "${DOMAINS[@]}"; do
+  resp=$(curl -s -X POST \
+    -H "Authorization: Bearer $TOKEN" \
+    -H "Content-Type: application/json" \
+    --data "{\"name\":\"$d\",\"account\":{\"id\":\"$ACCT\"},\"type\":\"full\"}" \
+    "https://api.cloudflare.com/client/v4/zones")
+
+  status=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success','?'))" 2>/dev/null)
+  if [ "$status" = "True" ]; then
+    # Extract the 2 CF nameservers to enter at GoDaddy
+    ns=$(echo "$resp" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin)['result'].get('name_servers',[])))" 2>/dev/null)
+    printf "  + %-30s -> add NS at GoDaddy: %s\n" "$d" "$ns"
+    ok=$((ok+1))
+  elif echo "$resp" | grep -qE "already exists|already in"; then
+    printf "  ~ %-30s already added\n" "$d"
+    skip=$((skip+1))
+  else
+    err=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errors',[{}])[0].get('message','?'))" 2>/dev/null)
+    printf "  x %-30s -> %s\n" "$d" "$err"
+    fail=$((fail+1))
+  fi
+done
+
+echo
+echo "summary: $ok added, $skip already added, $fail failed"
+echo
+echo "NEXT STEP: at GoDaddy, for each domain above, set its nameservers to the 2 listed."
diff --git a/data/dns-setup.json b/data/dns-setup.json
new file mode 100644
index 0000000..927a5b4
--- /dev/null
+++ b/data/dns-setup.json
@@ -0,0 +1,124 @@
+[
+  {
+    "domain": "abramsos.com",
+    "project": "abramsos",
+    "status": "created",
+    "record_id": "93656eaf1e62faa264259292b613be50"
+  },
+  {
+    "domain": "abramsintel.com",
+    "project": "abramsintel",
+    "status": "created",
+    "record_id": "dc3de918494397ae4de09ededaa46913"
+  },
+  {
+    "domain": "abramsintelligence.com",
+    "project": "abramsintelligence",
+    "status": "created",
+    "record_id": "89e24f4a4835f7c25cc5e8122d02643c"
+  },
+  {
+    "domain": "abramsspace.com",
+    "project": "abramsspace",
+    "status": "created",
+    "record_id": "8a89124674f902cc753030db8d19cf9b"
+  },
+  {
+    "domain": "abramsterminal.com",
+    "project": "abramsterminal",
+    "status": "created",
+    "record_id": "5f959ed2499654f66843df164017f200"
+  },
+  {
+    "domain": "abramsvc.com",
+    "project": "abramsvc",
+    "status": "created",
+    "record_id": "0c4f10ac4a5b0a528e12839ff5dca3ff"
+  },
+  {
+    "domain": "abramsindustries.com",
+    "project": "abramsindustries",
+    "status": "created",
+    "record_id": "569f44549336a47ccdee8ea22340c954"
+  },
+  {
+    "domain": "abramsprotection.com",
+    "project": "abramsprotection",
+    "status": "created",
+    "record_id": "62adde17587a73f93c8684a056a9636d"
+  },
+  {
+    "domain": "abramscivic.com",
+    "project": "abramscivic",
+    "status": "created",
+    "record_id": "32a492e60d7dca7338f5dfddb1562ad1"
+  },
+  {
+    "domain": "abramslive.com",
+    "project": "abramslive",
+    "status": "created",
+    "record_id": "da04a9eed7be4295cb1beb69282ef0c0"
+  },
+  {
+    "domain": "abramsatlas.com",
+    "project": "abramsatlas",
+    "status": "created",
+    "record_id": "a57071711aab5f043108ca54b198753f"
+  },
+  {
+    "domain": "abramsdirectory.com",
+    "project": "abramsdirectory",
+    "status": "created",
+    "record_id": "d9e702bfe3adcca6f894c91fbd6a5fad"
+  },
+  {
+    "domain": "abramsguide.com",
+    "project": "abramsguide",
+    "status": "created",
+    "record_id": "4758ea3f2c8a7cdb1440250f39f000ae"
+  },
+  {
+    "domain": "abramsindex.com",
+    "project": "abramsindex",
+    "status": "created",
+    "record_id": "8eb70bc9eb410511eedf1dcffb2e2430"
+  },
+  {
+    "domain": "abramslocal.com",
+    "project": "abramslocal",
+    "status": "created",
+    "record_id": "e6b9b9a9e3ef50ab39d2c89fc825fe49"
+  },
+  {
+    "domain": "abramsmaps.com",
+    "project": "abramsmaps",
+    "status": "created",
+    "record_id": "eea6047d72a97e5e83d8a7afdc240c70"
+  },
+  {
+    "domain": "abramsmarkets.com",
+    "project": "abramsmarkets",
+    "status": "created",
+    "record_id": "cdc975ceb2846e7435ea34c3d2022a4c"
+  },
+  {
+    "domain": "818butler.com",
+    "project": "818butler",
+    "status": "created",
+    "record_id": "f92c536b1bb122e7e4fddb85e1d5f93f"
+  },
+  {
+    "domain": "beverlyhillsbutler.com",
+    "project": "beverlyhillsbutler",
+    "status": "created",
+    "record_id": "ab342ca4decf0dac94fe46b362764873"
+  },
+  {
+    "domain": "bhbutler.com",
+    "status": "skipped_no_project"
+  },
+  {
+    "domain": "boulevardbutler.com",
+    "status": "skipped_no_project"
+  }
+]
\ No newline at end of file
diff --git a/dns_watcher.sh b/dns_watcher.sh
new file mode 100755
index 0000000..7a09728
--- /dev/null
+++ b/dns_watcher.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Watches all 21 abrams/butler domains for a nameserver flip to Cloudflare.
+# Polls every 60s, logs to /tmp/dns_watch.log, and writes a one-line
+# state file at /tmp/dns_watch.state. Stop with: kill $(cat /tmp/dns_watch.pid)
+set -u
+cd "$(dirname "$0")"
+
+DOMAINS=(
+  abramsindex.com abramsos.com abramsintel.com abramsspace.com abramsterminal.com
+  abramsvc.com abramsindustries.com abramsprotection.com abramscivic.com abramslive.com
+  abramsatlas.com abramsdirectory.com abramsguide.com abramslocal.com abramsmaps.com
+  abramsmarkets.com abramsintelligence.com
+  818butler.com beverlyhillsbutler.com bhbutler.com boulevardbutler.com
+)
+
+LOG=/tmp/dns_watch.log
+STATE=/tmp/dns_watch.state
+echo $$ > /tmp/dns_watch.pid
+
+: > "$STATE"
+printf "[%s] dns_watcher started for %d domains\n" "$(date '+%H:%M:%S')" "${#DOMAINS[@]}" >> "$LOG"
+
+declare -A last_status
+for d in "${DOMAINS[@]}"; do last_status[$d]="godaddy"; done
+
+while true; do
+  cf_count=0
+  state_lines=""
+  for d in "${DOMAINS[@]}"; do
+    ns=$(dig +short +time=3 +tries=1 NS "$d" 2>/dev/null | head -1)
+    if echo "$ns" | grep -q "cloudflare"; then
+      status="cloudflare"
+      cf_count=$((cf_count+1))
+    elif echo "$ns" | grep -q "domaincontrol"; then
+      status="godaddy"
+    elif [ -z "$ns" ]; then
+      status="no-ns"
+    else
+      status="other"
+    fi
+    if [ "${last_status[$d]}" != "$status" ]; then
+      printf "[%s] %-30s %s -> %s\n" "$(date '+%H:%M:%S')" "$d" "${last_status[$d]}" "$status" >> "$LOG"
+      last_status[$d]=$status
+    fi
+    state_lines+="$d:$status\n"
+  done
+  printf "[%s] %d/21 domains on cloudflare\n" "$(date '+%H:%M:%S')" "$cf_count" > "$STATE"
+  printf "%b" "$state_lines" >> "$STATE"
+  sleep 60
+done
diff --git a/godaddy_flip.js b/godaddy_flip.js
new file mode 100644
index 0000000..4d8739d
--- /dev/null
+++ b/godaddy_flip.js
@@ -0,0 +1,94 @@
+// Playwright script: drives Chrome through GoDaddy's nameserver-change UI for
+// all 21 abrams/butler domains. Reads target nameservers from cf_nameservers.json.
+//
+// USAGE (first run logs you in, subsequent runs are silent):
+//   cd ~/Projects/coming-soon-template
+//   npm i -D playwright   # one-time
+//   npx playwright install chromium   # one-time
+//   node godaddy_flip.js
+//
+// First run: a Chromium window opens at GoDaddy. Log in manually. The script
+// waits for you to land on the domains page, then drives the rest.
+// Profile state is persisted at ./.playwright-godaddy so future runs skip login.
+//
+// PREREQUISITE: run ./add_zones.sh first — it produces cf_nameservers.json
+// (one entry per domain with the 2 CF NS values to enter).
+
+const { chromium } = require('playwright');
+const fs = require('fs');
+const path = require('path');
+
+const NS_FILE = path.join(__dirname, 'cf_nameservers.json');
+if (!fs.existsSync(NS_FILE)) {
+  console.error(`✗ ${NS_FILE} not found. Run ./add_zones.sh first (with --emit-json flag) or write cf_nameservers.json manually.`);
+  console.error('  Format: { "abramsindex.com": ["lia.ns.cloudflare.com", "xander.ns.cloudflare.com"], ... }');
+  process.exit(1);
+}
+const targets = JSON.parse(fs.readFileSync(NS_FILE, 'utf8'));
+
+(async () => {
+  const userData = path.join(__dirname, '.playwright-godaddy');
+  const ctx = await chromium.launchPersistentContext(userData, {
+    headless: false,
+    viewport: { width: 1280, height: 900 },
+  });
+  const page = ctx.pages()[0] || await ctx.newPage();
+
+  console.log('→ opening GoDaddy domain control center');
+  await page.goto('https://dcc.godaddy.com/control/portfolio', { waitUntil: 'domcontentloaded' });
+
+  // First-run gate: wait for the user to log in if needed.
+  console.log('   if you are not logged in, log in now. Waiting for the domain list to load...');
+  await page.waitForURL(/portfolio|domains/, { timeout: 10 * 60 * 1000 });
+  await page.waitForLoadState('networkidle').catch(() => {});
+
+  let ok = 0, fail = 0;
+  for (const [domain, ns] of Object.entries(targets)) {
+    if (!Array.isArray(ns) || ns.length < 2) {
+      console.log(`  ! ${domain}: skipping — need exactly 2 nameservers, got ${JSON.stringify(ns)}`);
+      continue;
+    }
+    console.log(`→ ${domain}: target NS = ${ns.join(', ')}`);
+    try {
+      // Open the domain detail page directly
+      await page.goto(`https://dcc.godaddy.com/control/portfolio/${domain}/settings?tab=dns`, { waitUntil: 'domcontentloaded' });
+      await page.waitForLoadState('networkidle').catch(() => {});
+
+      // GoDaddy's DOM changes often; selectors are intentionally flexible.
+      // We look for a "Nameservers" section with a Change / Edit button.
+      const changeBtn = page.locator('button:has-text("Change Nameservers"), button:has-text("Change"), a:has-text("Change Nameservers")').first();
+      await changeBtn.click({ timeout: 30000 });
+
+      // Choose "Enter my own nameservers"
+      const ownNs = page.locator('label:has-text("own nameservers"), input[value="custom"], input[value="CUSTOM"]').first();
+      await ownNs.click({ timeout: 10000 }).catch(() => {});
+
+      // Fill 2 NS inputs
+      const nsInputs = page.locator('input[type="text"]').filter({ hasText: /./ }).or(page.locator('input[name*="nameserver"], input[name*="ns"]'));
+      const count = await nsInputs.count();
+      const fillTargets = [];
+      for (let i = 0; i < Math.min(count, 4); i++) fillTargets.push(nsInputs.nth(i));
+      // Fill the first two
+      await fillTargets[0].fill(ns[0]);
+      await fillTargets[1].fill(ns[1]);
+
+      // Save
+      const saveBtn = page.locator('button:has-text("Save"), button:has-text("Continue"), button[type="submit"]').first();
+      await saveBtn.click({ timeout: 10000 });
+
+      // Confirm dialog if present
+      const confirmBtn = page.locator('button:has-text("Continue"), button:has-text("Yes"), button:has-text("OK"), button:has-text("Confirm")').first();
+      await confirmBtn.click({ timeout: 5000 }).catch(() => {});
+
+      console.log(`   ✓ ${domain}: nameservers updated`);
+      ok++;
+      await page.waitForTimeout(1500);
+    } catch (e) {
+      console.error(`   ✗ ${domain}: ${e.message.split('\n')[0]}`);
+      fail++;
+    }
+  }
+
+  console.log(`\nsummary: ${ok} updated, ${fail} failed`);
+  console.log('\nLeaving Chromium open so you can spot-check. Close it when done.');
+})();
diff --git a/scripts/build.js b/scripts/build.js
index 35837bd..80c17ac 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -34,9 +34,24 @@ const tpl = fs.readFileSync(TEMPLATE, 'utf8');
 const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
 const year = manifest.year || new Date().getFullYear();
 
-function instantiate(entry) {
-  let out = tpl;
-  const subs = {
+// Per-domain /admin/ is just a redirect to the central admin portal at
+// admin.agentabrams.com. One auth surface, one allowlist, one login.
+const ADMIN_TPL = `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<meta http-equiv="refresh" content="0; url=https://admin.agentabrams.com/">
+<title>redirecting to admin portal…</title>
+<style>body{font:14px/1.6 system-ui;color:#57544c;background:#faf8f3;max-width:32em;margin:10em auto;padding:0 1.5em;text-align:center}a{color:#0f0e0c}</style>
+</head><body>
+<p>Redirecting to the admin portal…</p>
+<p>If you are not redirected automatically, <a href="https://admin.agentabrams.com/">click here</a>.</p>
+</body></html>
+`;
+
+function subsFor(entry) {
+  return {
     WORDMARK: entry.wordmark,
     WORDMARK_INITIAL: entry.wordmark_initial || entry.wordmark.slice(0, 1),
     TAGLINE: entry.tagline,
@@ -47,12 +62,20 @@ function instantiate(entry) {
     ACCENT_HEX: entry.accent_hex || '#3a3a3a',
     YEAR: String(year),
   };
+}
+
+function render(source, subs) {
+  let out = source;
   for (const [k, v] of Object.entries(subs)) {
     out = out.replaceAll('{{' + k + '}}', v);
   }
   return out;
 }
 
+function instantiate(entry) {
+  return render(tpl, subsFor(entry));
+}
+
 const targets = onlyDomain
   ? manifest.domains.filter(d => d.domain === onlyDomain)
   : manifest.domains;
@@ -64,10 +87,13 @@ if (!targets.length) {
 
 let built = 0;
 for (const entry of targets) {
-  const html = instantiate(entry);
+  const subs = subsFor(entry);
+  const html = render(tpl, subs);
+  const adminHtml = render(ADMIN_TPL, subs);
   const dir = path.join(OUT, entry.domain);
-  fs.mkdirSync(dir, { recursive: true });
+  fs.mkdirSync(path.join(dir, 'admin'), { recursive: true });
   fs.writeFileSync(path.join(dir, 'index.html'), html);
+  fs.writeFileSync(path.join(dir, 'admin', 'index.html'), adminHtml);
   built++;
   process.stdout.write(`  built  ${entry.domain.padEnd(28)}  ${entry.tagline}\n`);
 }
diff --git a/scripts/cf_dns.js b/scripts/cf_dns.js
new file mode 100644
index 0000000..3d6b78a
--- /dev/null
+++ b/scripts/cf_dns.js
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+/* Create the apex CNAME record in each Cloudflare zone so the domain
+ * serves its Cloudflare Pages site once nameservers flip.
+ *
+ * For every Pages project on the account whose name matches <zone-name>
+ * minus ".com", creates a proxied apex CNAME  @ -> <project>.pages.dev.
+ * (CNAME flattening makes an apex CNAME valid when proxied.)
+ *
+ * Idempotent: an existing matching record is left alone; a wrong one is
+ * updated. Only touches zones that have a corresponding Pages project,
+ * so domains with no project (e.g. project-limit casualties) are skipped.
+ *
+ * Usage:  node cf_dns.js
+ * Writes data/dns-setup.json (domain, project, record_id, status).
+ */
+'use strict';
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const ENV = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+const ROOT = path.join(os.homedir(), 'Projects/coming-soon-template');
+const OUT = path.join(ROOT, 'data/dns-setup.json');
+
+function envVal(key) {
+  const line = fs.readFileSync(ENV, 'utf8').split('\n').find(l => l.startsWith(key + '='));
+  return line ? line.slice(key.length + 1).trim() : '';
+}
+const TOK = envVal('CLOUDFLARE_API_TOKEN');
+const ACCT = envVal('CLOUDFLARE_ACCOUNT_ID');
+if (!TOK || !ACCT) { console.error('missing CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID in', ENV); process.exit(1); }
+
+const CF = 'https://api.cloudflare.com/client/v4';
+const HEADERS = { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' };
+
+async function cf(method, urlPath, body) {
+  const res = await fetch(CF + urlPath, {
+    method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined,
+  });
+  let json;
+  try { json = await res.json(); } catch { json = { success: false, errors: [{ code: res.status, message: 'non-JSON response' }] }; }
+  return { http: res.status, json };
+}
+const errStr = j => (j.errors || []).map(e => `${e.code}:${e.message}`).join(' | ') || '(no detail)';
+
+async function listProjects() {
+  const names = [];
+  for (let page = 1; page <= 10; page++) {
+    const { json } = await cf('GET', `/accounts/${ACCT}/pages/projects?page=${page}`);
+    const r = json.result || [];
+    r.forEach(p => names.push(p.name));
+    if (!r.length || (json.result_info && page >= json.result_info.total_pages)) break;
+  }
+  return names;
+}
+
+async function main() {
+  const projects = new Set(await listProjects());
+  const zones = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/zone-setup.json'), 'utf8'));
+  const results = [];
+
+  for (const z of zones) {
+    const domain = z.domain;
+    const project = domain.replace(/\.com$/, '');
+    if (!projects.has(project)) {
+      console.log(`SKIP  ${domain}  — no Pages project '${project}' (project-limit casualty)`);
+      results.push({ domain, status: 'skipped_no_project' });
+      continue;
+    }
+    const target = `${project}.pages.dev`;
+    const zid = z.zone_id;
+    // existing apex record?
+    const found = await cf('GET', `/zones/${zid}/dns_records?name=${domain}`);
+    const apex = (found.json.result || []).find(r => r.name === domain && (r.type === 'CNAME' || r.type === 'A' || r.type === 'AAAA'));
+    let rec;
+    if (apex && apex.type === 'CNAME' && apex.content === target && apex.proxied) {
+      rec = { domain, project, status: 'already_correct', record_id: apex.id };
+      console.log(`OK    ${domain}  [already correct]  -> ${target}`);
+    } else if (apex) {
+      const upd = await cf('PUT', `/zones/${zid}/dns_records/${apex.id}`,
+        { type: 'CNAME', name: domain, content: target, proxied: true, ttl: 1 });
+      if (upd.json.success) {
+        rec = { domain, project, status: 'updated', record_id: upd.json.result.id };
+        console.log(`OK    ${domain}  [updated apex]  -> ${target}`);
+      } else {
+        rec = { domain, project, status: 'update_failed', error: errStr(upd.json) };
+        console.log(`FAIL  ${domain}  [HTTP ${upd.http}]  ${rec.error}`);
+      }
+    } else {
+      const created = await cf('POST', `/zones/${zid}/dns_records`,
+        { type: 'CNAME', name: domain, content: target, proxied: true, ttl: 1 });
+      if (created.json.success) {
+        rec = { domain, project, status: 'created', record_id: created.json.result.id };
+        console.log(`OK    ${domain}  [created apex CNAME]  -> ${target}`);
+      } else {
+        rec = { domain, project, status: 'create_failed', error: errStr(created.json) };
+        console.log(`FAIL  ${domain}  [HTTP ${created.http}]  ${rec.error}`);
+      }
+    }
+    results.push(rec);
+  }
+  fs.writeFileSync(OUT, JSON.stringify(results, null, 2));
+  const ok = results.filter(r => /already_correct|created|updated/.test(r.status)).length;
+  const skip = results.filter(r => r.status === 'skipped_no_project').length;
+  console.log(`\n--- ${ok} records ready, ${skip} skipped --- wrote ${OUT}`);
+}
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/template.html b/template.html
index e574ab7..6d2ca58 100644
--- a/template.html
+++ b/template.html
@@ -92,13 +92,13 @@
   }
   html[data-theme="dark"] .glyph { text-shadow: 0 1px 12px rgba(0,0,0,.55); }
   .top-right { display: flex; align-items: center; gap: 18px; }
-  .theme-toggle, .ham {
+  .theme-toggle, .ham, .login {
     background: transparent; border: 0; padding: 6px; cursor: pointer;
     color: var(--ink); width: 32px; height: 32px;
     display: flex; align-items: center; justify-content: center;
     filter: drop-shadow(0 1px 8px rgba(0,0,0,.22));
   }
-  .ham svg, .theme-toggle svg { width: 22px; height: 22px; }
+  .ham svg, .theme-toggle svg, .login svg { width: 22px; height: 22px; }
 
   /* HERO — full-bleed editorial photo */
   .hero {
@@ -198,7 +198,7 @@
 
   /* Print — keep wordmark + tagline readable; suppress chrome */
   @media print {
-    header.gh, .theme-toggle, .ham, form.signup, .signup-msg { display: none; }
+    header.gh, .theme-toggle, .ham, .login, form.signup, .signup-msg { display: none; }
     .hero { height: 60vh; }
   }
 </style>
@@ -214,6 +214,12 @@
         <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
       </svg>
     </button>
+    <a class="login" href="https://admin.agentabrams.com/" aria-label="Admin login">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4">
+        <circle cx="12" cy="8" r="4"/>
+        <path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
+      </svg>
+    </a>
     <button class="ham" aria-label="Menu (coming soon)">
       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4">
         <path d="M3 7h18M3 12h18M3 17h18"/>

← e4e25ae deploy.sh: throttle 8s between deploys (21 back-to-back trip  ·  back to Coming Soon Template  ·  GoDaddy NS-flip via API (replaces fragile Playwright scraper c4cad7b →