← back to Cncp Mcp
scripts/cncp-reconcile-projects.mjs
230 lines
#!/usr/bin/env node
/**
* cncp-reconcile-projects.mjs — keep ~/cncp-starter/cncp-projects.json in sync
* with ~/Projects/ on disk.
*
* Sister to cncp-refresh.mjs (which handles domains). This one handles project
* dirs. Same atomic-write + bounded-backup-retention pattern.
*
* Modes:
* --dry-run (default) → report what would change, no writes
* --apply → write changes
*
* What it does:
* ADD — any ~/Projects/<dir>/ that's not in the registry, registered under
* a canonical lowercase key with notes: "(auto-discovered <date>)"
* FLAG — any registry entry whose canonical key has no matching dir → marks
* entry with __stale: <date>. NEVER deletes (case-rename rescue).
* UNFLAG — entries previously marked __stale that came back → __stale cleared
* MERGE — duplicate case-variants collapse to canonical lowercase key,
* merging notes/publishedUrl/repo (longer values win).
*
* Manual fields PRESERVED on existing entries: notes, publishedUrl, repo, and
* anything else set by hand. We only set them when creating a new entry.
*
* Noise filter — a dir in ~/Projects/ only counts as a project if it has any
* of: package.json, requirements.txt, Cargo.toml, go.mod, *.py, *.js, *.ts,
* *.html, *.sh, server.js, src/, public/, app/. Empty/stray dirs ignored.
*/
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join, dirname, basename } from "node:path";
const PROJECTS_DIR = process.env.PROJECTS_DIR || join(homedir(), "Projects");
const CNCP_DIR = process.env.CNCP_DIR || join(homedir(), "cncp-starter");
const REGISTRY = join(CNCP_DIR, "cncp-projects.json");
const PROJECT_MARKERS = new Set([
"package.json", "requirements.txt", "Cargo.toml", "go.mod", "Gemfile",
"pyproject.toml", "server.js", "server.ts", "main.py", "app.py", "index.js",
"index.ts", "index.html", "Makefile", "ecosystem.config.js",
"ecosystem.config.cjs", ".git", "src", "public", "app", "pages",
]);
const PROJECT_EXT = /\.(py|js|ts|tsx|jsx|html|sh|mjs|cjs|go|rs|rb|sql)$/i;
function todayIso() { return new Date().toISOString().slice(0, 10); }
function canonKey(name) { return String(name).toLowerCase().replace(/\s+/g, "-"); }
async function isProjectDir(absPath) {
try {
const entries = await fs.readdir(absPath);
for (const e of entries) {
if (PROJECT_MARKERS.has(e)) return true;
if (PROJECT_EXT.test(e)) return true;
}
} catch { return false; }
return false;
}
async function listProjectDirs() {
const entries = await fs.readdir(PROJECTS_DIR, { withFileTypes: true });
const out = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
if (e.name.startsWith(".")) continue;
const abs = join(PROJECTS_DIR, e.name);
if (await isProjectDir(abs)) out.push(e.name);
}
return out;
}
async function loadRegistry() {
try {
return JSON.parse(await fs.readFile(REGISTRY, "utf-8"));
} catch (e) {
if (e.code === "ENOENT") return {};
throw e;
}
}
async function saveRegistry(obj) {
try {
const current = await fs.readFile(REGISTRY, "utf-8");
await fs.writeFile(`${REGISTRY}.bak.${Date.now()}`, current, "utf-8");
} catch {}
const tmp = `${REGISTRY}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmp, JSON.stringify(obj, null, 2) + "\n", "utf-8");
await fs.rename(tmp, REGISTRY);
// Retention: newest 5 backups, delete rest.
try {
const dir = dirname(REGISTRY);
const base = basename(REGISTRY);
const entries = await fs.readdir(dir);
const baks = entries
.filter(n => n.startsWith(`${base}.bak.`))
.map(n => ({ n, ts: Number(n.split(".bak.")[1].replace(/\D/g, "")) || 0 }))
.sort((a, b) => b.ts - a.ts)
.slice(5);
for (const { n } of baks) {
try { await fs.unlink(join(dir, n)); } catch {}
}
} catch {}
}
function mergeEntry(a = {}, b = {}) {
const out = { ...a };
for (const k of ["notes", "publishedUrl", "repo"]) {
const av = (a[k] || "").trim();
const bv = (b[k] || "").trim();
out[k] = bv.length > av.length ? bv : av;
}
for (const k of Object.keys(b)) {
if (!(k in out) && !["notes", "publishedUrl", "repo"].includes(k)) {
out[k] = b[k];
}
}
return out;
}
async function reconcile({ apply }) {
const disk = await listProjectDirs();
const diskByCanon = new Map();
for (const d of disk) {
const k = canonKey(d);
if (!diskByCanon.has(k)) diskByCanon.set(k, []);
diskByCanon.get(k).push(d);
}
const registry = await loadRegistry();
const regByCanon = new Map();
for (const k of Object.keys(registry)) {
const c = canonKey(k);
if (!regByCanon.has(c)) regByCanon.set(c, []);
regByCanon.get(c).push(k);
}
const ops = { adds: [], stales: [], unstales: [], merges: [] };
// Pass 1: collapse case-variants in registry to canonical key.
const collapsed = {};
for (const [canon, keys] of regByCanon.entries()) {
if (keys.length === 1 && keys[0] === canon) {
collapsed[canon] = registry[keys[0]];
continue;
}
let merged = {};
for (const k of keys) merged = mergeEntry(merged, registry[k]);
if (keys.length > 1 || keys[0] !== canon) {
ops.merges.push({ canon, from: keys });
}
collapsed[canon] = merged;
}
// Pass 2: add missing.
for (const [canon, names] of diskByCanon.entries()) {
if (!collapsed[canon]) {
const displayName = names[0];
collapsed[canon] = {
notes: `(auto-discovered ${todayIso()})`,
publishedUrl: "",
repo: "",
displayName,
};
ops.adds.push(canon);
} else if (collapsed[canon].__stale) {
delete collapsed[canon].__stale;
ops.unstales.push(canon);
}
}
// Pass 3: flag stale. Skip entries marked registryOnly (intentional — they
// live outside ~/Projects/, e.g. ~/.claude/skills/, ~/DW-Agents/, Kamatera).
for (const canon of Object.keys(collapsed)) {
if (collapsed[canon].registryOnly) {
if (collapsed[canon].__stale) delete collapsed[canon].__stale;
continue;
}
if (!diskByCanon.has(canon) && !collapsed[canon].__stale) {
collapsed[canon].__stale = todayIso();
ops.stales.push(canon);
}
}
// Sort keys alphabetically for stable diffs.
const sorted = {};
for (const k of Object.keys(collapsed).sort()) sorted[k] = collapsed[k];
const changed = ops.adds.length + ops.stales.length + ops.unstales.length + ops.merges.length;
if (apply && changed > 0) await saveRegistry(sorted);
return {
mode: apply ? "apply" : "dry-run",
diskCount: disk.length,
registryCountBefore: Object.keys(registry).length,
registryCountAfter: Object.keys(sorted).length,
...ops,
};
}
const args = process.argv.slice(2);
const apply = args.includes("--apply");
try {
const r = await reconcile({ apply });
const summary = {
mode: r.mode,
disk: r.diskCount,
before: r.registryCountBefore,
after: r.registryCountAfter,
adds: r.adds.length,
stales: r.stales.length,
unstales: r.unstales.length,
merges: r.merges.length,
};
console.log(new Date().toISOString(), JSON.stringify(summary));
if (!apply && (r.adds.length || r.stales.length || r.merges.length)) {
if (r.adds.length) console.error("ADD :", r.adds.slice(0, 30).join(", ") + (r.adds.length > 30 ? ` …+${r.adds.length - 30}` : ""));
if (r.stales.length) console.error("STALE :", r.stales.slice(0, 30).join(", ") + (r.stales.length > 30 ? ` …+${r.stales.length - 30}` : ""));
if (r.merges.length) {
console.error("MERGE :");
for (const m of r.merges.slice(0, 20)) console.error(` ${m.canon} ← ${m.from.join(" + ")}`);
}
}
process.exit(0);
} catch (e) {
console.error(new Date().toISOString(), "ERROR:", e.message);
process.exit(1);
}