[object Object]

← back to Projects Agentabrams

projects.agentabrams.com viewer: list-left + live-iframe-right (all.agentabrams schema)

3a2f655229acde81b767c6e3c7622b01dff416f1 · 2026-07-28 07:23:20 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 3a2f655229acde81b767c6e3c7622b01dff416f1
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Tue Jul 28 07:23:20 2026 -0700

    projects.agentabrams.com viewer: list-left + live-iframe-right (all.agentabrams schema)
---
 .gitignore        |   8 +
 README.md         |  25 +++
 build-projects.js | 104 ++++++++++++
 package.json      |  10 ++
 projects.json     | 490 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html | 165 ++++++++++++++++++
 server.js         | 191 +++++++++++++++++++++
 7 files changed, 993 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..46f4591
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# projects.agentabrams.com — Project Viewer
+
+Left rail lists every buildable project; clicking one loads its **live site** in
+the right panel. Same schema as `all.agentabrams.com`'s domain viewer: a
+searchable list drives an iframe fed by a credential-injecting reverse proxy
+(`/site/<slug>/…`) that applies the unified `admin:DW2024!` upstream and strips
+`X-Frame-Options`/CSP so any fleet site frames cleanly.
+
+## Data
+`projects.json` (regenerate: `node build-projects.js`) merges two sources:
+- **local** — a live pm2 process with a listening port → `http://127.0.0.1:PORT`
+- **public** — a public domain from the project's `.deploy.conf` HEALTH_URL → `https://<domain>`
+Public domain wins when a project has both.
+
+## Run
+```
+PORT=9702 node server.js         # http://127.0.0.1:9702  (Basic admin:DW2024!)
+```
+
+## Deploy note — DOMAIN COLLISION
+`projects.agentabrams.com` currently resolves to the **build-pages** git-journal
+site (`~/Projects/build-pages`, Kamatera `/root/public-projects/build-pages`).
+Publishing this viewer to that domain would replace it — that decision is
+Steve-gated and NOT done here. Options: replace build-pages, use another
+subdomain (e.g. `projectviewer.agentabrams.com`), or keep this internal-only.
diff --git a/build-projects.js b/build-projects.js
new file mode 100644
index 0000000..44a7fbd
--- /dev/null
+++ b/build-projects.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+// build-projects.js — regenerate projects.json, the data behind the viewer.
+//
+// A "project" is viewable if it resolves to a URL we can proxy: either a LIVE
+// pm2 process with a listening port (→ http://127.0.0.1:PORT) or a public
+// domain declared in its .deploy.conf HEALTH_URL (→ https://<domain>). We merge
+// both by slug (the working-tree basename) and prefer the public domain when a
+// project has one, since the live customer-facing site is the nicer preview and
+// is reachable from anywhere the viewer runs.
+//
+// Git is the only source of truth for the project list; this script just reads
+// what's actually running + configured, so re-running it keeps the rail honest.
+
+const fs = require('fs');
+const path = require('path');
+const cp = require('child_process');
+
+const HOME = process.env.HOME;
+const PROJECTS_DIR = path.join(HOME, 'Projects');
+
+// slug -> { slug, name, local, domain }
+const map = new Map();
+const get = (slug) => {
+  if (!map.has(slug)) map.set(slug, { slug, name: pretty(slug), local: null, domain: null, pm2: null });
+  return map.get(slug);
+};
+
+// "small-business-builder" -> "Small Business Builder"; keeps ALLCAPS acronyms.
+function pretty(slug) {
+  return String(slug)
+    .replace(/[-_]+/g, ' ')
+    .replace(/\b\w/g, (c) => c.toUpperCase())
+    .replace(/\b(Dw|Aa|Sdcc|Rentv|Wpb|Ai|Faa|Gmc|Pdl|Crcp)\b/gi, (m) => m.toUpperCase());
+}
+
+// ---- 1. pm2 live ports -----------------------------------------------------
+function lsofPort(pid) {
+  try {
+    const o = cp.execSync(`lsof -nP -iTCP -sTCP:LISTEN -a -p ${pid} 2>/dev/null`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
+    const m = o.match(/:(\d+)\s*\(LISTEN\)/);
+    return m ? parseInt(m[1], 10) : null;
+  } catch (_) { return null; }
+}
+
+try {
+  const list = JSON.parse(cp.execSync('pm2 jlist', { stdio: ['ignore', 'pipe', 'ignore'] }).toString());
+  for (const p of list) {
+    const env = (p.pm2_env && p.pm2_env.env) || {};
+    let port = env.PORT ? parseInt(env.PORT, 10) : null;
+    if (!port && p.pid) port = lsofPort(p.pid);
+    if (!port) continue;
+    // High DNS / internal-gateway ports aren't browsable UIs — skip the obvious ones.
+    if (port === 53535 || port < 80) continue;
+    const cwd = (p.pm2_env && p.pm2_env.pm_cwd) || '';
+    const slug = cwd.split('/').pop() || p.name;
+    const rec = get(slug);
+    // First (lowest) port wins as the primary; keep the pm2 name for display.
+    if (!rec.local) { rec.local = port; rec.pm2 = p.name; }
+  }
+} catch (e) { console.error('pm2 read failed:', e.message); }
+
+// ---- 2. public domains from .deploy.conf HEALTH_URL ------------------------
+function publicDomain(url) {
+  try {
+    const u = new URL(url);
+    const h = u.hostname;
+    if (h === 'localhost' || /^127\./.test(h) || !h.includes('.')) return null;
+    return h;
+  } catch (_) { return null; }
+}
+
+for (const dir of fs.readdirSync(PROJECTS_DIR)) {
+  const conf = path.join(PROJECTS_DIR, dir, '.deploy.conf');
+  if (!fs.existsSync(conf)) continue;
+  let txt = '';
+  try { txt = fs.readFileSync(conf, 'utf8'); } catch (_) { continue; }
+  const m = txt.match(/^\s*(?:HEALTH_URL|URL)\s*=\s*["']?([^"'\s]+)/im);
+  if (!m) continue;
+  const dom = publicDomain(m[1]);
+  if (!dom) continue;
+  get(dir).domain = dom;
+}
+
+// ---- 3. finalize -----------------------------------------------------------
+// Keep only projects that have SOMEWHERE to point (a domain or a live port).
+const out = [];
+for (const r of map.values()) {
+  if (!r.domain && !r.local) continue;
+  const kind = r.domain ? 'public' : 'local';
+  const target = r.domain ? `https://${r.domain}` : `http://127.0.0.1:${r.local}`;
+  out.push({
+    slug: r.slug,
+    name: r.name,
+    kind,
+    target,
+    domain: r.domain,     // may be null
+    port: r.local,        // may be null
+  });
+}
+out.sort((a, b) => a.name.localeCompare(b.name));
+
+fs.writeFileSync(path.join(__dirname, 'projects.json'), JSON.stringify(out, null, 2) + '\n');
+const pub = out.filter((o) => o.kind === 'public').length;
+console.log(`projects.json: ${out.length} projects (${pub} public domain, ${out.length - pub} local port)`);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9a0850e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "projects-agentabrams",
+  "version": "0.1.0",
+  "private": true,
+  "description": "projects.agentabrams.com — project viewer (list-left + live iframe-right), all.agentabrams schema",
+  "scripts": {
+    "start": "node server.js",
+    "build": "node build-projects.js"
+  }
+}
diff --git a/projects.json b/projects.json
new file mode 100644
index 0000000..2c5eb24
--- /dev/null
+++ b/projects.json
@@ -0,0 +1,490 @@
+[
+  {
+    "slug": "AbramsEgo",
+    "name": "AbramsEgo",
+    "kind": "local",
+    "target": "http://127.0.0.1:9773",
+    "domain": null,
+    "port": 9773
+  },
+  {
+    "slug": "AbramsOS",
+    "name": "AbramsOS",
+    "kind": "local",
+    "target": "http://127.0.0.1:9774",
+    "domain": null,
+    "port": 9774
+  },
+  {
+    "slug": "agent-cabinet",
+    "name": "Agent Cabinet",
+    "kind": "local",
+    "target": "http://127.0.0.1:9766",
+    "domain": null,
+    "port": 9766
+  },
+  {
+    "slug": "agentabrams-viewer",
+    "name": "Agentabrams Viewer",
+    "kind": "local",
+    "target": "http://127.0.0.1:9783",
+    "domain": null,
+    "port": 9783
+  },
+  {
+    "slug": "ai-workforce",
+    "name": "AI Workforce",
+    "kind": "local",
+    "target": "http://127.0.0.1:9791",
+    "domain": null,
+    "port": 9791
+  },
+  {
+    "slug": "all-designerwallcoverings",
+    "name": "All Designerwallcoverings",
+    "kind": "local",
+    "target": "http://127.0.0.1:9958",
+    "domain": null,
+    "port": 9958
+  },
+  {
+    "slug": "animals",
+    "name": "Animals",
+    "kind": "local",
+    "target": "http://127.0.0.1:9720",
+    "domain": null,
+    "port": 9720
+  },
+  {
+    "slug": "build-pages",
+    "name": "Build Pages",
+    "kind": "public",
+    "target": "https://projects.agentabrams.com",
+    "domain": "projects.agentabrams.com",
+    "port": null
+  },
+  {
+    "slug": "claude-webdev-accelerator",
+    "name": "Claude Webdev Accelerator",
+    "kind": "local",
+    "target": "http://127.0.0.1:4901",
+    "domain": null,
+    "port": 4901
+  },
+  {
+    "slug": "clipreviewbydave",
+    "name": "Clipreviewbydave",
+    "kind": "local",
+    "target": "http://127.0.0.1:9736",
+    "domain": null,
+    "port": 9736
+  },
+  {
+    "slug": "colorwheel",
+    "name": "Colorwheel",
+    "kind": "local",
+    "target": "http://127.0.0.1:9851",
+    "domain": null,
+    "port": 9851
+  },
+  {
+    "slug": "consulting-intake",
+    "name": "Consulting Intake",
+    "kind": "local",
+    "target": "http://127.0.0.1:9700",
+    "domain": null,
+    "port": 9700
+  },
+  {
+    "slug": "consulting-rentv-com",
+    "name": "Consulting RENTV Com",
+    "kind": "local",
+    "target": "http://127.0.0.1:9701",
+    "domain": null,
+    "port": 9701
+  },
+  {
+    "slug": "crezana-internal",
+    "name": "Crezana Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:10072",
+    "domain": null,
+    "port": 10072
+  },
+  {
+    "slug": "designersalesreps",
+    "name": "Designersalesreps",
+    "kind": "public",
+    "target": "https://designersalesreps.com",
+    "domain": "designersalesreps.com",
+    "port": null
+  },
+  {
+    "slug": "designerssalesreps",
+    "name": "Designerssalesreps",
+    "kind": "public",
+    "target": "https://designerssalesreps.com",
+    "domain": "designerssalesreps.com",
+    "port": null
+  },
+  {
+    "slug": "dw-activation-calendar",
+    "name": "DW Activation Calendar",
+    "kind": "local",
+    "target": "http://127.0.0.1:9765",
+    "domain": null,
+    "port": 9765
+  },
+  {
+    "slug": "dw-cadence-next2",
+    "name": "DW Cadence Next2",
+    "kind": "local",
+    "target": "http://127.0.0.1:9764",
+    "domain": null,
+    "port": 9764
+  },
+  {
+    "slug": "dw-domain-viewer",
+    "name": "DW Domain Viewer",
+    "kind": "local",
+    "target": "http://127.0.0.1:9784",
+    "domain": null,
+    "port": 9784
+  },
+  {
+    "slug": "dw-internal-gateway",
+    "name": "DW Internal Gateway",
+    "kind": "local",
+    "target": "http://127.0.0.1:8090",
+    "domain": null,
+    "port": 8090
+  },
+  {
+    "slug": "dw-marketing-reels",
+    "name": "DW Marketing Reels",
+    "kind": "local",
+    "target": "http://127.0.0.1:9848",
+    "domain": null,
+    "port": 9848
+  },
+  {
+    "slug": "dw-pairs-well",
+    "name": "DW Pairs Well",
+    "kind": "public",
+    "target": "https://pairs.designerwallcoverings.com",
+    "domain": "pairs.designerwallcoverings.com",
+    "port": null
+  },
+  {
+    "slug": "dw-pitch-followup",
+    "name": "DW Pitch Followup",
+    "kind": "local",
+    "target": "http://127.0.0.1:9768",
+    "domain": null,
+    "port": 9768
+  },
+  {
+    "slug": "estimate-instant",
+    "name": "Estimate Instant",
+    "kind": "local",
+    "target": "http://127.0.0.1:3901",
+    "domain": null,
+    "port": 3901
+  },
+  {
+    "slug": "fantasea-consulting",
+    "name": "Fantasea Consulting",
+    "kind": "local",
+    "target": "http://127.0.0.1:9782",
+    "domain": null,
+    "port": 9782
+  },
+  {
+    "slug": "fentucci-naturals",
+    "name": "Fentucci Naturals",
+    "kind": "local",
+    "target": "http://127.0.0.1:9981",
+    "domain": null,
+    "port": 9981
+  },
+  {
+    "slug": "fromental-internal",
+    "name": "Fromental Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:10071",
+    "domain": null,
+    "port": 10071
+  },
+  {
+    "slug": "gracie-internal",
+    "name": "Gracie Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:10073",
+    "domain": null,
+    "port": 10073
+  },
+  {
+    "slug": "greenland-onboard",
+    "name": "Greenland Onboard",
+    "kind": "local",
+    "target": "http://127.0.0.1:9973",
+    "domain": null,
+    "port": 9973
+  },
+  {
+    "slug": "holdforme",
+    "name": "Holdforme",
+    "kind": "public",
+    "target": "https://butlr.agentabrams.com",
+    "domain": "butlr.agentabrams.com",
+    "port": null
+  },
+  {
+    "slug": "homesonspec",
+    "name": "Homesonspec",
+    "kind": "local",
+    "target": "http://127.0.0.1:3100",
+    "domain": null,
+    "port": 3100
+  },
+  {
+    "slug": "instagram-agent",
+    "name": "Instagram Agent",
+    "kind": "local",
+    "target": "http://127.0.0.1:9810",
+    "domain": null,
+    "port": 9810
+  },
+  {
+    "slug": "kalshi-dash",
+    "name": "Kalshi Dash",
+    "kind": "local",
+    "target": "http://127.0.0.1:7810",
+    "domain": null,
+    "port": 7810
+  },
+  {
+    "slug": "model-arena",
+    "name": "Model Arena",
+    "kind": "local",
+    "target": "http://127.0.0.1:9758",
+    "domain": null,
+    "port": 9758
+  },
+  {
+    "slug": "muralsource-internal",
+    "name": "Muralsource Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:9947",
+    "domain": null,
+    "port": 9947
+  },
+  {
+    "slug": "norma-sdcc-pitch",
+    "name": "Norma SDCC Pitch",
+    "kind": "local",
+    "target": "http://127.0.0.1:9876",
+    "domain": null,
+    "port": 9876
+  },
+  {
+    "slug": "pattern-vault",
+    "name": "Pattern Vault",
+    "kind": "local",
+    "target": "http://127.0.0.1:9779",
+    "domain": null,
+    "port": 9779
+  },
+  {
+    "slug": "patterndesignlab",
+    "name": "Patterndesignlab",
+    "kind": "public",
+    "target": "https://prestige.agentabrams.com",
+    "domain": "prestige.agentabrams.com",
+    "port": 9781
+  },
+  {
+    "slug": "permit-radar",
+    "name": "Permit Radar",
+    "kind": "local",
+    "target": "http://127.0.0.1:3905",
+    "domain": null,
+    "port": 3905
+  },
+  {
+    "slug": "prestige-car-wash",
+    "name": "Prestige Car Wash",
+    "kind": "local",
+    "target": "http://127.0.0.1:9808",
+    "domain": null,
+    "port": 9808
+  },
+  {
+    "slug": "reidwitlin-landing",
+    "name": "Reidwitlin Landing",
+    "kind": "public",
+    "target": "https://reidwitlin.designerwallcoverings.com",
+    "domain": "reidwitlin.designerwallcoverings.com",
+    "port": 9952
+  },
+  {
+    "slug": "reno-visualizer",
+    "name": "Reno Visualizer",
+    "kind": "local",
+    "target": "http://127.0.0.1:3904",
+    "domain": null,
+    "port": 3904
+  },
+  {
+    "slug": "rentv-v1",
+    "name": "RENTV V1",
+    "kind": "local",
+    "target": "http://127.0.0.1:9704",
+    "domain": null,
+    "port": 9704
+  },
+  {
+    "slug": "rigo-line-viewer",
+    "name": "Rigo Line Viewer",
+    "kind": "local",
+    "target": "http://127.0.0.1:9979",
+    "domain": null,
+    "port": 9979
+  },
+  {
+    "slug": "sample-box",
+    "name": "Sample Box",
+    "kind": "local",
+    "target": "http://127.0.0.1:3903",
+    "domain": null,
+    "port": 3903
+  },
+  {
+    "slug": "schumacher-internal",
+    "name": "Schumacher Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:9946",
+    "domain": null,
+    "port": 9946
+  },
+  {
+    "slug": "sdcc-journalists-viewer",
+    "name": "SDCC Journalists Viewer",
+    "kind": "local",
+    "target": "http://127.0.0.1:9878",
+    "domain": null,
+    "port": 9878
+  },
+  {
+    "slug": "slack-idea-board",
+    "name": "Slack Idea Board",
+    "kind": "local",
+    "target": "http://127.0.0.1:9820",
+    "domain": null,
+    "port": 9820
+  },
+  {
+    "slug": "slack-to-steve",
+    "name": "Slack To Steve",
+    "kind": "local",
+    "target": "http://127.0.0.1:9897",
+    "domain": null,
+    "port": 9897
+  },
+  {
+    "slug": "small-business-builder",
+    "name": "Small Business Builder",
+    "kind": "local",
+    "target": "http://127.0.0.1:9760",
+    "domain": null,
+    "port": 9760
+  },
+  {
+    "slug": "thesetdecorator",
+    "name": "Thesetdecorator",
+    "kind": "public",
+    "target": "https://thesetdecorator.com",
+    "domain": "thesetdecorator.com",
+    "port": null
+  },
+  {
+    "slug": "ticket-system",
+    "name": "Ticket System",
+    "kind": "local",
+    "target": "http://127.0.0.1:9794",
+    "domain": null,
+    "port": 9794
+  },
+  {
+    "slug": "trade-portal",
+    "name": "Trade Portal",
+    "kind": "local",
+    "target": "http://127.0.0.1:3902",
+    "domain": null,
+    "port": 3902
+  },
+  {
+    "slug": "trending-dw",
+    "name": "Trending DW",
+    "kind": "public",
+    "target": "https://trending.designerwallcoverings.com",
+    "domain": "trending.designerwallcoverings.com",
+    "port": null
+  },
+  {
+    "slug": "vahallan-line-viewer",
+    "name": "Vahallan Line Viewer",
+    "kind": "local",
+    "target": "http://127.0.0.1:9982",
+    "domain": null,
+    "port": 9982
+  },
+  {
+    "slug": "viewer-local",
+    "name": "Viewer Local",
+    "kind": "local",
+    "target": "http://127.0.0.1:9931",
+    "domain": null,
+    "port": 9931
+  },
+  {
+    "slug": "wallco-ai",
+    "name": "Wallco AI",
+    "kind": "public",
+    "target": "https://wallco.ai",
+    "domain": "wallco.ai",
+    "port": null
+  },
+  {
+    "slug": "wild-wallcoverings",
+    "name": "Wild Wallcoverings",
+    "kind": "local",
+    "target": "http://127.0.0.1:9787",
+    "domain": null,
+    "port": 9787
+  },
+  {
+    "slug": "wpb-sales-dashboard",
+    "name": "WPB Sales Dashboard",
+    "kind": "local",
+    "target": "http://127.0.0.1:9812",
+    "domain": null,
+    "port": 9812
+  },
+  {
+    "slug": "wpb-uploaders",
+    "name": "WPB Uploaders",
+    "kind": "local",
+    "target": "http://127.0.0.1:9813",
+    "domain": null,
+    "port": 9813
+  },
+  {
+    "slug": "zuber-internal",
+    "name": "Zuber Internal",
+    "kind": "local",
+    "target": "http://127.0.0.1:10074",
+    "domain": null,
+    "port": 10074
+  }
+]
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..d76c3cc
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,165 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>projects.agentabrams.com — Project Viewer</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2328412f'/%3E%3Ctext x='8' y='12' text-anchor='middle' font-family='Georgia,serif' font-size='11' fill='%23f7f1e6'%3Ep%3C/text%3E%3C/svg%3E">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<style>
+  :root { --rail: 320px; --bg:#0e1116; --panel:#161b22; --line:#2b313b; --txt:#dfe6ee; --dim:#8b95a3; --accent:#3fb6a8; }
+  * { box-sizing: border-box; }
+  body { margin:0; height:100vh; display:flex; font:14px/1.45 -apple-system, "SF Pro Text", Helvetica, sans-serif; background:var(--bg); color:var(--txt); }
+
+  #rail { width:var(--rail); min-width:250px; max-width:520px; display:flex; flex-direction:column; border-right:1px solid var(--line); background:var(--panel); }
+  #rail header { padding:14px 14px 10px; border-bottom:1px solid var(--line); }
+  #rail h1 { margin:0 0 8px; font-size:15px; font-weight:700; letter-spacing:.02em; }
+  #rail h1 small { color:var(--dim); font-weight:400; }
+  #q { width:100%; padding:7px 10px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); outline:none; }
+  #q:focus { border-color:var(--accent); }
+  #filters { display:flex; gap:6px; margin-top:8px; }
+  #filters button { flex:1; padding:5px 6px; font-size:11.5px; }
+  #filters button.on { border-color:var(--accent); color:var(--accent); background:#16221f; }
+  #list { flex:1; overflow-y:auto; padding:6px; }
+  .item { display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:7px; cursor:pointer; color:var(--txt); user-select:none; }
+  .item:hover { background:#1e2530; }
+  .item.active { background:#213132; outline:1px solid var(--accent); }
+  .dot { width:8px; height:8px; border-radius:50%; flex:none; background:#555; }
+  .dot.up { background:#3fbf6f; }
+  .dot.auth { background:#d9a53a; }
+  .dot.login { background:#4a90e2; }
+  .dot.down { background:#d95555; }
+  .name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .name b { color:var(--accent); font-weight:600; }
+  .badge { font-size:10px; padding:1px 6px; border-radius:10px; flex:none; border:1px solid var(--line); color:var(--dim); }
+  .badge.public { color:#7fd6c9; border-color:#2e5049; }
+  .ms { color:var(--dim); font-size:11px; flex:none; min-width:30px; text-align:right; }
+  #foot { padding:8px 14px; border-top:1px solid var(--line); color:var(--dim); font-size:11.5px; }
+
+  #main { flex:1; display:flex; flex-direction:column; min-width:0; }
+  #bar { display:flex; align-items:center; gap:10px; padding:8px 12px; border-bottom:1px solid var(--line); background:var(--panel); }
+  #cur { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  #cur a { color:var(--accent); text-decoration:none; }
+  #cur .sub { color:var(--dim); font-weight:400; font-size:12px; }
+  #bar .sp { flex:1; }
+  button { padding:6px 12px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); cursor:pointer; font-size:13px; }
+  button:hover { border-color:var(--accent); color:var(--accent); }
+  #frame { flex:1; border:0; width:100%; background:#fff; }
+  #empty { flex:1; display:flex; align-items:center; justify-content:center; color:var(--dim); flex-direction:column; gap:8px; }
+  #empty .big { font-size:40px; }
+</style>
+</head>
+<body>
+  <div id="rail">
+    <header>
+      <h1>projects.agentabrams.com <small id="count"></small></h1>
+      <input id="q" type="search" placeholder="Filter projects…" autocomplete="off">
+      <div id="filters">
+        <button data-f="all" class="on">All</button>
+        <button data-f="public">Public</button>
+        <button data-f="local">Local</button>
+      </div>
+    </header>
+    <div id="list"></div>
+    <div id="foot">click a project → loads live in the right panel · <span class="badge public">public</span> = live domain, <span class="badge">local</span> = pm2 port · dot: <span style="color:#3fbf6f">up</span> / <span style="color:#d9a53a">basic</span> / <span style="color:#4a90e2">login</span> / <span style="color:#d95555">down</span></div>
+  </div>
+  <div id="main">
+    <div id="bar">
+      <span id="cur">— pick a project</span>
+      <span class="sp"></span>
+      <button id="reload" title="Reload iframe">⟳ Reload</button>
+      <button id="newtab" title="Open the real site in a new browser tab">↗ New Tab</button>
+    </div>
+    <div id="empty"><div class="big">🗂️</div><div>Select a project on the left to view its live site here.</div></div>
+    <iframe id="frame" style="display:none"></iframe>
+  </div>
+
+<script>
+let projects = [], status = {}, filter = 'all';
+let current = localStorage.getItem('paa-current') || '';
+const list = document.getElementById('list'), q = document.getElementById('q');
+const frame = document.getElementById('frame'), empty = document.getElementById('empty');
+const cur = document.getElementById('cur'), count = document.getElementById('count');
+
+const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+
+function render() {
+  const f = q.value.trim().toLowerCase();
+  const shown = projects.filter(p =>
+    (filter === 'all' || p.kind === filter) &&
+    (p.name.toLowerCase().includes(f) || p.slug.toLowerCase().includes(f) || (p.domain || '').includes(f))
+  );
+  count.textContent = `(${shown.length}/${projects.length})`;
+  list.innerHTML = '';
+  for (const p of shown) {
+    const s = status[p.slug];
+    const row = document.createElement('div');
+    row.className = 'item' + (p.slug === current ? ' active' : '');
+    const dotCls = !s ? '' : s.authFail ? 'down'
+      : s.authType === 'basic' ? 'auth'
+      : s.authType === 'login' ? 'login'
+      : s.authType === 'down'  ? 'down'
+      : s.up ? 'up' : 'down';
+    const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
+      : s.authFail ? 'basic-auth wall — unified pw rejected'
+      : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
+      : s.authType === 'login' ? 'app login / SSO'
+      : 'up — no auth';
+    const en = esc(p.name);
+    const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
+    const badge = p.kind === 'public'
+      ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
+      : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
+    row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
+      + `<span class="name">${label}</span>${badge}`
+      + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
+    row.onclick = () => select(p.slug);
+    list.appendChild(row);
+  }
+}
+
+function select(slug) {
+  const p = projects.find(x => x.slug === slug);
+  if (!p) return;
+  current = slug;
+  localStorage.setItem('paa-current', slug);
+  const realUrl = p.kind === 'public' ? p.target : p.target;
+  cur.innerHTML = `<a href="${esc(realUrl)}" target="_blank" rel="noopener noreferrer">${esc(p.name)}</a> `
+    + `<span class="sub">${esc(p.domain || ('127.0.0.1:' + p.port))}</span>`;
+  empty.style.display = 'none';
+  frame.style.display = 'block';
+  frame.src = '/site/' + encodeURIComponent(slug) + '/';
+  render();
+}
+
+document.getElementById('reload').onclick = () => { if (current) frame.src = '/site/' + encodeURIComponent(current) + '/'; };
+document.getElementById('newtab').onclick = () => {
+  const p = projects.find(x => x.slug === current);
+  if (p) window.open(p.target, '_blank', 'noopener');
+};
+q.oninput = render;
+for (const b of document.querySelectorAll('#filters button')) {
+  b.onclick = () => {
+    filter = b.dataset.f;
+    document.querySelectorAll('#filters button').forEach(x => x.classList.toggle('on', x === b));
+    render();
+  };
+}
+
+(async () => {
+  try {
+    const r = await fetch('/api/projects');
+    if (!r.ok) throw new Error('projects HTTP ' + r.status);
+    projects = await r.json();
+    render();
+    if (current && projects.some(p => p.slug === current)) select(current);
+  } catch (e) { count.textContent = '(load failed — reload)'; return; }
+  try {
+    const sres = await fetch('/api/status');
+    if (!sres.ok) throw new Error('status HTTP ' + sres.status);
+    for (const s of await sres.json()) status[s.slug] = s;
+    render();
+  } catch (e) { /* dots stay grey; reload retries */ }
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..ea662c4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,191 @@
+#!/usr/bin/env node
+// projects-agentabrams — projects.agentabrams.com
+// Left rail: every buildable project (live pm2 port OR public domain).
+// Right pane: the project's live site, rendered in-panel.
+//
+// Schema mirrors all.agentabrams.com's domain viewer: a searchable list drives
+// an iframe that loads through /site/<slug>/… — a reverse proxy that injects the
+// unified admin credential upstream (browsers refuse to Basic-auth a cross-origin
+// iframe) and strips X-Frame-Options/CSP so every fleet site frames cleanly.
+// Targets are BOTH https public domains and http loopback ports, resolved from
+// projects.json (regenerate with `node build-projects.js`).
+const http = require('http');
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9702;
+const ROOT = __dirname;
+const AUTH = 'Basic ' + Buffer.from(process.env.BASIC_AUTH || 'admin:DW2024!').toString('base64');
+const UPSTREAM_AUTH = 'Basic ' + Buffer.from(process.env.UPSTREAM_AUTH || 'admin:DW2024!').toString('base64');
+
+const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
+
+function loadProjects() {
+  try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'projects.json'), 'utf8')); }
+  catch (e) { console.error('loadProjects failed:', e.message); return []; }
+}
+// slug -> project record, rebuilt per request so an edited projects.json is picked
+// up without a restart (the list is tiny; the read is sub-ms).
+function bySlug() {
+  const m = new Map();
+  for (const p of loadProjects()) m.set(p.slug, p);
+  return m;
+}
+
+// Parse a target URL into the pieces the proxy/probe need, picking http vs https.
+function parseTarget(target) {
+  const u = new URL(target);
+  const secure = u.protocol === 'https:';
+  return { secure, mod: secure ? https : http, host: u.hostname, port: u.port ? +u.port : (secure ? 443 : 80) };
+}
+
+// One request against a target (HEAD by default). status 0 = unreachable/timeout.
+function fetch1(t, pathStr, withAuth, method) {
+  return new Promise((resolve) => {
+    const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
+    const opts = { host: t.host, port: t.port, method, path: pathStr, headers, timeout: 6000 };
+    if (t.secure) opts.rejectUnauthorized = true;
+    const req = t.mod.request(opts, (res) => {
+      if (method === 'HEAD') { res.resume(); return resolve({ status: res.statusCode, location: res.headers.location, body: '' }); }
+      let body = '', bytes = 0;
+      res.on('data', (c) => { if (bytes < 24000) { body += c; bytes += c.length; } });
+      res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, body }));
+    });
+    req.on('timeout', () => { req.destroy(); resolve({ status: 0 }); });
+    req.on('error', () => resolve({ status: 0 }));
+    req.end();
+  });
+}
+
+const AUTH_PATH_RE = /\/(login|signin|sign-in|auth|sso|oauth|account\/login|users\/sign_in)/i;
+const PW_FIELD_RE  = /<input[^>]+type=["']?password|name=["']?(password|passwd|pwd)["']?[^>]*>/i;
+
+// Classify a project's reachability + auth posture so the rail can dot it.
+// authType: basic (401 wall) | login (redirect/pw form) | open | down.
+async function probe(p) {
+  const started = Date.now();
+  let t;
+  try { t = parseTarget(p.target); } catch { return { slug: p.slug, up: false, authType: 'down', ms: 0 }; }
+  let pathStr = '/', status = 0, gated = false, authFail = false, authType = 'open';
+
+  for (let hop = 0; hop < 5; hop++) {
+    const r = await fetch1(t, pathStr, false, 'HEAD');
+    status = r.status;
+    if (r.status === 0) { authType = 'down'; break; }
+    if (r.status === 401) { gated = true; authType = 'basic'; break; }
+    if (r.status >= 300 && r.status < 400 && r.location) {
+      let loc; try { loc = new URL(r.location, `${t.secure ? 'https' : 'http'}://${t.host}:${t.port}`); } catch { break; }
+      if (AUTH_PATH_RE.test(loc.pathname)) { gated = true; authType = 'login'; break; }
+      pathStr = loc.pathname + loc.search;
+      continue;
+    }
+    break;
+  }
+  // Basic gate → re-probe WITH the unified credential to tell a healthy gate from a dead pw.
+  if (authType === 'basic') {
+    const eff = await fetch1(t, '/', true, 'HEAD');
+    authFail = eff.status === 401;
+  }
+  if (!gated && status === 200) {
+    const g = await fetch1(t, pathStr, false, 'GET');
+    if (PW_FIELD_RE.test(g.body || '')) { gated = true; authType = 'login'; }
+  }
+  const up = authType === 'basic' ? !authFail
+           : authType === 'login' ? true
+           : (status >= 200 && status < 400);
+  return { slug: p.slug, ms: Date.now() - started, authType, authFail, up };
+}
+
+const server = http.createServer(async (req, res) => {
+  const url = new URL(req.url, 'http://x');
+
+  if (url.pathname === '/healthz') { res.writeHead(200); return res.end('ok'); }
+
+  if (req.headers.authorization !== AUTH) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="projects-agentabrams"' });
+    return res.end('auth required');
+  }
+
+  if (url.pathname === '/api/projects') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify(loadProjects()));
+  }
+
+  if (url.pathname === '/api/status') {
+    const projects = loadProjects();
+    const out = [];
+    for (let i = 0; i < projects.length; i += 12) {
+      out.push(...await Promise.all(projects.slice(i, i + 12).map(probe)));
+    }
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify(out));
+  }
+
+  // Reverse proxy: /site/<slug>/<path> → project.target/<path> with the unified
+  // admin credential injected + anti-framing headers stripped. Only known slugs.
+  if (url.pathname.startsWith('/site/')) {
+    const rest = url.pathname.slice('/site/'.length);
+    const slash = rest.indexOf('/');
+    const slug = slash < 0 ? rest : rest.slice(0, slash);
+    if (slash < 0) { res.writeHead(302, { Location: `/site/${slug}/` }); return res.end(); }
+    const p = bySlug().get(slug);
+    if (!p) { res.writeHead(404); return res.end('unknown project'); }
+    let t; try { t = parseTarget(p.target); } catch { res.writeHead(502); return res.end('bad target'); }
+    const upstreamPath = rest.slice(slash) + url.search;
+    const headers = { ...req.headers, host: `${t.host}:${t.port}`, authorization: UPSTREAM_AUTH };
+    delete headers.connection;
+    const opts = { host: t.host, port: t.port, method: req.method, path: upstreamPath, headers, timeout: 20000 };
+    if (t.secure) opts.rejectUnauthorized = true;
+    const preq = t.mod.request(opts, (pres) => {
+      const h = { ...pres.headers };
+      delete h['x-frame-options'];
+      delete h['content-security-policy'];
+      delete h['strict-transport-security'];
+      delete h['referrer-policy'];   // keep the Referer-based asset escape hatch alive
+      delete h.connection;
+      // Rewrite same-target redirects back through /site/<slug>/ so navigation stays in-panel.
+      if (h.location) {
+        try {
+          const loc = new URL(h.location, `${t.secure ? 'https' : 'http'}://${t.host}:${t.port}`);
+          if (loc.hostname === t.host) h.location = `/site/${slug}${loc.pathname}${loc.search}`;
+        } catch {}
+      }
+      if (h['set-cookie']) {
+        h['set-cookie'] = h['set-cookie'].map((c) => c
+          .replace(/;\s*Domain=[^;]+/gi, '')
+          .replace(/;\s*Secure/gi, '')
+          .replace(/;\s*Path=\/([^;]*)/i, `; Path=/site/${slug}/$1`));
+      }
+      res.writeHead(pres.statusCode, h);
+      pres.pipe(res);
+    });
+    preq.on('timeout', () => preq.destroy(new Error('upstream timeout')));
+    preq.on('error', (e) => { if (res.writableEnded) return; if (!res.headersSent) res.writeHead(502); res.end('proxy error: ' + e.message); });
+    req.pipe(preq);
+    return;
+  }
+
+  let file = url.pathname === '/' ? '/index.html' : url.pathname;
+  const fp = path.join(ROOT, 'public', path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
+  if (!fp.startsWith(path.join(ROOT, 'public'))) { res.writeHead(403); return res.end(); }
+
+  // Root-relative asset escape hatch: a proxied page requesting "/app.js" lands
+  // here; if the Referer is inside /site/<slug>/, bounce it back into the proxy.
+  if (!fs.existsSync(fp)) {
+    const m = (req.headers.referer || '').match(/\/site\/([^/]+)\//);
+    if (m && bySlug().has(m[1])) {
+      res.writeHead(302, { Location: `/site/${m[1]}${url.pathname}${url.search}` });
+      return res.end();
+    }
+  }
+
+  fs.readFile(fp, (err, data) => {
+    if (err) { res.writeHead(404); return res.end('not found'); }
+    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
+    res.end(data);
+  });
+});
+
+const HOST = process.env.HOST || '127.0.0.1';
+server.listen(PORT, HOST, () => console.log(`projects-agentabrams on http://${HOST}:${PORT}`));

(oldest)  ·  back to Projects Agentabrams  ·  deploy.conf: tailnet-proxy model; live at projects.agentabra d08aba3 →