@grainulation/orchard 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/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/orchard.js +209 -0
- package/lib/assignments.js +98 -0
- package/lib/conflicts.js +150 -0
- package/lib/dashboard.js +291 -0
- package/lib/doctor.js +137 -0
- package/lib/export.js +98 -0
- package/lib/farmer.js +107 -0
- package/lib/planner.js +198 -0
- package/lib/server.js +707 -0
- package/lib/sync.js +100 -0
- package/lib/tracker.js +124 -0
- package/package.json +50 -0
- package/public/index.html +922 -0
- package/templates/dashboard.html +981 -0
- package/templates/orchard-dashboard.html +171 -0
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scan for claims.json files in target directory.
|
|
8
|
+
* Two levels deep to handle structures like root/sprints/<name>/claims.json.
|
|
9
|
+
*/
|
|
10
|
+
function findSprintFiles(targetDir) {
|
|
11
|
+
const found = [];
|
|
12
|
+
|
|
13
|
+
// Direct claims.json in target dir
|
|
14
|
+
const direct = path.join(targetDir, 'claims.json');
|
|
15
|
+
if (fs.existsSync(direct)) {
|
|
16
|
+
found.push({ file: direct, dir: targetDir, name: path.basename(targetDir), cat: 'root' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Archive subdir (flat JSON files)
|
|
20
|
+
const archiveDir = path.join(targetDir, 'archive');
|
|
21
|
+
if (fs.existsSync(archiveDir) && fs.statSync(archiveDir).isDirectory()) {
|
|
22
|
+
for (const f of fs.readdirSync(archiveDir)) {
|
|
23
|
+
if (f.endsWith('.json') && f.includes('claims')) {
|
|
24
|
+
found.push({ file: path.join(archiveDir, f), dir: archiveDir, name: f.replace('.json', '').replace(/-/g, ' '), cat: 'archive' });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Scan subdirectories (two levels: sprints/<name>/claims.json, etc.)
|
|
30
|
+
try {
|
|
31
|
+
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
if (entry.name.startsWith('.') || entry.name === 'archive' || entry.name === 'node_modules') continue;
|
|
35
|
+
const childDir = path.join(targetDir, entry.name);
|
|
36
|
+
const childClaims = path.join(childDir, 'claims.json');
|
|
37
|
+
if (fs.existsSync(childClaims)) {
|
|
38
|
+
found.push({ file: childClaims, dir: childDir, name: entry.name, cat: 'active' });
|
|
39
|
+
}
|
|
40
|
+
// Second level
|
|
41
|
+
try {
|
|
42
|
+
const subEntries = fs.readdirSync(childDir, { withFileTypes: true });
|
|
43
|
+
for (const sub of subEntries) {
|
|
44
|
+
if (!sub.isDirectory()) continue;
|
|
45
|
+
if (sub.name.startsWith('.')) continue;
|
|
46
|
+
const subDir = path.join(childDir, sub.name);
|
|
47
|
+
const subClaims = path.join(subDir, 'claims.json');
|
|
48
|
+
if (fs.existsSync(subClaims)) {
|
|
49
|
+
found.push({ file: subClaims, dir: subDir, name: sub.name, cat: 'active' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
} catch { /* skip */ }
|
|
55
|
+
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a claims.json file into sprint metadata and claims array.
|
|
61
|
+
*/
|
|
62
|
+
function parseClaims(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
65
|
+
const meta = raw.meta || {};
|
|
66
|
+
const claims = Array.isArray(raw) ? raw : (raw.claims || []);
|
|
67
|
+
return { meta, claims };
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load all sprint data from a target directory.
|
|
75
|
+
* Returns the graph data structure: { sprints, edges, cycles, status }
|
|
76
|
+
* suitable for template injection.
|
|
77
|
+
*/
|
|
78
|
+
function loadSprints(targetDir) {
|
|
79
|
+
const sources = findSprintFiles(targetDir);
|
|
80
|
+
const sprints = [];
|
|
81
|
+
|
|
82
|
+
for (const src of sources) {
|
|
83
|
+
const parsed = parseClaims(src.file);
|
|
84
|
+
if (!parsed) continue;
|
|
85
|
+
const { meta, claims } = parsed;
|
|
86
|
+
if (claims.length === 0) continue;
|
|
87
|
+
|
|
88
|
+
const topicSet = new Set();
|
|
89
|
+
for (const c of claims) {
|
|
90
|
+
if (c.topic) topicSet.add(c.topic);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
sprints.push({
|
|
94
|
+
path: src.dir,
|
|
95
|
+
question: meta.question || '(no question)',
|
|
96
|
+
phase: meta.phase || 'unknown',
|
|
97
|
+
claimCount: claims.length,
|
|
98
|
+
topics: [...topicSet],
|
|
99
|
+
initiated: meta.initiated || null,
|
|
100
|
+
_claims: claims,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build edges from cross-references
|
|
105
|
+
const edges = [];
|
|
106
|
+
for (const sprint of sprints) {
|
|
107
|
+
for (const claim of sprint._claims) {
|
|
108
|
+
// Check resolved_by for cross-sprint references
|
|
109
|
+
if (claim.resolved_by && typeof claim.resolved_by === 'string') {
|
|
110
|
+
for (const other of sprints) {
|
|
111
|
+
if (other.path === sprint.path) continue;
|
|
112
|
+
const dirName = path.basename(other.path);
|
|
113
|
+
if (claim.resolved_by.includes(dirName)) {
|
|
114
|
+
addEdge(edges, sprint.path, other.path, 'resolved_by');
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check conflicts_with
|
|
121
|
+
if (Array.isArray(claim.conflicts_with)) {
|
|
122
|
+
for (const ref of claim.conflicts_with) {
|
|
123
|
+
if (typeof ref !== 'string') continue;
|
|
124
|
+
for (const other of sprints) {
|
|
125
|
+
if (other.path === sprint.path) continue;
|
|
126
|
+
const dirName = path.basename(other.path);
|
|
127
|
+
if (ref.includes(dirName)) {
|
|
128
|
+
addEdge(edges, sprint.path, other.path, 'conflict');
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check content for sprint name references
|
|
136
|
+
const content = claim.content || claim.text || '';
|
|
137
|
+
if (typeof content === 'string') {
|
|
138
|
+
for (const other of sprints) {
|
|
139
|
+
if (other.path === sprint.path) continue;
|
|
140
|
+
const sprintName = path.basename(other.path);
|
|
141
|
+
if (sprintName.length > 3 && content.includes(sprintName)) {
|
|
142
|
+
addEdge(edges, sprint.path, other.path, 'reference');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Detect cycles via DFS
|
|
150
|
+
const cycles = detectCycles(sprints, edges);
|
|
151
|
+
|
|
152
|
+
// Build status array
|
|
153
|
+
const status = sprints.map(s => {
|
|
154
|
+
let active = 0, resolved = 0, superseded = 0, other = 0;
|
|
155
|
+
let latestTimestamp = null;
|
|
156
|
+
const topicCounts = {};
|
|
157
|
+
|
|
158
|
+
for (const c of s._claims) {
|
|
159
|
+
const st = (c.status || '').toLowerCase();
|
|
160
|
+
if (st === 'active') active++;
|
|
161
|
+
else if (st === 'resolved') resolved++;
|
|
162
|
+
else if (st === 'superseded') superseded++;
|
|
163
|
+
else other++;
|
|
164
|
+
|
|
165
|
+
if (c.timestamp) {
|
|
166
|
+
if (!latestTimestamp || c.timestamp > latestTimestamp) latestTimestamp = c.timestamp;
|
|
167
|
+
}
|
|
168
|
+
if (c.topic) {
|
|
169
|
+
topicCounts[c.topic] = (topicCounts[c.topic] || 0) + 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let daysSinceUpdate = null;
|
|
174
|
+
if (latestTimestamp) {
|
|
175
|
+
const lastDate = new Date(latestTimestamp);
|
|
176
|
+
const now = new Date();
|
|
177
|
+
daysSinceUpdate = Math.floor((now - lastDate) / (1000 * 60 * 60 * 24));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const topTopics = Object.entries(topicCounts)
|
|
181
|
+
.sort((a, b) => b[1] - a[1])
|
|
182
|
+
.slice(0, 5)
|
|
183
|
+
.map(([topic, count]) => ({ topic, count }));
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
path: s.path,
|
|
187
|
+
question: s.question,
|
|
188
|
+
phase: s.phase,
|
|
189
|
+
initiated: s.initiated,
|
|
190
|
+
total: s._claims.length,
|
|
191
|
+
active,
|
|
192
|
+
resolved,
|
|
193
|
+
superseded,
|
|
194
|
+
other,
|
|
195
|
+
daysSinceUpdate,
|
|
196
|
+
topTopics,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Strip _claims from sprint objects before returning
|
|
201
|
+
const cleanSprints = sprints.map(s => {
|
|
202
|
+
const { _claims, ...rest } = s;
|
|
203
|
+
return rest;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return { sprints: cleanSprints, edges, cycles, status };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function addEdge(edges, from, to, type) {
|
|
210
|
+
const existing = edges.find(e => e.from === from && e.to === to && e.type === type);
|
|
211
|
+
if (existing) {
|
|
212
|
+
existing.count++;
|
|
213
|
+
} else {
|
|
214
|
+
edges.push({ from, to, type, count: 1 });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function detectCycles(sprints, edges) {
|
|
219
|
+
const cycles = [];
|
|
220
|
+
const adj = {};
|
|
221
|
+
for (const s of sprints) adj[s.path] = [];
|
|
222
|
+
for (const e of edges) {
|
|
223
|
+
if (!adj[e.from]) adj[e.from] = [];
|
|
224
|
+
adj[e.from].push(e.to);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
228
|
+
const color = {};
|
|
229
|
+
for (const s of sprints) color[s.path] = WHITE;
|
|
230
|
+
|
|
231
|
+
function dfs(u, pathStack) {
|
|
232
|
+
color[u] = GRAY;
|
|
233
|
+
pathStack.push(u);
|
|
234
|
+
for (const v of (adj[u] || [])) {
|
|
235
|
+
if (color[v] === GRAY) {
|
|
236
|
+
const cycleStart = pathStack.indexOf(v);
|
|
237
|
+
if (cycleStart !== -1) {
|
|
238
|
+
cycles.push(pathStack.slice(cycleStart).map(p => path.basename(p)));
|
|
239
|
+
}
|
|
240
|
+
} else if (color[v] === WHITE) {
|
|
241
|
+
dfs(v, pathStack);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
pathStack.pop();
|
|
245
|
+
color[u] = BLACK;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const s of sprints) {
|
|
249
|
+
if (color[s.path] === WHITE) dfs(s.path, []);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return cycles;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build the dashboard HTML string from sprint graph data.
|
|
257
|
+
* @param {object} graphData - { sprints, edges, cycles, status }
|
|
258
|
+
* @returns {string} Complete HTML string
|
|
259
|
+
*/
|
|
260
|
+
function buildHtml(graphData) {
|
|
261
|
+
const templatePath = path.join(__dirname, '..', 'templates', 'dashboard.html');
|
|
262
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
263
|
+
const jsonData = JSON.stringify(graphData).replace(/<\/script/gi, '<\\/script');
|
|
264
|
+
return template.replace('__SPRINT_DATA__', jsonData);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Return paths to all claims.json files for watching.
|
|
269
|
+
*/
|
|
270
|
+
function claimsPaths(targetDir) {
|
|
271
|
+
return findSprintFiles(targetDir).map(s => s.file);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Generate a self-contained HTML dashboard file (for `orchard dashboard` CLI command).
|
|
276
|
+
*/
|
|
277
|
+
function generateDashboard(config, root, outPath) {
|
|
278
|
+
const graphData = loadSprints(root);
|
|
279
|
+
|
|
280
|
+
if (graphData.sprints.length === 0) {
|
|
281
|
+
console.error('No sprints found (no claims.json files detected).');
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const html = buildHtml(graphData);
|
|
286
|
+
fs.writeFileSync(outPath, html);
|
|
287
|
+
console.log(`Dashboard written to ${outPath}`);
|
|
288
|
+
console.log(` ${graphData.sprints.length} sprints, ${graphData.edges.length} edges, ${graphData.cycles.length} cycles`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { loadSprints, buildHtml, claimsPaths, findSprintFiles, generateDashboard };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run all doctor checks against the orchard root directory.
|
|
8
|
+
* Returns { checks: [...], ok: boolean }
|
|
9
|
+
*/
|
|
10
|
+
function runChecks(root) {
|
|
11
|
+
const checks = [];
|
|
12
|
+
|
|
13
|
+
// 1. orchard.json present and parseable
|
|
14
|
+
const configPath = path.join(root, 'orchard.json');
|
|
15
|
+
const configExists = fs.existsSync(configPath);
|
|
16
|
+
checks.push({
|
|
17
|
+
name: 'orchard.json exists',
|
|
18
|
+
ok: configExists,
|
|
19
|
+
detail: configExists ? configPath : 'Not found. Run "orchard init" to create one.',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!configExists) {
|
|
23
|
+
return { checks, ok: false };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let config;
|
|
27
|
+
try {
|
|
28
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
29
|
+
checks.push({ name: 'orchard.json is valid JSON', ok: true, detail: '' });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
checks.push({ name: 'orchard.json is valid JSON', ok: false, detail: err.message });
|
|
32
|
+
return { checks, ok: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sprints = config.sprints || [];
|
|
36
|
+
checks.push({
|
|
37
|
+
name: 'sprints defined',
|
|
38
|
+
ok: sprints.length > 0,
|
|
39
|
+
detail: sprints.length > 0
|
|
40
|
+
? `${sprints.length} sprint(s) configured`
|
|
41
|
+
: 'No sprints in orchard.json',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 2. Sprint directories reachable
|
|
45
|
+
let allReachable = true;
|
|
46
|
+
for (const sprint of sprints) {
|
|
47
|
+
const sprintDir = sprint.path === '.'
|
|
48
|
+
? root
|
|
49
|
+
: path.isAbsolute(sprint.path)
|
|
50
|
+
? sprint.path
|
|
51
|
+
: path.join(root, sprint.path);
|
|
52
|
+
const exists = fs.existsSync(sprintDir);
|
|
53
|
+
if (!exists) allReachable = false;
|
|
54
|
+
checks.push({
|
|
55
|
+
name: `sprint dir: ${sprint.name || sprint.path}`,
|
|
56
|
+
ok: exists,
|
|
57
|
+
detail: exists ? sprintDir : `Missing: ${sprintDir}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Dependencies resolve (all depends_on targets exist in sprint list)
|
|
62
|
+
const sprintPaths = new Set(sprints.map((s) => s.path));
|
|
63
|
+
const sprintNames = new Set(sprints.map((s) => s.name).filter(Boolean));
|
|
64
|
+
let allDepsResolved = true;
|
|
65
|
+
|
|
66
|
+
for (const sprint of sprints) {
|
|
67
|
+
for (const dep of sprint.depends_on || []) {
|
|
68
|
+
// Match by path or by name
|
|
69
|
+
const resolved = sprintPaths.has(dep) || sprintNames.has(dep);
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
allDepsResolved = false;
|
|
72
|
+
checks.push({
|
|
73
|
+
name: `dependency: ${sprint.name || sprint.path} -> ${dep}`,
|
|
74
|
+
ok: false,
|
|
75
|
+
detail: `"${dep}" not found in sprint list`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (allDepsResolved && sprints.length > 0) {
|
|
81
|
+
const totalDeps = sprints.reduce((n, s) => n + (s.depends_on || []).length, 0);
|
|
82
|
+
checks.push({
|
|
83
|
+
name: 'all dependencies resolve',
|
|
84
|
+
ok: true,
|
|
85
|
+
detail: `${totalDeps} dependency link(s) verified`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4. Cycle detection
|
|
90
|
+
let hasCycles = false;
|
|
91
|
+
try {
|
|
92
|
+
const { detectCycles } = require('./planner.js');
|
|
93
|
+
const cycles = detectCycles(config);
|
|
94
|
+
hasCycles = cycles.length > 0;
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'no dependency cycles',
|
|
97
|
+
ok: !hasCycles,
|
|
98
|
+
detail: hasCycles ? `Cycle involving: ${cycles.join(', ')}` : 'Dependency graph is acyclic',
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
checks.push({
|
|
102
|
+
name: 'no dependency cycles',
|
|
103
|
+
ok: false,
|
|
104
|
+
detail: `Cycle check failed: ${err.message}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ok = checks.every((c) => c.ok);
|
|
109
|
+
return { checks, ok };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Print doctor results to stdout.
|
|
114
|
+
*/
|
|
115
|
+
function printReport(result) {
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(' orchard doctor');
|
|
118
|
+
console.log(' ' + '='.repeat(40));
|
|
119
|
+
console.log('');
|
|
120
|
+
|
|
121
|
+
for (const check of result.checks) {
|
|
122
|
+
const icon = check.ok ? 'ok' : 'FAIL';
|
|
123
|
+
const line = ` [${icon.padEnd(4)}] ${check.name}`;
|
|
124
|
+
console.log(line);
|
|
125
|
+
if (check.detail && !check.ok) {
|
|
126
|
+
console.log(` ${check.detail}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('');
|
|
131
|
+
const passed = result.checks.filter((c) => c.ok).length;
|
|
132
|
+
const total = result.checks.length;
|
|
133
|
+
console.log(` ${passed}/${total} checks passed` + (result.ok ? ' -- all healthy' : ' -- issues found'));
|
|
134
|
+
console.log('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { runChecks, printReport };
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* orchard -> mill edge: export trigger for completed sprints.
|
|
5
|
+
*
|
|
6
|
+
* When orchard detects a sprint is "done", it can trigger mill's
|
|
7
|
+
* export API to produce formatted output. Probes mill via localhost
|
|
8
|
+
* or filesystem. Graceful fallback if mill is not available.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const http = require('node:http');
|
|
14
|
+
|
|
15
|
+
const MILL_PORT = 9094;
|
|
16
|
+
const MILL_SIBLINGS = [
|
|
17
|
+
path.join(__dirname, '..', '..', 'mill'),
|
|
18
|
+
path.join(__dirname, '..', '..', '..', 'mill'),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if mill is reachable (HTTP or filesystem).
|
|
23
|
+
* Returns { available: true, method, formats? } or { available: false }.
|
|
24
|
+
*/
|
|
25
|
+
function detectMill() {
|
|
26
|
+
for (const dir of MILL_SIBLINGS) {
|
|
27
|
+
const pkg = path.join(dir, 'package.json');
|
|
28
|
+
if (fs.existsSync(pkg)) {
|
|
29
|
+
try {
|
|
30
|
+
const meta = JSON.parse(fs.readFileSync(pkg, 'utf8'));
|
|
31
|
+
if (meta.name === '@grainulation/mill') {
|
|
32
|
+
return { available: true, method: 'filesystem', path: dir };
|
|
33
|
+
}
|
|
34
|
+
} catch { continue; }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { available: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trigger an export via mill's HTTP API.
|
|
42
|
+
* @param {string} sprintPath — absolute path to the sprint directory
|
|
43
|
+
* @param {string} format — export format name (e.g. "markdown", "csv", "json-ld")
|
|
44
|
+
* @returns {Promise<{ok: boolean, job?, error?}>}
|
|
45
|
+
*/
|
|
46
|
+
function exportSprint(sprintPath, format) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const body = JSON.stringify({ format, options: { source: sprintPath } });
|
|
49
|
+
const req = http.request({
|
|
50
|
+
hostname: '127.0.0.1',
|
|
51
|
+
port: MILL_PORT,
|
|
52
|
+
path: '/api/export',
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
55
|
+
timeout: 5000,
|
|
56
|
+
}, (res) => {
|
|
57
|
+
let data = '';
|
|
58
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
59
|
+
res.on('end', () => {
|
|
60
|
+
try {
|
|
61
|
+
const result = JSON.parse(data);
|
|
62
|
+
resolve(result.error ? { ok: false, error: result.error } : { ok: true, job: result.job });
|
|
63
|
+
} catch {
|
|
64
|
+
resolve({ ok: false, error: 'Invalid response from mill' });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
req.on('error', () => resolve({ ok: false, error: 'mill not reachable on port ' + MILL_PORT }));
|
|
69
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'mill request timed out' }); });
|
|
70
|
+
req.write(body);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* List available export formats from mill's API.
|
|
77
|
+
* @returns {Promise<{available: boolean, formats?: Array}>}
|
|
78
|
+
*/
|
|
79
|
+
function listFormats() {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const req = http.get(`http://127.0.0.1:${MILL_PORT}/api/formats`, { timeout: 2000 }, (res) => {
|
|
82
|
+
let body = '';
|
|
83
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
84
|
+
res.on('end', () => {
|
|
85
|
+
try {
|
|
86
|
+
const data = JSON.parse(body);
|
|
87
|
+
resolve({ available: true, formats: data.formats || [] });
|
|
88
|
+
} catch {
|
|
89
|
+
resolve({ available: false });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
req.on('error', () => resolve({ available: false }));
|
|
94
|
+
req.on('timeout', () => { req.destroy(); resolve({ available: false }); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { detectMill, exportSprint, listFormats, MILL_PORT };
|
package/lib/farmer.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const http = require('node:http');
|
|
6
|
+
const https = require('node:https');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST an activity event to farmer.
|
|
10
|
+
* Graceful failure -- catch and warn, never crash.
|
|
11
|
+
* @param {string} farmerUrl - Base URL of farmer (e.g. http://localhost:9090)
|
|
12
|
+
* @param {object} event - Event object (e.g. { type: "scan", data: {...} })
|
|
13
|
+
*/
|
|
14
|
+
function notify(farmerUrl, event) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
try {
|
|
17
|
+
const payload = JSON.stringify({
|
|
18
|
+
tool: 'orchard',
|
|
19
|
+
event,
|
|
20
|
+
timestamp: new Date().toISOString()
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const url = new URL(`${farmerUrl}/hooks/activity`);
|
|
24
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
25
|
+
|
|
26
|
+
const req = transport.request({
|
|
27
|
+
hostname: url.hostname,
|
|
28
|
+
port: url.port,
|
|
29
|
+
path: url.pathname,
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
34
|
+
},
|
|
35
|
+
timeout: 5000
|
|
36
|
+
}, (res) => {
|
|
37
|
+
let body = '';
|
|
38
|
+
res.on('data', chunk => { body += chunk; });
|
|
39
|
+
res.on('end', () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body }));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
req.on('error', (err) => {
|
|
43
|
+
console.error(`[orchard] farmer notify failed: ${err.message}`);
|
|
44
|
+
resolve({ ok: false, error: err.message });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
req.on('timeout', () => {
|
|
48
|
+
req.destroy();
|
|
49
|
+
console.error('[orchard] farmer notify timed out');
|
|
50
|
+
resolve({ ok: false, error: 'timeout' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
req.write(payload);
|
|
54
|
+
req.end();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`[orchard] farmer notify failed: ${err.message}`);
|
|
57
|
+
resolve({ ok: false, error: err.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* CLI handler for `orchard connect farmer`.
|
|
64
|
+
* Reads/writes .farmer.json in targetDir.
|
|
65
|
+
* @param {string} targetDir - Working directory
|
|
66
|
+
* @param {string[]} args - CLI arguments (e.g. ["farmer", "--url", "http://..."])
|
|
67
|
+
*/
|
|
68
|
+
async function connect(targetDir, args) {
|
|
69
|
+
const subcommand = args[0];
|
|
70
|
+
if (subcommand !== 'farmer') {
|
|
71
|
+
console.error('Usage: orchard connect farmer [--url http://localhost:9090]');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const configPath = path.join(targetDir, '.farmer.json');
|
|
76
|
+
|
|
77
|
+
const urlIdx = args.indexOf('--url');
|
|
78
|
+
if (urlIdx !== -1 && args[urlIdx + 1]) {
|
|
79
|
+
const url = args[urlIdx + 1];
|
|
80
|
+
const config = { url };
|
|
81
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
82
|
+
console.log(`Farmer connection saved to ${configPath}`);
|
|
83
|
+
console.log(` URL: ${url}`);
|
|
84
|
+
|
|
85
|
+
// Test the connection
|
|
86
|
+
const result = await notify(url, { type: 'connect', data: { tool: 'orchard' } });
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
console.log(' Connection test: OK');
|
|
89
|
+
} else {
|
|
90
|
+
console.log(` Connection test: failed (${result.error || 'status ' + result.status})`);
|
|
91
|
+
console.log(' Farmer may not be running. The URL is saved and will be used when farmer is available.');
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Show current config
|
|
97
|
+
if (fs.existsSync(configPath)) {
|
|
98
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
99
|
+
console.log(`Farmer connection: ${config.url}`);
|
|
100
|
+
console.log(`Config: ${configPath}`);
|
|
101
|
+
} else {
|
|
102
|
+
console.log('No farmer connection configured.');
|
|
103
|
+
console.log('Usage: orchard connect farmer --url http://localhost:9090');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { connect, notify };
|