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