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