[object Object]

← back to Tools Dw Hub

start/stop from the hub: local mode spawns manifest start-commands with runtime secret injection (DRY_RUN=1 default), remote mode proxies to Mac2 hub over tailnet; UI Start/Stop buttons with boot-polling

94b2c2b4692087077aa60e19b530324e7de98b11 · 2026-07-28 15:11:07 -0700 · Steve

Files touched

Diff

commit 94b2c2b4692087077aa60e19b530324e7de98b11
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 15:11:07 2026 -0700

    start/stop from the hub: local mode spawns manifest start-commands with runtime secret injection (DRY_RUN=1 default), remote mode proxies to Mac2 hub over tailnet; UI Start/Stop buttons with boot-polling
---
 ecosystem.config.js |  3 +-
 public/index.html   | 17 ++++++++++++
 server.js           | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 99 insertions(+), 1 deletion(-)

diff --git a/ecosystem.config.js b/ecosystem.config.js
index 5402c37..fe9661e 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -7,7 +7,8 @@ module.exports = {
       PORT: 9815,                    // fixed port for pm2/prod
       BASIC_AUTH: 'admin:DW2024!',
       PROBE_HOST: '100.82.17.107',   // Mac Studio tailnet IP — where the tool apps actually listen (bind *)
-      TOOL_HOST: '100.82.17.107'     // Launch links resolve here; reachable from any tailnet device
+      TOOL_HOST: '100.82.17.107',    // Launch links resolve here; reachable from any tailnet device
+      MAC_HUB: 'http://100.82.17.107:9815' // Kamatera proxies /api/start|stop to the Mac2 hub (ignored on Mac2 itself)
     },
     env_local: {
       PORT: 9815, BASIC_AUTH: 'admin:DW2024!', PROBE_HOST: '127.0.0.1', TOOL_HOST: ''
diff --git a/public/index.html b/public/index.html
index 447ca6e..f58eec6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,6 +38,9 @@
   .btn.go { background:var(--ink); color:#fff; border-color:var(--ink); }
   .btn.go.off { opacity:.45; pointer-events:none; }
   .btn.copy:active { background:#f0ece3; }
+  .btn.startbtn { border-color:#22c55e; color:#15803d; }
+  .btn.stopbtn { border-color:#ef4444; color:#b91c1c; }
+  .btn:disabled { opacity:.5; cursor:wait; }
   .foot { padding:0 28px 40px; color:var(--muted); font-size:.72rem; }
   .count { color:var(--muted); font-size:.75rem; margin-left:auto; }
 </style>
@@ -115,6 +118,9 @@ function render(){
       </div>
       <div class="actions">
         ${go}
+        ${(!t.cli && !t.missingSource && t.port) ? (st==='up'
+          ? `<button class="btn stopbtn" data-slug="${t.slug}">Stop ■</button>`
+          : `<button class="btn startbtn" data-slug="${t.slug}">Start ▶</button>`) : ''}
         <button class="btn copy" data-cmd="${(t.start||'').replace(/"/g,'&quot;')}">Copy start ⌘</button>
       </div>
     </div>`;
@@ -122,6 +128,17 @@ function render(){
   document.querySelectorAll('.btn.copy').forEach(b=>b.onclick=()=>{
     navigator.clipboard.writeText(b.dataset.cmd); const o=b.textContent; b.textContent='Copied ✓'; setTimeout(()=>b.textContent=o,1200);
   });
+  document.querySelectorAll('.btn.startbtn,.btn.stopbtn').forEach(b=>b.onclick=async()=>{
+    const verb = b.classList.contains('startbtn')?'start':'stop';
+    b.disabled = true; b.textContent = verb==='start'?'Starting…':'Stopping…';
+    try{
+      const r = await (await fetch(`/api/${verb}/${b.dataset.slug}`,{method:'POST'})).json();
+      if(!r.ok){ alert(`${verb} failed: ${r.error||'unknown'}`); }
+    }catch(e){ alert(`${verb} failed: ${e.message}`); }
+    // poll status a few times while the tool boots (Next dev servers take ~10-20s)
+    for(let i=0;i<8;i++){ await new Promise(r=>setTimeout(r,3000)); await loadStatus();
+      if((STATUS[b.dataset.slug]==='up')===(verb==='start')) break; }
+  });
 }
 
 function renderPills(cats){
diff --git a/server.js b/server.js
index 7027cb3..e266aed 100644
--- a/server.js
+++ b/server.js
@@ -42,6 +42,71 @@ function probe(port, marker, timeout = 2500, path = '/', hop = 0, host = PROBE_H
   });
 }
 
+// --- Start/Stop tools from the hub ---
+// LOCAL mode (Mac2, where the tools live): spawns the manifest's own start command in a
+// detached process group, injecting secrets from secrets-manager at RUNTIME (never stored).
+// REMOTE mode (Kamatera public hub): proxies /api/start|stop to the Mac2 hub over tailnet
+// (MAC_HUB env). Only manifest-defined commands ever run — slugs resolve server-side.
+const { spawn, execSync } = require('child_process');
+const os = require('os');
+const IS_TOOL_HOST = process.env.TOOLS_LOCAL === '1' || os.platform() === 'darwin';
+const MAC_HUB = process.env.MAC_HUB || '';
+const LOG_DIR = '/tmp/dw-tools';
+
+function loadSecretsEnv() {
+  const env = {};
+  try {
+    const raw = fs.readFileSync(path.join(process.env.HOME || '', 'Projects/secrets-manager/.env'), 'utf8');
+    for (const line of raw.split('\n')) {
+      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+      if (m) env[m[1]] = m[2].replace(/^["']|["']\s*$/g, '');
+    }
+  } catch { /* no secrets file — tools that need keys will warn themselves */ }
+  return env;
+}
+
+function pidOnPort(port) {
+  try { return parseInt(execSync(`lsof -nP -tiTCP:${port} -sTCP:LISTEN`).toString().split('\n')[0], 10) || null; }
+  catch { return null; }
+}
+
+function startTool(t) {
+  if (!t || !t.start || t.cli || t.missingSource) return { ok: false, error: 'not startable' };
+  if (t.port && pidOnPort(t.port)) return { ok: true, already: true };
+  fs.mkdirSync(LOG_DIR, { recursive: true });
+  const log = fs.openSync(path.join(LOG_DIR, `${t.slug}.log`), 'a');
+  const env = {
+    ...process.env, ...loadSecretsEnv(),
+    ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'DW2024!',
+    DRY_RUN: '1',                                   // hub-launched taggers never push; re-launch manually for writes
+    VISION_SCAN_CAP: process.env.VISION_SCAN_CAP || '25',
+  };
+  if (t.port) { env.PORT = String(t.port); env.NEXTAUTH_URL = `http://localhost:${t.port}`; }
+  const child = spawn('bash', ['-lc', t.start], { detached: true, stdio: ['ignore', log, log], env });
+  child.unref();
+  return { ok: true, pid: child.pid };
+}
+
+function stopTool(t) {
+  if (!t || !t.port) return { ok: false, error: 'no port' };
+  const pid = pidOnPort(t.port);
+  if (!pid) return { ok: true, already: true };
+  try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch {} }
+  setTimeout(() => { const p2 = pidOnPort(t.port); if (p2) { try { process.kill(p2, 'SIGKILL'); } catch {} } }, 1500);
+  return { ok: true, killed: pid };
+}
+
+function proxyToMacHub(req, res, urlPath) {
+  const target = new URL(urlPath, MAC_HUB);
+  const preq = http.request(target, { method: 'POST', headers: { Authorization: req.headers.authorization || '' }, timeout: 25000 }, (pres) => {
+    res.writeHead(pres.statusCode, { 'Content-Type': 'application/json' });
+    pres.pipe(res);
+  });
+  preq.on('error', (e) => { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: `Mac2 hub unreachable: ${e.message}` })); });
+  preq.on('timeout', () => { preq.destroy(); });
+  preq.end();
+}
+
 function unauthorized(res) {
   res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Tools"', 'Content-Type': 'text/plain' });
   res.end('Authentication required');
@@ -71,6 +136,21 @@ const server = http.createServer(async (req, res) => {
     return res.end(JSON.stringify(m));
   }
 
+  // Start/Stop a tool: local hub executes; public hub proxies to the Mac2 hub over tailnet.
+  const action = url.match(/^\/api\/(start|stop)\/([a-z0-9-]+)$/);
+  if (action && req.method === 'POST') {
+    const [, verb, slug] = action;
+    const t = manifest().tools.find((x) => x.slug === slug);
+    if (!t) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'unknown tool' })); }
+    if (!IS_TOOL_HOST) {
+      if (!MAC_HUB) { res.writeHead(501, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'MAC_HUB not configured' })); }
+      return proxyToMacHub(req, res, url);
+    }
+    const result = verb === 'start' ? startTool(t) : stopTool(t);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify(result));
+  }
+
   if (url === '/api/status') {
     const { tools } = manifest();
     const results = await Promise.all(tools.map(async (t) => ({ slug: t.slug, status: await probe(t.port, t.marker, 2500, t.probePath || '/', 0, t.probeHost || PROBE_HOST) })));

← 337ae8b per-tool probePath (scraper-api probes /health on Kamatera-l  ·  back to Tools Dw Hub  ·  start-fleet.sh: keep the curated 13-tool set always-on via p ae319d6 →