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