← back to Dw Domain Launcher

server.js

304 lines

// DW Domain Launcher — port 9787
// Read-only viewer + decision capture for ~151 DW domains.
// Saves selections to data/decisions.json. NEVER actually deletes domains;
// the "delete-info" endpoint just emits a manifest for Steve to act on.

const express = require('express');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 9787;
const ROOT = __dirname;
const DOMAINS_FILE = '/Users/macstudio3/Projects/_dw-batch/design-domains-live.txt';
const GODADDY_FILE = path.join(ROOT, 'data', 'godaddy-active.json');
const CONFIGS_DIR  = '/Users/macstudio3/Projects/_dw-batch/configs';
const PROJECTS_ROOT = '/Users/macstudio3/Projects';
const DECISIONS_FILE = path.join(ROOT, 'data', 'decisions.json');

// GoDaddy creds — pull at runtime from secrets-manager .env
function getGodaddyAuth() {
  try {
    const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
    const pat = env.match(/^GODADDY_PAT=(.+)$/m)?.[1];
    if (pat) return `Bearer ${pat}`;
    const key = env.match(/^GODADDY_API_KEY=(.+)$/m)?.[1];
    const sec = env.match(/^GODADDY_API_SECRET=(.+)$/m)?.[1];
    if (key && sec) return `sso-key ${key}:${sec}`;
  } catch {}
  return null;
}

const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.static(path.join(ROOT, 'public')));

function loadDomains() {
  // Prefer the live GoDaddy snapshot if present — that's the full ~294-domain inventory.
  if (fs.existsSync(GODADDY_FILE)) {
    try {
      const arr = JSON.parse(fs.readFileSync(GODADDY_FILE, 'utf8'));
      if (Array.isArray(arr) && arr.length) {
        return arr.map(x => x.domain).filter(Boolean).sort();
      }
    } catch {}
  }
  if (!fs.existsSync(DOMAINS_FILE)) return [];
  return fs.readFileSync(DOMAINS_FILE, 'utf8')
    .split('\n')
    .map(s => s.trim())
    .filter(s => s && !s.startsWith('#'));
}

function loadGodaddyMeta() {
  if (!fs.existsSync(GODADDY_FILE)) return {};
  try {
    const arr = JSON.parse(fs.readFileSync(GODADDY_FILE, 'utf8'));
    const map = {};
    for (const x of arr) {
      map[x.domain] = {
        expires: x.expires,
        renewAuto: x.renewAuto,
        locked: x.locked,
        privacy: x.privacy,
        createdAt: x.createdAt,
        nameServers: x.nameServers,
      };
    }
    return map;
  } catch { return {}; }
}

// Map a domain → most likely local project directory (stem-match).
// E.g. "1800swallpaper.com" → "1800swallpaper", "wallpapercanada.com" → "wallpapercanada".
function projectDirFor(domain) {
  const stem = domain.replace(/\.[a-z]+$/i, '').toLowerCase();
  const candidates = [
    path.join(PROJECTS_ROOT, stem),
    path.join(PROJECTS_ROOT, stem.replace(/-/g, '')),
    path.join(PROJECTS_ROOT, stem.replace(/(\w)/, c => c.toUpperCase())),
  ];
  for (const c of candidates) {
    if (fs.existsSync(c) && fs.statSync(c).isDirectory()) return c;
  }
  return null;
}

function hasConfig(domain) {
  const stem = domain.replace(/\.[a-z]+$/i, '').toLowerCase();
  return fs.existsSync(path.join(CONFIGS_DIR, `${stem}.json`));
}

function loadDecisions() {
  if (!fs.existsSync(DECISIONS_FILE)) return {};
  try { return JSON.parse(fs.readFileSync(DECISIONS_FILE, 'utf8')); }
  catch { return {}; }
}
function saveDecisions(d) {
  fs.mkdirSync(path.dirname(DECISIONS_FILE), { recursive: true });
  fs.writeFileSync(DECISIONS_FILE, JSON.stringify(d, null, 2));
}

app.get('/api/domains', (req, res) => {
  const domains = loadDomains();
  const decisions = loadDecisions();
  const meta = loadGodaddyMeta();
  const rows = domains.map(d => {
    const dir = projectDirFor(d);
    const m = meta[d] || {};
    return {
      domain: d,
      stem: d.replace(/\.[a-z]+$/i, '').toLowerCase(),
      hasConfig: hasConfig(d),
      hasProjectDir: !!dir,
      projectDir: dir,
      decision: decisions[d]?.decision || 'undecided',
      category: decisions[d]?.category || '',
      note: decisions[d]?.note || '',
      updatedAt: decisions[d]?.updatedAt || null,
      expires: m.expires || null,
      renewAuto: m.renewAuto,
      locked: m.locked,
      privacy: m.privacy,
      registered: m.createdAt || null,
    };
  });
  const counts = {
    total: rows.length,
    launch: rows.filter(r => r.decision === 'launch').length,
    delete: rows.filter(r => r.decision === 'delete').length,
    undecided: rows.filter(r => r.decision === 'undecided').length,
    withProject: rows.filter(r => r.hasProjectDir).length,
    withConfig: rows.filter(r => r.hasConfig).length,
    byCategory: rows.reduce((acc, r) => {
      const k = r.category || '(none)';
      acc[k] = (acc[k] || 0) + 1;
      return acc;
    }, {}),
  };
  res.json({ rows, counts });
});

// CSV export of every decision — for offline review / sharing.
app.get('/api/export.csv', (req, res) => {
  const decisions = loadDecisions();
  const domains = loadDomains();
  const lines = ['domain,decision,category,note,updatedAt,hasProject,hasConfig'];
  for (const dom of domains) {
    const r = decisions[dom] || {};
    const dir = projectDirFor(dom);
    const note = (r.note || '').replace(/"/g, '""');
    lines.push([
      dom,
      r.decision || 'undecided',
      r.category || '',
      `"${note}"`,
      r.updatedAt || '',
      dir ? 'yes' : 'no',
      hasConfig(dom) ? 'yes' : 'no',
    ].join(','));
  }
  res.setHeader('content-type', 'text/csv');
  res.setHeader('content-disposition', `attachment; filename="dw-domain-decisions-${new Date().toISOString().slice(0,10)}.csv"`);
  res.send(lines.join('\n'));
});

// Valid categories — what business unit/project a domain belongs to.
// Steve can add more via the "custom" free-text field on each row.
const CATEGORIES = [
  'dw',          // Designer Wallcoverings family (default for most fleet)
  'bubbe',       // BubbesBlock / Jewish community
  'wlt',         // WhoLivedThere / home history
  'forza',       // Forza Legal
  'animals',     // animals directory
  'lawyer',      // lawyer directory
  'restaurants', // restaurant directory / lacountyeats
  'ventura',     // ventura-corridor / leads
  'sdcc',        // student debt crisis
  'personal',    // steve personal / family
  'sell',        // park & sell at registrar (Afternic etc.)
  'park',        // keep parked, no plans yet
  'misc',        // misc / unclassified
];

app.get('/api/categories', (req, res) => {
  res.json({ categories: CATEGORIES });
});

app.post('/api/decision', (req, res) => {
  const { domain, decision, note, category } = req.body || {};
  if (!domain) return res.status(400).json({ error: 'bad request - need domain' });
  if (decision && !['launch', 'delete', 'undecided'].includes(decision)) {
    return res.status(400).json({ error: 'bad decision' });
  }
  const d = loadDecisions();
  const prev = d[domain] || {};
  d[domain] = {
    decision: decision || prev.decision || 'undecided',
    category: category !== undefined ? category : (prev.category || ''),
    note: note !== undefined ? note : (prev.note || ''),
    updatedAt: new Date().toISOString(),
  };
  saveDecisions(d);
  res.json({ ok: true, domain, row: d[domain] });
});

app.post('/api/bulk-decision', (req, res) => {
  const { domains, decision, category } = req.body || {};
  if (!Array.isArray(domains)) return res.status(400).json({ error: 'bad request' });
  if (decision && !['launch', 'delete', 'undecided'].includes(decision)) {
    return res.status(400).json({ error: 'bad decision' });
  }
  const d = loadDecisions();
  const ts = new Date().toISOString();
  for (const dom of domains) {
    const prev = d[dom] || {};
    d[dom] = {
      decision: decision !== undefined ? decision : (prev.decision || 'undecided'),
      category: category !== undefined ? category : (prev.category || ''),
      note: prev.note || '',
      updatedAt: ts,
    };
  }
  saveDecisions(d);
  res.json({ ok: true, updated: domains.length });
});

// Manifest of what WOULD happen — read-only preview. NEVER actually deletes.
app.get('/api/delete-manifest', (req, res) => {
  const decisions = loadDecisions();
  const targets = Object.entries(decisions)
    .filter(([_, v]) => v.decision === 'delete')
    .map(([domain]) => {
      const stem = domain.replace(/\.[a-z]+$/i, '').toLowerCase();
      const dir = projectDirFor(domain);
      const cfg = hasConfig(domain) ? path.join(CONFIGS_DIR, `${stem}.json`) : null;
      return {
        domain,
        stem,
        actions: [
          dir   ? `rm -rf "${dir}"` : null,
          cfg   ? `rm "${cfg}"` : null,
          `pm2 delete "${stem}" 2>/dev/null || true`,
          `# DNS: review ${domain} at registrar (manual)`,
        ].filter(Boolean),
      };
    });
  res.json({
    note: 'DRY-RUN MANIFEST — nothing has been deleted. Review and execute manually.',
    count: targets.length,
    targets,
  });
});

app.get('/api/launch-manifest', (req, res) => {
  const decisions = loadDecisions();
  const targets = Object.entries(decisions)
    .filter(([_, v]) => v.decision === 'launch')
    .map(([domain]) => {
      const stem = domain.replace(/\.[a-z]+$/i, '').toLowerCase();
      const dir = projectDirFor(domain);
      return {
        domain,
        stem,
        hasProjectDir: !!dir,
        hasConfig: hasConfig(domain),
        nextStep: dir
          ? `deploy from ${dir}`
          : `scaffold via /dw-site-build skill, then deploy`,
      };
    });
  res.json({ count: targets.length, targets });
});

// Live-refresh GoDaddy inventory.
app.post('/api/refresh-godaddy', async (req, res) => {
  const auth = getGodaddyAuth();
  if (!auth) return res.status(500).json({ error: 'no godaddy creds in secrets-manager/.env' });
  try {
    const all = [];
    let marker = null;
    while (true) {
      const url = new URL('https://api.godaddy.com/v1/domains');
      url.searchParams.set('limit', '1000');
      url.searchParams.set('statuses', 'ACTIVE');
      if (marker) url.searchParams.set('marker', marker);
      const r = await fetch(url, { headers: { Authorization: auth } });
      if (!r.ok) return res.status(r.status).json({ error: `godaddy api: ${r.status}` });
      const data = await r.json();
      if (!Array.isArray(data) || !data.length) break;
      all.push(...data);
      if (data.length < 1000) break;
      marker = data[data.length - 1].domain;
      await new Promise(s => setTimeout(s, 300));
    }
    fs.writeFileSync(GODADDY_FILE, JSON.stringify(all, null, 2));
    res.json({ ok: true, count: all.length, file: GODADDY_FILE });
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`dw-domain-launcher listening on http://127.0.0.1:${PORT}`);
});