cohorte 1.0.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/CHANGELOG.md +264 -0
- package/LICENSE +661 -0
- package/README.md +269 -0
- package/bin/cli.js +339 -0
- package/core/agents/implementer.template.md +74 -0
- package/core/agents/release.md +51 -0
- package/core/agents/review.md +85 -0
- package/core/commands/align-ds.md +32 -0
- package/core/commands/audit.md +31 -0
- package/core/commands/brainstorm.md +48 -0
- package/core/commands/build.md +91 -0
- package/core/commands/doctor.md +50 -0
- package/core/commands/fix.md +62 -0
- package/core/commands/init-pipeline.md +32 -0
- package/core/commands/refactor.md +38 -0
- package/core/commands/review.md +68 -0
- package/core/commands/ship.md +68 -0
- package/core/commands/smoke.md +55 -0
- package/core/commands/spec.md +67 -0
- package/core/commands/update-pipeline.md +96 -0
- package/core/hooks/__pycache__/gate.cpython-312.pyc +0 -0
- package/core/hooks/gate.py +129 -0
- package/core/templates/agent-handoff.md +34 -0
- package/core/templates/brainstorm-return.md +36 -0
- package/core/templates/design-brief.md +35 -0
- package/core/templates/pr-body.md +29 -0
- package/core/templates/review-feedback.md +36 -0
- package/core/templates/spec.template.md +84 -0
- package/core/templates/steps/init-pipeline/01-detect-stack.md +40 -0
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +41 -0
- package/core/templates/steps/init-pipeline/03-draft-profile.md +10 -0
- package/core/templates/steps/init-pipeline/04-write-render.md +88 -0
- package/core/templates/steps/init-pipeline/05-report.md +12 -0
- package/dashboard/README.md +54 -0
- package/dashboard/dist/apple-touch-icon-180.png +0 -0
- package/dashboard/dist/assets/index-CoBuEdy-.js +42 -0
- package/dashboard/dist/assets/index-DN5OGW9g.css +1 -0
- 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 +16 -0
- package/dashboard/server/doctor.js +266 -0
- package/dashboard/server/fleet.js +119 -0
- package/dashboard/server/index.js +306 -0
- package/dashboard/server/kanban.js +158 -0
- package/dashboard/server/versions.js +111 -0
- package/dashboard/server/yaml.js +126 -0
- package/install.ps1 +359 -0
- package/install.sh +301 -0
- package/package.json +40 -0
- package/profile/PIPELINE.template.md +208 -0
- package/profile/SCHEMA.md +303 -0
- package/profile/cohorte.config.template.yaml +43 -0
- package/scripts/new-feature.sh.template +89 -0
- package/scripts/remove-feature.sh.template +53 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Programmatic port of the /doctor checks (core/commands/doctor.md), for the dashboard.
|
|
3
|
+
// Read-only: inspects files only. Checks that need a live process (MCP connectivity,
|
|
4
|
+
// git worktree state, DesignSync) are reported as `skip` with a note — the node server
|
|
5
|
+
// can't run them, and honest "not checked here" beats a false green.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { parseProfileBlock } = require('./yaml');
|
|
10
|
+
const { versions } = require('./versions');
|
|
11
|
+
|
|
12
|
+
// Rendered surface agents live alongside these fixed (non-surface) agents; exclude them
|
|
13
|
+
// from the orphan check so they're never mistaken for a stray surface agent.
|
|
14
|
+
const FIXED_AGENTS = new Set([
|
|
15
|
+
'review', 'release',
|
|
16
|
+
'implementer.template',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const VALID_STATUS = ['draft', 'frozen', 'in-review', 'shipped'];
|
|
20
|
+
|
|
21
|
+
const exists = p => { try { return fs.existsSync(p); } catch { return false; } };
|
|
22
|
+
const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
|
|
23
|
+
const readJson = p => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
|
|
24
|
+
const isTrue = v => v === true;
|
|
25
|
+
const sameSet = (a, b) => {
|
|
26
|
+
const A = new Set(a || []), B = new Set(b || []);
|
|
27
|
+
if (A.size !== B.size) return false;
|
|
28
|
+
for (const x of A) if (!B.has(x)) return false;
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function mk(id, label, status, detail, fix) {
|
|
33
|
+
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- individual checks -------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function checkCore(v) {
|
|
39
|
+
if (v.installMode === 'none') {
|
|
40
|
+
return mk('core', 'Core & pointer', 'bad', 'no pipeline core installed for this project',
|
|
41
|
+
'npx cohorte install (or --global)');
|
|
42
|
+
}
|
|
43
|
+
if (v.pointer.present && v.pointer.core_version && v.installedVersion &&
|
|
44
|
+
v.pointer.core_version !== v.installedVersion) {
|
|
45
|
+
return mk('core', 'Core & pointer', 'warn',
|
|
46
|
+
`pointer says core ${v.pointer.core_version} but installed core is ${v.installedVersion}`,
|
|
47
|
+
'npx cohorte update (reconcile the pointer)');
|
|
48
|
+
}
|
|
49
|
+
if (v.freshness === -1) {
|
|
50
|
+
return mk('core', 'Core & pointer', 'warn',
|
|
51
|
+
`core ${v.installedVersion} installed (${v.installMode}); npm latest is ${v.latest}`,
|
|
52
|
+
'/update-pipeline (or npx cohorte update)');
|
|
53
|
+
}
|
|
54
|
+
const tail = v.latest ? `, npm latest ${v.latest}` : ', npm unreachable';
|
|
55
|
+
return mk('core', 'Core & pointer', 'ok', `core ${v.installedVersion} (${v.installMode})${tail}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkProfile(profile, hasPipelineMd) {
|
|
59
|
+
if (!hasPipelineMd) {
|
|
60
|
+
return mk('profile', 'Profile (PIPELINE.md)', 'bad', 'PIPELINE.md not found',
|
|
61
|
+
'/init-pipeline (generate the project profile)');
|
|
62
|
+
}
|
|
63
|
+
if (!profile) {
|
|
64
|
+
return mk('profile', 'Profile (PIPELINE.md)', 'bad',
|
|
65
|
+
'PIPELINE.md present but its `yaml pipeline-profile` block is missing or unparseable',
|
|
66
|
+
'/init-pipeline (or fix the fenced yaml block)');
|
|
67
|
+
}
|
|
68
|
+
const n = (profile.surfaces || []).length;
|
|
69
|
+
return mk('profile', 'Profile (PIPELINE.md)', n ? 'ok' : 'warn',
|
|
70
|
+
`${profile.name || 'unnamed'} · ${n} surface${n === 1 ? '' : 's'}`,
|
|
71
|
+
n ? undefined : 'add at least one surface to §surfaces');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function checkAgents(profile, projectRoot) {
|
|
75
|
+
if (!profile || !(profile.surfaces || []).length) {
|
|
76
|
+
return mk('agents', 'Surfaces ↔ agents', 'skip', 'no surfaces to reconcile');
|
|
77
|
+
}
|
|
78
|
+
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
|
79
|
+
const surfaceAgents = profile.surfaces.map(s => s.agent).filter(Boolean);
|
|
80
|
+
|
|
81
|
+
const missing = surfaceAgents.filter(a => !exists(path.join(agentsDir, `${a}.md`)));
|
|
82
|
+
|
|
83
|
+
let files = [];
|
|
84
|
+
try { files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)); }
|
|
85
|
+
catch { /* dir absent → handled by `missing` */ }
|
|
86
|
+
const orphans = files.filter(f => !FIXED_AGENTS.has(f) && !surfaceAgents.includes(f));
|
|
87
|
+
|
|
88
|
+
if (missing.length) {
|
|
89
|
+
return mk('agents', 'Surfaces ↔ agents', 'bad',
|
|
90
|
+
`surface(s) with no rendered agent: ${missing.join(', ')}`,
|
|
91
|
+
'/init-pipeline (re-render surface agents)');
|
|
92
|
+
}
|
|
93
|
+
if (orphans.length) {
|
|
94
|
+
return mk('agents', 'Surfaces ↔ agents', 'warn',
|
|
95
|
+
`agent file(s) with no owning surface: ${orphans.join(', ')}`,
|
|
96
|
+
'remove the stale agent file, or add its surface to PIPELINE.md');
|
|
97
|
+
}
|
|
98
|
+
return mk('agents', 'Surfaces ↔ agents', 'ok',
|
|
99
|
+
`${surfaceAgents.length} surface agent(s) all rendered, no orphans`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkGate(profile, projectRoot) {
|
|
103
|
+
const gate = profile && profile.gate;
|
|
104
|
+
if (!gate || (!gate.deny && !gate.ask && !gate.ask_on_default_branch)) {
|
|
105
|
+
return mk('gate', 'Gate config', 'skip', 'no gate block in the profile');
|
|
106
|
+
}
|
|
107
|
+
const cfg = readJson(path.join(projectRoot, '.claude', 'gate-config.json'));
|
|
108
|
+
if (!cfg) {
|
|
109
|
+
return mk('gate', 'Gate config', 'bad', '.claude/gate-config.json missing or unreadable',
|
|
110
|
+
'/init-pipeline (regenerate gate-config.json from the gate block)');
|
|
111
|
+
}
|
|
112
|
+
const drifted = [];
|
|
113
|
+
if (!sameSet(cfg.deny, gate.deny)) drifted.push('deny');
|
|
114
|
+
if (!sameSet(cfg.ask, gate.ask)) drifted.push('ask');
|
|
115
|
+
if (!sameSet(cfg.ask_on_default_branch, gate.ask_on_default_branch)) drifted.push('ask_on_default_branch');
|
|
116
|
+
if ((cfg.default_branch || 'main') !== (gate.default_branch || 'main')) drifted.push('default_branch');
|
|
117
|
+
if (drifted.length) {
|
|
118
|
+
return mk('gate', 'Gate config', 'warn',
|
|
119
|
+
`gate-config.json drifted from PIPELINE.md gate block (${drifted.join(', ')})`,
|
|
120
|
+
'regenerate .claude/gate-config.json to mirror the gate block');
|
|
121
|
+
}
|
|
122
|
+
const branchGated = (gate.ask_on_default_branch || []).length;
|
|
123
|
+
return mk('gate', 'Gate config', 'ok',
|
|
124
|
+
`mirrors the profile (${(gate.deny || []).length} deny, ${(gate.ask || []).length} ask` +
|
|
125
|
+
(branchGated ? `, ${branchGated} gated on ${gate.default_branch || 'main'}` : '') + ')');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkHooks(projectRoot, globalDir, installMode) {
|
|
129
|
+
const settingsPath = installMode === 'global'
|
|
130
|
+
? path.join(globalDir, 'settings.json')
|
|
131
|
+
: path.join(projectRoot, '.claude', 'settings.json');
|
|
132
|
+
const data = readJson(settingsPath);
|
|
133
|
+
const pre = data && data.hooks && Array.isArray(data.hooks.PreToolUse) ? data.hooks.PreToolUse : [];
|
|
134
|
+
const regs = pre.filter(e => (e.hooks || []).some(
|
|
135
|
+
h => typeof h.command === 'string' && h.command.trim().endsWith('gate.py')));
|
|
136
|
+
|
|
137
|
+
if (regs.length === 0) {
|
|
138
|
+
return mk('hooks', 'Gate hook', 'warn', `gate.py not registered in ${installMode} settings.json`,
|
|
139
|
+
installMode === 'global'
|
|
140
|
+
? 'npx cohorte install --global (re-registers the hook)'
|
|
141
|
+
: '/init-pipeline (register the PreToolUse gate hook)');
|
|
142
|
+
}
|
|
143
|
+
if (regs.length > 1) {
|
|
144
|
+
return mk('hooks', 'Gate hook', 'warn', `gate.py registered ${regs.length}× — it will double-prompt`,
|
|
145
|
+
'remove the duplicate PreToolUse entry in settings.json');
|
|
146
|
+
}
|
|
147
|
+
return mk('hooks', 'Gate hook', 'ok', `registered once (${installMode} settings.json)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function checkRetrieval(profile) {
|
|
151
|
+
const provider = profile && profile.retrieval && profile.retrieval.provider;
|
|
152
|
+
if (!provider || provider === 'none' || String(provider).startsWith('<')) {
|
|
153
|
+
return mk('retrieval', 'Code retrieval', 'skip', 'provider: none');
|
|
154
|
+
}
|
|
155
|
+
// Connectivity (server actually connects) needs a live session — note it, don't fake green.
|
|
156
|
+
return mk('retrieval', 'Code retrieval', 'ok',
|
|
157
|
+
`provider: ${provider} — connectivity not checked here (run /doctor in-session)`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function checkDesign(profile, projectRoot) {
|
|
161
|
+
const d = profile && profile.design;
|
|
162
|
+
if (!d || !isTrue(d.enabled)) return mk('design', 'Design system', 'skip', 'design.enabled: false');
|
|
163
|
+
const targets = [['snapshot_dir', d.snapshot_dir], ['ui_kit_path', d.ui_kit_path], ['tokens_path', d.tokens_path]];
|
|
164
|
+
const missing = targets.filter(([, p]) => p && !exists(path.join(projectRoot, p))).map(([k]) => k);
|
|
165
|
+
if (missing.length) {
|
|
166
|
+
return mk('design', 'Design system', 'warn', `missing path(s): ${missing.join(', ')}`,
|
|
167
|
+
'create the missing design paths or fix them in PIPELINE.md §design');
|
|
168
|
+
}
|
|
169
|
+
return mk('design', 'Design system', 'ok', `provider: ${d.provider} — DS paths present`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkIsolation(profile, projectRoot) {
|
|
173
|
+
const iso = profile && profile.isolation;
|
|
174
|
+
if (!iso || !isTrue(iso.enabled)) return mk('isolation', 'Isolation', 'skip', 'isolation.enabled: false');
|
|
175
|
+
const scripts = ['scripts/new-feature.sh', 'scripts/remove-feature.sh'];
|
|
176
|
+
const problems = [];
|
|
177
|
+
for (const rel of scripts) {
|
|
178
|
+
const txt = readText(path.join(projectRoot, rel));
|
|
179
|
+
if (txt == null) problems.push(`${rel} missing`);
|
|
180
|
+
else if (/__[A-Z_]+__/.test(txt)) problems.push(`${rel} has unrendered __TOKEN__`);
|
|
181
|
+
}
|
|
182
|
+
if (problems.length) {
|
|
183
|
+
return mk('isolation', 'Isolation', 'warn', problems.join('; '),
|
|
184
|
+
'/init-pipeline (re-render the isolation scripts)');
|
|
185
|
+
}
|
|
186
|
+
return mk('isolation', 'Isolation', 'ok', 'feature scripts rendered (worktree state not checked here)');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scanSpecs(projectRoot) {
|
|
190
|
+
const dir = path.join(projectRoot, 'specs');
|
|
191
|
+
const specs = [];
|
|
192
|
+
let files = [];
|
|
193
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_')); }
|
|
194
|
+
catch { return specs; }
|
|
195
|
+
for (const f of files) {
|
|
196
|
+
const txt = readText(path.join(dir, f)) || '';
|
|
197
|
+
const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
198
|
+
const body = fm ? fm[1] : '';
|
|
199
|
+
const get = k => { const m = body.match(new RegExp(`^${k}:\\s*(.*)$`, 'm')); return m ? m[1].trim() : null; };
|
|
200
|
+
specs.push({
|
|
201
|
+
file: f,
|
|
202
|
+
id: get('feature_id') || f.replace(/\.md$/, ''),
|
|
203
|
+
title: get('title'),
|
|
204
|
+
status: get('status') ? get('status').split('#')[0].trim() : null,
|
|
205
|
+
branch: get('branch'),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return specs;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkSpecs(specs) {
|
|
212
|
+
if (!specs.length) return mk('specs', 'Specs', 'skip', 'no specs yet');
|
|
213
|
+
const bad = specs.filter(s => !VALID_STATUS.includes(s.status));
|
|
214
|
+
const counts = {};
|
|
215
|
+
for (const s of specs) counts[s.status || '?'] = (counts[s.status || '?'] || 0) + 1;
|
|
216
|
+
const summary = VALID_STATUS.filter(st => counts[st]).map(st => `${counts[st]} ${st}`).join(', ');
|
|
217
|
+
if (bad.length) {
|
|
218
|
+
return mk('specs', 'Specs', 'warn',
|
|
219
|
+
`invalid status in: ${bad.map(s => s.file).join(', ')}`,
|
|
220
|
+
`set status to one of: ${VALID_STATUS.join(' · ')}`);
|
|
221
|
+
}
|
|
222
|
+
return mk('specs', 'Specs', 'ok', `${specs.length} spec(s) — ${summary}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- orchestrator ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function state({ projectRoot, globalDir, cliVersion }) {
|
|
228
|
+
const v = await versions({ projectRoot, globalDir, cliVersion });
|
|
229
|
+
|
|
230
|
+
const pipelineMd = readText(path.join(projectRoot, 'PIPELINE.md'));
|
|
231
|
+
const profile = pipelineMd ? parseProfileBlock(pipelineMd) : null;
|
|
232
|
+
const specs = scanSpecs(projectRoot);
|
|
233
|
+
|
|
234
|
+
const checks = [
|
|
235
|
+
checkCore(v),
|
|
236
|
+
checkProfile(profile, pipelineMd != null),
|
|
237
|
+
checkAgents(profile, projectRoot),
|
|
238
|
+
checkGate(profile, projectRoot),
|
|
239
|
+
checkHooks(projectRoot, globalDir, v.installMode),
|
|
240
|
+
checkRetrieval(profile),
|
|
241
|
+
checkDesign(profile, projectRoot),
|
|
242
|
+
checkIsolation(profile, projectRoot),
|
|
243
|
+
checkSpecs(specs),
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const summary = { ok: 0, warn: 0, bad: 0, skip: 0 };
|
|
247
|
+
for (const c of checks) summary[c.status]++;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
project: projectRoot,
|
|
251
|
+
versions: v,
|
|
252
|
+
profile: profile ? {
|
|
253
|
+
name: profile.name,
|
|
254
|
+
one_liner: profile.one_liner,
|
|
255
|
+
surfaces: (profile.surfaces || []).map(s => ({
|
|
256
|
+
key: s.key, label: s.label, agent: s.agent, path: s.path,
|
|
257
|
+
model: s.model, uses_design: s.uses_design, tools: s.tools,
|
|
258
|
+
})),
|
|
259
|
+
} : null,
|
|
260
|
+
specs,
|
|
261
|
+
checks,
|
|
262
|
+
summary,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { state, scanSpecs };
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
const projects = read(globalDir);
|
|
46
|
+
if (projectRoot && !projects.includes(projectRoot)) {
|
|
47
|
+
projects.push(projectRoot);
|
|
48
|
+
write(globalDir, projects);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function add(globalDir, dir) {
|
|
53
|
+
const abs = normalizeDir(dir);
|
|
54
|
+
if (!fs.existsSync(abs)) throw new Error(`path not found: ${abs}`);
|
|
55
|
+
if (!fs.statSync(abs).isDirectory()) throw new Error(`not a directory: ${abs}`);
|
|
56
|
+
const projects = read(globalDir);
|
|
57
|
+
if (!projects.includes(abs)) { projects.push(abs); write(globalDir, projects); }
|
|
58
|
+
return abs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function remove(globalDir, dir) {
|
|
62
|
+
const abs = path.resolve(dir);
|
|
63
|
+
const projects = read(globalDir).filter(p => p !== abs);
|
|
64
|
+
write(globalDir, projects);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Compact per-project summary for the overview cards.
|
|
68
|
+
async function summarize(projectRoot, globalDir, cliVersion) {
|
|
69
|
+
if (!fs.existsSync(projectRoot)) {
|
|
70
|
+
return { path: projectRoot, exists: false, error: 'path no longer exists' };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const s = await state({ projectRoot, globalDir, cliVersion });
|
|
74
|
+
return {
|
|
75
|
+
path: projectRoot,
|
|
76
|
+
exists: true,
|
|
77
|
+
name: (s.profile && s.profile.name) || path.basename(projectRoot),
|
|
78
|
+
hasProfile: !!s.profile,
|
|
79
|
+
surfaces: s.profile ? s.profile.surfaces.length : 0,
|
|
80
|
+
specs: s.specs.length,
|
|
81
|
+
versions: {
|
|
82
|
+
installMode: s.versions.installMode,
|
|
83
|
+
installedVersion: s.versions.installedVersion,
|
|
84
|
+
latest: s.versions.latest,
|
|
85
|
+
freshness: s.versions.freshness,
|
|
86
|
+
},
|
|
87
|
+
summary: s.summary,
|
|
88
|
+
};
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { path: projectRoot, exists: true, error: String((e && e.message) || e) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function list(globalDir, cliVersion) {
|
|
95
|
+
const projects = read(globalDir);
|
|
96
|
+
return Promise.all(projects.map(p => summarize(p, globalDir, cliVersion)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Server-side directory browser for the folder picker. Lists immediate sub-directories of
|
|
100
|
+
// `dir` (default: home), flagging those that look like a pipeline project (have PIPELINE.md).
|
|
101
|
+
// Localhost-only tool, so exposing the filesystem to the picker is acceptable.
|
|
102
|
+
function browse(dir) {
|
|
103
|
+
let base = String(dir || '').trim() || os.homedir();
|
|
104
|
+
if (base === '~' || base.startsWith('~/')) base = path.join(os.homedir(), base.slice(1));
|
|
105
|
+
base = path.resolve(base);
|
|
106
|
+
const parent = path.dirname(base);
|
|
107
|
+
const looksLikeProject = d => fs.existsSync(path.join(d, 'PIPELINE.md'));
|
|
108
|
+
try {
|
|
109
|
+
const dirs = fs.readdirSync(base, { withFileTypes: true })
|
|
110
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
111
|
+
.map(e => ({ name: e.name, path: path.join(base, e.name), isProject: looksLikeProject(path.join(base, e.name)) }))
|
|
112
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
113
|
+
return { dir: base, parent: parent === base ? null : parent, isProject: looksLikeProject(base), dirs };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { dir: base, parent: parent === base ? null : parent, isProject: false, dirs: [], error: String((e && e.message) || e) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { registryPath, read, ensureSeed, add, remove, list, browse };
|