← back to Abrams

scripts/inventory.js

132 lines

#!/usr/bin/env node
// inventory.js — discover every Abrams / AgentAbrams asset on this machine
// and stamp data/projects.json. Read-only. No prod writes.

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const http = require('http');

const ROOT = path.resolve(__dirname, '..');
const HOME = process.env.HOME;
const OUT = path.join(ROOT, 'data', 'projects.json');
const LOG = path.join(ROOT, 'data', 'build-log.jsonl');

const log = (type, payload) => fs.appendFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), type, ...payload }) + '\n');

// ── 1) walk ~/Projects/ for abrams / agentabrams dirs
const PROJECTS_DIR = path.join(HOME, 'Projects');
let dirs = [];
try {
  dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
    .filter(d => d.isDirectory())
    .filter(d => /abrams/i.test(d.name))
    .map(d => path.join(PROJECTS_DIR, d.name));
} catch (e) { /* swallow */ }

// ── 2) pull pm2 jlist for live ports + status
let pm2 = [];
try {
  const raw = execSync('pm2 jlist 2>/dev/null', { encoding: 'utf8' });
  pm2 = JSON.parse(raw);
} catch (e) { pm2 = []; }

const pm2For = (dir) => pm2.find(p => (p.pm2_env?.pm_cwd || '').toLowerCase() === dir.toLowerCase());
const pm2ForName = (name) => pm2.find(p => p.name === name);

// ── 3) build project entries
const projects = [];
for (const dir of dirs) {
  const name = path.basename(dir);
  let pkg = {};
  try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch {}
  let readme = '';
  try { readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8').split('\n').slice(0,8).join('\n'); } catch {}

  const description = pkg.description || readme.match(/^[A-Z][^\n]{30,200}/m)?.[0] || '';
  const p = pm2For(dir);
  const status = p ? (p.pm2_env?.status === 'online' ? 'live' : 'dev') : 'dev';
  const port = p?.pm2_env?.env?.PORT || (readme.match(/[Pp]ort[: ]+(\d{4})/) || [])[1] || null;

  // git stats
  let lastCommit = null, commitCount = 0;
  try {
    lastCommit = execSync(`cd "${dir}" && git log -1 --format="%ai %s" 2>/dev/null`, { encoding: 'utf8' }).trim();
    commitCount = parseInt(execSync(`cd "${dir}" && git rev-list --count HEAD 2>/dev/null`, { encoding: 'utf8' }).trim(), 10) || 0;
  } catch {}

  projects.push({
    slug: name.toLowerCase(),
    name,
    path: dir,
    description: description.slice(0, 240),
    status,
    port,
    domain: pkg.homepage || guessDomain(name),
    category: classifyCategory(name, description),
    pm2_name: p?.name || null,
    pm2_pid: p?.pid || null,
    pm2_uptime_ms: p?.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : null,
    commit_count: commitCount,
    last_commit: lastCommit,
    created_at: tryCtime(dir),
  });
}

function classifyCategory(name, desc) {
  const n = (name + ' ' + desc).toLowerCase();
  if (/report|news|drudge/.test(n)) return 'media';
  if (/mp3|audio|video|podcast/.test(n)) return 'media';
  if (/agentos|abramsos|rights|warranty|receipt|claim/.test(n)) return 'household-os';
  if (/discovery|viewer|panel|dashboard/.test(n)) return 'panel';
  if (/agentabrams|public/.test(n)) return 'umbrella';
  if (/worker|cron|tick/.test(n)) return 'infra';
  return 'misc';
}

function guessDomain(name) {
  const n = name.toLowerCase();
  if (n === 'agentabrams') return 'agentabrams.com';
  if (n === 'abrams-report') return 'report.agentabrams.com';
  if (n === 'mp3-agentabrams') return 'mp3.agentabrams.com';
  if (n === 'abramsos') return 'os.agentabrams.com';
  if (n === 'abrams-discovery-viewer') return 'discovery.agentabrams.com';
  if (n === 'videos-agentabrams-worker') return 'videos.agentabrams.com';
  if (n === 'abrams') return 'abrams.agentabrams.com (this panel)';
  return null;
}

function tryCtime(dir) {
  try { return new Date(fs.statSync(dir).birthtime || fs.statSync(dir).ctime).toISOString(); } catch { return null; }
}

// ── 4) also include known agentabrams.com subdomain projects living outside dirs
const externalKnown = [
  { name: 'lawyers.agentabrams.com', slug: 'lawyers-agentabrams', description: 'California bar attorney directory (lawyer-directory-builder).', status: 'live', domain: 'lawyers.agentabrams.com', category: 'directory', path: path.join(HOME, 'Projects/lawyer-directory-builder') },
  { name: 'sdccanalysis.agentabrams.com', slug: 'sdcc-agentabrams', description: 'Student loan & SDCC analysis hub.', status: 'live', domain: 'sdccanalysis.agentabrams.com', category: 'analysis', path: null },
  { name: 'butlr.agentabrams.com', slug: 'butlr-agentabrams', description: 'Butlr — AI dialer that holds for you.', status: 'live', domain: 'butlr.agentabrams.com', category: 'saas', path: path.join(HOME, 'Projects/butlr') },
];

for (const ek of externalKnown) {
  if (!projects.find(p => p.slug === ek.slug)) projects.push({ ...ek, port: null, commit_count: null, last_commit: null, created_at: null });
}

// ── 5) self-reference: the Panel itself
projects.unshift({
  slug: 'abrams-panel',
  name: 'Abrams Panel',
  path: ROOT,
  description: 'The umbrella — one screen for every Abrams / AgentAbrams project. Live build log, lead capture, trademark hub, SWOT ledger.',
  status: pm2ForName('abrams-panel') ? 'live' : 'dev',
  port: 9898,
  domain: 'panel.agentabrams.com',
  category: 'panel',
  created_at: tryCtime(ROOT),
});

// ── 6) write
const out = { updated_at: new Date().toISOString(), count: projects.length, projects };
fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
log('inventory', { count: projects.length, categories: [...new Set(projects.map(p => p.category))] });
console.log(`inventory: ${projects.length} projects → ${OUT}`);