← 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
M ecosystem.config.jsM public/index.htmlM server.js
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,'"')}">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 →