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