[object Object]

← back to Cncp Mcp

feat: cncp-reconcile-projects.mjs — keep cncp-projects.json in sync with disk

45874a802115cc0f852c09f32f082df53fe8a4ac · 2026-05-11 12:04:03 -0700 · Steve Abrams

Sister to cncp-refresh.mjs (which handles domains). Walks ~/Projects/,
filters out stray files via project-marker check, adds new dirs to the
registry, flags missing entries with __stale (never deletes — case-rename
rescue), case-collapses duplicate entries.

Same atomic-write + 5-backup retention as cncp-refresh.

First run: 201 → 279 entries (78 adds, 24 flagged stale, 5 case-merges).

Driven by com.steve.cncp-reconcile-projects.plist every 30 min, plus the
mkproj zsh function for instant pickup on new dirs.

Files touched

Diff

commit 45874a802115cc0f852c09f32f082df53fe8a4ac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 12:04:03 2026 -0700

    feat: cncp-reconcile-projects.mjs — keep cncp-projects.json in sync with disk
    
    Sister to cncp-refresh.mjs (which handles domains). Walks ~/Projects/,
    filters out stray files via project-marker check, adds new dirs to the
    registry, flags missing entries with __stale (never deletes — case-rename
    rescue), case-collapses duplicate entries.
    
    Same atomic-write + 5-backup retention as cncp-refresh.
    
    First run: 201 → 279 entries (78 adds, 24 flagged stale, 5 case-merges).
    
    Driven by com.steve.cncp-reconcile-projects.plist every 30 min, plus the
    mkproj zsh function for instant pickup on new dirs.
---
 scripts/cncp-reconcile-projects.mjs | 224 ++++++++++++++++++++++++++++++++++++
 1 file changed, 224 insertions(+)

diff --git a/scripts/cncp-reconcile-projects.mjs b/scripts/cncp-reconcile-projects.mjs
new file mode 100755
index 0000000..cd4ef53
--- /dev/null
+++ b/scripts/cncp-reconcile-projects.mjs
@@ -0,0 +1,224 @@
+#!/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.
+  for (const canon of Object.keys(collapsed)) {
+    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);
+}

← ae1c6b7 security: cncp-refresh keeps newest 5 backups, deletes the r  ·  back to Cncp Mcp  ·  feat(reconciler): skip stale-flagging for entries marked reg 9cc6d6b →