spectoflow 0.23.5 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -16
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +226 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/connector.js +193 -0
- package/lib/dashboard/handlers.js +53 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
- package/lib/dashboard/inject-design.js +41 -0
- package/lib/dashboard/meeting.js +116 -0
- package/lib/dashboard/ops.js +257 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +743 -124
- package/{templates → lib}/dashboard/public/charts.js +3 -3
- package/lib/dashboard/public/commands.js +77 -0
- package/{templates → lib}/dashboard/public/designs/console.css +7 -19
- package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
- package/{templates → lib}/dashboard/public/designs.js +2 -2
- package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
- package/{templates → lib}/dashboard/public/hub.html +1 -1
- package/{templates → lib}/dashboard/public/hub.js +27 -4
- package/{templates → lib}/dashboard/public/i18n.js +60 -18
- package/{templates → lib}/dashboard/public/icons.js +2 -0
- package/{templates → lib}/dashboard/public/index.html +106 -13
- package/{templates → lib}/dashboard/public/styles.css +161 -26
- package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
- package/lib/dashboard/routes.js +38 -0
- package/{templates → lib}/dashboard/runner.js +13 -7
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +119 -0
- package/package.json +3 -3
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/config.json +23 -0
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
|
-
* Pure helpers for user-generated custom dashboards (.spectoflow/
|
|
3
|
+
* Pure helpers for user-generated custom dashboards (.spectoflow/dashboards/<id>.json).
|
|
4
4
|
*
|
|
5
5
|
* A custom dashboard is a DECLARATIVE block spec, never raw HTML/CSS/JS: the generating agent picks
|
|
6
6
|
* blocks from a fixed vocabulary (BLOCK_TYPES) that the dashboard already knows how to render, using
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the user switches to later — with zero per-dashboard styling to keep in sync, and no arbitrary code
|
|
10
10
|
* ever running in the dashboard.
|
|
11
11
|
*
|
|
12
|
-
* Zero dependency; consumed by
|
|
12
|
+
* Zero dependency; consumed by lib/dashboard/handlers.js (Node) via readCustomDashboards() in
|
|
13
13
|
* store.js. The browser-side renderer (dashboard/public/app.js) re-implements the tiny `resolveBind`
|
|
14
14
|
* walk independently — sharing code across the Node/browser boundary would need a build step, which
|
|
15
15
|
* this project avoids on purpose (see CLAUDE.md's zero-runtime-dependency invariant).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Builds the exact natural-language prompts the dashboard's Settings → Customize UI posts to
|
|
3
|
-
// /api/run (see
|
|
3
|
+
// /api/run (see lib/dashboard/public/app.js's CZ_KINDS) — the single source of truth so the
|
|
4
4
|
// CLI (`spectoflow skill/agent/dashboard create`) and the dashboard button never drift apart. The
|
|
5
5
|
// browser side can't require this Node module (no build step), so its literal strings are mirrored
|
|
6
6
|
// there by hand; test/customize-prompts.test.js guards against the two falling out of sync.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The local hub's outbound connector to an online dashboard (docs/online-dashboard-connector-design.md §2).
|
|
4
|
+
* One connection per machine; JSON frames, identical on both transports:
|
|
5
|
+
* up: auth {token,version} · hello {machineName,projects} · event {p,event} · snapshot {p,project} · reply {reqId,result|error} · pong
|
|
6
|
+
* down: auth-ok {machineId} · op {reqId,p,op,args} · ping
|
|
7
|
+
* Transport 1 is Node's native WebSocket client (zero-dep, Node ≥ 22); transport 2 is HTTP long-poll
|
|
8
|
+
* (POST batches + GET held ≤ 25 s) for hosts that don't pass WebSockets (cPanel/Passenger). The hub
|
|
9
|
+
* decides nothing here: it hands in listPublished/readSnapshot/execOp and tees its emit into
|
|
10
|
+
* pushEvent/pushSnapshot. Reconnection: exponential backoff 1 s → 30 s with jitter; after an upgrade
|
|
11
|
+
* failure or 3 drops in 60 s the connector falls back to HTTP and retries WebSocket every 10 min.
|
|
12
|
+
* The token travels in-band (auth frame / Authorization header) — never in a URL, never logged.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_TIMING = { backoffMin: 1000, backoffMax: 30000, dropWindow: 60000, dropLimit: 3, wsRetryEvery: 600000, pollTimeout: 35000, wsOpenTimeout: 15000 };
|
|
15
|
+
const REJECTED = 'token rejected by the server — run `spectoflow dashboard login` again';
|
|
16
|
+
|
|
17
|
+
function createConnector(opts) {
|
|
18
|
+
const { token, machineName, listPublished, readSnapshot, execOp } = opts;
|
|
19
|
+
const version = opts.version || '0.0.0';
|
|
20
|
+
const base = String(opts.url || '').replace(/\/+$/, '');
|
|
21
|
+
const preferred = opts.transport === 'http' ? 'http' : 'ws';
|
|
22
|
+
const timing = { ...DEFAULT_TIMING, ...(opts.timing || {}) };
|
|
23
|
+
const log = opts.log || (() => {});
|
|
24
|
+
|
|
25
|
+
const state = { connected: false, lastError: null, machineId: null, attempts: 0, since: null };
|
|
26
|
+
let mode = preferred; // transport in use ('ws' | 'http'); may fall back to 'http'
|
|
27
|
+
let stopped = true;
|
|
28
|
+
let active = null; // the live transport: { send(frame), close() }
|
|
29
|
+
let generation = 0; // bumps on every connect/stop so a stale transport's callbacks are ignored
|
|
30
|
+
const drops = [];
|
|
31
|
+
let reconnectTimer = null, wsRetryTimer = null;
|
|
32
|
+
|
|
33
|
+
function status() {
|
|
34
|
+
return { url: base, machineName, connected: state.connected, transport: mode, lastError: state.lastError, machineId: state.machineId, since: state.since };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- frames ----
|
|
38
|
+
async function handleDownward(frame, send) {
|
|
39
|
+
if (!frame || typeof frame !== 'object') return;
|
|
40
|
+
if (frame.type === 'auth-ok') { state.machineId = frame.machineId || null; onConnected(); return; }
|
|
41
|
+
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
|
42
|
+
if (frame.type === 'op') {
|
|
43
|
+
let reply;
|
|
44
|
+
try {
|
|
45
|
+
const result = await execOp(frame.p, frame.op, frame.args || {});
|
|
46
|
+
reply = { type: 'reply', reqId: frame.reqId, result: result === undefined ? {} : result };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
reply = { type: 'reply', reqId: frame.reqId, error: { status: Number(e && e.status) || 500, message: String(e && e.message || e) } };
|
|
49
|
+
}
|
|
50
|
+
send(reply);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function onConnected() {
|
|
54
|
+
state.connected = true; state.lastError = null; state.attempts = 0; state.since = new Date().toISOString();
|
|
55
|
+
log(`online dashboard: connected to ${base} (${mode})`);
|
|
56
|
+
announce();
|
|
57
|
+
}
|
|
58
|
+
async function announce() {
|
|
59
|
+
const t = active;
|
|
60
|
+
if (!t || !state.connected) return;
|
|
61
|
+
const projects = listPublished();
|
|
62
|
+
t.send({ type: 'hello', machineName, projects });
|
|
63
|
+
for (const p of projects) {
|
|
64
|
+
try { const project = await readSnapshot(p.localId); if (project && t === active) t.send({ type: 'snapshot', p: p.localId, project }); }
|
|
65
|
+
catch (e) { log(`online dashboard: snapshot of ${p.localId} failed: ${e.message}`); }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function pushEvent(localId, event) { if (active && state.connected) active.send({ type: 'event', p: localId, event }); }
|
|
69
|
+
function pushSnapshot(localId, project) { if (active && state.connected) active.send({ type: 'snapshot', p: localId, project }); }
|
|
70
|
+
|
|
71
|
+
// ---- lifecycle ----
|
|
72
|
+
function onDisconnected(gen, err, { upgradeFailed = false } = {}) {
|
|
73
|
+
if (gen !== generation) return; // a transport we already replaced
|
|
74
|
+
const was = state.connected;
|
|
75
|
+
state.connected = false; active = null;
|
|
76
|
+
if (err) state.lastError = String(err.message || err);
|
|
77
|
+
if (was) log(`online dashboard: disconnected from ${base}${err ? ' — ' + state.lastError : ''}`);
|
|
78
|
+
if (stopped) return;
|
|
79
|
+
if (mode === 'ws' && preferred === 'ws') {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
drops.push(now); while (drops.length && now - drops[0] > timing.dropWindow) drops.shift();
|
|
82
|
+
if (upgradeFailed || drops.length >= timing.dropLimit) {
|
|
83
|
+
mode = 'http'; drops.length = 0;
|
|
84
|
+
log('online dashboard: WebSocket unavailable, falling back to HTTP long-poll');
|
|
85
|
+
scheduleWsRetry();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
scheduleReconnect();
|
|
89
|
+
}
|
|
90
|
+
function backoff() {
|
|
91
|
+
const exp = Math.min(timing.backoffMax, timing.backoffMin * 2 ** Math.min(state.attempts, 10));
|
|
92
|
+
state.attempts++;
|
|
93
|
+
return Math.round(exp * (0.75 + Math.random() * 0.5));
|
|
94
|
+
}
|
|
95
|
+
function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, backoff()); }
|
|
96
|
+
function scheduleWsRetry() {
|
|
97
|
+
clearTimeout(wsRetryTimer);
|
|
98
|
+
wsRetryTimer = setTimeout(() => { if (!stopped && mode === 'http') { mode = 'ws'; forceReconnect(); } }, timing.wsRetryEvery);
|
|
99
|
+
}
|
|
100
|
+
function connect() {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
clearTimeout(reconnectTimer);
|
|
103
|
+
const gen = ++generation;
|
|
104
|
+
active = mode === 'http' ? httpTransport(gen) : wsTransport(gen);
|
|
105
|
+
}
|
|
106
|
+
function start() { if (!stopped) return; stopped = false; mode = preferred; state.attempts = 0; connect(); }
|
|
107
|
+
function stop() {
|
|
108
|
+
stopped = true; generation++;
|
|
109
|
+
clearTimeout(reconnectTimer); clearTimeout(wsRetryTimer);
|
|
110
|
+
const t = active; active = null; state.connected = false;
|
|
111
|
+
if (t) t.close();
|
|
112
|
+
}
|
|
113
|
+
function forceReconnect() {
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
const t = active; generation++; active = null; state.connected = false; state.attempts = 0;
|
|
116
|
+
if (t) t.close();
|
|
117
|
+
connect();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- transport 1: native WebSocket ----
|
|
121
|
+
// A handshake that never resolves (no open/error/close at all — observed on some platforms when
|
|
122
|
+
// the server rejects the upgrade with a plain HTTP response) must still fail: wsOpenTimeout bounds
|
|
123
|
+
// it so the connector always falls back to HTTP instead of hanging forever in 'ws' mode.
|
|
124
|
+
function wsTransport(gen) {
|
|
125
|
+
let ws, opened = false, settled = false, lastErr = null;
|
|
126
|
+
const settle = (err, opts) => { if (settled) return; settled = true; clearTimeout(openTimer); onDisconnected(gen, err, opts); };
|
|
127
|
+
try { ws = new WebSocket(base.replace(/^http/, 'ws') + '/connector/ws'); }
|
|
128
|
+
catch (e) { setImmediate(() => settle(e, { upgradeFailed: true })); return { send() {}, close() {} }; }
|
|
129
|
+
const openTimer = setTimeout(() => {
|
|
130
|
+
try { ws.close(); } catch (_) {}
|
|
131
|
+
settle(new Error('WebSocket handshake timed out'), { upgradeFailed: true });
|
|
132
|
+
}, timing.wsOpenTimeout);
|
|
133
|
+
const send = (frame) => { if (ws.readyState === 1) ws.send(JSON.stringify(frame)); };
|
|
134
|
+
ws.addEventListener('open', () => { opened = true; clearTimeout(openTimer); send({ type: 'auth', token, version }); });
|
|
135
|
+
ws.addEventListener('message', (ev) => { let f; try { f = JSON.parse(String(ev.data)); } catch { return; } handleDownward(f, send); });
|
|
136
|
+
ws.addEventListener('error', (ev) => { lastErr = new Error((ev && ev.message) || 'websocket error'); });
|
|
137
|
+
ws.addEventListener('close', (ev) => {
|
|
138
|
+
const code = ev && ev.code;
|
|
139
|
+
const err = code === 4401 ? new Error(REJECTED) : lastErr || (code && code !== 1000 && code !== 1005 ? new Error(`connection closed (${code}${ev.reason ? ' ' + ev.reason : ''})`) : null);
|
|
140
|
+
settle(err, { upgradeFailed: !opened });
|
|
141
|
+
});
|
|
142
|
+
return { send, close: () => { clearTimeout(openTimer); try { ws.close(1000); } catch (_) {} } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- transport 2: HTTP long-poll ----
|
|
146
|
+
// Upward frames are POSTed in batches, strictly one batch in flight at a time (ordering). Downward
|
|
147
|
+
// frames arrive in the POST responses and through a GET loop the server holds open ≤ 25 s. The first
|
|
148
|
+
// batch carries `auth` so the frame sequence is the same as on WebSocket; the Authorization header is
|
|
149
|
+
// what actually authenticates every request.
|
|
150
|
+
function httpTransport(gen) {
|
|
151
|
+
const ac = new AbortController();
|
|
152
|
+
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
153
|
+
let queue = [{ type: 'auth', token, version }];
|
|
154
|
+
let flushing = false, closed = false;
|
|
155
|
+
const alive = () => !closed && gen === generation;
|
|
156
|
+
const signal = () => AbortSignal.any([ac.signal, AbortSignal.timeout(timing.pollTimeout)]);
|
|
157
|
+
const fail = (e) => { if (!alive()) return; closed = true; ac.abort(); onDisconnected(gen, e); };
|
|
158
|
+
const check = (res, what) => {
|
|
159
|
+
if (res.status === 401) throw new Error(REJECTED);
|
|
160
|
+
if (!res.ok) throw new Error(`${what} /connector/frames → HTTP ${res.status}`);
|
|
161
|
+
return res.json();
|
|
162
|
+
};
|
|
163
|
+
async function flush() {
|
|
164
|
+
if (flushing) return;
|
|
165
|
+
flushing = true;
|
|
166
|
+
try {
|
|
167
|
+
while (alive() && queue.length) {
|
|
168
|
+
const batch = queue; queue = [];
|
|
169
|
+
const res = await fetch(base + '/connector/frames', { method: 'POST', headers, body: JSON.stringify(batch), signal: signal() });
|
|
170
|
+
const down = await check(res, 'POST');
|
|
171
|
+
for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
|
|
172
|
+
}
|
|
173
|
+
} catch (e) { fail(e); } finally { flushing = false; }
|
|
174
|
+
}
|
|
175
|
+
async function poll() {
|
|
176
|
+
try {
|
|
177
|
+
while (alive()) {
|
|
178
|
+
const res = await fetch(base + '/connector/frames', { headers: { Authorization: headers.Authorization }, signal: signal() });
|
|
179
|
+
if (!alive()) return;
|
|
180
|
+
const down = await check(res, 'GET');
|
|
181
|
+
for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
|
|
182
|
+
}
|
|
183
|
+
} catch (e) { fail(e); }
|
|
184
|
+
}
|
|
185
|
+
function send(frame) { if (!alive()) return; queue.push(frame); flush(); }
|
|
186
|
+
flush(); poll();
|
|
187
|
+
return { send, close: () => { closed = true; ac.abort(); } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { start, stop, status, announce, pushEvent, pushSnapshot, forceReconnect };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { createConnector, DEFAULT_TIMING };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* HTTP glue for one project: parse the request, pick the op, call it, serialize. Every operation
|
|
4
|
+
* lives in ops.js (pure, transport-agnostic) — nothing here decides anything about the project.
|
|
5
|
+
*
|
|
6
|
+
* createHandlers(root) returns what a listener-owning process (hub-server.js) needs:
|
|
7
|
+
* - handleApi(req, res, u, emit): Promise<boolean> — true if this was an API route (handled).
|
|
8
|
+
* Excludes /api/events: SSE registration stays with whoever owns the HTTP listener.
|
|
9
|
+
* - watchDirs: dirs (relative to root) whose changes should emit {type:'change'}.
|
|
10
|
+
* - onBoot(): once per project per process (creates the custom-views dir, clears a stale
|
|
11
|
+
* in-flight orchestration).
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { ops, OpError } = require('./ops');
|
|
16
|
+
const orchestrator = require('./orchestrator');
|
|
17
|
+
|
|
18
|
+
function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
|
|
19
|
+
function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
|
|
20
|
+
|
|
21
|
+
const { ROUTES, findRoute } = require('./routes');
|
|
22
|
+
|
|
23
|
+
function createHandlers(root) {
|
|
24
|
+
async function handleApi(req, res, u, emit) {
|
|
25
|
+
const p = u.pathname;
|
|
26
|
+
const route = findRoute(req.method, p);
|
|
27
|
+
if (!route) return false;
|
|
28
|
+
const [, , opName, args] = route;
|
|
29
|
+
const b = req.method === 'GET' ? {} : await body(req);
|
|
30
|
+
try {
|
|
31
|
+
const result = await ops[opName](root, args(u, b, p), { emit });
|
|
32
|
+
sendJSON(res, 200, result);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e instanceof OpError) sendJSON(res, e.status, { error: e.message });
|
|
35
|
+
else sendJSON(res, 500, { error: String(e && e.message || e) });
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function onBoot() {
|
|
40
|
+
try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboards'), { recursive: true }); } catch (_) {}
|
|
41
|
+
// A process restart loses any in-flight orchestration; clear a stale 'running'/'awaiting_approval'
|
|
42
|
+
// so the 409 guard in orchestrate.start can't wedge forever. Not a resume — just un-wedging.
|
|
43
|
+
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
handleApi,
|
|
47
|
+
// The legacy custom-views dir is watched too, until `spectoflow update` migrates it (Task 4/8).
|
|
48
|
+
watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboards', '.spectoflow/dashboard/custom'],
|
|
49
|
+
onBoot,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { createHandlers, ROUTES };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
3
|
* The multi-project hub's server process — global (ships under lib/, never vendored into a
|
|
4
|
-
* project's .spectoflow/). Registry-driven: resolves a project's root
|
|
4
|
+
* project's .spectoflow/). Registry-driven: resolves a project's root on demand from
|
|
5
5
|
* ~/.spectoflow/projects.json (see lib/registry.js), keyed by the opaque id in /p/<id>/... URLs.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The route logic is THIS package's own handlers.js (./handlers.js), shared by every project (D64):
|
|
7
|
+
* nothing is ever require()'d from a project, so a project that has never run `spectoflow update`
|
|
8
|
+
* opens exactly like a fresh one (see docs/multi-project-hub-design.md).
|
|
9
9
|
*
|
|
10
10
|
* URL scheme (settled in the design doc): pages use a path prefix (/p/<id>/board, bookmarkable on
|
|
11
11
|
* their own); every /api/* call instead takes a ?p=<id> query param (smaller client diff, and
|
|
@@ -21,74 +21,129 @@ const http = require('http');
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const os = require('os');
|
|
23
23
|
const path = require('path');
|
|
24
|
-
const registry = require('
|
|
24
|
+
const registry = require('../registry');
|
|
25
|
+
const workspace = require('../workspace');
|
|
26
|
+
const { createHandlers } = require('./handlers');
|
|
27
|
+
const store = require('../store');
|
|
28
|
+
const { createConnector } = require('./connector');
|
|
29
|
+
const { ops, OpError } = require('./ops');
|
|
30
|
+
const { injectDesign, DEFAULT_DESIGN } = require('./inject-design');
|
|
25
31
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
32
|
+
const migrated = workspace.migrateLegacyHome();
|
|
33
|
+
if (!workspace.exists()) workspace.init({});
|
|
34
|
+
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : workspace.settings().port;
|
|
35
|
+
const PUBLIC = path.join(__dirname, 'public');
|
|
36
|
+
const TEMPLATES = path.join(__dirname, '..', '..', 'templates');
|
|
37
|
+
const VERSION = require('../../package.json').version;
|
|
30
38
|
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
|
|
31
39
|
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
32
40
|
function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
|
|
33
41
|
|
|
34
|
-
// id -> { id, root, handlers, clients:Set, emit
|
|
35
|
-
//
|
|
42
|
+
// id -> { id, root, handlers, clients:Set, emit, watchers:[] }. The route logic is THIS package's
|
|
43
|
+
// handlers.js for every project (D64): nothing is ever require()'d from a project, so a project that
|
|
44
|
+
// has never run `spectoflow update` opens exactly like a fresh one.
|
|
36
45
|
const projects = new Map();
|
|
46
|
+
|
|
47
|
+
// ---- online dashboard (C1): the connector role ----
|
|
48
|
+
// One outbound connection to the online dashboard named in the workspace's remote.json (written by
|
|
49
|
+
// `spectoflow dashboard login`). Only published projects (meta.json → published:true, cached here as
|
|
50
|
+
// a Set so a chatty run-line stream never re-reads the file) are announced and relayed. Every project's
|
|
51
|
+
// emit is teed: SSE clients first, then the connector — the same emit an op called from the relay
|
|
52
|
+
// uses, so a local click and an online click hit one orchestrator, one pending gate, one state.
|
|
53
|
+
let connector = null;
|
|
54
|
+
let publishedIds = new Set();
|
|
55
|
+
function refreshPublished() { publishedIds = new Set(workspace.listPublished().map((p) => p.id)); }
|
|
56
|
+
const snapshotTimers = new Map();
|
|
57
|
+
function scheduleSnapshot(id) {
|
|
58
|
+
clearTimeout(snapshotTimers.get(id));
|
|
59
|
+
snapshotTimers.set(id, setTimeout(async () => {
|
|
60
|
+
snapshotTimers.delete(id);
|
|
61
|
+
if (!connector || !publishedIds.has(id)) return;
|
|
62
|
+
try { const project = await readSnapshot(id); if (project) connector.pushSnapshot(id, project); } catch (_) {}
|
|
63
|
+
}, 500));
|
|
64
|
+
}
|
|
65
|
+
async function readSnapshot(id) {
|
|
66
|
+
const proj = getProject(id);
|
|
67
|
+
return proj ? ops['project.read'](proj.root, {}, { emit() {} }) : null;
|
|
68
|
+
}
|
|
69
|
+
function listPublishedForHello() {
|
|
70
|
+
return workspace.listPublished().map((p) => ({ localId: p.id, name: p.name, kind: p.kind || 'spectoflow', stats: projectStats(p.path) }));
|
|
71
|
+
}
|
|
72
|
+
async function execOp(localId, op, args) {
|
|
73
|
+
if (!publishedIds.has(localId)) throw new OpError(403, 'This project is not published.');
|
|
74
|
+
const proj = getProject(localId);
|
|
75
|
+
if (!proj) throw new OpError(404, projectErrorMessage(localId));
|
|
76
|
+
if (!Object.prototype.hasOwnProperty.call(ops, op)) throw new OpError(404, `Unknown operation "${op}".`);
|
|
77
|
+
return ops[op](proj.root, args || {}, { emit: proj.emit });
|
|
78
|
+
}
|
|
79
|
+
function startConnector() {
|
|
80
|
+
if (connector) { connector.stop(); connector = null; }
|
|
81
|
+
refreshPublished();
|
|
82
|
+
const remote = workspace.readRemote();
|
|
83
|
+
if (!remote) return;
|
|
84
|
+
connector = createConnector({
|
|
85
|
+
url: remote.url, token: remote.token, machineName: remote.machineName || os.hostname(), transport: remote.transport, version: VERSION,
|
|
86
|
+
listPublished: listPublishedForHello, readSnapshot, execOp, log: (m) => console.log(`spectoflow · ${m}`),
|
|
87
|
+
});
|
|
88
|
+
connector.start();
|
|
89
|
+
}
|
|
90
|
+
function remoteStatus() {
|
|
91
|
+
const remote = workspace.readRemote();
|
|
92
|
+
if (!remote) return { configured: false, url: null, machineName: null, connected: false, transport: null, lastError: null, since: null };
|
|
93
|
+
const s = connector ? connector.status() : { connected: false, transport: remote.transport, lastError: null, since: null };
|
|
94
|
+
return { configured: true, url: remote.url, machineName: remote.machineName, connected: s.connected, transport: s.transport, lastError: s.lastError, since: s.since };
|
|
95
|
+
}
|
|
96
|
+
|
|
37
97
|
function getProject(id) {
|
|
38
98
|
if (projects.has(id)) return projects.get(id);
|
|
39
99
|
const entry = registry.listProjects().find((p) => p.id === id);
|
|
40
|
-
if (!entry) return null;
|
|
41
|
-
const handlersPath = path.join(entry.path, '.spectoflow', 'dashboard', 'handlers.js');
|
|
42
|
-
let createHandlers;
|
|
43
|
-
try { ({ createHandlers } = require(handlersPath)); }
|
|
44
|
-
catch { return null; } // project's folder moved/deleted, or predates the handlers.js split
|
|
100
|
+
if (!entry || !fs.existsSync(entry.path)) return null;
|
|
45
101
|
const handlers = createHandlers(entry.path);
|
|
46
102
|
const clients = new Set();
|
|
47
|
-
const emit = (obj) => {
|
|
103
|
+
const emit = (obj) => {
|
|
104
|
+
const line = 'data: ' + JSON.stringify(obj) + '\n\n';
|
|
105
|
+
for (const res of clients) res.write(line);
|
|
106
|
+
if (connector && publishedIds.has(id)) { connector.pushEvent(id, obj); if (obj.type === 'change') scheduleSnapshot(id); }
|
|
107
|
+
};
|
|
48
108
|
handlers.onBoot();
|
|
109
|
+
const watchers = [];
|
|
110
|
+
// A single logical write (e.g. store.js's write-then-rename) can fire an fs.watch directory
|
|
111
|
+
// listener several times in a row for one underlying change (worst on Windows, which also reports
|
|
112
|
+
// sibling-entry metadata churn) — coalesce any burst within 150ms into exactly one 'change' emit,
|
|
113
|
+
// so a published project's connector feed gets one 'event' frame per real change, not several.
|
|
114
|
+
let changeTimer = null;
|
|
115
|
+
const onDirChange = () => { clearTimeout(changeTimer); changeTimer = setTimeout(() => emit({ type: 'change' }), 150); };
|
|
49
116
|
handlers.watchDirs.forEach((d) => {
|
|
50
117
|
const dir = path.join(entry.path, d);
|
|
51
|
-
if (fs.existsSync(dir)) { try { fs.watch(dir, { recursive: false },
|
|
118
|
+
if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, onDirChange)); } catch (_) {} }
|
|
52
119
|
});
|
|
53
|
-
const proj = { id, root: entry.path, handlers, clients, emit };
|
|
120
|
+
const proj = { id, root: entry.path, handlers, clients, emit, watchers };
|
|
54
121
|
projects.set(id, proj);
|
|
55
122
|
return proj;
|
|
56
123
|
}
|
|
57
124
|
|
|
58
|
-
// Only called after getProject(id)
|
|
59
|
-
// user at the right fix instead of a bare "unknown project". The two common real causes: never
|
|
60
|
-
// registered at all, vs. registered but this project predates handlers.js (needs `spectoflow
|
|
61
|
-
// update`) or its folder moved/was deleted.
|
|
125
|
+
// Only called after getProject(id) returned null. Two causes remain (D64 removed "needs an update").
|
|
62
126
|
function projectErrorMessage(id) {
|
|
63
127
|
const entry = registry.listProjects().find((p) => p.id === id);
|
|
64
128
|
if (!entry) return 'Unknown project.';
|
|
65
|
-
|
|
66
|
-
const handlersPath = path.join(entry.path, '.spectoflow', 'dashboard', 'handlers.js');
|
|
67
|
-
if (!fs.existsSync(handlersPath)) return `Project "${entry.name}" needs an update — run \`spectoflow update\` inside it, then reload this page.`;
|
|
68
|
-
return `Project "${entry.name}" is registered, but its dashboard code failed to load — check its .spectoflow/dashboard/handlers.js for errors.`;
|
|
129
|
+
return `Project "${entry.name}" is registered, but its folder no longer exists at ${entry.path}.`;
|
|
69
130
|
}
|
|
70
131
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// absolute path, so this can never touch another project's identically-named files. Returns false
|
|
75
|
-
// (a harmless no-op, not an error) if this id was never loaded — nothing to invalidate.
|
|
132
|
+
// Re-opens a project: drops its cached entry and closes its watchers so the next request re-runs
|
|
133
|
+
// onBoot and re-watches (a project's dirs may have changed after `spectoflow update`). Returns false
|
|
134
|
+
// (a harmless no-op) if this id was never loaded.
|
|
76
135
|
function reloadProject(id) {
|
|
77
136
|
const proj = projects.get(id);
|
|
78
137
|
if (!proj) return false;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (key.startsWith(prefix)) delete require.cache[key];
|
|
82
|
-
}
|
|
138
|
+
proj.watchers.forEach((w) => { try { w.close(); } catch (_) {} });
|
|
139
|
+
require('./orchestrator').clearPending(proj.root);
|
|
83
140
|
projects.delete(id);
|
|
84
141
|
return true;
|
|
85
142
|
}
|
|
86
143
|
|
|
87
144
|
// ---- hub API: list/add/remove registered projects, browse the filesystem to find one ----
|
|
88
145
|
function projectStats(root) {
|
|
89
|
-
// Best-effort — a moved/deleted/corrupt project must never break the whole listing.
|
|
90
146
|
try {
|
|
91
|
-
const store = require(path.join(root, '.spectoflow', 'lib', 'store.js'));
|
|
92
147
|
const plans = store.readPlans(root);
|
|
93
148
|
let total = 0, done = 0;
|
|
94
149
|
for (const pl of plans) for (const ph of pl.phases) for (const t of ph.tasks) { total++; if (t.status === 'done') done++; }
|
|
@@ -96,7 +151,7 @@ function projectStats(root) {
|
|
|
96
151
|
} catch { return null; }
|
|
97
152
|
}
|
|
98
153
|
function listHubProjects() {
|
|
99
|
-
return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path) }));
|
|
154
|
+
return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path), published: publishedIds.has(p.id) }));
|
|
100
155
|
}
|
|
101
156
|
function listRoots() {
|
|
102
157
|
const home = os.homedir();
|
|
@@ -130,15 +185,15 @@ function addHubProject(rawPath) {
|
|
|
130
185
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'That folder does not exist.' };
|
|
131
186
|
const hasSpectoflow = fs.existsSync(path.join(abs, '.spectoflow'));
|
|
132
187
|
if (!hasSpectoflow) {
|
|
133
|
-
const { runInit } = require('
|
|
188
|
+
const { runInit } = require('../init');
|
|
134
189
|
runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
|
|
135
190
|
}
|
|
136
|
-
const entry =
|
|
191
|
+
const entry = workspace.registerProject(abs);
|
|
137
192
|
return { entry, initialized: !hasSpectoflow };
|
|
138
193
|
}
|
|
139
194
|
async function handleHubApi(req, res, u) {
|
|
140
195
|
const p = u.pathname;
|
|
141
|
-
if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects() }); return true; }
|
|
196
|
+
if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects(), remote: remoteStatus() }); return true; }
|
|
142
197
|
if (p === '/api/hub/projects' && req.method === 'POST') {
|
|
143
198
|
const { path: rawPath } = await body(req);
|
|
144
199
|
if (!rawPath || !String(rawPath).trim()) { sendJSON(res, 400, { error: 'A folder path is required.' }); return true; }
|
|
@@ -149,6 +204,7 @@ async function handleHubApi(req, res, u) {
|
|
|
149
204
|
if (/^\/api\/hub\/projects\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
150
205
|
const id = decodeURIComponent(p.split('/')[4] || '');
|
|
151
206
|
const ok = registry.removeProject(id);
|
|
207
|
+
if (ok) { refreshPublished(); if (connector) connector.announce(); }
|
|
152
208
|
sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'No project registered with that id.' });
|
|
153
209
|
return true;
|
|
154
210
|
}
|
|
@@ -164,13 +220,38 @@ async function handleHubApi(req, res, u) {
|
|
|
164
220
|
sendJSON(res, 200, { ok: true, reloaded });
|
|
165
221
|
return true;
|
|
166
222
|
}
|
|
223
|
+
if (p === '/api/hub/remote' && req.method === 'GET') { sendJSON(res, 200, remoteStatus()); return true; }
|
|
224
|
+
if (p === '/api/hub/remote/reconnect' && req.method === 'POST') { startConnector(); sendJSON(res, 200, { ok: true, ...remoteStatus() }); return true; }
|
|
225
|
+
if (/^\/api\/hub\/projects\/[^/]+\/publish$/.test(p) && req.method === 'POST') {
|
|
226
|
+
const id = decodeURIComponent(p.split('/')[4] || '');
|
|
227
|
+
const { published } = await body(req);
|
|
228
|
+
const meta = workspace.setPublished(id, published !== false);
|
|
229
|
+
if (!meta) { sendJSON(res, 404, { error: 'No project registered with that id.' }); return true; }
|
|
230
|
+
refreshPublished();
|
|
231
|
+
if (connector) connector.announce();
|
|
232
|
+
sendJSON(res, 200, { ok: true, id, published: meta.published });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
167
235
|
return false;
|
|
168
236
|
}
|
|
169
237
|
|
|
238
|
+
// A viewer's own localStorage choice (once one exists) always wins client-side (app.js's IIFE at the
|
|
239
|
+
// top of the file, and the reconciliation in renderSettings()) — this is only filling the gap for a
|
|
240
|
+
// visitor with nothing saved yet, so the very first paint already matches the project's own
|
|
241
|
+
// configured design instead of flashing whatever static default shipped in the HTML file.
|
|
242
|
+
function projectDesign(root) {
|
|
243
|
+
if (!root) return DEFAULT_DESIGN;
|
|
244
|
+
try { return (store.readConfig(root) || {}).design || DEFAULT_DESIGN; }
|
|
245
|
+
catch { return DEFAULT_DESIGN; }
|
|
246
|
+
}
|
|
247
|
+
|
|
170
248
|
// Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
|
|
171
|
-
// shared, globally-installed PUBLIC dir —
|
|
172
|
-
//
|
|
173
|
-
|
|
249
|
+
// shared, globally-installed PUBLIC dir — factored into a function since both the root-level and
|
|
250
|
+
// /p/<id>/-prefixed requests need it. `root` (the specific project's folder), when known, is used to
|
|
251
|
+
// stamp the served HTML's <html data-design="..."> synchronously with that project's own
|
|
252
|
+
// config.design, before the file ever reaches the browser — avoiding a flash of the wrong theme on a
|
|
253
|
+
// viewer's very first-ever page load (no localStorage value saved yet to reconcile against).
|
|
254
|
+
function serveStatic(reqPath, req, res, root) {
|
|
174
255
|
const file = reqPath === '/' ? '/index.html' : reqPath;
|
|
175
256
|
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
|
|
176
257
|
if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('Forbidden'); }
|
|
@@ -180,24 +261,26 @@ function serveStatic(reqPath, req, res) {
|
|
|
180
261
|
if (req.method === 'GET' && !path.extname(reqPath)) {
|
|
181
262
|
return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
|
|
182
263
|
if (e2) { res.writeHead(404); return res.end('Not found'); }
|
|
183
|
-
|
|
264
|
+
const html = root ? injectDesign(d2.toString('utf8'), projectDesign(root)) : d2;
|
|
265
|
+
res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(html);
|
|
184
266
|
});
|
|
185
267
|
}
|
|
186
268
|
res.writeHead(404); return res.end('Not found');
|
|
187
269
|
}
|
|
188
270
|
const ext = path.extname(full);
|
|
189
271
|
const headers = ext === '.woff2' || ext === '.woff' ? { 'Cache-Control': 'public, max-age=604800' } : noCache;
|
|
190
|
-
|
|
272
|
+
const payload = (root && ext === '.html') ? injectDesign(data.toString('utf8'), projectDesign(root)) : data;
|
|
273
|
+
res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(payload);
|
|
191
274
|
});
|
|
192
275
|
}
|
|
193
276
|
|
|
194
277
|
const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
|
|
195
278
|
|
|
196
|
-
const LOCK =
|
|
279
|
+
const LOCK = workspace.lockPath();
|
|
197
280
|
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
198
281
|
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
199
282
|
process.on('exit', clearLock);
|
|
200
|
-
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
|
|
283
|
+
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
|
|
201
284
|
|
|
202
285
|
const server = http.createServer(async (req, res) => {
|
|
203
286
|
const u = new URL(req.url, `http://localhost:${PORT}`);
|
|
@@ -235,7 +318,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
235
318
|
const proj = getProject(id);
|
|
236
319
|
if (!proj) { res.writeHead(404); return res.end(projectErrorMessage(id)); }
|
|
237
320
|
registry.touchProject(id);
|
|
238
|
-
return serveStatic(m[2] || '/', req, res);
|
|
321
|
+
return serveStatic(m[2] || '/', req, res, proj.root);
|
|
239
322
|
}
|
|
240
323
|
|
|
241
324
|
if (p === '/') {
|
|
@@ -255,4 +338,4 @@ const server = http.createServer(async (req, res) => {
|
|
|
255
338
|
} catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
|
|
256
339
|
});
|
|
257
340
|
|
|
258
|
-
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}`); });
|
|
341
|
+
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Shared by both dashboard surfaces — the local hub (hub-server.js) and the online relay
|
|
4
|
+
* (server/src/app.js) — so a project's own configured design (config.design) is stamped onto the
|
|
5
|
+
* served index.html's <html data-design="..."> attribute BEFORE the file ever reaches the browser.
|
|
6
|
+
* Design is a shared, project-level setting (not a per-viewer choice, Sous-projet B) — this is the
|
|
7
|
+
* ONLY place the design attribute is ever set for first paint; app.js has no localStorage fallback
|
|
8
|
+
* or reconciliation for it any more, it only reflects P.config.design once the client boots.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Mirrors app.js's currentDesign() own default ('console') when the attribute is absent — same
|
|
12
|
+
// fallback a project with no design saved yet (or an unresolvable project) would land on anyway.
|
|
13
|
+
const DEFAULT_DESIGN = 'console';
|
|
14
|
+
|
|
15
|
+
// The real, live registry of selectable designs (lib/dashboard/public/designs.js) — required, not
|
|
16
|
+
// re-declared here, so an id added/removed there is instantly reflected here with zero drift risk.
|
|
17
|
+
const DESIGNS = require('./public/designs.js');
|
|
18
|
+
const KNOWN_IDS = new Set(DESIGNS.map((d) => d.id));
|
|
19
|
+
|
|
20
|
+
// Only ever accept a value that is one of the real, currently-registered design ids — never trust an
|
|
21
|
+
// arbitrary stored/cached string into the HTML (that would be an injection risk), and never trust a
|
|
22
|
+
// shape-valid but unregistered id either (a stale/typo'd/removed design would silently produce an
|
|
23
|
+
// unstyled page instead of falling back to the same default a missing value gets).
|
|
24
|
+
function sanitizeDesign(d) {
|
|
25
|
+
return (typeof d === 'string' && KNOWN_IDS.has(d)) ? d : DEFAULT_DESIGN;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Rewrites (or adds) the <html> tag's data-design attribute to the given value — leaves everything
|
|
29
|
+
// else in the file untouched, including data-theme (config.theme is applied client-side once app.js
|
|
30
|
+
// boots, same as before — this injector was only ever built to solve design's first-paint flash).
|
|
31
|
+
function injectDesign(html, design) {
|
|
32
|
+
const safe = sanitizeDesign(design);
|
|
33
|
+
return html.replace(/<html([^>]*)>/i, (full, attrs) => {
|
|
34
|
+
const newAttrs = /\bdata-design=/.test(attrs)
|
|
35
|
+
? attrs.replace(/data-design="[^"]*"/, `data-design="${safe}"`)
|
|
36
|
+
: `${attrs} data-design="${safe}"`;
|
|
37
|
+
return `<html${newAttrs}>`;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { injectDesign, sanitizeDesign, DEFAULT_DESIGN };
|