← back to Small Business Builder

src/lib/builds-index.js

239 lines

// builds-index — scan ~/Projects + parse MEMORY.md, return structured build rows.
//
// Each row:
//   { name, slug, path, category, description, url, port, last_commit_at, has_git }
//
// "category" is heuristic from the directory name + MEMORY.md tag (project, agent, skill, idea).
// "url" / "port" pulled from MEMORY.md description text where present (e.g. ":9762", "http://...").

import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import os from 'node:os';

// Snapshot pm2 once and index by both app name and CWD basename — directories
// don't always match pm2 app names exactly (e.g. dir `small-business-builder`
// runs pm2 app `smb-builder`).
function pm2Snapshot() {
  try {
    const raw = execFileSync('pm2', ['jlist'], { stdio: ['ignore','pipe','ignore'], timeout: 4000 }).toString();
    const list = JSON.parse(raw);
    const byName = new Map();
    const byCwd  = new Map();
    for (const a of list) {
      const env = a.pm2_env || {};
      const port = env.env?.PORT ? parseInt(env.env.PORT, 10) : null;
      const entry = {
        name: a.name,
        status: env.status || 'unknown',
        port: Number.isFinite(port) && port > 0 ? port : null,
        memory_mb: a.monit?.memory ? Math.round(a.monit.memory / 1048576) : null,
        cwd: env.pm_cwd || null,
        restarts: env.restart_time ?? 0,
      };
      byName.set(a.name.toLowerCase(), entry);
      if (env.pm_cwd) byCwd.set(path.basename(env.pm_cwd).toLowerCase(), entry);
    }
    return { byName, byCwd };
  } catch {
    return { byName: new Map(), byCwd: new Map() };
  }
}

function findPm2(name, snap) {
  const n = name.toLowerCase();
  return snap.byName.get(n) || snap.byCwd.get(n) || null;
}

const PROJECTS = path.join(os.homedir(), 'Projects');
const MEMORY = path.join(os.homedir(), '.claude', 'projects', '-Users-stevestudio2', 'memory');
const MEMORY_MD = path.join(MEMORY, 'MEMORY.md');

// Compliance-sensitive project slugs that must NEVER appear on a public-facing
// /website-analysis surface. /builds is basic-auth gated today so they show up
// there; this flag lets future public views filter them out.
//
// Why each one:
//   lawyer-directory-builder — Cal §6155 / Rule 7.x: synthesized stats next to
//     real firm names is admin-only per feedback_lawyer_directory_compliance.md
//   abrams-v-pabo            — active PI litigation w/ Steve as plaintiff
//   professional-directory   — doctor directory containing real practitioners
//   pawcircles               — has real pet-owner profiles (PII)
const COMPLIANCE_SENSITIVE_SLUGS = new Set([
  'lawyer-directory-builder',
  'lawyer-directory',
  'abrams-v-pabo',
  'professional-directory',
  'doctor',
  'pawcircles',
]);
const COMPLIANCE_SENSITIVE_PATTERNS = [
  /^session-\d{4}-/,       // private session recaps / handoff docs
  /^claude-codex-meeting/, // private meeting outputs
  /^codex-review-\d/,      // dated codex review outputs
];

export function isComplianceSensitive(slug) {
  if (!slug) return false;
  const s = slug.toLowerCase();
  if (COMPLIANCE_SENSITIVE_SLUGS.has(s)) return true;
  return COMPLIANCE_SENSITIVE_PATTERNS.some(re => re.test(s));
}

function gitLastCommit(dir) {
  try {
    const iso = execFileSync('git', ['-C', dir, 'log', '-1', '--format=%cI'], {
      stdio: ['ignore', 'pipe', 'ignore'],
      timeout: 2000,
    }).toString().trim();
    return iso || null;
  } catch { return null; }
}

function fsLastTouched(dir) {
  // Fallback when not a git repo — most-recent mtime among top-level items.
  try {
    const entries = fs.readdirSync(dir).filter(e => !e.startsWith('.'));
    let max = 0;
    for (const e of entries) {
      try {
        const st = fs.statSync(path.join(dir, e));
        if (st.mtimeMs > max) max = st.mtimeMs;
      } catch {}
    }
    return max ? new Date(max).toISOString() : null;
  } catch { return null; }
}

// Extract `:9999` port + first http(s) URL from a free-form description.
function parseHints(text) {
  const portMatches = [...text.matchAll(/:(\d{4,5})\b/g)].map(m => parseInt(m[1], 10));
  const port = portMatches.find(p => p >= 1024 && p <= 65535) || null;
  const urlM = text.match(/https?:\/\/[^\s)\]]+/);
  const url = urlM ? urlM[0].replace(/[.,;]+$/, '') : null;
  return { port, url };
}

function parseMemoryIndex() {
  // Returns map: slug → { title, description, file }.
  if (!fs.existsSync(MEMORY_MD)) return new Map();
  const lines = fs.readFileSync(MEMORY_MD, 'utf8').split('\n');
  const re = /^- \[([^\]]+)\]\(([^)]+\.md)\)\s*[—\-]\s*(.+?)\s*$/;
  const map = new Map();
  for (const line of lines) {
    const m = line.match(re);
    if (!m) continue;
    const [, title, file, desc] = m;
    const slugMatch = file.match(/^project_([a-z0-9_-]+)\.md$/i);
    const slug = slugMatch ? slugMatch[1].replace(/_/g, '-') : file.replace(/\.md$/, '');
    map.set(slug, { title, description: desc, file });
  }
  return map;
}

function categorize(name, memEntry) {
  const n = name.toLowerCase();
  if (memEntry?.file?.startsWith('feedback_')) return 'feedback';
  if (memEntry?.file?.startsWith('reference_')) return 'reference';
  if (n.includes('scraper') || n.includes('scrape')) return 'scraper';
  if (n.includes('agent')) return 'agent';
  if (n.includes('viewer') || n.includes('dashboard')) return 'viewer';
  if (n.includes('factory') || n.includes('builder')) return 'factory';
  if (n.endsWith('-website') || n.endsWith('-app') || n.endsWith('-site')) return 'site';
  if (n.startsWith('codex-') || n.includes('claude')) return 'tooling';
  if (n.includes('directory')) return 'directory';
  return 'project';
}

function bestMemoryMatch(name, memMap) {
  // Try several slug variants — directory names don't perfectly match memory slug rules.
  const canon = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
  const variants = [name, name.toLowerCase(), canon(name)];
  for (const v of variants) {
    const direct = memMap.get(v);
    if (direct) return direct;
  }
  // Fuzzy: collapse hyphens/underscores and look both ways for substring match.
  const c = canon(name).replace(/-/g, '');
  if (c.length < 4) return null;  // avoid noise matches
  for (const [slug, e] of memMap.entries()) {
    const sc = slug.replace(/-/g, '');
    if (sc === c || sc.includes(c) || c.includes(sc)) return e;
  }
  // Last-ditch: word-overlap on title (e.g. "Lawyer Directory Builder" → lawyer-directory-builder dir).
  const dirWords = new Set(canon(name).split('-').filter(w => w.length >= 4));
  if (dirWords.size === 0) return null;
  let best = null, bestScore = 0;
  for (const [, e] of memMap.entries()) {
    const titleWords = new Set(canon(e.title || '').split('-').filter(w => w.length >= 4));
    const overlap = [...dirWords].filter(w => titleWords.has(w)).length;
    if (overlap >= 2 && overlap > bestScore) { best = e; bestScore = overlap; }
  }
  return best;
}

// Read package.json#description as a fallback when memory has nothing.
function pkgDescription(dir) {
  try {
    const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
    const pkg = JSON.parse(raw);
    return typeof pkg.description === 'string' && pkg.description.trim()
      ? pkg.description.trim()
      : null;
  } catch { return null; }
}

export function listBuilds({ analysisSlugs = new Set(), grades = new Map() } = {}) {
  const memMap = parseMemoryIndex();
  const pm2 = pm2Snapshot();
  let entries = [];
  try { entries = fs.readdirSync(PROJECTS, { withFileTypes: true }); }
  catch { return []; }

  const rows = [];
  for (const e of entries) {
    if (!e.isDirectory()) continue;
    if (e.name.startsWith('_') || e.name.startsWith('.')) continue;
    const dir = path.join(PROJECTS, e.name);
    const isGit = fs.existsSync(path.join(dir, '.git'));
    const last = (isGit ? gitLastCommit(dir) : null) || fsLastTouched(dir);
    const mem = bestMemoryMatch(e.name, memMap);
    const hints = parseHints(mem?.description || '');
    const pm2Hit = findPm2(e.name, pm2);
    const desc = mem?.description || pkgDescription(dir) || '';
    const slug = e.name.toLowerCase();
    rows.push({
      name: e.name,
      slug,
      path: dir,
      category: categorize(e.name, mem),
      compliance_sensitive: isComplianceSensitive(slug),
      description: desc,
      description_source: mem?.description ? 'memory' : (desc ? 'package' : null),
      memory_title: mem?.title || null,
      memory_file: mem?.file || null,
      url: hints.url,
      port: pm2Hit?.port || hints.port,
      last_at: last,
      has_git: isGit,
      analysis_slug: analysisSlugs.has(slug) ? slug : null,
      grade: grades.get(slug) || null,
      deep_search: grades.get(slug)?.deep_search || '',
      audit_score: grades.get(slug)?.audit_score ?? null,
      pm2: pm2Hit ? {
        name: pm2Hit.name,
        status: pm2Hit.status,
        port: pm2Hit.port,
        memory_mb: pm2Hit.memory_mb,
        restarts: pm2Hit.restarts,
      } : null,
    });
  }
  rows.sort((a, b) => {
    const aT = a.last_at ? Date.parse(a.last_at) : 0;
    const bT = b.last_at ? Date.parse(b.last_at) : 0;
    return bT - aT;
  });
  return rows;
}