cohorte 2.9.0 → 2.10.1
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/CHANGELOG.md +33 -0
- package/README.md +37 -45
- package/bin/cli.js +15 -43
- package/bin/report.js +2 -2
- package/core/adapter/render.js +33 -7
- package/core/commands/cohorte-doctor.md +28 -4
- package/core/commands/cohorte-fleet.md +2 -3
- package/core/commands/cohorte-ship.md +2 -2
- package/core/commands/cohorte-update-pipeline.md +34 -3
- package/core/hooks/gate.py +13 -5
- package/core/runtimes/codex.json +5 -3
- package/core/templates/steps/init-pipeline/04-write-render.md +32 -2
- package/core/workflows/review.js +1 -1
- package/{dashboard/server → lib}/doctor.js +48 -17
- package/{dashboard/server → lib}/runtime.js +6 -1
- package/{dashboard/server → lib}/versions.js +2 -2
- package/package.json +3 -8
- package/profile/PIPELINE.template.md +10 -2
- package/profile/SCHEMA.md +43 -6
- package/scripts/test-adapter.mjs +70 -1
- package/scripts/test-gate.mjs +15 -0
- package/scripts/{test-dashboard.mjs → test-lib.mjs} +58 -229
- package/scripts/validate-core.mjs +9 -19
- package/dashboard/README.md +0 -71
- package/dashboard/dist/apple-touch-icon-180.png +0 -0
- package/dashboard/dist/assets/index-BZ_LQlEj.css +0 -1
- package/dashboard/dist/assets/index-vtFc6Gyc.js +0 -43
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon-48.png +0 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +0 -16
- package/dashboard/server/fleet.js +0 -133
- package/dashboard/server/index.js +0 -408
- package/dashboard/server/kanban.js +0 -169
- package/dashboard/server/metrics.js +0 -120
- package/dashboard/server/usage.js +0 -61
- /package/{dashboard/server → lib}/yaml.js +0 -0
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
// Fleet registry — the set of projects the dashboard tracks. Persisted in
|
|
3
|
-
// ~/.claude/cohorte-dashboard.json (user-scoped, machine-wide). Each /api/fleet call
|
|
4
|
-
// runs a compact doctor pass per project so the overview shows freshness + health at a glance.
|
|
5
|
-
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const os = require('os');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const { state } = require('./doctor');
|
|
10
|
-
|
|
11
|
-
// Expand a leading ~ and require an absolute path — resolving a bare name against the
|
|
12
|
-
// server's cwd is surprising ("samo" → <repo>/samo), so reject it with a clear message.
|
|
13
|
-
function normalizeDir(dir) {
|
|
14
|
-
let d = String(dir || '').trim();
|
|
15
|
-
if (d === '~' || d.startsWith('~/')) d = path.join(os.homedir(), d.slice(1));
|
|
16
|
-
if (!path.isAbsolute(d)) {
|
|
17
|
-
throw new Error(`path must be absolute (got "${dir}") — e.g. ${path.join(os.homedir(), 'projects', 'my-app')}`);
|
|
18
|
-
}
|
|
19
|
-
return d;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function registryPath(globalDir) {
|
|
23
|
-
return path.join(globalDir, 'cohorte-dashboard.json');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function read(globalDir) {
|
|
27
|
-
// cohorte-dashboard.json, then the pre-rename legacy name (read-only fallback; the next
|
|
28
|
-
// write() migrates the registry forward to the new path).
|
|
29
|
-
for (const n of ['cohorte-dashboard.json', 'thebidouille-dashboard.json']) {
|
|
30
|
-
try {
|
|
31
|
-
const data = JSON.parse(fs.readFileSync(path.join(globalDir, n), 'utf8'));
|
|
32
|
-
if (Array.isArray(data.projects)) return data.projects;
|
|
33
|
-
} catch { /* try next */ }
|
|
34
|
-
}
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function write(globalDir, projects) {
|
|
39
|
-
fs.mkdirSync(globalDir, { recursive: true });
|
|
40
|
-
fs.writeFileSync(registryPath(globalDir), JSON.stringify({ projects }, null, 2) + '\n');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Add the launch project on first use so the fleet is never empty.
|
|
44
|
-
function ensureSeed(globalDir, projectRoot) {
|
|
45
|
-
if (!projectRoot) return;
|
|
46
|
-
// Normalize like add(), or launching from `.` vs an absolute path (or a
|
|
47
|
-
// differently-cased Windows path) seeds the same project twice.
|
|
48
|
-
let abs;
|
|
49
|
-
try { abs = normalizeDir(projectRoot); } catch { abs = path.resolve(projectRoot); }
|
|
50
|
-
const projects = read(globalDir);
|
|
51
|
-
const known = projects.some(p => p === abs
|
|
52
|
-
|| (process.platform === 'win32' && p.toLowerCase() === abs.toLowerCase()));
|
|
53
|
-
if (!known) {
|
|
54
|
-
projects.push(abs);
|
|
55
|
-
write(globalDir, projects);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function add(globalDir, dir) {
|
|
60
|
-
const abs = normalizeDir(dir);
|
|
61
|
-
if (!fs.existsSync(abs)) throw new Error(`path not found: ${abs}`);
|
|
62
|
-
if (!fs.statSync(abs).isDirectory()) throw new Error(`not a directory: ${abs}`);
|
|
63
|
-
const projects = read(globalDir);
|
|
64
|
-
if (!projects.includes(abs)) { projects.push(abs); write(globalDir, projects); }
|
|
65
|
-
return abs;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function remove(globalDir, dir) {
|
|
69
|
-
// normalizeDir like add() — a bare path.resolve leaves `~/…` unexpanded, the
|
|
70
|
-
// filter matches nothing, and the endpoint reports `removed` for a no-op.
|
|
71
|
-
let abs;
|
|
72
|
-
try { abs = normalizeDir(dir); } catch { abs = path.resolve(dir); }
|
|
73
|
-
const projects = read(globalDir).filter(p => p !== abs);
|
|
74
|
-
write(globalDir, projects);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Compact per-project summary for the overview cards.
|
|
78
|
-
async function summarize(projectRoot, globalDir, cliVersion) {
|
|
79
|
-
if (!fs.existsSync(projectRoot)) {
|
|
80
|
-
return { path: projectRoot, exists: false, error: 'path no longer exists' };
|
|
81
|
-
}
|
|
82
|
-
try {
|
|
83
|
-
const s = await state({ projectRoot, globalDir, cliVersion });
|
|
84
|
-
return {
|
|
85
|
-
path: projectRoot,
|
|
86
|
-
exists: true,
|
|
87
|
-
name: (s.profile && s.profile.name) || path.basename(projectRoot),
|
|
88
|
-
hasProfile: !!s.profile,
|
|
89
|
-
surfaces: s.profile ? ((s.profile.surfaces || []).length) : 0,
|
|
90
|
-
specs: s.specs.length,
|
|
91
|
-
versions: {
|
|
92
|
-
installMode: s.versions.installMode,
|
|
93
|
-
installedVersion: s.versions.installedVersion,
|
|
94
|
-
latest: s.versions.latest,
|
|
95
|
-
freshness: s.versions.freshness,
|
|
96
|
-
},
|
|
97
|
-
summary: s.summary,
|
|
98
|
-
};
|
|
99
|
-
} catch (e) {
|
|
100
|
-
return {
|
|
101
|
-
path: projectRoot, exists: true,
|
|
102
|
-
name: path.basename(projectRoot), // the card header must still identify the project
|
|
103
|
-
error: String((e && e.message) || e),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function list(globalDir, cliVersion) {
|
|
109
|
-
const projects = read(globalDir);
|
|
110
|
-
return Promise.all(projects.map(p => summarize(p, globalDir, cliVersion)));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Server-side directory browser for the folder picker. Lists immediate sub-directories of
|
|
114
|
-
// `dir` (default: home), flagging those that look like a pipeline project (have PIPELINE.md).
|
|
115
|
-
// Localhost-only tool, so exposing the filesystem to the picker is acceptable.
|
|
116
|
-
function browse(dir) {
|
|
117
|
-
let base = String(dir || '').trim() || os.homedir();
|
|
118
|
-
if (base === '~' || base.startsWith('~/')) base = path.join(os.homedir(), base.slice(1));
|
|
119
|
-
base = path.resolve(base);
|
|
120
|
-
const parent = path.dirname(base);
|
|
121
|
-
const looksLikeProject = d => fs.existsSync(path.join(d, 'PIPELINE.md'));
|
|
122
|
-
try {
|
|
123
|
-
const dirs = fs.readdirSync(base, { withFileTypes: true })
|
|
124
|
-
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
125
|
-
.map(e => ({ name: e.name, path: path.join(base, e.name), isProject: looksLikeProject(path.join(base, e.name)) }))
|
|
126
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
-
return { dir: base, parent: parent === base ? null : parent, isProject: looksLikeProject(base), dirs };
|
|
128
|
-
} catch (e) {
|
|
129
|
-
return { dir: base, parent: parent === base ? null : parent, isProject: false, dirs: [], error: String((e && e.message) || e) };
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = { registryPath, read, ensureSeed, add, remove, list, browse };
|
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
// Local dashboard HTTP server — dependency-free (node built-ins only).
|
|
3
|
-
// Serves the built React app from dashboard/dist and a small JSON API.
|
|
4
|
-
// Phase 0: GET /api/versions (freshness). Later phases add /api/state, /api/fleet, /api/action.
|
|
5
|
-
|
|
6
|
-
const http = require('http');
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const { spawn } = require('child_process');
|
|
10
|
-
const { versions } = require('./versions');
|
|
11
|
-
const { state } = require('./doctor');
|
|
12
|
-
const { kanban } = require('./kanban');
|
|
13
|
-
const { metrics } = require('./metrics');
|
|
14
|
-
const { usage } = require('./usage');
|
|
15
|
-
const fleet = require('./fleet');
|
|
16
|
-
|
|
17
|
-
const MIME = {
|
|
18
|
-
'.html': 'text/html; charset=utf-8',
|
|
19
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
20
|
-
'.css': 'text/css; charset=utf-8',
|
|
21
|
-
'.json': 'application/json; charset=utf-8',
|
|
22
|
-
'.svg': 'image/svg+xml',
|
|
23
|
-
'.png': 'image/png',
|
|
24
|
-
'.ico': 'image/x-icon',
|
|
25
|
-
'.woff2': 'font/woff2',
|
|
26
|
-
'.woff': 'font/woff',
|
|
27
|
-
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
28
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
29
|
-
'.jpg': 'image/jpeg',
|
|
30
|
-
'.jpeg': 'image/jpeg',
|
|
31
|
-
'.webp': 'image/webp',
|
|
32
|
-
'.map': 'application/json; charset=utf-8',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function sendJson(res, status, body) {
|
|
36
|
-
const data = JSON.stringify(body);
|
|
37
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
38
|
-
res.end(data);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Read + JSON-parse a request body (POST/DELETE), capped to avoid unbounded buffering.
|
|
42
|
-
function readBody(req) {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
let raw = '';
|
|
45
|
-
req.on('data', chunk => {
|
|
46
|
-
raw += chunk;
|
|
47
|
-
if (raw.length > 1e6) { reject(new Error('body too large')); req.destroy(); }
|
|
48
|
-
});
|
|
49
|
-
req.on('end', () => {
|
|
50
|
-
if (raw.trim() === '') return resolve({});
|
|
51
|
-
try { resolve(JSON.parse(raw)); } catch { reject(new Error('invalid JSON body')); }
|
|
52
|
-
});
|
|
53
|
-
req.on('error', reject);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function notBuiltPage(distDir) {
|
|
58
|
-
return `<!doctype html><meta charset="utf-8"><title>dashboard — not built</title>
|
|
59
|
-
<body style="font:16px/1.6 ui-monospace,monospace;max-width:42rem;margin:4rem auto;padding:0 1rem;color:#ddd;background:#111">
|
|
60
|
-
<h1>Dashboard not built yet</h1>
|
|
61
|
-
<p>No <code>${distDir}</code> found. Build the React app once:</p>
|
|
62
|
-
<pre style="background:#000;padding:1rem;border-radius:8px;overflow:auto">npm --prefix dashboard/app install
|
|
63
|
-
npm --prefix dashboard/app run build</pre>
|
|
64
|
-
<p>Or, for live development, run the Vite dev server (<code>npm --prefix dashboard/app run dev</code>)
|
|
65
|
-
which proxies <code>/api</code> here.</p></body>`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Serve a static file from distDir; SPA-fallback to index.html for unknown routes.
|
|
69
|
-
function serveStatic(req, res, distDir) {
|
|
70
|
-
let rel;
|
|
71
|
-
// A malformed escape (`/%`) makes decodeURIComponent throw — a 400 is the honest
|
|
72
|
-
// answer, not the caller's 500.
|
|
73
|
-
try { rel = decodeURIComponent(req.url.split('?')[0]); }
|
|
74
|
-
catch { res.writeHead(400); return res.end('bad request'); }
|
|
75
|
-
if (rel === '/' || rel === '') rel = '/index.html';
|
|
76
|
-
// Contain the path inside distDir (no traversal). Compare with the separator
|
|
77
|
-
// appended: a bare startsWith would also accept a sibling `…/dist-something`.
|
|
78
|
-
const abs = path.join(distDir, path.normalize(rel));
|
|
79
|
-
if (abs !== distDir && !abs.startsWith(distDir + path.sep)) {
|
|
80
|
-
res.writeHead(403); return res.end('forbidden');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
fs.readFile(abs, (err, buf) => {
|
|
84
|
-
if (err) {
|
|
85
|
-
// A missing file WITH an extension is a real 404 (e.g. a stale cached
|
|
86
|
-
// /assets/index-<oldhash>.js after an update) — serving index.html there
|
|
87
|
-
// hands a module script text/html and it dies on an opaque MIME error.
|
|
88
|
-
if (path.extname(abs)) { res.writeHead(404); return res.end('not found'); }
|
|
89
|
-
// SPA fallback: hand index.html to the client router.
|
|
90
|
-
const index = path.join(distDir, 'index.html');
|
|
91
|
-
return fs.readFile(index, (e2, html) => {
|
|
92
|
-
if (e2) { res.writeHead(404, { 'content-type': 'text/html' }); return res.end(notBuiltPage(distDir)); }
|
|
93
|
-
res.writeHead(200, { 'content-type': MIME['.html'] });
|
|
94
|
-
res.end(html);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
res.writeHead(200, { 'content-type': MIME[path.extname(abs)] || 'application/octet-stream' });
|
|
98
|
-
res.end(buf);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const STREAM_HEADERS = {
|
|
103
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
104
|
-
'cache-control': 'no-cache',
|
|
105
|
-
'x-accel-buffering': 'no', // defeat proxy buffering so lines stream live
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
// Run `cli.js install|update [target] [--global]` and stream its output back as a plain-text
|
|
109
|
-
// chunked response (the client reads it via fetch's ReadableStream). Ends with __EXIT__ <code>.
|
|
110
|
-
function runAction(req, res, body, { pkgRoot }) {
|
|
111
|
-
const { action, scope, project } = body;
|
|
112
|
-
if (action !== 'install' && action !== 'update') {
|
|
113
|
-
return sendJson(res, 400, { error: "action must be 'install' or 'update'" });
|
|
114
|
-
}
|
|
115
|
-
// A project-scoped install writes <target>/.claude, and cli.js mkdir -p's the
|
|
116
|
-
// target — so an unchecked path silently creates a pipeline tree in a directory
|
|
117
|
-
// that does not exist (a typo in the fleet registry lands a phantom project on
|
|
118
|
-
// disk). The other two runners already validate; this one never did.
|
|
119
|
-
if (scope !== 'global' && !project) {
|
|
120
|
-
// Without a target, cli.js would run against its own cwd — the cohorte package
|
|
121
|
-
// checkout itself, which is never the project the caller meant.
|
|
122
|
-
return sendJson(res, 400, { error: 'a project path is required for a project-scope action' });
|
|
123
|
-
}
|
|
124
|
-
if (scope !== 'global' && !fs.existsSync(path.resolve(project))) {
|
|
125
|
-
return sendJson(res, 400, { error: `project path not found: ${project}` });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const args = [path.join(pkgRoot, 'bin', 'cli.js'), action];
|
|
129
|
-
if (scope === 'global') args.push('--global');
|
|
130
|
-
else if (project) args.push(path.resolve(project));
|
|
131
|
-
|
|
132
|
-
res.writeHead(200, STREAM_HEADERS);
|
|
133
|
-
res.write(`$ node cli.js ${args.slice(1).join(' ')}\n\n`);
|
|
134
|
-
|
|
135
|
-
const child = spawn(process.execPath, args, { cwd: pkgRoot, env: process.env });
|
|
136
|
-
child.stdout.on('data', d => res.write(d));
|
|
137
|
-
child.stderr.on('data', d => res.write(d));
|
|
138
|
-
child.on('close', code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); });
|
|
139
|
-
child.on('error', err => { res.write(`\nspawn error: ${err.message}\n__EXIT__ 1\n`); res.end(); });
|
|
140
|
-
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Run a pipeline slash-command through Claude Code headless (`claude -p`) in the project dir,
|
|
144
|
-
// streaming its output. The command is whitelisted (no arbitrary injection into claude -p) and
|
|
145
|
-
// runs autonomously (--dangerously-skip-permissions), so it never hangs waiting on a prompt.
|
|
146
|
-
// Headless caveat the UI warns about: the run starts without any confirmation prompt and there
|
|
147
|
-
// is no resume — if the claude process dies mid-run, the run is simply gone.
|
|
148
|
-
function runClaude(req, res, body) {
|
|
149
|
-
const project = body.project ? path.resolve(body.project) : null;
|
|
150
|
-
const command = String(body.command || '');
|
|
151
|
-
if (!/^\/cohorte-(init-pipeline|update-pipeline|audit)$/.test(command)) {
|
|
152
|
-
return sendJson(res, 400, { error: 'unsupported command (only /cohorte-init-pipeline, /cohorte-update-pipeline or /cohorte-audit)' });
|
|
153
|
-
}
|
|
154
|
-
if (!project || !fs.existsSync(project)) {
|
|
155
|
-
return sendJson(res, 400, { error: 'project path not found' });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
res.writeHead(200, STREAM_HEADERS);
|
|
159
|
-
res.write(`$ claude -p "${command}" (cwd: ${project})\n\n`);
|
|
160
|
-
|
|
161
|
-
const args = ['-p', command, '--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--verbose'];
|
|
162
|
-
// shell on Windows: `claude` is a .cmd shim, which Node refuses to spawn
|
|
163
|
-
// shell-less. Build ONE static string (no args array — that combination is
|
|
164
|
-
// DEP0190-deprecated): `command` is regex-whitelisted above and nothing else
|
|
165
|
-
// is request-supplied, so the shell adds no injection surface.
|
|
166
|
-
const child = process.platform === 'win32'
|
|
167
|
-
? spawn(`claude ${args.map(a => (a.startsWith('/') ? `"${a}"` : a)).join(' ')}`, { cwd: project, env: process.env, shell: true })
|
|
168
|
-
: spawn('claude', args, { cwd: project, env: process.env });
|
|
169
|
-
child.stdout.on('data', d => res.write(d));
|
|
170
|
-
child.stderr.on('data', d => res.write(d));
|
|
171
|
-
child.on('close', code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); });
|
|
172
|
-
child.on('error', err => {
|
|
173
|
-
res.write(`\nspawn error: ${err.message}\n(is the \`claude\` CLI on PATH and authenticated?)\n__EXIT__ 1\n`);
|
|
174
|
-
res.end();
|
|
175
|
-
});
|
|
176
|
-
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Full project reset: back up the project's pipeline footprint (every runtime's dir, PIPELINE.md, and
|
|
180
|
-
// optionally specs/) to .claude.bak-<ts>, remove it, then reinstall a fresh BUNDLED core (or,
|
|
181
|
-
// for global-mode projects, leave the shared ~/.claude core untouched). Never touches ~/.claude.
|
|
182
|
-
// Streams progress; ends with __EXIT__ <code>. The profile is regenerated by /cohorte-init-pipeline after.
|
|
183
|
-
function runReset(req, res, body, { pkgRoot, globalDir }) {
|
|
184
|
-
const project = body.project ? path.resolve(body.project) : null;
|
|
185
|
-
const purgeSpecs = !!body.purgeSpecs;
|
|
186
|
-
if (!project || !fs.existsSync(project)) {
|
|
187
|
-
return sendJson(res, 400, { error: 'project path not found' });
|
|
188
|
-
}
|
|
189
|
-
// The whole promise of this endpoint — echoed in the modal's copy — is that the
|
|
190
|
-
// shared global core is never touched. Nothing enforced it: reset moves
|
|
191
|
-
// <project>/.claude, so a project of `~` (or wherever CLAUDE_CONFIG_DIR's parent
|
|
192
|
-
// is) would move the global core itself into a backup dir, silently breaking
|
|
193
|
-
// every repo on the machine. Refuse that path outright.
|
|
194
|
-
const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
195
|
-
if (same(path.join(project, '.claude'), globalDir)) {
|
|
196
|
-
return sendJson(res, 400, {
|
|
197
|
-
error: `refusing to reset ${project}: its .claude IS the shared global core (${globalDir}). ` +
|
|
198
|
-
'Reset only ever touches a project\'s own pipeline footprint.',
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
res.writeHead(200, STREAM_HEADERS);
|
|
203
|
-
const log = s => res.write(s + '\n');
|
|
204
|
-
const done = code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); };
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
const claudeDir = path.join(project, '.claude');
|
|
208
|
-
const pipelineMd = path.join(project, 'PIPELINE.md');
|
|
209
|
-
const specsDir = path.join(project, 'specs');
|
|
210
|
-
|
|
211
|
-
// Detect the prior install mode before we move anything.
|
|
212
|
-
let priorMode = 'unknown';
|
|
213
|
-
const ptr = path.join(claudeDir, 'pipeline.json');
|
|
214
|
-
if (fs.existsSync(ptr)) { try { priorMode = JSON.parse(fs.readFileSync(ptr, 'utf8')).mode || 'unknown'; } catch { /* keep unknown */ } }
|
|
215
|
-
const hadBundledCore = fs.existsSync(path.join(claudeDir, 'pipeline', 'VERSION'));
|
|
216
|
-
const bundled = priorMode === 'bundled' || (priorMode === 'unknown' && hadBundledCore);
|
|
217
|
-
|
|
218
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
219
|
-
const backup = path.join(project, `.claude.bak-${ts}`);
|
|
220
|
-
fs.mkdirSync(backup, { recursive: true });
|
|
221
|
-
log(`Backing up the project's pipeline footprint → ${path.basename(backup)}/`);
|
|
222
|
-
|
|
223
|
-
let moved = 0;
|
|
224
|
-
if (fs.existsSync(claudeDir)) { fs.renameSync(claudeDir, path.join(backup, '.claude')); log(' · moved .claude/'); moved++; }
|
|
225
|
-
// A repo installed for another coding agent keeps its footprint elsewhere. Leaving these
|
|
226
|
-
// behind would make a "full reset" silently partial: the old rendered commands survive and
|
|
227
|
-
// the fresh install lands next to them.
|
|
228
|
-
for (const d of ['.cohorte', '.agents', '.cursor', '.gemini', '.opencode', '.codex']) {
|
|
229
|
-
const from = path.join(project, d);
|
|
230
|
-
if (!fs.existsSync(from)) continue;
|
|
231
|
-
fs.renameSync(from, path.join(backup, d));
|
|
232
|
-
log(` · moved ${d}/`);
|
|
233
|
-
moved++;
|
|
234
|
-
}
|
|
235
|
-
if (fs.existsSync(pipelineMd)) { fs.renameSync(pipelineMd, path.join(backup, 'PIPELINE.md')); log(' · moved PIPELINE.md'); moved++; }
|
|
236
|
-
if (purgeSpecs && fs.existsSync(specsDir)) { fs.renameSync(specsDir, path.join(backup, 'specs')); log(' · moved specs/'); moved++; }
|
|
237
|
-
if (!moved) log(' · nothing to move (no .claude/ or PIPELINE.md found)');
|
|
238
|
-
|
|
239
|
-
log(`\nPrior mode: ${bundled ? 'bundled' : 'global'} — the shared ~/.claude core is never touched.`);
|
|
240
|
-
|
|
241
|
-
if (bundled) {
|
|
242
|
-
log('\nReinstalling a fresh bundled core…\n');
|
|
243
|
-
const args = [path.join(pkgRoot, 'bin', 'cli.js'), 'install', project];
|
|
244
|
-
const child = spawn(process.execPath, args, { cwd: pkgRoot, env: process.env });
|
|
245
|
-
child.stdout.on('data', d => res.write(d));
|
|
246
|
-
child.stderr.on('data', d => res.write(d));
|
|
247
|
-
child.on('close', code => {
|
|
248
|
-
log('\n✔ Reset complete. Now run /cohorte-init-pipeline in Claude Code to regenerate PIPELINE.md + the surface agents.');
|
|
249
|
-
done(code);
|
|
250
|
-
});
|
|
251
|
-
child.on('error', err => { log(`\nspawn error: ${err.message}`); done(1); });
|
|
252
|
-
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
253
|
-
} else {
|
|
254
|
-
log('\n✔ Reset complete. The shared global core stays installed in ~/.claude.');
|
|
255
|
-
log('Now run /cohorte-init-pipeline in Claude Code to regenerate this project\'s PIPELINE.md + agents.');
|
|
256
|
-
done(0);
|
|
257
|
-
}
|
|
258
|
-
} catch (e) {
|
|
259
|
-
log(`\nreset error: ${(e && e.message) || e}`);
|
|
260
|
-
done(1);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
265
|
-
|
|
266
|
-
// Browser-facing guard for the API. Loopback binding is NOT a security boundary
|
|
267
|
-
// against a browser: any web page the user visits can fire form/fetch requests at
|
|
268
|
-
// 127.0.0.1 (CSRF), and DNS rebinding can even make the responses readable. Two
|
|
269
|
-
// checks close both without needing a token round-trip:
|
|
270
|
-
// - Host must be a loopback origin (kills rebinding — an attacker-controlled
|
|
271
|
-
// domain resolving to 127.0.0.1 still sends its own Host header). Skipped
|
|
272
|
-
// when the user explicitly bound a non-loopback host (they were warned).
|
|
273
|
-
// - State-changing methods must carry content-type: application/json. A
|
|
274
|
-
// cross-origin fetch with that header triggers a CORS preflight, which this
|
|
275
|
-
// server never answers — so a browser can't deliver it cross-origin; forms
|
|
276
|
-
// can only send urlencoded/multipart/text.
|
|
277
|
-
function guardBrowser(req, res, bindHost) {
|
|
278
|
-
if (LOOPBACK.has(bindHost)) {
|
|
279
|
-
const host = String(req.headers.host || '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
280
|
-
if (!LOOPBACK.has(host)) {
|
|
281
|
-
sendJson(res, 403, { error: `forbidden host header: ${req.headers.host || '(none)'}` });
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
286
|
-
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
287
|
-
if (ct !== 'application/json') {
|
|
288
|
-
sendJson(res, 403, { error: 'state-changing requests require content-type: application/json' });
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Best-effort browser open (opt-in via --open). Never throws; failure is silent.
|
|
296
|
-
function openBrowserAt(url) {
|
|
297
|
-
const cmd = process.platform === 'darwin' ? 'open'
|
|
298
|
-
: process.platform === 'win32' ? 'cmd'
|
|
299
|
-
: 'xdg-open';
|
|
300
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
301
|
-
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); }
|
|
302
|
-
catch { /* no opener available */ }
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, version }) {
|
|
306
|
-
const distDir = path.join(pkgRoot, 'dashboard', 'dist');
|
|
307
|
-
const bindHost = host || '127.0.0.1';
|
|
308
|
-
try { fleet.ensureSeed(globalDir, projectRoot); } catch { /* registry is best-effort */ }
|
|
309
|
-
|
|
310
|
-
const server = http.createServer(async (req, res) => {
|
|
311
|
-
const url = req.url.split('?')[0];
|
|
312
|
-
try {
|
|
313
|
-
if (url.startsWith('/api/') && !guardBrowser(req, res, bindHost)) return;
|
|
314
|
-
if (url === '/api/versions') {
|
|
315
|
-
return sendJson(res, 200, await versions({ projectRoot, globalDir, cliVersion: version }));
|
|
316
|
-
}
|
|
317
|
-
if (url === '/api/state') {
|
|
318
|
-
// ?project=<abs path> overrides the launch cwd (fleet-ready); default = launch project.
|
|
319
|
-
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
320
|
-
const root = q ? path.resolve(q) : projectRoot;
|
|
321
|
-
return sendJson(res, 200, await state({ projectRoot: root, globalDir, cliVersion: version }));
|
|
322
|
-
}
|
|
323
|
-
if (url === '/api/fleet') {
|
|
324
|
-
return sendJson(res, 200, { projects: await fleet.list(globalDir, version) });
|
|
325
|
-
}
|
|
326
|
-
if (url === '/api/browse') {
|
|
327
|
-
const dir = new URL(req.url, 'http://localhost').searchParams.get('dir');
|
|
328
|
-
return sendJson(res, 200, fleet.browse(dir));
|
|
329
|
-
}
|
|
330
|
-
if (url === '/api/kanban') {
|
|
331
|
-
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
332
|
-
const root = q ? path.resolve(q) : projectRoot;
|
|
333
|
-
return sendJson(res, 200, kanban({ projectRoot: root, globalDir }));
|
|
334
|
-
}
|
|
335
|
-
if (url === '/api/metrics') {
|
|
336
|
-
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
337
|
-
const root = q ? path.resolve(q) : projectRoot;
|
|
338
|
-
return sendJson(res, 200, metrics({ projectRoot: root, globalDir }));
|
|
339
|
-
}
|
|
340
|
-
if (url === '/api/usage') {
|
|
341
|
-
const q = new URL(req.url, 'http://localhost');
|
|
342
|
-
const root = q.searchParams.get('project') ? path.resolve(q.searchParams.get('project')) : projectRoot;
|
|
343
|
-
const days = Number(q.searchParams.get('days')) || null;
|
|
344
|
-
return sendJson(res, 200, usage({ projectRoot: root, days }));
|
|
345
|
-
}
|
|
346
|
-
if (url === '/api/projects') {
|
|
347
|
-
const body = await readBody(req);
|
|
348
|
-
if (req.method === 'POST') {
|
|
349
|
-
if (!body.path) return sendJson(res, 400, { error: 'path is required' });
|
|
350
|
-
let abs;
|
|
351
|
-
try { abs = fleet.add(globalDir, body.path); }
|
|
352
|
-
catch (e) { return sendJson(res, 400, { error: String((e && e.message) || e) }); }
|
|
353
|
-
return sendJson(res, 200, { added: abs, projects: await fleet.list(globalDir, version) });
|
|
354
|
-
}
|
|
355
|
-
if (req.method === 'DELETE') {
|
|
356
|
-
if (!body.path) return sendJson(res, 400, { error: 'path is required' });
|
|
357
|
-
fleet.remove(globalDir, body.path);
|
|
358
|
-
return sendJson(res, 200, { removed: body.path, projects: await fleet.list(globalDir, version) });
|
|
359
|
-
}
|
|
360
|
-
return sendJson(res, 405, { error: 'use POST to add, DELETE to remove' });
|
|
361
|
-
}
|
|
362
|
-
if (url === '/api/action' && req.method === 'POST') {
|
|
363
|
-
let body;
|
|
364
|
-
try { body = await readBody(req); } catch (e) { return sendJson(res, 400, { error: e.message }); }
|
|
365
|
-
if (body.action === 'reset') return runReset(req, res, body, { pkgRoot, globalDir });
|
|
366
|
-
if (body.action === 'claude') return runClaude(req, res, body);
|
|
367
|
-
return runAction(req, res, body, { pkgRoot });
|
|
368
|
-
}
|
|
369
|
-
if (url.startsWith('/api/')) {
|
|
370
|
-
return sendJson(res, 404, { error: `unknown endpoint: ${url}` });
|
|
371
|
-
}
|
|
372
|
-
return serveStatic(req, res, distDir);
|
|
373
|
-
} catch (err) {
|
|
374
|
-
return sendJson(res, 500, { error: String(err && err.message || err) });
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
server.listen(port, bindHost, () => {
|
|
379
|
-
const shownHost = LOOPBACK.has(bindHost) ? 'localhost' : bindHost;
|
|
380
|
-
const url = `http://${shownHost}:${port}`;
|
|
381
|
-
console.log(`\n cohorte dashboard v${version}`);
|
|
382
|
-
console.log(` ┌${'─'.repeat(url.length + 10)}┐`);
|
|
383
|
-
console.log(` │ open ${url} │`);
|
|
384
|
-
console.log(` └${'─'.repeat(url.length + 10)}┘`);
|
|
385
|
-
console.log(` project : ${projectRoot}`);
|
|
386
|
-
console.log(` bind : ${bindHost}:${port}`);
|
|
387
|
-
if (!LOOPBACK.has(bindHost)) {
|
|
388
|
-
console.log(' ⚠ SECURITY: bound to a non-loopback address — the dashboard\'s actions execute');
|
|
389
|
-
console.log(' code (install/update/reset/claude). Anyone who can reach this host+port can');
|
|
390
|
-
console.log(' run them. Only do this on a trusted network.');
|
|
391
|
-
}
|
|
392
|
-
if (!fs.existsSync(path.join(distDir, 'index.html'))) {
|
|
393
|
-
console.log(' note : React app not built yet — run npm --prefix dashboard/app run build');
|
|
394
|
-
}
|
|
395
|
-
console.log(' (Ctrl-C to stop)\n');
|
|
396
|
-
if (openBrowser) openBrowserAt(url);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
server.on('error', (err) => {
|
|
400
|
-
if (err.code === 'EADDRINUSE') {
|
|
401
|
-
console.error(`error: port ${port} is in use — pass another with --port=<N>`);
|
|
402
|
-
process.exit(1);
|
|
403
|
-
}
|
|
404
|
-
throw err;
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
module.exports = start;
|