← back to Projects Agentabrams
build-projects.js
133 lines
#!/usr/bin/env node
// build-projects.js — regenerate projects.json, the data behind the viewer.
//
// A "project" is viewable if it resolves to a URL we can proxy: either a LIVE
// pm2 process with a listening port (→ http://127.0.0.1:PORT) or a public
// domain declared in its .deploy.conf HEALTH_URL (→ https://<domain>). We merge
// both by slug (the working-tree basename) and prefer the public domain when a
// project has one, since the live customer-facing site is the nicer preview and
// is reachable from anywhere the viewer runs.
//
// Git is the only source of truth for the project list; this script just reads
// what's actually running + configured, so re-running it keeps the rail honest.
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const HOME = process.env.HOME;
const PROJECTS_DIR = path.join(HOME, 'Projects');
// slug -> { slug, name, local, domain }
const map = new Map();
const get = (slug) => {
if (!map.has(slug)) map.set(slug, { slug, name: pretty(slug), local: null, domain: null, pm2: null });
return map.get(slug);
};
// Bucket a project into a collapsible section. Ordered rules, first match wins;
// keyword-based so newly-added projects auto-sort without editing a static list.
const CATEGORIES = [
'DW Vendor Lines',
'DW Catalog & Marketing',
"Wallpaper's Back & Patterns",
'Consulting & Client Sites',
'Directories & Verticals',
'Webdev Accelerator',
'Agents, Ops & Tools',
'Other',
];
function categorize(slug) {
const s = slug.toLowerCase();
const rules = [
["Wallpaper's Back & Patterns", /^wpb-|pattern-vault|patterndesignlab|wild-wallcoverings|wallco-ai/],
['DW Vendor Lines', /-internal$|-line-viewer$|-onboard$|-naturals$|^schumacher|^muralsource|^reidwitlin|viewer-local|all-designerwallcoverings/],
['Consulting & Client Sites', /consulting|fantasea|prestige-car-wash|norma-sdcc/],
['DW Catalog & Marketing', /^dw-|trending-dw|colorwheel|instagram-agent/],
['Webdev Accelerator', /webdev-accelerator|estimate-instant|trade-portal|sample-box|reno-visualizer|permit-radar/],
['Directories & Verticals', /animals|rentv|model-arena|sdcc-journalists|thesetdecorator|small-business-builder|designers?salesreps|homesonspec/],
['Agents, Ops & Tools', /abrams|agent-cabinet|ai-workforce|ticket-system|^slack-|build-pages|clipreviewbydave|holdforme|kalshi/],
];
for (const [cat, re] of rules) if (re.test(s)) return cat;
return 'Other';
}
// "small-business-builder" -> "Small Business Builder"; keeps ALLCAPS acronyms.
function pretty(slug) {
return String(slug)
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.replace(/\b(Dw|Aa|Sdcc|Rentv|Wpb|Ai|Faa|Gmc|Pdl|Crcp)\b/gi, (m) => m.toUpperCase());
}
// ---- 1. pm2 live ports -----------------------------------------------------
function lsofPort(pid) {
try {
const o = cp.execSync(`lsof -nP -iTCP -sTCP:LISTEN -a -p ${pid} 2>/dev/null`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
const m = o.match(/:(\d+)\s*\(LISTEN\)/);
return m ? parseInt(m[1], 10) : null;
} catch (_) { return null; }
}
try {
const list = JSON.parse(cp.execSync('pm2 jlist', { stdio: ['ignore', 'pipe', 'ignore'] }).toString());
for (const p of list) {
const env = (p.pm2_env && p.pm2_env.env) || {};
let port = env.PORT ? parseInt(env.PORT, 10) : null;
if (!port && p.pid) port = lsofPort(p.pid);
if (!port) continue;
// High DNS / internal-gateway ports aren't browsable UIs — skip the obvious ones.
if (port === 53535 || port < 80) continue;
const cwd = (p.pm2_env && p.pm2_env.pm_cwd) || '';
const slug = cwd.split('/').pop() || p.name;
const rec = get(slug);
// First (lowest) port wins as the primary; keep the pm2 name for display.
if (!rec.local) { rec.local = port; rec.pm2 = p.name; }
}
} catch (e) { console.error('pm2 read failed:', e.message); }
// ---- 2. public domains from .deploy.conf HEALTH_URL ------------------------
function publicDomain(url) {
try {
const u = new URL(url);
const h = u.hostname;
if (h === 'localhost' || /^127\./.test(h) || !h.includes('.')) return null;
return h;
} catch (_) { return null; }
}
for (const dir of fs.readdirSync(PROJECTS_DIR)) {
const conf = path.join(PROJECTS_DIR, dir, '.deploy.conf');
if (!fs.existsSync(conf)) continue;
let txt = '';
try { txt = fs.readFileSync(conf, 'utf8'); } catch (_) { continue; }
const m = txt.match(/^\s*(?:HEALTH_URL|URL)\s*=\s*["']?([^"'\s]+)/im);
if (!m) continue;
const dom = publicDomain(m[1]);
if (!dom) continue;
get(dir).domain = dom;
}
// ---- 3. finalize -----------------------------------------------------------
// Keep only projects that have SOMEWHERE to point (a domain or a live port).
const out = [];
for (const r of map.values()) {
if (!r.domain && !r.local) continue;
const kind = r.domain ? 'public' : 'local';
const target = r.domain ? `https://${r.domain}` : `http://127.0.0.1:${r.local}`;
out.push({
slug: r.slug,
name: r.name,
kind,
target,
domain: r.domain, // may be null
port: r.local, // may be null
category: categorize(r.slug),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
fs.writeFileSync(path.join(__dirname, 'projects.json'), JSON.stringify(out, null, 2) + '\n');
const pub = out.filter((o) => o.kind === 'public').length;
console.log(`projects.json: ${out.length} projects (${pub} public domain, ${out.length - pub} local port)`);