spectoflow 0.22.5 → 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 +59 -153
- package/lib/hub-server.js +245 -0
- package/lib/init.js +116 -0
- package/lib/registry.js +4 -1
- package/package.json +1 -1
- 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 +59 -0
package/bin/spectoflow.js
CHANGED
|
@@ -10,6 +10,7 @@ const detect = require('../lib/detect');
|
|
|
10
10
|
const ownership = require('../lib/ownership');
|
|
11
11
|
const manifest = require('../lib/manifest');
|
|
12
12
|
const registry = require('../lib/registry');
|
|
13
|
+
const initLib = require('../lib/init');
|
|
13
14
|
const mcp = require('../lib/mcp');
|
|
14
15
|
const { startRun } = require('../templates/dashboard/runner');
|
|
15
16
|
const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
|
|
@@ -94,125 +95,16 @@ function probeDashboard(port, timeoutMs = 500) {
|
|
|
94
95
|
});
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function copyDir(src, dst) {
|
|
98
|
-
fs.mkdirSync(dst, { recursive: true });
|
|
99
|
-
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
100
|
-
const s = path.join(src, e.name), d = path.join(dst, e.name);
|
|
101
|
-
if (e.isDirectory()) copyDir(s, d);
|
|
102
|
-
else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Existing project: give id-less checkbox tasks a stable id, in place.
|
|
107
|
-
const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
|
|
108
|
-
function normalizePlans(root, config) {
|
|
109
|
-
const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
|
|
110
|
-
const dir = path.join(root, dirName);
|
|
111
|
-
if (!fs.existsSync(dir)) return 0;
|
|
112
|
-
let added = 0, seq = 1;
|
|
113
|
-
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
|
|
114
|
-
const fp = path.join(dir, f);
|
|
115
|
-
const lines = fs.readFileSync(fp, 'utf8').split('\n');
|
|
116
|
-
let touched = false;
|
|
117
|
-
for (let i = 0; i < lines.length; i++) {
|
|
118
|
-
const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
|
|
119
|
-
if (m && !ID_RE.test(m[2])) {
|
|
120
|
-
const id = 'T-' + String(seq++).padStart(3, '0');
|
|
121
|
-
lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
|
|
122
|
-
touched = true; added++;
|
|
123
|
-
} else if (m) { seq++; }
|
|
124
|
-
}
|
|
125
|
-
if (touched) fs.writeFileSync(fp, lines.join('\n'));
|
|
126
|
-
}
|
|
127
|
-
return added;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
98
|
function init() {
|
|
131
99
|
const target = path.resolve(argv[1] && !argv[1].startsWith('--') ? argv[1] : '.');
|
|
132
100
|
const agentsArg = (argv.find((a) => a.startsWith('--agent=')) || '').split('=')[1];
|
|
133
|
-
|
|
134
|
-
const notes = [];
|
|
135
|
-
|
|
136
|
-
// explicit --agent wins; otherwise detect installed agents; otherwise fall back to claude + codex
|
|
137
|
-
let agents, detected = [];
|
|
138
|
-
if (agentsArg) {
|
|
139
|
-
agents = agentsArg.split(',');
|
|
140
|
-
} else {
|
|
141
|
-
detected = detect.detectAgents(target);
|
|
142
|
-
agents = detected.length ? detected : ['claude', 'codex'];
|
|
143
|
-
notes.push(detected.length
|
|
144
|
-
? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
|
|
145
|
-
: 'No agent CLI detected — defaulted to claude + codex.');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// preserve an existing CLAUDE.md
|
|
149
|
-
const claude = path.join(target, 'CLAUDE.md');
|
|
150
|
-
if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
|
|
151
|
-
fs.renameSync(claude, claude + '.tomerge');
|
|
152
|
-
notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// canonical framework → .spectoflow/
|
|
156
|
-
const spectoflowDir = path.join(target, '.spectoflow');
|
|
157
|
-
copyDir(TPL, spectoflowDir);
|
|
158
|
-
|
|
159
|
-
// record the install baseline so `update` can tell untouched framework files from user edits
|
|
160
|
-
const frameworkFiles = ownership.listFrameworkFiles(TPL);
|
|
161
|
-
manifest.writeManifest(spectoflowDir, {
|
|
162
|
-
version: VERSION,
|
|
163
|
-
files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// set the active agent and seed runner commands from the selected/detected agents
|
|
167
|
-
const cfgPath = path.join(spectoflowDir, 'config.json');
|
|
168
|
-
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
169
|
-
cfg.agent = agents[0];
|
|
170
|
-
cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
|
|
171
|
-
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
172
|
-
|
|
173
|
-
// artifact folders — reuse an existing differently-named folder (e.g. a project that already
|
|
174
|
-
// keeps its plans in `plan/`, singular) instead of always forcing the plans/specs convention;
|
|
175
|
-
// mkdir is a no-op when the resolved folder already exists.
|
|
176
|
-
const plansDirName = store.resolvePlansDir(target, cfg);
|
|
177
|
-
const specsDirName = store.resolveSpecsDir(target, cfg);
|
|
178
|
-
fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
|
|
179
|
-
fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
|
|
180
|
-
if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
|
|
181
|
-
if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
|
|
182
|
-
|
|
183
|
-
// existing project: id-normalize any plans already there
|
|
184
|
-
const added = normalizePlans(target, cfg);
|
|
185
|
-
if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
|
|
186
|
-
|
|
187
|
-
// per-agent shims
|
|
188
|
-
const written = adapters.generate(target, agents);
|
|
189
|
-
|
|
190
|
-
// wire Playwright MCP into the project's MCP config so the E2E agent can drive a real browser and
|
|
191
|
-
// generate/run Playwright tests. Idempotent + non-destructive: never touches an existing entry.
|
|
192
|
-
// npx fetches the server on first use, so this config IS the whole install — spectoflow stays
|
|
193
|
-
// zero-dep (this writes into the user's project, never into spectoflow).
|
|
194
|
-
const mcpTargets = [path.join(target, '.mcp.json')];
|
|
195
|
-
if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
|
|
196
|
-
for (const fp of mcpTargets) {
|
|
197
|
-
const rel = path.relative(target, fp).split(path.sep).join('/');
|
|
198
|
-
const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
|
|
199
|
-
if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
|
|
200
|
-
else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// gitignore the volatile runtime
|
|
204
|
-
const gi = path.join(target, '.gitignore');
|
|
205
|
-
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
206
|
-
for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
|
|
207
|
-
if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
|
|
208
|
-
}
|
|
209
|
-
|
|
101
|
+
const r = initLib.runInit({ target, templatesDir: TPL, version: VERSION, agentsArg });
|
|
210
102
|
console.log(logo());
|
|
211
|
-
console.log(`${c.g('✓')} installed in ${c.bold(target)}`);
|
|
103
|
+
console.log(`${c.g('✓')} installed in ${c.bold(r.target)}`);
|
|
212
104
|
console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, dashboard, config`);
|
|
213
105
|
console.log(` ${c.dim('specs/ plans/')} markdown artifacts (your source of truth)`);
|
|
214
|
-
written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
|
|
215
|
-
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)}`));
|
|
216
108
|
const port = resolvePort(argv);
|
|
217
109
|
console.log(`\n${c.bold('Next')}`);
|
|
218
110
|
console.log(` ${c.dim('1)')} Open your agent here — or just say what you want to build.`);
|
|
@@ -254,21 +146,27 @@ async function update() {
|
|
|
254
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`)}`);
|
|
255
147
|
console.log('');
|
|
256
148
|
|
|
257
|
-
// A running
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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).
|
|
261
153
|
if (!dryRun && changed) {
|
|
262
|
-
const
|
|
154
|
+
const lockPath = registry.hubLockPath();
|
|
263
155
|
let info = null;
|
|
264
|
-
try { info = JSON.parse(fs.readFileSync(
|
|
156
|
+
try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
|
|
265
157
|
if (info && info.port && (await probeDashboard(info.port, 2000))) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
170
|
}
|
|
273
171
|
}
|
|
274
172
|
}
|
|
@@ -358,26 +256,31 @@ async function runCustomize(kind) {
|
|
|
358
256
|
process.exitCode = code;
|
|
359
257
|
}
|
|
360
258
|
|
|
361
|
-
// Start in the background and return control.
|
|
362
|
-
//
|
|
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.
|
|
363
262
|
async function startDashboard() {
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
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))}`);
|
|
368
271
|
return printDashboardCommands();
|
|
369
272
|
}
|
|
370
|
-
const
|
|
371
|
-
const
|
|
273
|
+
const port = resolvePort(argv);
|
|
274
|
+
const hubPath = path.join(KIT, 'lib', 'hub-server.js');
|
|
372
275
|
const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
|
|
373
|
-
const child = spawn('node', [
|
|
374
|
-
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
|
|
375
278
|
// Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
|
|
376
279
|
// startup error, would otherwise print a false "started" while the detached process silently died).
|
|
377
280
|
let up = false;
|
|
378
281
|
for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
|
|
379
|
-
if (up) console.log(`${c.g('✓')}
|
|
380
|
-
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.`);
|
|
381
284
|
printDashboardCommands();
|
|
382
285
|
}
|
|
383
286
|
|
|
@@ -390,12 +293,13 @@ function printDashboardCommands() {
|
|
|
390
293
|
}
|
|
391
294
|
|
|
392
295
|
async function dashboardStatus() {
|
|
393
|
-
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);
|
|
394
300
|
const running = await probeDashboard(port);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (running) console.log(`${c.g('●')} dashboard running → ${c.bold('http://localhost:' + port)}${pid ? c.dim(' (pid ' + pid + ')') : ''}`);
|
|
398
|
-
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`);
|
|
399
303
|
}
|
|
400
304
|
|
|
401
305
|
async function restartDashboard() {
|
|
@@ -408,28 +312,27 @@ async function restartDashboard() {
|
|
|
408
312
|
return startDashboard();
|
|
409
313
|
}
|
|
410
314
|
|
|
411
|
-
// Stop the running
|
|
315
|
+
// Stop the running hub: read the global lock it wrote, verify it's actually up, then terminate it
|
|
412
316
|
// and clear the lock. Safe against a stale lock (a recycled pid) because it only kills when the port
|
|
413
317
|
// still responds.
|
|
414
318
|
async function stopDashboard() {
|
|
415
|
-
const
|
|
416
|
-
const lock = path.join(root, '.spectoflow', '.dashboard.lock');
|
|
319
|
+
const lockPath = registry.hubLockPath();
|
|
417
320
|
let info = null;
|
|
418
|
-
try { info = JSON.parse(fs.readFileSync(
|
|
321
|
+
try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
|
|
419
322
|
const port = (info && info.port) || resolvePort(argv);
|
|
420
323
|
const running = await probeDashboard(port);
|
|
421
324
|
if (!running) {
|
|
422
|
-
if (info) { try { fs.unlinkSync(
|
|
423
|
-
return console.log('No spectoflow
|
|
325
|
+
if (info) { try { fs.unlinkSync(lockPath); } catch {} } // stale lock
|
|
326
|
+
return console.log('No spectoflow hub is running.');
|
|
424
327
|
}
|
|
425
328
|
if (info && info.pid) {
|
|
426
329
|
try {
|
|
427
|
-
process.kill(info.pid); // SIGTERM →
|
|
428
|
-
try { fs.unlinkSync(
|
|
429
|
-
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}).`);
|
|
430
333
|
} catch {}
|
|
431
334
|
}
|
|
432
|
-
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).`);
|
|
433
336
|
}
|
|
434
337
|
|
|
435
338
|
async function status() {
|
|
@@ -445,7 +348,10 @@ async function status() {
|
|
|
445
348
|
console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
|
|
446
349
|
console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
|
|
447
350
|
tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
|
|
448
|
-
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);
|
|
449
355
|
const running = await probeDashboard(port);
|
|
450
356
|
console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
|
|
451
357
|
}
|
|
@@ -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}`); });
|
package/lib/init.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Scaffolds .spectoflow/ into a target folder — the logic behind `spectoflow init`, extracted from
|
|
4
|
+
* bin/spectoflow.js so server code (the hub's Add Project auto-init step, sub-project 4) can call it
|
|
5
|
+
* too, without any CLI argv/console.log coupling. bin/spectoflow.js's init() is now a thin wrapper:
|
|
6
|
+
* parse argv, call runInit(), print the result.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const detect = require('./detect');
|
|
11
|
+
const adapters = require('./adapters');
|
|
12
|
+
const ownership = require('./ownership');
|
|
13
|
+
const manifest = require('./manifest');
|
|
14
|
+
const mcp = require('./mcp');
|
|
15
|
+
const store = require('../templates/lib/store');
|
|
16
|
+
|
|
17
|
+
function copyDir(src, dst) {
|
|
18
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
19
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
20
|
+
const s = path.join(src, e.name), d = path.join(dst, e.name);
|
|
21
|
+
if (e.isDirectory()) copyDir(s, d);
|
|
22
|
+
else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Existing project: give id-less checkbox tasks a stable id, in place.
|
|
27
|
+
const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
|
|
28
|
+
function normalizePlans(root, config) {
|
|
29
|
+
const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
|
|
30
|
+
const dir = path.join(root, dirName);
|
|
31
|
+
if (!fs.existsSync(dir)) return 0;
|
|
32
|
+
let added = 0, seq = 1;
|
|
33
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
|
|
34
|
+
const fp = path.join(dir, f);
|
|
35
|
+
const lines = fs.readFileSync(fp, 'utf8').split('\n');
|
|
36
|
+
let touched = false;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
|
|
39
|
+
if (m && !ID_RE.test(m[2])) {
|
|
40
|
+
const id = 'T-' + String(seq++).padStart(3, '0');
|
|
41
|
+
lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
|
|
42
|
+
touched = true; added++;
|
|
43
|
+
} else if (m) { seq++; }
|
|
44
|
+
}
|
|
45
|
+
if (touched) fs.writeFileSync(fp, lines.join('\n'));
|
|
46
|
+
}
|
|
47
|
+
return added;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runInit({ target, templatesDir, version, agentsArg }) {
|
|
51
|
+
fs.mkdirSync(target, { recursive: true });
|
|
52
|
+
const notes = [];
|
|
53
|
+
|
|
54
|
+
let agents, detected = [];
|
|
55
|
+
if (agentsArg) {
|
|
56
|
+
agents = agentsArg.split(',');
|
|
57
|
+
} else {
|
|
58
|
+
detected = detect.detectAgents(target);
|
|
59
|
+
agents = detected.length ? detected : ['claude', 'codex'];
|
|
60
|
+
notes.push(detected.length
|
|
61
|
+
? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
|
|
62
|
+
: 'No agent CLI detected — defaulted to claude + codex.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const claude = path.join(target, 'CLAUDE.md');
|
|
66
|
+
if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
|
|
67
|
+
fs.renameSync(claude, claude + '.tomerge');
|
|
68
|
+
notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const spectoflowDir = path.join(target, '.spectoflow');
|
|
72
|
+
copyDir(templatesDir, spectoflowDir);
|
|
73
|
+
|
|
74
|
+
const frameworkFiles = ownership.listFrameworkFiles(templatesDir);
|
|
75
|
+
manifest.writeManifest(spectoflowDir, {
|
|
76
|
+
version,
|
|
77
|
+
files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const cfgPath = path.join(spectoflowDir, 'config.json');
|
|
81
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
82
|
+
cfg.agent = agents[0];
|
|
83
|
+
cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
|
|
84
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
85
|
+
|
|
86
|
+
const plansDirName = store.resolvePlansDir(target, cfg);
|
|
87
|
+
const specsDirName = store.resolveSpecsDir(target, cfg);
|
|
88
|
+
fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
|
|
89
|
+
fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
|
|
90
|
+
if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
|
|
91
|
+
if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
|
|
92
|
+
|
|
93
|
+
const added = normalizePlans(target, cfg);
|
|
94
|
+
if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
|
|
95
|
+
|
|
96
|
+
const written = adapters.generate(target, agents);
|
|
97
|
+
|
|
98
|
+
const mcpTargets = [path.join(target, '.mcp.json')];
|
|
99
|
+
if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
|
|
100
|
+
for (const fp of mcpTargets) {
|
|
101
|
+
const rel = path.relative(target, fp).split(path.sep).join('/');
|
|
102
|
+
const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
|
|
103
|
+
if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
|
|
104
|
+
else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const gi = path.join(target, '.gitignore');
|
|
108
|
+
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
109
|
+
for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
|
|
110
|
+
if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { target, agents, detected, written, notes };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { runInit };
|
package/lib/registry.js
CHANGED
|
@@ -20,6 +20,9 @@ function registryDir(baseDir) {
|
|
|
20
20
|
function registryPath(baseDir) {
|
|
21
21
|
return path.join(registryDir(baseDir), REGISTRY_FILE);
|
|
22
22
|
}
|
|
23
|
+
function hubLockPath(baseDir) {
|
|
24
|
+
return path.join(registryDir(baseDir), 'hub.lock');
|
|
25
|
+
}
|
|
23
26
|
|
|
24
27
|
function readRegistry(baseDir) {
|
|
25
28
|
try { return JSON.parse(fs.readFileSync(registryPath(baseDir), 'utf8')); }
|
|
@@ -94,5 +97,5 @@ function listProjects(baseDir) {
|
|
|
94
97
|
|
|
95
98
|
module.exports = {
|
|
96
99
|
readRegistry, writeRegistry, genId, addProject, removeProject, touchProject,
|
|
97
|
-
findByPath, listProjects, registryPath,
|
|
100
|
+
findByPath, listProjects, registryPath, hubLockPath,
|
|
98
101
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -15,6 +15,23 @@ let attnFilter = 'open'; // attention tab filter — cl
|
|
|
15
15
|
// apply the persisted design skin as early as possible (before the first paint of app-driven DOM)
|
|
16
16
|
(function(){ try{ const d=localStorage.getItem('spf-design'); if(d) document.documentElement.setAttribute('data-design',d); }catch{} })();
|
|
17
17
|
|
|
18
|
+
// The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
|
|
19
|
+
// hub-server's legacy-route redirect (sub-project 3) guarantees a bookmark without this prefix never
|
|
20
|
+
// reaches this file directly; it 302s to a /p/<id>/... URL first. Null when served by the older
|
|
21
|
+
// single-project templates/dashboard/server.js (no prefix at all) — every helper below no-ops in
|
|
22
|
+
// that case, preserving today's exact single-project behavior.
|
|
23
|
+
const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
|
|
24
|
+
// Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
|
|
25
|
+
// attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
|
|
26
|
+
// one" (&p=, e.g. '/api/agentfile?path=...').
|
|
27
|
+
function withProject(url) { if (!PROJECT_ID) return url; return url + (url.includes('?') ? '&' : '?') + 'p=' + encodeURIComponent(PROJECT_ID); }
|
|
28
|
+
// Prefixes an app-internal path (e.g. '/board', '/custom/x') with /p/<id> for history.pushState/
|
|
29
|
+
// replaceState — every page navigation this file performs stays within the current project.
|
|
30
|
+
function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : rest; }
|
|
31
|
+
// location.pathname's segments with a leading /p/<id> stripped, if present — the single place that
|
|
32
|
+
// strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
|
|
33
|
+
function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
|
|
34
|
+
|
|
18
35
|
const $ = (s,r=document)=>r.querySelector(s);
|
|
19
36
|
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
|
|
20
37
|
const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
|
|
@@ -22,7 +39,7 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
|
|
|
22
39
|
const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
|
|
23
40
|
|
|
24
41
|
async function load(){
|
|
25
|
-
const r = await fetch('/api/project'); P = await r.json(); render();
|
|
42
|
+
const r = await fetch(withProject('/api/project')); P = await r.json(); render();
|
|
26
43
|
if(openTaskId) openDrawer(openTaskId,true);
|
|
27
44
|
}
|
|
28
45
|
// Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
|
|
@@ -30,7 +47,7 @@ async function load(){
|
|
|
30
47
|
let loadTimer=null;
|
|
31
48
|
function scheduleLoad(){ clearTimeout(loadTimer); loadTimer=setTimeout(load,180); }
|
|
32
49
|
function connect(){
|
|
33
|
-
const es = new EventSource('/api/events');
|
|
50
|
+
const es = new EventSource(withProject('/api/events'));
|
|
34
51
|
es.onopen = ()=>{ $('#sync').classList.remove('offline'); $('#syncLabel').textContent='live'; };
|
|
35
52
|
es.onmessage = (ev)=>{
|
|
36
53
|
let m; try{ m=JSON.parse(ev.data); }catch{ return; }
|
|
@@ -123,32 +140,32 @@ async function doRun(promptEl,agentEl){
|
|
|
123
140
|
promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
|
|
124
141
|
const prompt=promptEl.value.trim(); if(!prompt) return;
|
|
125
142
|
const agent=agentEl.value;
|
|
126
|
-
await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
|
|
143
|
+
await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
|
|
127
144
|
promptEl.value=''; // the prompt renders as a bubble from the message log
|
|
128
145
|
}
|
|
129
146
|
async function doOrchestrate(promptEl){
|
|
130
147
|
if(isChatBusy()) return;
|
|
131
148
|
promptEl=promptEl||$('#runPrompt');
|
|
132
149
|
const prompt=promptEl.value.trim(); if(!prompt) return;
|
|
133
|
-
await fetch('/api/orchestrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
|
|
150
|
+
await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
|
|
134
151
|
promptEl.value='';
|
|
135
152
|
}
|
|
136
|
-
async function approve(decision){ await fetch('/api/orchestrate/approve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
|
|
153
|
+
async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
|
|
137
154
|
// ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
|
|
138
155
|
// floating widget stays "quick access", full controls live where there's room to read them) ----
|
|
139
156
|
async function summarizeChat(agentEl){
|
|
140
157
|
if(isChatBusy()) return;
|
|
141
158
|
const agent=(agentEl||$('#tabRunAgent'))?.value;
|
|
142
159
|
flash();
|
|
143
|
-
await fetch('/api/chat/summarize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
|
|
160
|
+
await fetch(withProject('/api/chat/summarize'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
|
|
144
161
|
}
|
|
145
162
|
async function clearChat(){
|
|
146
163
|
flash();
|
|
147
|
-
await fetch('/api/chat/clear',{method:'POST'});
|
|
164
|
+
await fetch(withProject('/api/chat/clear'),{method:'POST'});
|
|
148
165
|
}
|
|
149
|
-
async function patchTask(id,patch){ flash(); await fetch('/api/task/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
150
|
-
async function addComment(id,text,action){ flash(); await fetch('/api/task/'+encodeURIComponent(id)+'/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
|
|
151
|
-
async function toggleStep(name){ flash(); await fetch('/api/workflow/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
|
|
166
|
+
async function patchTask(id,patch){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
167
|
+
async function addComment(id,text,action){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)+'/comment'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
|
|
168
|
+
async function toggleStep(name){ flash(); await fetch(withProject('/api/workflow/toggle'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
|
|
152
169
|
function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel').textContent='writing…'; setTimeout(()=>{ s.classList.remove('saving'); $('#syncLabel').textContent='live'; },800); }
|
|
153
170
|
|
|
154
171
|
// ---- "agent is running" state — no visible feedback used to exist between clicking Send/
|
|
@@ -731,7 +748,7 @@ function editAttn(it,txtNode){
|
|
|
731
748
|
ta.addEventListener('blur',save);
|
|
732
749
|
ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
|
|
733
750
|
}
|
|
734
|
-
async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
|
|
751
|
+
async function addAttn(text){ flash(); await fetch(withProject('/api/attention'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
|
|
735
752
|
|
|
736
753
|
// ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
|
|
737
754
|
function openBacklogAddForm(){
|
|
@@ -756,7 +773,7 @@ async function submitBacklogAdd(){
|
|
|
756
773
|
const owner=($('#blAddOwner').value||'').trim();
|
|
757
774
|
const level=$('#blAddLevel').value;
|
|
758
775
|
flash();
|
|
759
|
-
const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
776
|
+
const r=await fetch(withProject('/api/task'),{method:'POST',headers:{'Content-Type':'application/json'},
|
|
760
777
|
body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
|
|
761
778
|
if(!r.ok){
|
|
762
779
|
const j=await r.json().catch(()=>({}));
|
|
@@ -765,9 +782,9 @@ async function submitBacklogAdd(){
|
|
|
765
782
|
}
|
|
766
783
|
closeBacklogAddForm();
|
|
767
784
|
}
|
|
768
|
-
async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
769
|
-
async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
|
|
770
|
-
async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
|
|
785
|
+
async function patchAttn(id,patch){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
786
|
+
async function deleteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'DELETE'}); }
|
|
787
|
+
async function promoteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)+'/promote'),{method:'POST'}); }
|
|
771
788
|
|
|
772
789
|
// ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
|
|
773
790
|
// Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
|
|
@@ -826,13 +843,13 @@ function showAgentError(msg){
|
|
|
826
843
|
async function saveAgent(id){
|
|
827
844
|
if(!id) return;
|
|
828
845
|
flash();
|
|
829
|
-
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
|
|
846
|
+
const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
|
|
830
847
|
if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
|
|
831
848
|
}
|
|
832
849
|
// ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
|
|
833
850
|
function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
|
|
834
851
|
function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
|
|
835
|
-
async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
|
|
852
|
+
async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
|
|
836
853
|
|
|
837
854
|
function renderSettings(){
|
|
838
855
|
const c=(P&&P.config)||{};
|
|
@@ -864,7 +881,7 @@ function renderSettings(){
|
|
|
864
881
|
async function saveSettings(){
|
|
865
882
|
flash();
|
|
866
883
|
const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
|
|
867
|
-
await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
|
|
884
|
+
await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
|
|
868
885
|
const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
|
|
869
886
|
}
|
|
870
887
|
|
|
@@ -1032,7 +1049,7 @@ function renderCustomize(){
|
|
|
1032
1049
|
async function czSubmit(kind,description,agent){
|
|
1033
1050
|
const cfg=CZ_KINDS.find((c)=>c.kind===kind);
|
|
1034
1051
|
const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
|
|
1035
|
-
await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
|
|
1052
|
+
await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
|
|
1036
1053
|
const root=$('#czRoot'); if(root) root.dataset.open='';
|
|
1037
1054
|
navigateTab('chat');
|
|
1038
1055
|
}
|
|
@@ -1046,17 +1063,17 @@ const ROUTES=['board','requests','attention','backlog','workflow','team','files'
|
|
|
1046
1063
|
// under that name still land on the Personalize tab instead of a blank panel.
|
|
1047
1064
|
function normalizeTab(t){ return t==='settings'?'personalize':t; }
|
|
1048
1065
|
function tabFromPath(){
|
|
1049
|
-
const s=
|
|
1066
|
+
const s=pathSegments();
|
|
1050
1067
|
if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
|
|
1051
1068
|
const t=normalizeTab(s[0]);
|
|
1052
1069
|
return ROUTES.includes(t)?t:null;
|
|
1053
1070
|
}
|
|
1054
|
-
function taskFromPath(){ const s=
|
|
1071
|
+
function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
1055
1072
|
function navigateTab(tabId,push){
|
|
1056
1073
|
activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
|
|
1057
1074
|
if(push!==false){
|
|
1058
1075
|
const isCustom=tabId.indexOf('custom:')===0;
|
|
1059
|
-
history.pushState(null,'', isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId);
|
|
1076
|
+
history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
|
|
1060
1077
|
}
|
|
1061
1078
|
applyActiveTab();
|
|
1062
1079
|
closeNav(); // a tab pick closes the mobile menu
|
|
@@ -1163,7 +1180,7 @@ async function openFileDrawer(kind,obj){
|
|
|
1163
1180
|
sec.append(body); b.append(sec);
|
|
1164
1181
|
$('#drawer').setAttribute('aria-hidden','false');
|
|
1165
1182
|
try{
|
|
1166
|
-
const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
|
|
1183
|
+
const r=await fetch(withProject('/api/agentfile?path='+encodeURIComponent(rel)));
|
|
1167
1184
|
const data=await r.json().catch(()=>({}));
|
|
1168
1185
|
if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
|
|
1169
1186
|
body.innerHTML=mdLite(data.content||'');
|
|
@@ -1325,7 +1342,7 @@ let filesSelectedDir=''; // '' = project root — the folder + File/+ Folder cre
|
|
|
1325
1342
|
const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
|
|
1326
1343
|
async function loadFilesTree(){
|
|
1327
1344
|
try{
|
|
1328
|
-
const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
|
|
1345
|
+
const r=await fetch(withProject('/api/files/tree')); const d=await r.json().catch(()=>({}));
|
|
1329
1346
|
filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
|
|
1330
1347
|
}catch{ filesTreeData=filesTreeData||[]; }
|
|
1331
1348
|
renderFilesTree();
|
|
@@ -1488,7 +1505,7 @@ async function openFilesFile(relPath){
|
|
|
1488
1505
|
const box=$('#filesContent'); box.innerHTML='';
|
|
1489
1506
|
box.append(el('div','files-empty',t('drawer.loading')));
|
|
1490
1507
|
let data;
|
|
1491
|
-
try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
|
|
1508
|
+
try{ const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:relPath}))); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
|
|
1492
1509
|
catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
|
|
1493
1510
|
box.innerHTML='';
|
|
1494
1511
|
const bar=el('div','files-toolbar-row');
|
|
@@ -1515,7 +1532,7 @@ function filesDiscardBtn(relPath){
|
|
|
1515
1532
|
}
|
|
1516
1533
|
async function filesSave(relPath,content,actions){
|
|
1517
1534
|
try{
|
|
1518
|
-
const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
|
|
1535
|
+
const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
|
|
1519
1536
|
const d=await r.json().catch(()=>({}));
|
|
1520
1537
|
if(!r.ok) throw new Error(d.error||'error');
|
|
1521
1538
|
filesOpenDirty=false; filesSavedTip(actions);
|
|
@@ -1610,7 +1627,7 @@ async function submitFilesCreate(){
|
|
|
1610
1627
|
const kind=filesCreateKind;
|
|
1611
1628
|
const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
|
|
1612
1629
|
const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
|
|
1613
|
-
const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1630
|
+
const r=await fetch(withProject(endpoint),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1614
1631
|
const d=await r.json().catch(()=>({}));
|
|
1615
1632
|
if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
|
|
1616
1633
|
closeFilesCreateForm();
|
|
@@ -1623,7 +1640,7 @@ function openDrawer(id,keep){
|
|
|
1623
1640
|
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
1624
1641
|
const task=allTasks().find(x=>x.id===id); if(!task) return;
|
|
1625
1642
|
openTaskId=id;
|
|
1626
|
-
if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
|
|
1643
|
+
if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
|
|
1627
1644
|
const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
|
|
1628
1645
|
b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
|
|
1629
1646
|
b.append(el('div','d-title',task.title));
|
|
@@ -1657,7 +1674,7 @@ function openDrawer(id,keep){
|
|
|
1657
1674
|
$('#drawer').setAttribute('aria-hidden','false');
|
|
1658
1675
|
if(keep) $('.drawer-panel').scrollTop=prev;
|
|
1659
1676
|
}
|
|
1660
|
-
function closeDrawer(){ if(taskFromPath()) history.pushState(null,'','/'+activeTab); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
|
|
1677
|
+
function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
|
|
1661
1678
|
const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
|
|
1662
1679
|
|
|
1663
1680
|
// tabs — activeTab is the single source of truth (persisted), so a click sets it and applies it,
|
|
@@ -1707,7 +1724,7 @@ const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('clic
|
|
|
1707
1724
|
applyActiveTab(); // sync to the resolved tab before the first render
|
|
1708
1725
|
// an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
|
|
1709
1726
|
// route once resolved, so the visible URL matches the "Personalize" tab it landed on.
|
|
1710
|
-
if(
|
|
1727
|
+
if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
|
|
1711
1728
|
// filters (status chips + search) — client-side only, does not write anything
|
|
1712
1729
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
1713
1730
|
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>spectoflow · projects</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/logo-dark.png" />
|
|
8
|
+
<link rel="icon" type="image/png" media="(prefers-color-scheme: dark)" href="/logo-white.png" />
|
|
9
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body class="hub-body">
|
|
12
|
+
<header class="hub-header">
|
|
13
|
+
<div class="hub-brand">
|
|
14
|
+
<img class="brand-logo-img is-dark" src="/logo-white.png" alt="" />
|
|
15
|
+
<img class="brand-logo-img is-light" src="/logo-dark.png" alt="" />
|
|
16
|
+
<span class="hub-brand-name">spectoflow</span>
|
|
17
|
+
</div>
|
|
18
|
+
<button class="hub-theme-toggle" id="hubThemeToggle" aria-label="Toggle theme" title="Toggle theme">◐</button>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<main class="hub-main">
|
|
22
|
+
<div class="hub-titlebar">
|
|
23
|
+
<h1>Your projects</h1>
|
|
24
|
+
<button class="hub-add-btn" id="hubAddBtn">+ Add project</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div id="hubEmpty" class="hub-empty" hidden>
|
|
28
|
+
<p class="hub-empty-title">No projects yet</p>
|
|
29
|
+
<p class="hub-empty-sub">Add your first project to get started — point spectoflow at any folder on your computer.</p>
|
|
30
|
+
<button class="hub-add-btn hub-add-btn-lg" id="hubAddBtnEmpty">+ Add your first project</button>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div id="hubGrid" class="hub-grid"></div>
|
|
34
|
+
</main>
|
|
35
|
+
|
|
36
|
+
<div id="hubModal" class="hub-modal" hidden>
|
|
37
|
+
<div class="hub-modal-card">
|
|
38
|
+
<div class="hub-modal-head">
|
|
39
|
+
<h2>Add a project</h2>
|
|
40
|
+
<button class="hub-modal-close" id="hubModalClose" aria-label="Close">×</button>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="hub-modal-tabs">
|
|
43
|
+
<button class="hub-modal-tab is-active" data-mode="browse">Browse</button>
|
|
44
|
+
<button class="hub-modal-tab" data-mode="paste">Paste a path</button>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div id="hubBrowsePane" class="hub-modal-pane">
|
|
48
|
+
<div class="hub-browse-crumb" id="hubBrowseCrumb"></div>
|
|
49
|
+
<div class="hub-browse-list" id="hubBrowseList"></div>
|
|
50
|
+
<div class="hub-browse-footer">
|
|
51
|
+
<span class="hub-browse-current" id="hubBrowseCurrent"></span>
|
|
52
|
+
<button class="hub-add-btn" id="hubBrowseUse">Use this folder</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div id="hubPastePane" class="hub-modal-pane" hidden>
|
|
57
|
+
<label class="hub-paste-label" for="hubPasteInput">Folder path</label>
|
|
58
|
+
<input class="hub-paste-input" id="hubPasteInput" type="text" placeholder="e.g. C:\Users\you\Projects\my-app" autocomplete="off" spellcheck="false" />
|
|
59
|
+
<button class="hub-add-btn" id="hubPasteUse">Use this path</button>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<p class="hub-modal-error" id="hubModalError" hidden></p>
|
|
63
|
+
<p class="hub-modal-status" id="hubModalStatus" hidden></p>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<script src="/hub.js"></script>
|
|
68
|
+
</body>
|
|
69
|
+
</html>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
(function () {
|
|
3
|
+
const s = localStorage.getItem('spf-theme');
|
|
4
|
+
if (s) document.documentElement.setAttribute('data-theme', s);
|
|
5
|
+
})();
|
|
6
|
+
(function () {
|
|
7
|
+
const grid = document.getElementById('hubGrid');
|
|
8
|
+
const empty = document.getElementById('hubEmpty');
|
|
9
|
+
const modal = document.getElementById('hubModal');
|
|
10
|
+
const modalError = document.getElementById('hubModalError');
|
|
11
|
+
const modalStatus = document.getElementById('hubModalStatus');
|
|
12
|
+
const browsePane = document.getElementById('hubBrowsePane');
|
|
13
|
+
const pastePane = document.getElementById('hubPastePane');
|
|
14
|
+
const browseCrumb = document.getElementById('hubBrowseCrumb');
|
|
15
|
+
const browseList = document.getElementById('hubBrowseList');
|
|
16
|
+
const browseCurrent = document.getElementById('hubBrowseCurrent');
|
|
17
|
+
let browsePath = null; // null = show starting points (home dir / drives)
|
|
18
|
+
|
|
19
|
+
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
|
20
|
+
function timeAgo(iso) {
|
|
21
|
+
if (!iso) return '';
|
|
22
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
23
|
+
const m = Math.floor(ms / 60000);
|
|
24
|
+
if (m < 1) return 'just now';
|
|
25
|
+
if (m < 60) return m + 'm ago';
|
|
26
|
+
const h = Math.floor(m / 60);
|
|
27
|
+
if (h < 24) return h + 'h ago';
|
|
28
|
+
return Math.floor(h / 24) + 'd ago';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadProjects() {
|
|
32
|
+
const r = await fetch('/api/hub/projects');
|
|
33
|
+
const data = await r.json();
|
|
34
|
+
const rows = data.projects || [];
|
|
35
|
+
empty.hidden = rows.length > 0;
|
|
36
|
+
grid.innerHTML = rows.map((p) => {
|
|
37
|
+
const pct = p.stats && p.stats.total ? Math.round(100 * p.stats.done / p.stats.total) : null;
|
|
38
|
+
return `<div class="hub-card" data-id="${p.id}">
|
|
39
|
+
<a class="hub-card-open" href="/p/${p.id}/board">
|
|
40
|
+
<div class="hub-card-name">${esc(p.name)}</div>
|
|
41
|
+
<div class="hub-card-path">${esc(p.path)}</div>
|
|
42
|
+
${pct !== null ? `<div class="hub-card-progress"><div class="hub-card-progress-fill" style="width:${pct}%"></div></div><div class="hub-card-pct">${pct}% · ${p.stats.done}/${p.stats.total} tasks</div>` : ''}
|
|
43
|
+
<div class="hub-card-meta">Opened ${esc(timeAgo(p.lastOpened))}</div>
|
|
44
|
+
</a>
|
|
45
|
+
<button class="hub-card-remove" data-remove="${p.id}" title="Remove from this list" aria-label="Remove ${esc(p.name)}">×</button>
|
|
46
|
+
</div>`;
|
|
47
|
+
}).join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// No native confirm() — it blocks the tab (and this codebase never uses it, see D46). A second
|
|
51
|
+
// click within 3s on the same remove button confirms; the button flips to a checkmark meanwhile.
|
|
52
|
+
let pendingRemoveId = null;
|
|
53
|
+
function confirmRemove(id) {
|
|
54
|
+
if (pendingRemoveId === id) { pendingRemoveId = null; return true; }
|
|
55
|
+
pendingRemoveId = id;
|
|
56
|
+
const btn = grid.querySelector('[data-remove="' + id + '"]');
|
|
57
|
+
if (btn) {
|
|
58
|
+
const orig = btn.textContent;
|
|
59
|
+
btn.textContent = '✓'; btn.title = 'Click again to confirm';
|
|
60
|
+
setTimeout(() => { if (btn.textContent === '✓') btn.textContent = orig; }, 3000);
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
grid.addEventListener('click', async (e) => {
|
|
65
|
+
const btn = e.target.closest('[data-remove]');
|
|
66
|
+
if (!btn) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const id = btn.getAttribute('data-remove');
|
|
69
|
+
if (!confirmRemove(id)) return;
|
|
70
|
+
await fetch('/api/hub/projects/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
71
|
+
loadProjects();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function openModal() { modal.hidden = false; modalError.hidden = true; modalStatus.hidden = true; browsePath = null; loadBrowse(); }
|
|
75
|
+
function closeModal() { modal.hidden = true; }
|
|
76
|
+
document.getElementById('hubAddBtn').addEventListener('click', openModal);
|
|
77
|
+
document.getElementById('hubAddBtnEmpty').addEventListener('click', openModal);
|
|
78
|
+
document.getElementById('hubModalClose').addEventListener('click', closeModal);
|
|
79
|
+
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
|
80
|
+
|
|
81
|
+
document.querySelectorAll('.hub-modal-tab').forEach((tab) => {
|
|
82
|
+
tab.addEventListener('click', () => {
|
|
83
|
+
document.querySelectorAll('.hub-modal-tab').forEach((t) => t.classList.remove('is-active'));
|
|
84
|
+
tab.classList.add('is-active');
|
|
85
|
+
const mode = tab.getAttribute('data-mode');
|
|
86
|
+
browsePane.hidden = mode !== 'browse';
|
|
87
|
+
pastePane.hidden = mode !== 'paste';
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function loadBrowse() {
|
|
92
|
+
const q = browsePath ? ('?path=' + encodeURIComponent(browsePath)) : '';
|
|
93
|
+
const r = await fetch('/api/hub/browse' + q);
|
|
94
|
+
const data = await r.json();
|
|
95
|
+
if (data.error) { browseList.innerHTML = '<p class="hub-browse-empty">' + esc(data.error) + '</p>'; return; }
|
|
96
|
+
browsePath = data.current || null;
|
|
97
|
+
browseCurrent.textContent = browsePath || 'Choose a starting point';
|
|
98
|
+
// A drive root (D:\) or similar has no OS-level parent (data.parent is null) but the picker must
|
|
99
|
+
// never dead-end there — "Up" from any real folder always goes somewhere: its real parent, or
|
|
100
|
+
// back to the starting points list if there isn't one. Only hide it once we're AT that list.
|
|
101
|
+
const showUp = browsePath !== null;
|
|
102
|
+
browseCrumb.innerHTML = showUp ? `<button class="hub-crumb-up" id="hubCrumbUp">← Up</button>` : '';
|
|
103
|
+
const up = document.getElementById('hubCrumbUp');
|
|
104
|
+
if (up) up.addEventListener('click', () => { browsePath = data.parent || null; loadBrowse(); });
|
|
105
|
+
browseList.innerHTML = (data.entries || []).map((e) =>
|
|
106
|
+
`<button class="hub-browse-item" data-path="${esc(e.path)}">${esc(e.name)}</button>`
|
|
107
|
+
).join('') || '<p class="hub-browse-empty">No sub-folders here.</p>';
|
|
108
|
+
browseList.querySelectorAll('[data-path]').forEach((el) => {
|
|
109
|
+
el.addEventListener('click', () => { browsePath = el.getAttribute('data-path'); loadBrowse(); });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function submitPath(p) {
|
|
114
|
+
modalError.hidden = true; modalStatus.hidden = false; modalStatus.textContent = 'Adding…';
|
|
115
|
+
const r = await fetch('/api/hub/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: p }) });
|
|
116
|
+
const data = await r.json();
|
|
117
|
+
if (!r.ok) { modalStatus.hidden = true; modalError.hidden = false; modalError.textContent = data.error || 'Could not add that folder.'; return; }
|
|
118
|
+
location.href = '/p/' + data.entry.id + '/board';
|
|
119
|
+
}
|
|
120
|
+
document.getElementById('hubBrowseUse').addEventListener('click', () => { if (browsePath) submitPath(browsePath); });
|
|
121
|
+
document.getElementById('hubPasteUse').addEventListener('click', () => {
|
|
122
|
+
const v = document.getElementById('hubPasteInput').value.trim();
|
|
123
|
+
if (v) submitPath(v);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
document.getElementById('hubThemeToggle').addEventListener('click', () => {
|
|
127
|
+
const cur = document.documentElement.getAttribute('data-theme');
|
|
128
|
+
const next = cur === 'dark' ? 'light' : 'dark';
|
|
129
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
130
|
+
localStorage.setItem('spf-theme', next);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
loadProjects();
|
|
134
|
+
})();
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<img class="brand-logo-img is-dark" src="/logo-white.png" alt="spectoflow" />
|
|
19
19
|
<img class="brand-logo-img is-light" src="/logo-dark.png" alt="spectoflow" />
|
|
20
20
|
</a>
|
|
21
|
+
<a class="hub-back-link" href="/" title="Back to your projects" aria-label="Back to your projects">⌂</a>
|
|
21
22
|
<div class="brand-text">
|
|
22
23
|
<div class="brand-line">
|
|
23
24
|
<span class="brand-name">spectoflow</span>
|
|
@@ -968,3 +968,62 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
|
|
|
968
968
|
|
|
969
969
|
/* Phase progress: cap the list so a big project (many phases) doesn't dominate the overview */
|
|
970
970
|
.bars-block.scroll-cap { max-height:340px; overflow-y:auto; padding-right:6px; }
|
|
971
|
+
|
|
972
|
+
/* ---- Hub landing page (multi-project) — reuses the same tokens as the per-project dashboard,
|
|
973
|
+
deliberately simpler: no per-design skins, no tabs, just a calm project picker. ---- */
|
|
974
|
+
.hub-body { min-height:100%; display:flex; flex-direction:column; }
|
|
975
|
+
.hub-header { display:flex; align-items:center; justify-content:space-between; padding:16px 24px; border-bottom:1px solid var(--line); }
|
|
976
|
+
.hub-brand { display:flex; align-items:center; gap:9px; font-weight:700; }
|
|
977
|
+
.hub-brand .brand-logo-img { width:22px; height:22px; }
|
|
978
|
+
.hub-brand-name { font-size:15px; }
|
|
979
|
+
.hub-theme-toggle { width:32px; height:32px; border-radius:8px; border:1px solid var(--line); background:var(--surface); color:var(--ink); cursor:pointer; font-size:14px; }
|
|
980
|
+
.hub-main { flex:1; max-width:1080px; width:100%; margin:0 auto; padding:32px 24px 60px; }
|
|
981
|
+
.hub-titlebar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:24px; flex-wrap:wrap; }
|
|
982
|
+
.hub-titlebar h1 { font-size:24px; font-weight:700; margin:0; }
|
|
983
|
+
.hub-add-btn { font-family:var(--sans); font-size:13.5px; font-weight:600; padding:9px 16px; border-radius:9px; border:1px solid transparent; background:var(--signal); color:var(--on-accent); cursor:pointer; transition:filter .15s; }
|
|
984
|
+
.hub-add-btn:hover { filter:brightness(1.08); }
|
|
985
|
+
.hub-add-btn-lg { padding:12px 22px; font-size:14.5px; margin-top:14px; }
|
|
986
|
+
.hub-empty { text-align:center; padding:60px 20px; border:1px dashed var(--line); border-radius:var(--radius); }
|
|
987
|
+
.hub-empty-title { font-size:18px; font-weight:700; margin:0 0 8px; }
|
|
988
|
+
.hub-empty-sub { color:var(--muted); font-size:13.5px; max-width:420px; margin:0 auto; }
|
|
989
|
+
.hub-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
|
|
990
|
+
.hub-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); transition:border-color .15s,transform .15s; }
|
|
991
|
+
.hub-card:hover { border-color:var(--signal); transform:translateY(-1px); }
|
|
992
|
+
.hub-card-open { display:block; padding:16px; text-decoration:none; color:inherit; }
|
|
993
|
+
.hub-card-name { font-size:15px; font-weight:700; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
994
|
+
.hub-card-path { font-family:var(--mono); font-size:10.5px; color:var(--faint); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
995
|
+
.hub-card-progress { height:5px; border-radius:999px; background:var(--surface-2); margin-top:12px; overflow:hidden; }
|
|
996
|
+
.hub-card-progress-fill { height:100%; background:var(--s-done); border-radius:999px; }
|
|
997
|
+
.hub-card-pct { font-family:var(--mono); font-size:10.5px; color:var(--muted); margin-top:6px; }
|
|
998
|
+
.hub-card-meta { font-size:11px; color:var(--faint); margin-top:10px; }
|
|
999
|
+
.hub-card-remove { position:absolute; top:8px; right:8px; width:22px; height:22px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; font-size:13px; line-height:1; }
|
|
1000
|
+
.hub-card-remove:hover { color:var(--s-blocked); border-color:var(--s-blocked); }
|
|
1001
|
+
|
|
1002
|
+
.hub-modal { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:20; padding:20px; }
|
|
1003
|
+
.hub-modal[hidden] { display:none; }
|
|
1004
|
+
.hub-modal-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); width:100%; max-width:460px; max-height:86vh; display:flex; flex-direction:column; padding:20px; }
|
|
1005
|
+
.hub-modal-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
|
|
1006
|
+
.hub-modal-head h2 { font-size:16px; margin:0; }
|
|
1007
|
+
.hub-modal-close { width:28px; height:28px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); cursor:pointer; font-size:16px; line-height:1; }
|
|
1008
|
+
.hub-modal-tabs { display:flex; gap:6px; margin-bottom:14px; }
|
|
1009
|
+
.hub-modal-tab { flex:1; font-family:var(--sans); font-size:12.5px; font-weight:600; padding:7px 10px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; }
|
|
1010
|
+
.hub-modal-tab.is-active { background:var(--signal); border-color:var(--signal); color:var(--on-accent); }
|
|
1011
|
+
.hub-modal-pane { display:flex; flex-direction:column; gap:10px; min-height:0; }
|
|
1012
|
+
.hub-modal-pane[hidden] { display:none; }
|
|
1013
|
+
.hub-browse-crumb { min-height:20px; }
|
|
1014
|
+
.hub-crumb-up { font-family:var(--mono); font-size:11.5px; color:var(--cool); background:none; border:0; cursor:pointer; padding:0; }
|
|
1015
|
+
.hub-browse-list { display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; border:1px solid var(--line); border-radius:9px; padding:6px; }
|
|
1016
|
+
.hub-browse-item { text-align:left; font-family:var(--sans); font-size:13px; padding:7px 9px; border-radius:6px; border:0; background:none; color:var(--ink); cursor:pointer; }
|
|
1017
|
+
.hub-browse-item:hover { background:var(--surface-2); }
|
|
1018
|
+
.hub-browse-empty { color:var(--faint); font-size:12.5px; padding:6px 2px; margin:0; }
|
|
1019
|
+
.hub-browse-footer { display:flex; align-items:center; gap:10px; justify-content:space-between; }
|
|
1020
|
+
.hub-browse-current { font-family:var(--mono); font-size:10.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
|
|
1021
|
+
.hub-paste-label { font-size:12px; color:var(--muted); }
|
|
1022
|
+
.hub-paste-input { font-family:var(--mono); font-size:13px; padding:9px 11px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); }
|
|
1023
|
+
.hub-modal-error { color:var(--s-blocked); font-size:12.5px; margin:4px 0 0; }
|
|
1024
|
+
.hub-modal-status { color:var(--muted); font-size:12.5px; margin:4px 0 0; }
|
|
1025
|
+
|
|
1026
|
+
/* "back to hub" — plain link next to the brand logo on the per-project dashboard; a full navigation
|
|
1027
|
+
(not SPA), since leaving to the hub means leaving this project's dashboard entirely. */
|
|
1028
|
+
.hub-back-link { display:flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:7px; color:var(--muted); text-decoration:none; font-size:15px; flex-shrink:0; transition:background .15s,color .15s; }
|
|
1029
|
+
.hub-back-link:hover { background:var(--surface-2); color:var(--ink); }
|