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 +88 -153
- package/lib/hub-server.js +245 -0
- package/lib/init.js +116 -0
- package/lib/registry.js +101 -0
- package/package.json +1 -1
- package/templates/dashboard/handlers.js +241 -0
- package/templates/dashboard/public/app.js +47 -30
- package/templates/dashboard/public/hub.html +69 -0
- package/templates/dashboard/public/hub.js +134 -0
- package/templates/dashboard/public/index.html +1 -0
- package/templates/dashboard/public/styles.css +64 -2
- package/templates/dashboard/server.js +13 -226
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
|
-
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
|
154
|
+
const lockPath = registry.hubLockPath();
|
|
262
155
|
let info = null;
|
|
263
|
-
try { info = JSON.parse(fs.readFileSync(
|
|
156
|
+
try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
|
|
264
157
|
if (info && info.port && (await probeDashboard(info.port, 2000))) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
339
|
-
//
|
|
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
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
348
|
-
const
|
|
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', [
|
|
351
|
-
child.unref(); // let this CLI exit while the
|
|
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('✓')}
|
|
357
|
-
else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
400
|
-
return console.log('No spectoflow
|
|
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 →
|
|
405
|
-
try { fs.unlinkSync(
|
|
406
|
-
return console.log(`spectoflow
|
|
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
|
|
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
|
|
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}`); });
|