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.
Files changed (90) hide show
  1. package/README.md +49 -16
  2. package/bin/postinstall.js +1 -0
  3. package/bin/spectoflow.js +226 -35
  4. package/lib/adapters.js +8 -2
  5. package/{templates/lib → lib}/custom-dashboard.js +2 -2
  6. package/{templates/lib → lib}/customize-prompts.js +1 -1
  7. package/lib/dashboard/connector.js +193 -0
  8. package/lib/dashboard/handlers.js +53 -0
  9. package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
  10. package/lib/dashboard/inject-design.js +41 -0
  11. package/lib/dashboard/meeting.js +116 -0
  12. package/lib/dashboard/ops.js +257 -0
  13. package/{templates → lib}/dashboard/orchestrator.js +17 -8
  14. package/{templates → lib}/dashboard/public/app.js +743 -124
  15. package/{templates → lib}/dashboard/public/charts.js +3 -3
  16. package/lib/dashboard/public/commands.js +77 -0
  17. package/{templates → lib}/dashboard/public/designs/console.css +7 -19
  18. package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
  19. package/{templates → lib}/dashboard/public/designs.js +2 -2
  20. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  21. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  22. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  23. package/{templates → lib}/dashboard/public/hub.html +1 -1
  24. package/{templates → lib}/dashboard/public/hub.js +27 -4
  25. package/{templates → lib}/dashboard/public/i18n.js +60 -18
  26. package/{templates → lib}/dashboard/public/icons.js +2 -0
  27. package/{templates → lib}/dashboard/public/index.html +106 -13
  28. package/{templates → lib}/dashboard/public/styles.css +161 -26
  29. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  46. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  47. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  48. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  49. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  50. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  51. package/lib/dashboard/routes.js +38 -0
  52. package/{templates → lib}/dashboard/runner.js +13 -7
  53. package/{templates → lib}/dashboard/summarize.js +1 -1
  54. package/lib/detect.js +16 -1
  55. package/lib/global-config.js +65 -0
  56. package/lib/init.js +10 -4
  57. package/lib/registry.js +8 -8
  58. package/{templates/lib → lib}/store.js +16 -12
  59. package/lib/update.js +69 -1
  60. package/lib/workspace.js +119 -0
  61. package/package.json +3 -3
  62. package/templates/AGENTS.md +6 -6
  63. package/templates/README.md +4 -3
  64. package/templates/agents/framework-curator.md +11 -11
  65. package/templates/capabilities.md +1 -1
  66. package/templates/config.json +23 -0
  67. package/templates/dashboards/.gitkeep +0 -0
  68. package/templates/skills/generate-dashboard/SKILL.md +15 -13
  69. package/templates/dashboard/custom/.gitkeep +0 -3
  70. package/templates/dashboard/handlers.js +0 -251
  71. package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  72. package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  73. package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
  74. package/templates/dashboard/server.js +0 -73
  75. package/templates/lib/agents-registry.js +0 -65
  76. /package/{templates → lib}/dashboard/files.js +0 -0
  77. /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
  78. /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
  79. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
  80. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
  81. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
  82. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
  83. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
  84. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
  85. /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
  86. /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
  87. /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
  88. /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
  89. /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
  90. /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/dashboard/custom/<id>.json).
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 templates/dashboard/server.js (Node) via readCustomDashboards() in
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 templates/dashboard/public/app.js's CZ_KINDS) — the single source of truth so the
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 + route logic on demand from
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
- * Each project's own vendored handlers.js is require()'d dynamically by absolute path — Node's
7
- * require cache keys by resolved path, so two different projects' identically-named handlers.js
8
- * files are cached and run completely independently (see docs/multi-project-hub-design.md).
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('./registry');
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 PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
27
- const PUBLIC = path.join(__dirname, '..', 'templates', 'dashboard', 'public');
28
- const TEMPLATES = path.join(__dirname, '..', 'templates');
29
- const VERSION = require('../package.json').version;
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 }, populated lazily on first request for that id and
35
- // kept alive for the process's life (registries are small; no need to tear down on last-tab-close).
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) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
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 }, () => emit({ type: 'change' })); } catch (_) {} }
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) already returned null — figures out WHY, so the 404 points the
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
- if (!fs.existsSync(entry.path)) return `Project "${entry.name}" is registered, but its folder no longer exists at ${entry.path}.`;
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
- // Clears every require.cache entry under this project's own .spectoflow/ tree (its vendored
72
- // handlers.js and everything IT requires — orchestrator.js, runner.js, files.js, summarize.js,
73
- // lib/store.js, lib/agents-registry.js) and drops its cached Map entry. The require cache keys by
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
- const prefix = path.join(proj.root, '.spectoflow') + path.sep;
80
- for (const key of Object.keys(require.cache)) {
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('./init');
188
+ const { runInit } = require('../init');
134
189
  runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
135
190
  }
136
- const entry = registry.addProject(abs);
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 — identical logic to templates/dashboard/server.js's own,
172
- // just factored into a function since both the root-level and /p/<id>/-prefixed requests need it.
173
- function serveStatic(reqPath, req, res) {
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
- res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(d2);
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
- res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(data);
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 = registry.hubLockPath();
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 };