← 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
A scripts/cncp-reconcile-projects.mjs
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 →