← back to Build Debrief

scripts/inventory.js

130 lines

#!/usr/bin/env node
// Walk ~/Projects/ and collect metadata on every git repo.
// Output → data/projects.json. Re-runnable; merges with existing verdicts/notes.

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

const HOME = os.homedir();
const PROJECTS = path.join(HOME, 'Projects');
const DATA = path.join(__dirname, '..', 'data');
const OUT = path.join(DATA, 'projects.json');

if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });

function sh(cmd, cwd) {
  try { return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
  catch { return ''; }
}

function dirSizeMB(p) {
  const o = sh(`du -sxm "${p}" 2>/dev/null | awk '{print $1}'`);
  return parseInt(o, 10) || 0;
}

function readReadmeSnippet(p) {
  for (const name of ['README.md', 'readme.md', 'Readme.md', 'README']) {
    const f = path.join(p, name);
    if (fs.existsSync(f)) {
      const txt = fs.readFileSync(f, 'utf8').replace(/\r/g, '');
      const firstPara = txt.split(/\n\s*\n/).find(p => p.trim().length > 30);
      if (firstPara) return firstPara.trim().slice(0, 600);
    }
  }
  return '';
}

function pkgJsonInfo(p) {
  try {
    const pj = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8'));
    return { name: pj.name, description: pj.description, version: pj.version };
  } catch { return null; }
}

const existing = (() => {
  try { return JSON.parse(fs.readFileSync(OUT, 'utf8')); } catch { return { projects: [] }; }
})();
const existingByPath = new Map((existing.projects || []).map(p => [p.path, p]));

const dirs = fs.readdirSync(PROJECTS)
  .filter(d => !d.startsWith('.') && !d.startsWith('_'))
  .map(d => path.join(PROJECTS, d))
  .filter(d => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });

const out = [];
let i = 0;
for (const d of dirs) {
  i++;
  if (i % 25 === 0) process.stderr.write(`  ${i}/${dirs.length}…\n`);
  const name = path.basename(d);
  const hasGit = fs.existsSync(path.join(d, '.git'));
  const firstCommit = hasGit
    ? sh(`git log --reverse --format=%aI 2>/dev/null | head -1`, d)
    : '';
  const lastCommit = hasGit
    ? sh(`git log -1 --format=%aI 2>/dev/null`, d)
    : '';
  const lastSubject = hasGit
    ? sh(`git log -1 --format=%s 2>/dev/null`, d)
    : '';
  const commitCount = hasGit
    ? parseInt(sh(`git rev-list --count HEAD 2>/dev/null`, d), 10) || 0
    : 0;
  const fileMtime = sh(`stat -f "%Sm" -t %FT%T "${d}"`);
  const sizeMB = dirSizeMB(d);
  const pkg = pkgJsonInfo(d);
  const readme = readReadmeSnippet(d);

  // Best-effort first/last date — fall back to fs mtime if no git.
  const first = firstCommit || (fileMtime ? fileMtime + 'Z' : '');
  const last = lastCommit || (fileMtime ? fileMtime + 'Z' : '');

  // Activity heuristic: days since last commit
  const lastDate = last ? new Date(last) : null;
  const daysSince = lastDate ? Math.floor((Date.now() - lastDate.getTime()) / 86400000) : 999;
  let health = 'cold';
  if (daysSince <= 3) health = 'hot';
  else if (daysSince <= 14) health = 'warm';
  else if (daysSince <= 60) health = 'cool';

  const prev = existingByPath.get(d) || {};
  out.push({
    path: d,
    name,
    has_git: hasGit,
    first_at: first,
    last_at: last,
    last_subject: lastSubject.slice(0, 200),
    commit_count: commitCount,
    size_mb: sizeMB,
    days_since: daysSince,
    health,
    pkg_name: pkg?.name || null,
    pkg_description: pkg?.description || null,
    pkg_version: pkg?.version || null,
    readme_snippet: readme,
    // user-editable / persisted between runs
    pros: prev.pros || '',
    cons: prev.cons || '',
    verdict: prev.verdict || null,        // 'keep' | 'kill' | 'archive' | 'pivot' | null
    priority: prev.priority || 0,         // user-set 0..10
    notes: prev.notes || '',
    chat: prev.chat || [],                // [{role, content, at}]
  });
}

// Sort by first_at descending so newest builds bubble up.
out.sort((a, b) => (b.first_at || '').localeCompare(a.first_at || ''));

const payload = {
  generated_at: new Date().toISOString(),
  total: out.length,
  projects: out,
};

fs.writeFileSync(OUT, JSON.stringify(payload, null, 2));
console.log(`inventory: ${out.length} projects → ${OUT}`);
console.log(`  hot=${out.filter(p => p.health === 'hot').length}  warm=${out.filter(p => p.health === 'warm').length}  cool=${out.filter(p => p.health === 'cool').length}  cold=${out.filter(p => p.health === 'cold').length}`);