spectoflow 0.22.4 → 0.23.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/bin/spectoflow.js CHANGED
@@ -9,6 +9,8 @@ const adapters = require('../lib/adapters');
9
9
  const detect = require('../lib/detect');
10
10
  const ownership = require('../lib/ownership');
11
11
  const manifest = require('../lib/manifest');
12
+ const registry = require('../lib/registry');
13
+ const initLib = require('../lib/init');
12
14
  const mcp = require('../lib/mcp');
13
15
  const { startRun } = require('../templates/dashboard/runner');
14
16
  const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
@@ -93,125 +95,16 @@ function probeDashboard(port, timeoutMs = 500) {
93
95
  });
94
96
  }
95
97
 
96
- function copyDir(src, dst) {
97
- fs.mkdirSync(dst, { recursive: true });
98
- for (const e of fs.readdirSync(src, { withFileTypes: true })) {
99
- const s = path.join(src, e.name), d = path.join(dst, e.name);
100
- if (e.isDirectory()) copyDir(s, d);
101
- else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
102
- }
103
- }
104
-
105
- // Existing project: give id-less checkbox tasks a stable id, in place.
106
- const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
107
- function normalizePlans(root, config) {
108
- const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
109
- const dir = path.join(root, dirName);
110
- if (!fs.existsSync(dir)) return 0;
111
- let added = 0, seq = 1;
112
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
113
- const fp = path.join(dir, f);
114
- const lines = fs.readFileSync(fp, 'utf8').split('\n');
115
- let touched = false;
116
- for (let i = 0; i < lines.length; i++) {
117
- const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
118
- if (m && !ID_RE.test(m[2])) {
119
- const id = 'T-' + String(seq++).padStart(3, '0');
120
- lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
121
- touched = true; added++;
122
- } else if (m) { seq++; }
123
- }
124
- if (touched) fs.writeFileSync(fp, lines.join('\n'));
125
- }
126
- return added;
127
- }
128
-
129
98
  function init() {
130
99
  const target = path.resolve(argv[1] && !argv[1].startsWith('--') ? argv[1] : '.');
131
100
  const agentsArg = (argv.find((a) => a.startsWith('--agent=')) || '').split('=')[1];
132
- fs.mkdirSync(target, { recursive: true });
133
- const notes = [];
134
-
135
- // explicit --agent wins; otherwise detect installed agents; otherwise fall back to claude + codex
136
- let agents, detected = [];
137
- if (agentsArg) {
138
- agents = agentsArg.split(',');
139
- } else {
140
- detected = detect.detectAgents(target);
141
- agents = detected.length ? detected : ['claude', 'codex'];
142
- notes.push(detected.length
143
- ? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
144
- : 'No agent CLI detected — defaulted to claude + codex.');
145
- }
146
-
147
- // preserve an existing CLAUDE.md
148
- const claude = path.join(target, 'CLAUDE.md');
149
- if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
150
- fs.renameSync(claude, claude + '.tomerge');
151
- notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
152
- }
153
-
154
- // canonical framework → .spectoflow/
155
- const spectoflowDir = path.join(target, '.spectoflow');
156
- copyDir(TPL, spectoflowDir);
157
-
158
- // record the install baseline so `update` can tell untouched framework files from user edits
159
- const frameworkFiles = ownership.listFrameworkFiles(TPL);
160
- manifest.writeManifest(spectoflowDir, {
161
- version: VERSION,
162
- files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
163
- });
164
-
165
- // set the active agent and seed runner commands from the selected/detected agents
166
- const cfgPath = path.join(spectoflowDir, 'config.json');
167
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
168
- cfg.agent = agents[0];
169
- cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
170
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
171
-
172
- // artifact folders — reuse an existing differently-named folder (e.g. a project that already
173
- // keeps its plans in `plan/`, singular) instead of always forcing the plans/specs convention;
174
- // mkdir is a no-op when the resolved folder already exists.
175
- const plansDirName = store.resolvePlansDir(target, cfg);
176
- const specsDirName = store.resolveSpecsDir(target, cfg);
177
- fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
178
- fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
179
- if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
180
- if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
181
-
182
- // existing project: id-normalize any plans already there
183
- const added = normalizePlans(target, cfg);
184
- if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
185
-
186
- // per-agent shims
187
- const written = adapters.generate(target, agents);
188
-
189
- // wire Playwright MCP into the project's MCP config so the E2E agent can drive a real browser and
190
- // generate/run Playwright tests. Idempotent + non-destructive: never touches an existing entry.
191
- // npx fetches the server on first use, so this config IS the whole install — spectoflow stays
192
- // zero-dep (this writes into the user's project, never into spectoflow).
193
- const mcpTargets = [path.join(target, '.mcp.json')];
194
- if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
195
- for (const fp of mcpTargets) {
196
- const rel = path.relative(target, fp).split(path.sep).join('/');
197
- const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
198
- if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
199
- else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
200
- }
201
-
202
- // gitignore the volatile runtime
203
- const gi = path.join(target, '.gitignore');
204
- const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
205
- for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
206
- if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
207
- }
208
-
101
+ const r = initLib.runInit({ target, templatesDir: TPL, version: VERSION, agentsArg });
209
102
  console.log(logo());
210
- console.log(`${c.g('✓')} installed in ${c.bold(target)}`);
103
+ console.log(`${c.g('✓')} installed in ${c.bold(r.target)}`);
211
104
  console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, dashboard, config`);
212
105
  console.log(` ${c.dim('specs/ plans/')} markdown artifacts (your source of truth)`);
213
- written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
214
- notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
106
+ r.written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
107
+ r.notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
215
108
  const port = resolvePort(argv);
216
109
  console.log(`\n${c.bold('Next')}`);
217
110
  console.log(` ${c.dim('1)')} Open your agent here — or just say what you want to build.`);
@@ -253,21 +146,27 @@ async function update() {
253
146
  if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge — or re-run: spectoflow update --force`)}`);
254
147
  console.log('');
255
148
 
256
- // A running dashboard has the OLD framework code loaded into memory (Node caches `require()`d
257
- // modules at process start) — new bytes on disk change nothing until it restarts. Do that
258
- // automatically so an update always actually takes effect, instead of leaving a confusing
259
- // half-updated dashboard (new static files, stale server logic) until someone thinks to restart.
149
+ // A running hub has the OLD framework code loaded into memory (Node caches `require()`d modules
150
+ // per project on first open) — new bytes on disk change nothing until that project's cached code
151
+ // is invalidated. Do that via a surgical per-project reload so an update always actually takes
152
+ // effect, without restarting the whole hub (which would disturb every other project open in it).
260
153
  if (!dryRun && changed) {
261
- const lock = path.join(root, '.spectoflow', '.dashboard.lock');
154
+ const lockPath = registry.hubLockPath();
262
155
  let info = null;
263
- try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
156
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
264
157
  if (info && info.port && (await probeDashboard(info.port, 2000))) {
265
- console.log(` ${c.dim('Dashboard is running — restarting it on port ' + info.port + ' to apply the update…')}`);
266
- // Restart on the SAME port it was already on, not resolvePort(argv)'s default — `update`
267
- // itself was never given a --port, so a naive restartDashboard() would silently move a
268
- // non-default-port dashboard back to 4319.
269
- argv.push(`--port=${info.port}`);
270
- await restartDashboard();
158
+ const entry = registry.findByPath(root);
159
+ if (entry) {
160
+ try {
161
+ const res = await fetch(`http://localhost:${info.port}/api/hub/reload/${entry.id}`, { method: 'POST' });
162
+ const body = await res.json().catch(() => ({}));
163
+ console.log(` ${c.dim(body.reloaded
164
+ ? 'Hub is running — reloaded this project\'s server code (other open projects unaffected).'
165
+ : 'Hub is running, but this project wasn\'t loaded in it yet — nothing to reload.')}`);
166
+ } catch {
167
+ console.log(` ${c.y('!')} Hub is running on port ${info.port} but the reload request failed — restart it yourself if changes don't seem to take effect: ${c.g('spectoflow dashboard restart')}`);
168
+ }
169
+ }
271
170
  }
272
171
  }
273
172
  }
@@ -283,6 +182,28 @@ async function dashboard() {
283
182
  return startDashboard();
284
183
  }
285
184
 
185
+ // ---- projects: the multi-project registry's CLI surface (~/.spectoflow/projects.json) ----
186
+ function projectsCmd() {
187
+ const sub = argv[1];
188
+ if (sub === 'remove') return projectsRemove(argv[2]);
189
+ return projectsList();
190
+ }
191
+ function projectsList() {
192
+ console.log(wordmark());
193
+ const rows = registry.listProjects();
194
+ if (!rows.length) {
195
+ console.log(c.dim(' no projects registered yet — run `spectoflow dashboard` inside one'));
196
+ return;
197
+ }
198
+ const w = Math.max(4, ...rows.map((r) => r.name.length));
199
+ rows.forEach((r) => console.log(` ${c.g(r.id)} ${r.name.padEnd(w)} ${c.dim(r.path)}`));
200
+ }
201
+ function projectsRemove(id) {
202
+ if (!id) { console.log('Usage: spectoflow projects remove <id>'); return; }
203
+ const ok = registry.removeProject(id);
204
+ console.log(ok ? `${c.g('✓')} removed ${id}` : `${c.y('!')} no project registered with id ${id}`);
205
+ }
206
+
286
207
  // ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
287
208
  // Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
288
209
  // and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
@@ -335,26 +256,31 @@ async function runCustomize(kind) {
335
256
  process.exitCode = code;
336
257
  }
337
258
 
338
- // Start in the background and return control. Probes first so a second start just reports the running
339
- // one instead of spawning a duplicate (and never crashes on EADDRINUSE).
259
+ // Start in the background and return control. Registers (or touches) the current folder in the
260
+ // global registry first, then either joins an already-running hub or spawns a new one probing first
261
+ // so a second start just reports the running one instead of spawning a duplicate.
340
262
  async function startDashboard() {
341
- const port = resolvePort(argv);
342
- const url = `http://localhost:${port}`;
343
- if (await probeDashboard(port)) {
344
- console.log(`${c.g('●')} dashboard already running → ${c.bold(url)}`);
263
+ const root = process.cwd();
264
+ const entry = registry.addProject(root);
265
+ const boardUrl = (p) => `http://localhost:${p}/p/${entry.id}/board`;
266
+ const lockPath = registry.hubLockPath();
267
+ let info = null;
268
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
269
+ if (info && info.port && await probeDashboard(info.port)) {
270
+ console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
345
271
  return printDashboardCommands();
346
272
  }
347
- const local = path.resolve('.spectoflow', 'dashboard', 'server.js');
348
- const bundled = path.join(TPL, 'dashboard', 'server.js');
273
+ const port = resolvePort(argv);
274
+ const hubPath = path.join(KIT, 'lib', 'hub-server.js');
349
275
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
350
- const child = spawn('node', [fs.existsSync(local) ? local : bundled], { detached: true, stdio: 'ignore', env });
351
- child.unref(); // let this CLI exit while the server keeps running
276
+ const child = spawn('node', [hubPath], { detached: true, stdio: 'ignore', env });
277
+ child.unref(); // let this CLI exit while the hub keeps running
352
278
  // Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
353
279
  // startup error, would otherwise print a false "started" while the detached process silently died).
354
280
  let up = false;
355
281
  for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
356
- if (up) console.log(`${c.g('✓')} dashboard started → ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
357
- else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on ${url} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
282
+ if (up) console.log(`${c.g('✓')} hub started → ${c.bold(boardUrl(port))} ${c.dim('(pid ' + child.pid + ')')}`);
283
+ else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on http://localhost:${port} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
358
284
  printDashboardCommands();
359
285
  }
360
286
 
@@ -367,12 +293,13 @@ function printDashboardCommands() {
367
293
  }
368
294
 
369
295
  async function dashboardStatus() {
370
- const port = resolvePort(argv);
296
+ const lockPath = registry.hubLockPath();
297
+ let info = null;
298
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
299
+ const port = (info && info.port) || resolvePort(argv);
371
300
  const running = await probeDashboard(port);
372
- let pid = null;
373
- try { pid = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.spectoflow', '.dashboard.lock'), 'utf8')).pid; } catch {}
374
- if (running) console.log(`${c.g('●')} dashboard running → ${c.bold('http://localhost:' + port)}${pid ? c.dim(' (pid ' + pid + ')') : ''}`);
375
- else console.log(`${c.dim('○')} dashboard not running`);
301
+ if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
302
+ else console.log(`${c.dim('')} hub not running`);
376
303
  }
377
304
 
378
305
  async function restartDashboard() {
@@ -385,28 +312,27 @@ async function restartDashboard() {
385
312
  return startDashboard();
386
313
  }
387
314
 
388
- // Stop the running dashboard: read the pidfile it wrote, verify it's actually up, then terminate it
315
+ // Stop the running hub: read the global lock it wrote, verify it's actually up, then terminate it
389
316
  // and clear the lock. Safe against a stale lock (a recycled pid) because it only kills when the port
390
317
  // still responds.
391
318
  async function stopDashboard() {
392
- const root = process.cwd();
393
- const lock = path.join(root, '.spectoflow', '.dashboard.lock');
319
+ const lockPath = registry.hubLockPath();
394
320
  let info = null;
395
- try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
321
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
396
322
  const port = (info && info.port) || resolvePort(argv);
397
323
  const running = await probeDashboard(port);
398
324
  if (!running) {
399
- if (info) { try { fs.unlinkSync(lock); } catch {} } // stale lock
400
- return console.log('No spectoflow dashboard is running.');
325
+ if (info) { try { fs.unlinkSync(lockPath); } catch {} } // stale lock
326
+ return console.log('No spectoflow hub is running.');
401
327
  }
402
328
  if (info && info.pid) {
403
329
  try {
404
- process.kill(info.pid); // SIGTERM → server clears its own lock (POSIX)
405
- try { fs.unlinkSync(lock); } catch {} // and we clear it too (Windows has no real signals)
406
- return console.log(`spectoflow dashboard stopped (pid ${info.pid}, was on http://localhost:${port}).`);
330
+ process.kill(info.pid); // SIGTERM → hub clears its own lock (POSIX)
331
+ try { fs.unlinkSync(lockPath); } catch {} // and we clear it too (Windows has no real signals)
332
+ return console.log(`spectoflow hub stopped (pid ${info.pid}, was on http://localhost:${port}).`);
407
333
  } catch {}
408
334
  }
409
- console.log(`A dashboard is responding on http://localhost:${port} but isn't stoppable via the lock file — stop it where you launched it (Ctrl+C).`);
335
+ console.log(`A hub is responding on http://localhost:${port} but isn't stoppable via the lock file — stop it where you launched it (Ctrl+C).`);
410
336
  }
411
337
 
412
338
  async function status() {
@@ -422,7 +348,10 @@ async function status() {
422
348
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
423
349
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
424
350
  tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
425
- const port = resolvePort(argv);
351
+ const lockPath = registry.hubLockPath();
352
+ let info = null;
353
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
354
+ const port = (info && info.port) || resolvePort(argv);
426
355
  const running = await probeDashboard(port);
427
356
  console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
428
357
  }
@@ -479,6 +408,7 @@ ${c.bold('Dashboard')}
479
408
  ${c.g('dashboard status')} is it running? (url + pid)
480
409
  ${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
481
410
  ${c.g('dashboard restart')} stop then start
411
+ ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far (~/.spectoflow/projects.json)
482
412
 
483
413
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
484
414
  ${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
@@ -522,6 +452,10 @@ const HELP = {
522
452
  ${c.g('stop')} stop it ${c.dim('(alias: spectoflow stop)')}
523
453
  ${c.g('restart')} stop then start
524
454
  ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}`,
455
+ projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
456
+ List every registered project in the global registry at ${c.dim('~/.spectoflow/projects.json')} (stored by
457
+ ${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
458
+ or was deleted) from this list only; it never touches that project's own files.`,
525
459
  skill: `${c.bold('spectoflow skill create')} ${c.dim('"<description>" [--agent=name]')}\n${c.bold('spectoflow skill create')} ${c.dim('--auto [--agent=name]')}\n
526
460
  Generate a project-specific skill — the CLI mirror of Settings → Customize → ${c.bold('Skills')} →
527
461
  ${c.bold('Add skill')} in the dashboard. Describe what it should do, or pass ${c.g('--auto')} to have
@@ -547,6 +481,7 @@ const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
547
481
  // ---- dispatch ---------------------------------------------------------------
548
482
  const fns = {
549
483
  init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
484
+ projects: projectsCmd,
550
485
  agents: () => { console.log(wordmark()); printAgents(false); },
551
486
  skills: () => { console.log(wordmark()); printSkills(false); },
552
487
  workflow: () => { console.log(wordmark()); printWorkflow(false); },
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+ /*
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
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).
9
+ *
10
+ * URL scheme (settled in the design doc): pages use a path prefix (/p/<id>/board, bookmarkable on
11
+ * their own); every /api/* call instead takes a ?p=<id> query param (smaller client diff, and
12
+ * /api/events already needed a query-param shape for its per-project SSE subscription either way).
13
+ * A legacy no-prefix route (e.g. /board, a bookmark from before the hub existed) 302s to the
14
+ * most-recently-opened project, or to the hub root if none are registered yet.
15
+ *
16
+ * GET / serves hub.html — the landing page listing every registered project, with an "+ Add project"
17
+ * flow (browse the filesystem server-side, or paste a path; either way auto-inits a plain folder) —
18
+ * see /api/hub/* below and docs/multi-project-hub-design.md §3bis.
19
+ */
20
+ const http = require('http');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const registry = require('./registry');
25
+
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;
30
+ 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
+ function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
32
+ 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
+
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).
36
+ const projects = new Map();
37
+ function getProject(id) {
38
+ if (projects.has(id)) return projects.get(id);
39
+ 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
45
+ const handlers = createHandlers(entry.path);
46
+ const clients = new Set();
47
+ const emit = (obj) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
48
+ handlers.onBoot();
49
+ handlers.watchDirs.forEach((d) => {
50
+ const dir = path.join(entry.path, d);
51
+ if (fs.existsSync(dir)) { try { fs.watch(dir, { recursive: false }, () => emit({ type: 'change' })); } catch (_) {} }
52
+ });
53
+ const proj = { id, root: entry.path, handlers, clients, emit };
54
+ projects.set(id, proj);
55
+ return proj;
56
+ }
57
+
58
+ // Clears every require.cache entry under this project's own .spectoflow/ tree (its vendored
59
+ // handlers.js and everything IT requires — orchestrator.js, runner.js, files.js, summarize.js,
60
+ // lib/store.js, lib/agents-registry.js) and drops its cached Map entry. The require cache keys by
61
+ // absolute path, so this can never touch another project's identically-named files. Returns false
62
+ // (a harmless no-op, not an error) if this id was never loaded — nothing to invalidate.
63
+ function reloadProject(id) {
64
+ const proj = projects.get(id);
65
+ if (!proj) return false;
66
+ const prefix = path.join(proj.root, '.spectoflow') + path.sep;
67
+ for (const key of Object.keys(require.cache)) {
68
+ if (key.startsWith(prefix)) delete require.cache[key];
69
+ }
70
+ projects.delete(id);
71
+ return true;
72
+ }
73
+
74
+ // ---- hub API: list/add/remove registered projects, browse the filesystem to find one ----
75
+ function projectStats(root) {
76
+ // Best-effort — a moved/deleted/corrupt project must never break the whole listing.
77
+ try {
78
+ const store = require(path.join(root, '.spectoflow', 'lib', 'store.js'));
79
+ const plans = store.readPlans(root);
80
+ let total = 0, done = 0;
81
+ for (const pl of plans) for (const ph of pl.phases) for (const t of ph.tasks) { total++; if (t.status === 'done') done++; }
82
+ return { total, done };
83
+ } catch { return null; }
84
+ }
85
+ function listHubProjects() {
86
+ return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path) }));
87
+ }
88
+ function listRoots() {
89
+ const home = os.homedir();
90
+ const roots = [{ name: path.basename(home) || home, path: home }];
91
+ if (process.platform === 'win32') {
92
+ for (const code of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
93
+ const drive = `${code}:\\`;
94
+ if (fs.existsSync(drive)) roots.push({ name: drive, path: drive });
95
+ }
96
+ } else if (home !== '/') {
97
+ roots.push({ name: '/', path: '/' });
98
+ }
99
+ return roots;
100
+ }
101
+ function browseDirs(reqPath) {
102
+ if (!reqPath) return { entries: listRoots(), parent: null, current: null };
103
+ let abs;
104
+ try { abs = path.resolve(reqPath); } catch { return { error: 'Invalid path.' }; }
105
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'Not a folder.' };
106
+ let names;
107
+ try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return { error: 'Cannot read this folder.' }; }
108
+ const entries = names.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
109
+ .map((e) => ({ name: e.name, path: path.join(abs, e.name) }))
110
+ .sort((a, b) => a.name.localeCompare(b.name));
111
+ const parent = path.dirname(abs) !== abs ? path.dirname(abs) : null;
112
+ return { entries, parent, current: abs };
113
+ }
114
+ function addHubProject(rawPath) {
115
+ let abs;
116
+ try { abs = path.resolve(rawPath); } catch { return { error: 'Invalid path.' }; }
117
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'That folder does not exist.' };
118
+ const hasSpectoflow = fs.existsSync(path.join(abs, '.spectoflow'));
119
+ if (!hasSpectoflow) {
120
+ const { runInit } = require('./init');
121
+ runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
122
+ }
123
+ const entry = registry.addProject(abs);
124
+ return { entry, initialized: !hasSpectoflow };
125
+ }
126
+ async function handleHubApi(req, res, u) {
127
+ const p = u.pathname;
128
+ if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects() }); return true; }
129
+ if (p === '/api/hub/projects' && req.method === 'POST') {
130
+ const { path: rawPath } = await body(req);
131
+ if (!rawPath || !String(rawPath).trim()) { sendJSON(res, 400, { error: 'A folder path is required.' }); return true; }
132
+ const r = addHubProject(String(rawPath).trim());
133
+ if (r.error) { sendJSON(res, 400, r); return true; }
134
+ sendJSON(res, 200, r); return true;
135
+ }
136
+ if (/^\/api\/hub\/projects\/[^/]+$/.test(p) && req.method === 'DELETE') {
137
+ const id = decodeURIComponent(p.split('/')[4] || '');
138
+ const ok = registry.removeProject(id);
139
+ sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'No project registered with that id.' });
140
+ return true;
141
+ }
142
+ if (p === '/api/hub/browse' && req.method === 'GET') {
143
+ const reqPath = u.searchParams.get('path') || '';
144
+ const r = browseDirs(reqPath);
145
+ sendJSON(res, r.error ? 400 : 200, r);
146
+ return true;
147
+ }
148
+ if (/^\/api\/hub\/reload\/[^/]+$/.test(p) && req.method === 'POST') {
149
+ const id = decodeURIComponent(p.split('/')[4] || '');
150
+ const reloaded = reloadProject(id);
151
+ sendJSON(res, 200, { ok: true, reloaded });
152
+ return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ // Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
158
+ // shared, globally-installed PUBLIC dir — identical logic to templates/dashboard/server.js's own,
159
+ // just factored into a function since both the root-level and /p/<id>/-prefixed requests need it.
160
+ function serveStatic(reqPath, req, res) {
161
+ const file = reqPath === '/' ? '/index.html' : reqPath;
162
+ const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
163
+ if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('Forbidden'); }
164
+ const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
165
+ fs.readFile(full, (err, data) => {
166
+ if (err) {
167
+ if (req.method === 'GET' && !path.extname(reqPath)) {
168
+ return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
169
+ if (e2) { res.writeHead(404); return res.end('Not found'); }
170
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(d2);
171
+ });
172
+ }
173
+ res.writeHead(404); return res.end('Not found');
174
+ }
175
+ const ext = path.extname(full);
176
+ const headers = ext === '.woff2' || ext === '.woff' ? { 'Cache-Control': 'public, max-age=604800' } : noCache;
177
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(data);
178
+ });
179
+ }
180
+
181
+ const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
182
+
183
+ const LOCK = registry.hubLockPath();
184
+ 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{} }
185
+ function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
186
+ process.on('exit', clearLock);
187
+ ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
188
+
189
+ const server = http.createServer(async (req, res) => {
190
+ const u = new URL(req.url, `http://localhost:${PORT}`);
191
+ const p = u.pathname;
192
+ try {
193
+ const m = p.match(PROJECT_PREFIX);
194
+
195
+ if (p === '/api/events') {
196
+ const id = u.searchParams.get('p');
197
+ const proj = id && getProject(id);
198
+ if (!proj) return sendJSON(res, 404, { error: 'Unknown or unreachable project.' });
199
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
200
+ res.write('data: ' + JSON.stringify({ type: 'hello' }) + '\n\n');
201
+ proj.clients.add(res); req.on('close', () => proj.clients.delete(res));
202
+ return;
203
+ }
204
+
205
+ if (p.startsWith('/api/hub/')) {
206
+ const handled = await handleHubApi(req, res, u);
207
+ if (handled) return;
208
+ res.writeHead(404); return res.end('Not found');
209
+ }
210
+
211
+ if (p.startsWith('/api/')) {
212
+ const id = u.searchParams.get('p');
213
+ const proj = id && getProject(id);
214
+ if (!proj) return sendJSON(res, 404, { error: 'Unknown or unreachable project.' });
215
+ const handled = await proj.handlers.handleApi(req, res, u, proj.emit);
216
+ if (handled) return;
217
+ res.writeHead(404); return res.end('Not found');
218
+ }
219
+
220
+ if (m) {
221
+ const id = m[1];
222
+ const proj = getProject(id);
223
+ if (!proj) { res.writeHead(404); return res.end('Unknown project.'); }
224
+ registry.touchProject(id);
225
+ return serveStatic(m[2] || '/', req, res);
226
+ }
227
+
228
+ if (p === '/') {
229
+ return serveStatic('/hub.html', req, res);
230
+ }
231
+
232
+ if (!path.extname(p)) {
233
+ // legacy no-prefix bookmark (e.g. /board from before the hub existed)
234
+ const rows = registry.listProjects();
235
+ const dest = rows.length ? `/p/${rows[0].id}/board` : '/';
236
+ res.writeHead(302, { Location: dest }); return res.end();
237
+ }
238
+
239
+ // a real static asset (styles.css, app.js, fonts) requested without a /p/<id> prefix — every
240
+ // page's own asset links are root-absolute, so this is the common case for every page load.
241
+ return serveStatic(p, req, res);
242
+ } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
243
+ });
244
+
245
+ server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}`); });