← 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
A .gitignoreA README.mdA build-projects.jsA package.jsonA projects.jsonA public/index.htmlA server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →