← 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
A .wrangler/cache/pages.jsonA add_domains.shA add_zones.shA data/dns-setup.jsonA dns_watcher.shA godaddy_flip.jsM scripts/build.jsA scripts/cf_dns.jsM template.html
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 →