spectoflow 0.23.5 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -10
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +144 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/handlers.js +78 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +35 -44
- package/lib/dashboard/ops.js +176 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +5 -6
- package/{templates → lib}/dashboard/runner.js +6 -5
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +84 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/charts.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/designs.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/hub.html +0 -0
- /package/{templates → lib}/dashboard/public/hub.js +0 -0
- /package/{templates → lib}/dashboard/public/i18n.js +0 -0
- /package/{templates → lib}/dashboard/public/icons.js +0 -0
- /package/{templates → lib}/dashboard/public/index.html +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
- /package/{templates → lib}/dashboard/public/styles.css +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
3
|
* The multi-project hub's server process — global (ships under lib/, never vendored into a
|
|
4
|
-
* project's .spectoflow/). Registry-driven: resolves a project's root
|
|
4
|
+
* project's .spectoflow/). Registry-driven: resolves a project's root on demand from
|
|
5
5
|
* ~/.spectoflow/projects.json (see lib/registry.js), keyed by the opaque id in /p/<id>/... URLs.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The route logic is THIS package's own handlers.js (./handlers.js), shared by every project (D64):
|
|
7
|
+
* nothing is ever require()'d from a project, so a project that has never run `spectoflow update`
|
|
8
|
+
* opens exactly like a fresh one (see docs/multi-project-hub-design.md).
|
|
9
9
|
*
|
|
10
10
|
* URL scheme (settled in the design doc): pages use a path prefix (/p/<id>/board, bookmarkable on
|
|
11
11
|
* their own); every /api/* call instead takes a ?p=<id> query param (smaller client diff, and
|
|
@@ -21,74 +21,65 @@ const http = require('http');
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const os = require('os');
|
|
23
23
|
const path = require('path');
|
|
24
|
-
const registry = require('
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
24
|
+
const registry = require('../registry');
|
|
25
|
+
const workspace = require('../workspace');
|
|
26
|
+
const { createHandlers } = require('./handlers');
|
|
27
|
+
const store = require('../store');
|
|
28
|
+
|
|
29
|
+
const migrated = workspace.migrateLegacyHome();
|
|
30
|
+
if (!workspace.exists()) workspace.init({});
|
|
31
|
+
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : workspace.settings().port;
|
|
32
|
+
const PUBLIC = path.join(__dirname, 'public');
|
|
33
|
+
const TEMPLATES = path.join(__dirname, '..', '..', 'templates');
|
|
34
|
+
const VERSION = require('../../package.json').version;
|
|
30
35
|
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
36
|
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
32
37
|
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
38
|
|
|
34
|
-
// id -> { id, root, handlers, clients:Set, emit
|
|
35
|
-
//
|
|
39
|
+
// id -> { id, root, handlers, clients:Set, emit, watchers:[] }. The route logic is THIS package's
|
|
40
|
+
// handlers.js for every project (D64): nothing is ever require()'d from a project, so a project that
|
|
41
|
+
// has never run `spectoflow update` opens exactly like a fresh one.
|
|
36
42
|
const projects = new Map();
|
|
37
43
|
function getProject(id) {
|
|
38
44
|
if (projects.has(id)) return projects.get(id);
|
|
39
45
|
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
|
|
46
|
+
if (!entry || !fs.existsSync(entry.path)) return null;
|
|
45
47
|
const handlers = createHandlers(entry.path);
|
|
46
48
|
const clients = new Set();
|
|
47
49
|
const emit = (obj) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
|
|
48
50
|
handlers.onBoot();
|
|
51
|
+
const watchers = [];
|
|
49
52
|
handlers.watchDirs.forEach((d) => {
|
|
50
53
|
const dir = path.join(entry.path, d);
|
|
51
|
-
if (fs.existsSync(dir)) { try { fs.watch(dir, { recursive: false }, () => emit({ type: 'change' })); } catch (_) {} }
|
|
54
|
+
if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, () => emit({ type: 'change' }))); } catch (_) {} }
|
|
52
55
|
});
|
|
53
|
-
const proj = { id, root: entry.path, handlers, clients, emit };
|
|
56
|
+
const proj = { id, root: entry.path, handlers, clients, emit, watchers };
|
|
54
57
|
projects.set(id, proj);
|
|
55
58
|
return proj;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
// Only called after getProject(id)
|
|
59
|
-
// user at the right fix instead of a bare "unknown project". The two common real causes: never
|
|
60
|
-
// registered at all, vs. registered but this project predates handlers.js (needs `spectoflow
|
|
61
|
-
// update`) or its folder moved/was deleted.
|
|
61
|
+
// Only called after getProject(id) returned null. Two causes remain (D64 removed "needs an update").
|
|
62
62
|
function projectErrorMessage(id) {
|
|
63
63
|
const entry = registry.listProjects().find((p) => p.id === id);
|
|
64
64
|
if (!entry) return 'Unknown project.';
|
|
65
|
-
|
|
66
|
-
const handlersPath = path.join(entry.path, '.spectoflow', 'dashboard', 'handlers.js');
|
|
67
|
-
if (!fs.existsSync(handlersPath)) return `Project "${entry.name}" needs an update — run \`spectoflow update\` inside it, then reload this page.`;
|
|
68
|
-
return `Project "${entry.name}" is registered, but its dashboard code failed to load — check its .spectoflow/dashboard/handlers.js for errors.`;
|
|
65
|
+
return `Project "${entry.name}" is registered, but its folder no longer exists at ${entry.path}.`;
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// absolute path, so this can never touch another project's identically-named files. Returns false
|
|
75
|
-
// (a harmless no-op, not an error) if this id was never loaded — nothing to invalidate.
|
|
68
|
+
// Re-opens a project: drops its cached entry and closes its watchers so the next request re-runs
|
|
69
|
+
// onBoot and re-watches (a project's dirs may have changed after `spectoflow update`). Returns false
|
|
70
|
+
// (a harmless no-op) if this id was never loaded.
|
|
76
71
|
function reloadProject(id) {
|
|
77
72
|
const proj = projects.get(id);
|
|
78
73
|
if (!proj) return false;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (key.startsWith(prefix)) delete require.cache[key];
|
|
82
|
-
}
|
|
74
|
+
proj.watchers.forEach((w) => { try { w.close(); } catch (_) {} });
|
|
75
|
+
require('./orchestrator').clearPending(proj.root);
|
|
83
76
|
projects.delete(id);
|
|
84
77
|
return true;
|
|
85
78
|
}
|
|
86
79
|
|
|
87
80
|
// ---- hub API: list/add/remove registered projects, browse the filesystem to find one ----
|
|
88
81
|
function projectStats(root) {
|
|
89
|
-
// Best-effort — a moved/deleted/corrupt project must never break the whole listing.
|
|
90
82
|
try {
|
|
91
|
-
const store = require(path.join(root, '.spectoflow', 'lib', 'store.js'));
|
|
92
83
|
const plans = store.readPlans(root);
|
|
93
84
|
let total = 0, done = 0;
|
|
94
85
|
for (const pl of plans) for (const ph of pl.phases) for (const t of ph.tasks) { total++; if (t.status === 'done') done++; }
|
|
@@ -130,10 +121,10 @@ function addHubProject(rawPath) {
|
|
|
130
121
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'That folder does not exist.' };
|
|
131
122
|
const hasSpectoflow = fs.existsSync(path.join(abs, '.spectoflow'));
|
|
132
123
|
if (!hasSpectoflow) {
|
|
133
|
-
const { runInit } = require('
|
|
124
|
+
const { runInit } = require('../init');
|
|
134
125
|
runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
|
|
135
126
|
}
|
|
136
|
-
const entry =
|
|
127
|
+
const entry = workspace.registerProject(abs);
|
|
137
128
|
return { entry, initialized: !hasSpectoflow };
|
|
138
129
|
}
|
|
139
130
|
async function handleHubApi(req, res, u) {
|
|
@@ -168,8 +159,8 @@ async function handleHubApi(req, res, u) {
|
|
|
168
159
|
}
|
|
169
160
|
|
|
170
161
|
// Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
|
|
171
|
-
// shared, globally-installed PUBLIC dir —
|
|
172
|
-
//
|
|
162
|
+
// shared, globally-installed PUBLIC dir — factored into a function since both the root-level and
|
|
163
|
+
// /p/<id>/-prefixed requests need it.
|
|
173
164
|
function serveStatic(reqPath, req, res) {
|
|
174
165
|
const file = reqPath === '/' ? '/index.html' : reqPath;
|
|
175
166
|
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
|
|
@@ -193,7 +184,7 @@ function serveStatic(reqPath, req, res) {
|
|
|
193
184
|
|
|
194
185
|
const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
|
|
195
186
|
|
|
196
|
-
const LOCK =
|
|
187
|
+
const LOCK = workspace.lockPath();
|
|
197
188
|
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
198
189
|
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
199
190
|
process.on('exit', clearLock);
|
|
@@ -255,4 +246,4 @@ const server = http.createServer(async (req, res) => {
|
|
|
255
246
|
} catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
|
|
256
247
|
});
|
|
257
248
|
|
|
258
|
-
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}`); });
|
|
249
|
+
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); });
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The dashboard's operations — one pure function per action, (root, args, ctx) → result, with no
|
|
4
|
+
* HTTP in sight. handlers.js maps HTTP routes onto this table; the online dashboard (sub-project C)
|
|
5
|
+
* will map WebSocket messages onto the very same table. ctx.emit broadcasts SSE events to every
|
|
6
|
+
* client of this project; ops call it themselves after a successful mutation so any caller gets
|
|
7
|
+
* the same live behaviour. Errors are OpError(status, message) — the transport turns status into
|
|
8
|
+
* its own vocabulary (HTTP status code today).
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const store = require('../store');
|
|
13
|
+
const files = require('./files');
|
|
14
|
+
const { startRun } = require('./runner');
|
|
15
|
+
const { runSummarize } = require('./summarize');
|
|
16
|
+
const orchestrator = require('./orchestrator');
|
|
17
|
+
const adapters = require('../adapters');
|
|
18
|
+
const detect = require('../detect');
|
|
19
|
+
|
|
20
|
+
const PKG_VERSION = require('../../package.json').version;
|
|
21
|
+
|
|
22
|
+
class OpError extends Error {
|
|
23
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
24
|
+
}
|
|
25
|
+
const bad = (msg) => { throw new OpError(400, msg); };
|
|
26
|
+
const notFound = (msg) => { throw new OpError(404, msg); };
|
|
27
|
+
const text = (v, msg) => { if (!v || !String(v).trim()) bad(msg); return String(v).trim(); };
|
|
28
|
+
|
|
29
|
+
function frameworkVersion(root) {
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version || PKG_VERSION; } catch { return PKG_VERSION; }
|
|
31
|
+
}
|
|
32
|
+
function findPlanFileForTask(root, id) {
|
|
33
|
+
for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function readAgentFile(root, rel) {
|
|
37
|
+
const base = path.join(root, '.spectoflow');
|
|
38
|
+
const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
|
|
39
|
+
const abs = path.resolve(base, rel || '');
|
|
40
|
+
const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
|
|
41
|
+
if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory()) bad('not an agent/skill file');
|
|
42
|
+
let real; try { real = fs.realpathSync(abs); } catch { real = null; }
|
|
43
|
+
const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
|
|
44
|
+
const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
|
|
45
|
+
const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
|
|
46
|
+
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory()) bad('not an agent/skill file');
|
|
47
|
+
return { content: fs.readFileSync(real, 'utf8') };
|
|
48
|
+
}
|
|
49
|
+
function writeConfig(root, patch, detectOpts) {
|
|
50
|
+
const cp = path.join(root, '.spectoflow', 'config.json');
|
|
51
|
+
const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
52
|
+
if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
53
|
+
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
54
|
+
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
55
|
+
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
56
|
+
const id = patch.agent.trim();
|
|
57
|
+
const known = adapters.knownAgents().find((a) => a.id === id);
|
|
58
|
+
// Never activate an agent whose CLI isn't actually there — it would just fail silently later.
|
|
59
|
+
if (!detect.isAgentInstalled(id, root, detectOpts)) bad(`${known ? known.label : id} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
|
|
60
|
+
cfg.agent = id;
|
|
61
|
+
if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
64
|
+
return cfg;
|
|
65
|
+
}
|
|
66
|
+
const filesResult = (r) => { if (r.error) bad(r.error); return r; };
|
|
67
|
+
const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
|
|
68
|
+
|
|
69
|
+
const ops = {
|
|
70
|
+
'project.read': async (root) => {
|
|
71
|
+
const p = store.readProject(root);
|
|
72
|
+
p.version = frameworkVersion(root);
|
|
73
|
+
p.projectName = path.basename(root);
|
|
74
|
+
p.knownAgents = adapters.knownAgents().map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
75
|
+
p.installedAgents = detect.installedAgents(root);
|
|
76
|
+
return p;
|
|
77
|
+
},
|
|
78
|
+
'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
|
|
79
|
+
|
|
80
|
+
'files.tree': async (root) => ({ tree: files.tree(root) }),
|
|
81
|
+
'files.read': async (root, { path: rel }) => filesResult(files.readFile(root, rel || '')),
|
|
82
|
+
'files.write': async (root, { path: rel, content }, ctx) => changed(ctx, filesResult(files.writeFile(root, rel, content))),
|
|
83
|
+
'files.mkdir': async (root, { path: rel }, ctx) => changed(ctx, filesResult(files.mkdir(root, rel))),
|
|
84
|
+
|
|
85
|
+
'task.add': async (root, { title, phase, file, owner, level }, ctx) => {
|
|
86
|
+
const t = store.addTask(root, { title: text(title, 'A title is required.'), phase, file, owner, level });
|
|
87
|
+
return changed(ctx, { task: t });
|
|
88
|
+
},
|
|
89
|
+
'task.update': async (root, { id, patch }, ctx) => {
|
|
90
|
+
const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
|
|
91
|
+
store.updateTaskLine(root, file, id, patch || {});
|
|
92
|
+
return changed(ctx, { ok: true });
|
|
93
|
+
},
|
|
94
|
+
'task.comment': async (root, { id, text: body, action }, ctx) => {
|
|
95
|
+
const msg = text(body, 'Empty comment.');
|
|
96
|
+
const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
|
|
97
|
+
store.addTaskComment(root, file, id, msg, 'me');
|
|
98
|
+
if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
|
|
99
|
+
return changed(ctx, { ok: true });
|
|
100
|
+
},
|
|
101
|
+
'workflow.toggle': async (root, { name }, ctx) => {
|
|
102
|
+
const wf = path.join(root, '.spectoflow', 'workflow.md');
|
|
103
|
+
const lines = fs.readFileSync(wf, 'utf8').split('\n');
|
|
104
|
+
// Strip the trailing {cap:... skill:... policy} annotation BEFORE "(optional)" — the same order as
|
|
105
|
+
// store.readWorkflow(), which is what the client's step names come from (D60).
|
|
106
|
+
const stepName = (rest) => { const ann = rest.match(/\{([^}]*)\}\s*$/); if (ann) rest = rest.slice(0, ann.index).trim(); return rest.replace(/\s*\(optional\)\s*$/i, '').trim(); };
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
109
|
+
if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4];
|
|
110
|
+
}
|
|
111
|
+
fs.writeFileSync(wf, lines.join('\n'));
|
|
112
|
+
return changed(ctx, { ok: true });
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
'run.start': async (root, { prompt, agent }, ctx) => {
|
|
116
|
+
text(prompt, 'Empty request.');
|
|
117
|
+
const r = startRun(root, { prompt, agent }, ctx.emit);
|
|
118
|
+
if (r.error) bad(r.error);
|
|
119
|
+
return { runId: r.runId };
|
|
120
|
+
},
|
|
121
|
+
'chat.summarize': async (root, { agent }, ctx) => {
|
|
122
|
+
const r = runSummarize(root, { agent }, ctx.emit);
|
|
123
|
+
if (r.error) bad(r.error);
|
|
124
|
+
return { ok: true };
|
|
125
|
+
},
|
|
126
|
+
'chat.clear': async (root, _args, ctx) => {
|
|
127
|
+
const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
|
|
128
|
+
return changed(ctx, { ok: true });
|
|
129
|
+
},
|
|
130
|
+
'orchestrate.start': async (root, { request }, ctx) => {
|
|
131
|
+
const req = text(request, 'Empty request.');
|
|
132
|
+
const active = store.readRuntime(root).orchestration;
|
|
133
|
+
if (active && ['running', 'awaiting_approval'].includes(active.status)) throw new OpError(409, 'An orchestration is already active.');
|
|
134
|
+
const mode = store.readConfig(root).mode || 'semi';
|
|
135
|
+
orchestrator.runOrchestration({ root, request: req, mode, runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, ctx.emit)
|
|
136
|
+
.catch((e) => ctx.emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
137
|
+
const o = store.readRuntime(root).orchestration;
|
|
138
|
+
return { orchestrationId: o && o.id };
|
|
139
|
+
},
|
|
140
|
+
'orchestrate.approve': async (root, { decision, note }) => {
|
|
141
|
+
if (!orchestrator.submitDecision(decision, note, root)) throw new OpError(409, 'No pending approval.');
|
|
142
|
+
return { ok: true };
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
'settings.save': async (root, patch, ctx) => changed(ctx, { config: writeConfig(root, patch || {}, ctx.env ? { env: ctx.env } : undefined) }),
|
|
146
|
+
|
|
147
|
+
'attention.add': async (root, { text: body }, ctx) => {
|
|
148
|
+
const msg = text(body, 'Empty note.');
|
|
149
|
+
const rt = store.readRuntime(root); rt.attention = rt.attention || [];
|
|
150
|
+
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: msg };
|
|
151
|
+
rt.attention.unshift(item); store.writeRuntime(root, rt);
|
|
152
|
+
return changed(ctx, { item });
|
|
153
|
+
},
|
|
154
|
+
'attention.promote': async (root, { id }, ctx) => {
|
|
155
|
+
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
156
|
+
if (!it) notFound('Note not found.');
|
|
157
|
+
const t = store.addTask(root, { phase: 'Attention', title: it.text, owner: 'user' });
|
|
158
|
+
it.status = 'resolved'; it.promotedTo = t.id; store.writeRuntime(root, rt);
|
|
159
|
+
return changed(ctx, { task: t });
|
|
160
|
+
},
|
|
161
|
+
'attention.update': async (root, { id, patch }, ctx) => {
|
|
162
|
+
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
163
|
+
if (!it) notFound('Note not found.');
|
|
164
|
+
const p = patch || {};
|
|
165
|
+
if (typeof p.text === 'string' && p.text.trim()) it.text = p.text.trim();
|
|
166
|
+
if (p.status && ['open', 'resolved'].includes(p.status)) it.status = p.status;
|
|
167
|
+
store.writeRuntime(root, rt);
|
|
168
|
+
return changed(ctx, { item: it });
|
|
169
|
+
},
|
|
170
|
+
'attention.remove': async (root, { id }, ctx) => {
|
|
171
|
+
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
|
|
172
|
+
return changed(ctx, { ok: true });
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
module.exports = { ops, OpError };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const store = require('../
|
|
4
|
+
const store = require('../store');
|
|
5
5
|
const { startRun } = require('./runner');
|
|
6
6
|
|
|
7
7
|
// step (from store.readWorkflow) -> { agent, skill } or { error }
|
|
@@ -54,7 +54,7 @@ async function runOrchestration({ root, request, mode, runStep, confirm, resume
|
|
|
54
54
|
if (needConfirm) {
|
|
55
55
|
st.status = 'awaiting_approval'; o.status = 'awaiting_approval'; saveState(root, o, emit);
|
|
56
56
|
post(root, 'orchestrator', 'question', `Approve step "${step.name}" (${r.agent})${policyGated ? ' — policy gate' : ''}?`, emit);
|
|
57
|
-
const dec = await confirm(step, { policy: policyGated });
|
|
57
|
+
const dec = await confirm(step, { policy: policyGated, root });
|
|
58
58
|
post(root, 'orchestrator', 'status', `decision: ${dec.decision}${dec.note ? ' — ' + dec.note : ''}`, emit);
|
|
59
59
|
if (dec.decision === 'cancel') { st.status = 'skipped'; o.status = 'cancelled'; saveState(root, o, emit); return o; }
|
|
60
60
|
o.status = 'running'; saveState(root, o, emit);
|
|
@@ -94,12 +94,21 @@ function defaultRunStep({ root, step, agent, skill, request }, emit) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
// Keyed by project root — before this hub shared one orchestrator.js instance across every project
|
|
98
|
+
// (D64), a single module-level `pending` here was a real cross-project leak: approving in one
|
|
99
|
+
// project could resolve (or silently overwrite) another project's pending gate.
|
|
100
|
+
const pending = new Map(); // root -> { resolve }
|
|
101
|
+
function defaultConfirm(step, reason) { return new Promise((resolve) => { pending.set(reason && reason.root, { resolve }); }); }
|
|
102
|
+
function submitDecision(decision, note, root) {
|
|
103
|
+
const p = pending.get(root);
|
|
104
|
+
if (!p) return false;
|
|
105
|
+
pending.delete(root);
|
|
106
|
+
p.resolve({ decision, note });
|
|
107
|
+
return true;
|
|
102
108
|
}
|
|
109
|
+
// Drops any pending approval for `root` — called when a project is reloaded (its whole in-flight
|
|
110
|
+
// session is considered gone) so a stale resolver can never be sitting around after the fact.
|
|
111
|
+
function clearPending(root) { pending.delete(root); }
|
|
103
112
|
|
|
104
113
|
// Boot-time reconcile: a process restart loses any in-flight orchestration (the runOrchestration
|
|
105
114
|
// call stack, and the in-memory `pending` approval) even though runtime.json still records it as
|
|
@@ -116,4 +125,4 @@ function reconcileOnBoot(root) {
|
|
|
116
125
|
return true;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, reconcileOnBoot };
|
|
128
|
+
module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, clearPending, reconcileOnBoot };
|
|
@@ -17,9 +17,8 @@ let attnFilter = 'open'; // attention tab filter — cl
|
|
|
17
17
|
|
|
18
18
|
// The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
|
|
19
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
|
|
21
|
-
//
|
|
22
|
-
// that case, preserving today's exact single-project behavior.
|
|
20
|
+
// reaches this file directly; it 302s to a /p/<id>/... URL first. Null defensively (no prefix at
|
|
21
|
+
// all) — every helper below no-ops in that case rather than assuming the prefix is always present.
|
|
23
22
|
const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
|
|
24
23
|
// Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
|
|
25
24
|
// attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
|
|
@@ -906,12 +905,12 @@ async function saveSettings(){
|
|
|
906
905
|
}
|
|
907
906
|
|
|
908
907
|
// ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
|
|
909
|
-
// A custom dashboard is a DECLARATIVE block spec (.spectoflow/
|
|
908
|
+
// A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboards/<id>.json, embedded
|
|
910
909
|
// in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
|
|
911
910
|
// same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
|
|
912
911
|
// generated dashboard automatically matches the active design — and any design switched to later —
|
|
913
|
-
// with zero page-specific styling.
|
|
914
|
-
// Node-side validator); this is the independent browser-side reader for the same shape.
|
|
912
|
+
// with zero page-specific styling. The block schema is enforced by `spectoflow dashboard validate`
|
|
913
|
+
// (Node-side validator); this is the independent browser-side reader for the same shape.
|
|
915
914
|
function resolveBind(s,bindPath,fallback){
|
|
916
915
|
if(bindPath==null) return fallback;
|
|
917
916
|
let v=s;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* printing sentinel lines (::spectoflow role=… kind=… msg=…) which become structured messages;
|
|
6
6
|
* any other output streams raw as run-line events. `emit` publishes SSE events to the dashboard.
|
|
7
7
|
*
|
|
8
|
-
* Kept separate from
|
|
8
|
+
* Kept separate from the HTTP layer so the pipeline is unit-testable without a server.
|
|
9
9
|
*/
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
|
-
const store = require('../
|
|
12
|
-
const
|
|
11
|
+
const store = require('../store');
|
|
12
|
+
const adapters = require('../adapters');
|
|
13
|
+
const detect = require('../detect');
|
|
13
14
|
|
|
14
15
|
// The command to run `which`: config.json's own runners map first (an explicit user choice always
|
|
15
16
|
// wins), falling back to the registry's default for a known, headless-capable, genuinely-installed
|
|
@@ -17,8 +18,8 @@ const agentsRegistry = require('../lib/agents-registry');
|
|
|
17
18
|
// the project's "active agent" and had a runner seeded into config.json for it.
|
|
18
19
|
function resolveRunnerCommand(root, cfg, which, opts) {
|
|
19
20
|
if (cfg.runners && cfg.runners[which]) return cfg.runners[which];
|
|
20
|
-
const known =
|
|
21
|
-
if (known && known.runner &&
|
|
21
|
+
const known = adapters.knownAgents().find((a) => a.id === which);
|
|
22
|
+
if (known && known.runner && detect.isAgentInstalled(which, root, opts)) return known.runner;
|
|
22
23
|
return null;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* sitting right below it doesn't condense anything — it just adds noise on top of noise.
|
|
8
8
|
*/
|
|
9
9
|
const { spawn } = require('child_process');
|
|
10
|
-
const store = require('../
|
|
10
|
+
const store = require('../store');
|
|
11
11
|
const { resolveRunnerCommand } = require('./runner');
|
|
12
12
|
|
|
13
13
|
const DEFAULT_LIMIT = 40;
|
package/lib/detect.js
CHANGED
|
@@ -31,4 +31,19 @@ function detectAgents(projectRoot, opts = {}) {
|
|
|
31
31
|
}).map((a) => a.id);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
|
|
35
|
+
// config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
|
|
36
|
+
// remote/CI runner). Unknown ids are never "installed".
|
|
37
|
+
function isAgentInstalled(id, projectRoot, opts) {
|
|
38
|
+
const a = REGISTRY.find((x) => x.id === id);
|
|
39
|
+
if (!a) return false;
|
|
40
|
+
if (a.detect.bin && binOnPath(a.detect.bin, opts)) return true;
|
|
41
|
+
return (a.detect.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ids of every known agent actually installed for this project, in REGISTRY (priority) order.
|
|
45
|
+
function installedAgents(projectRoot, opts) {
|
|
46
|
+
return REGISTRY.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { binOnPath, detectAgents, isAgentInstalled, installedAgents };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Global config — ~/.spectoflow/config.json (or $SPECTOFLOW_HOME/config.json). Settings that apply
|
|
4
|
+
* to every project on this machine: where the dashboard workspace lives, which dashboard URL
|
|
5
|
+
* projects talk to, and the defaults `spectoflow init` seeds a new project's config.json with.
|
|
6
|
+
* Layering, lowest to highest: kit templates < these defaults < the project's own config.json.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { REGISTRY } = require('./adapters');
|
|
12
|
+
|
|
13
|
+
const KEYS = ['dashboard.url', 'dashboard.path', 'defaults.agent', 'defaults.language', 'defaults.mode', 'defaults.design'];
|
|
14
|
+
const MODES = ['autopilot', 'semi', 'manual'];
|
|
15
|
+
|
|
16
|
+
function homeDir() { return process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow'); }
|
|
17
|
+
function configPath() { return path.join(homeDir(), 'config.json'); }
|
|
18
|
+
function defaultDashboardPath() { return path.join(homeDir(), 'dashboard'); }
|
|
19
|
+
function expandHome(p) { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p; }
|
|
20
|
+
|
|
21
|
+
function defaults() {
|
|
22
|
+
return { dashboard: { url: 'http://localhost:4319', path: defaultDashboardPath() }, defaults: { agent: 'claude', language: 'en', mode: 'semi', design: 'console' } };
|
|
23
|
+
}
|
|
24
|
+
function readRaw() {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')) || {}; } catch { return {}; }
|
|
26
|
+
}
|
|
27
|
+
function writeRaw(obj) {
|
|
28
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
29
|
+
fs.writeFileSync(configPath(), JSON.stringify(obj, null, 2) + '\n');
|
|
30
|
+
}
|
|
31
|
+
const getPath = (obj, key) => key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
|
32
|
+
const setPath = (obj, key, value) => { const ks = key.split('.'); let o = obj; for (const k of ks.slice(0, -1)) o = (o[k] = o[k] || {}); o[ks[ks.length - 1]] = value; };
|
|
33
|
+
|
|
34
|
+
function read() {
|
|
35
|
+
const d = defaults(), raw = readRaw();
|
|
36
|
+
return { dashboard: { ...d.dashboard, ...(raw.dashboard || {}) }, defaults: { ...d.defaults, ...(raw.defaults || {}) } };
|
|
37
|
+
}
|
|
38
|
+
function get(key) {
|
|
39
|
+
if (!KEYS.includes(key)) throw new Error(`unknown key "${key}" — valid keys: ${KEYS.join(', ')}`);
|
|
40
|
+
const raw = getPath(readRaw(), key);
|
|
41
|
+
return raw !== undefined ? { value: raw, source: 'set' } : { value: getPath(defaults(), key), source: 'default' };
|
|
42
|
+
}
|
|
43
|
+
function list() { return KEYS.map((key) => ({ key, ...get(key) })); }
|
|
44
|
+
|
|
45
|
+
function validate(key, value) {
|
|
46
|
+
const v = String(value).trim();
|
|
47
|
+
switch (key) {
|
|
48
|
+
case 'dashboard.url': { let u; try { u = new URL(v); } catch { throw new Error('dashboard.url must be a URL, e.g. http://localhost:4319'); } if (!/^https?:$/.test(u.protocol)) throw new Error('dashboard.url must start with http:// or https://'); return u.origin; }
|
|
49
|
+
case 'dashboard.path': { if (!v) throw new Error('dashboard.path must be a folder path'); return path.resolve(expandHome(v)); }
|
|
50
|
+
case 'defaults.agent': { if (!REGISTRY.some((a) => a.id === v)) throw new Error(`unknown agent "${v}" — one of: ${REGISTRY.map((a) => a.id).join(', ')}`); return v; }
|
|
51
|
+
case 'defaults.language': { if (!/^[a-z]{2}$/.test(v)) throw new Error('defaults.language must be a 2-letter code (en, fr, es, de, pt, it…)'); return v; }
|
|
52
|
+
case 'defaults.mode': { if (!MODES.includes(v)) throw new Error(`defaults.mode must be one of: ${MODES.join(', ')}`); return v; }
|
|
53
|
+
case 'defaults.design': { if (!/^[a-z0-9-]{1,40}$/.test(v)) throw new Error('defaults.design must be a design id (console, orbit, …)'); return v; }
|
|
54
|
+
default: throw new Error(`unknown key "${key}" — valid keys: ${KEYS.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function set(key, value) {
|
|
58
|
+
const v = validate(key, value);
|
|
59
|
+
const raw = readRaw(); setPath(raw, key, v); writeRaw(raw);
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
// Creates the file (empty object) if it doesn't exist — never touches an existing one.
|
|
63
|
+
function ensure() { if (!fs.existsSync(configPath())) writeRaw({}); }
|
|
64
|
+
|
|
65
|
+
module.exports = { KEYS, homeDir, configPath, defaultDashboardPath, expandHome, read, get, set, list, ensure };
|
package/lib/init.js
CHANGED
|
@@ -12,7 +12,8 @@ const adapters = require('./adapters');
|
|
|
12
12
|
const ownership = require('./ownership');
|
|
13
13
|
const manifest = require('./manifest');
|
|
14
14
|
const mcp = require('./mcp');
|
|
15
|
-
const store = require('
|
|
15
|
+
const store = require('./store');
|
|
16
|
+
const globalConfig = require('./global-config');
|
|
16
17
|
|
|
17
18
|
function copyDir(src, dst) {
|
|
18
19
|
fs.mkdirSync(dst, { recursive: true });
|
|
@@ -47,7 +48,7 @@ function normalizePlans(root, config) {
|
|
|
47
48
|
return added;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function runInit({ target, templatesDir, version, agentsArg }) {
|
|
51
|
+
function runInit({ target, templatesDir, version, agentsArg, defaults }) {
|
|
51
52
|
fs.mkdirSync(target, { recursive: true });
|
|
52
53
|
const notes = [];
|
|
53
54
|
|
|
@@ -79,7 +80,12 @@ function runInit({ target, templatesDir, version, agentsArg }) {
|
|
|
79
80
|
|
|
80
81
|
const cfgPath = path.join(spectoflowDir, 'config.json');
|
|
81
82
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
82
|
-
|
|
83
|
+
const d = defaults || globalConfig.read().defaults;
|
|
84
|
+
cfg.mode = d.mode; cfg.language = d.language; cfg.design = d.design;
|
|
85
|
+
// The active agent: an explicit --agent wins; else the global default when it's actually detected
|
|
86
|
+
// here (or nothing is); else the first detected one.
|
|
87
|
+
cfg.agent = agentsArg ? agents[0] : ((detected.includes(d.agent) || !detected.length) ? d.agent : detected[0]);
|
|
88
|
+
if (!agents.includes(cfg.agent)) agents.unshift(cfg.agent);
|
|
83
89
|
cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
|
|
84
90
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
85
91
|
|
|
@@ -106,7 +112,7 @@ function runInit({ target, templatesDir, version, agentsArg }) {
|
|
|
106
112
|
|
|
107
113
|
const gi = path.join(target, '.gitignore');
|
|
108
114
|
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
109
|
-
for (const line of ['.spectoflow/runtime.json'
|
|
115
|
+
for (const line of ['.spectoflow/runtime.json']) {
|
|
110
116
|
if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
|
|
111
117
|
}
|
|
112
118
|
|
package/lib/registry.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
|
-
* The project registry —
|
|
4
|
-
*
|
|
5
|
-
* and switch between. This module owns only the registry
|
|
6
|
-
*
|
|
3
|
+
* The project registry — projects.json inside the dashboard workspace (see lib/workspace.js; default
|
|
4
|
+
* ~/.spectoflow/dashboard/). Tracks every project spectoflow has seen (via `spectoflow dashboard`), so
|
|
5
|
+
* the multi-project hub knows what to list and switch between. This module owns only the registry
|
|
6
|
+
* file itself; it has no opinion about ports or servers.
|
|
7
7
|
*/
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
10
|
const crypto = require('crypto');
|
|
12
11
|
|
|
13
12
|
const REGISTRY_FILE = 'projects.json';
|
|
14
13
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// The registry lives inside the dashboard workspace (D64). An explicit baseDir (unit tests) wins;
|
|
15
|
+
// otherwise the workspace is wherever the global config says (default $SPECTOFLOW_HOME/dashboard).
|
|
17
16
|
function registryDir(baseDir) {
|
|
18
|
-
return baseDir ||
|
|
17
|
+
return baseDir || require('./global-config').read().dashboard.path;
|
|
19
18
|
}
|
|
20
19
|
function registryPath(baseDir) {
|
|
21
20
|
return path.join(registryDir(baseDir), REGISTRY_FILE);
|
|
@@ -62,6 +61,7 @@ function addProject(projectPath, baseDir) {
|
|
|
62
61
|
path: target,
|
|
63
62
|
name: path.basename(target),
|
|
64
63
|
lastOpened: new Date().toISOString(),
|
|
64
|
+
kind: 'spectoflow',
|
|
65
65
|
};
|
|
66
66
|
reg.projects.push(entry);
|
|
67
67
|
} else {
|