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