@grainulation/grainulation 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 +108 -0
- package/bin/grainulation.js +52 -0
- package/lib/doctor.js +245 -0
- package/lib/ecosystem.js +122 -0
- package/lib/pm.js +237 -0
- package/lib/router.js +586 -0
- package/lib/server.mjs +522 -0
- package/lib/setup.js +130 -0
- package/package.json +44 -0
- package/public/grainulation-tokens.css +321 -0
- package/public/index.html +1139 -0
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* grainulation serve — local HTTP server for the ecosystem control center
|
|
4
|
+
*
|
|
5
|
+
* Tool catalog, doctor health checks, scaffold actions, and
|
|
6
|
+
* cross-tool navigation. SSE for live updates.
|
|
7
|
+
* Zero npm dependencies (node:http only).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* grainulation serve [--port 9098]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync, watchFile } from 'node:fs';
|
|
15
|
+
import { join, resolve, extname, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const pm = require('./pm.js');
|
|
23
|
+
|
|
24
|
+
// ── Crash handlers ──
|
|
25
|
+
process.on('uncaughtException', (err) => {
|
|
26
|
+
process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
29
|
+
process.on('unhandledRejection', (reason) => {
|
|
30
|
+
process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
|
34
|
+
|
|
35
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
function arg(name, fallback) {
|
|
39
|
+
const i = args.indexOf(`--${name}`);
|
|
40
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PORT = parseInt(arg('port', '9098'), 10);
|
|
44
|
+
const CORS_ORIGIN = arg('cors', null);
|
|
45
|
+
const SPRINT_ROOT = arg('root', null); // forwarded to tools as --root
|
|
46
|
+
|
|
47
|
+
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
50
|
+
function vlog(...a) {
|
|
51
|
+
if (!verbose) return;
|
|
52
|
+
const ts = new Date().toISOString();
|
|
53
|
+
process.stderr.write(`[${ts}] grainulation: ${a.join(' ')}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const ROUTES = [
|
|
59
|
+
{ method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
|
|
60
|
+
{ method: 'GET', path: '/api/ecosystem', description: 'List all tools with install status' },
|
|
61
|
+
{ method: 'GET', path: '/api/doctor', description: 'Run health checks on ecosystem' },
|
|
62
|
+
{ method: 'GET', path: '/api/tools/:name', description: 'Get details for a specific tool' },
|
|
63
|
+
{ method: 'POST', path: '/api/scaffold', description: 'Create a new sprint directory' },
|
|
64
|
+
{ method: 'POST', path: '/api/pm/start', description: 'Start a tool by name' },
|
|
65
|
+
{ method: 'POST', path: '/api/pm/stop', description: 'Stop a tool by name' },
|
|
66
|
+
{ method: 'POST', path: '/api/pm/up', description: 'Start all tools' },
|
|
67
|
+
{ method: 'POST', path: '/api/pm/down', description: 'Stop all tools' },
|
|
68
|
+
{ method: 'GET', path: '/api/pm/ps', description: 'Get status of all tool processes' },
|
|
69
|
+
{ method: 'GET', path: '/api/docs', description: 'This API documentation page' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// ── Tool registry ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const TOOLS = [
|
|
75
|
+
{ name: 'wheat', pkg: '@grainulation/wheat', port: 9091, accent: '#fbbf24', role: 'Research sprint engine', category: 'core' },
|
|
76
|
+
{ name: 'farmer', pkg: '@grainulation/farmer', port: 9090, accent: '#3b82f6', role: 'Permission dashboard', category: 'core' },
|
|
77
|
+
{ name: 'barn', pkg: '@grainulation/barn', port: 9093, accent: '#f43f5e', role: 'Design system & templates', category: 'foundation' },
|
|
78
|
+
{ name: 'mill', pkg: '@grainulation/mill', port: 9094, accent: '#a78bfa', role: 'Export & publish engine', category: 'output' },
|
|
79
|
+
{ name: 'silo', pkg: '@grainulation/silo', port: 9095, accent: '#6ee7b7', role: 'Reusable claim libraries', category: 'storage' },
|
|
80
|
+
{ name: 'harvest', pkg: '@grainulation/harvest', port: 9096, accent: '#fb923c', role: 'Analytics & retrospectives', category: 'analytics' },
|
|
81
|
+
{ name: 'orchard', pkg: '@grainulation/orchard', port: 9097, accent: '#14b8a6', role: 'Multi-sprint orchestrator', category: 'orchestration' },
|
|
82
|
+
{ name: 'grainulation', pkg: '@grainulation/grainulation', port: 9098, accent: '#9ca3af', role: 'Ecosystem entry point', category: 'meta' },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
let state = {
|
|
88
|
+
ecosystem: [],
|
|
89
|
+
doctorResults: null,
|
|
90
|
+
lastCheck: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sseClients = new Set();
|
|
94
|
+
|
|
95
|
+
function broadcast(event) {
|
|
96
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
97
|
+
for (const res of sseClients) {
|
|
98
|
+
try { res.write(data); } catch { sseClients.delete(res); }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Doctor — health checks ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function detectTool(pkg) {
|
|
105
|
+
// 0. Self-detect: grainulation is always installed (it's running this code)
|
|
106
|
+
if (pkg === '@grainulation/grainulation' || pkg === 'grainulation') {
|
|
107
|
+
const selfPkg = join(__dirname, '..', 'package.json');
|
|
108
|
+
try {
|
|
109
|
+
const p = JSON.parse(readFileSync(selfPkg, 'utf8'));
|
|
110
|
+
return { installed: true, version: p.version || '1.0.0', method: 'self' };
|
|
111
|
+
} catch {
|
|
112
|
+
return { installed: true, version: '1.0.0', method: 'self' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 1. Global npm
|
|
117
|
+
try {
|
|
118
|
+
const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null`, { stdio: 'pipe', encoding: 'utf-8' });
|
|
119
|
+
const match = out.match(new RegExp(escapeRegex(pkg) + '@(\\S+)'));
|
|
120
|
+
if (match) return { installed: true, version: match[1], method: 'global' };
|
|
121
|
+
} catch { /* not found */ }
|
|
122
|
+
|
|
123
|
+
// 2. npx cache
|
|
124
|
+
try {
|
|
125
|
+
const prefix = execSync('npm config get cache', { stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
126
|
+
const npxDir = join(prefix, '_npx');
|
|
127
|
+
if (existsSync(npxDir)) {
|
|
128
|
+
const entries = readdirSync(npxDir, { withFileTypes: true });
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (!entry.isDirectory()) continue;
|
|
131
|
+
const pkgJson = join(npxDir, entry.name, 'node_modules', pkg, 'package.json');
|
|
132
|
+
if (existsSync(pkgJson)) {
|
|
133
|
+
try {
|
|
134
|
+
const p = JSON.parse(readFileSync(pkgJson, 'utf8'));
|
|
135
|
+
return { installed: true, version: p.version || 'installed', method: 'npx-cache' };
|
|
136
|
+
} catch {
|
|
137
|
+
return { installed: true, version: 'installed', method: 'npx-cache' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch { /* not found */ }
|
|
143
|
+
|
|
144
|
+
// 3. Local node_modules
|
|
145
|
+
const localPkg = join(process.cwd(), 'node_modules', pkg, 'package.json');
|
|
146
|
+
if (existsSync(localPkg)) {
|
|
147
|
+
try {
|
|
148
|
+
const p = JSON.parse(readFileSync(localPkg, 'utf8'));
|
|
149
|
+
return { installed: true, version: p.version || 'installed', method: 'local' };
|
|
150
|
+
} catch { /* ignore */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4. Sibling source directory
|
|
154
|
+
const siblingDir = join(__dirname, '..', '..', pkg.replace(/^@[^/]+\//, ''));
|
|
155
|
+
const siblingPkg = join(siblingDir, 'package.json');
|
|
156
|
+
if (existsSync(siblingPkg)) {
|
|
157
|
+
try {
|
|
158
|
+
const p = JSON.parse(readFileSync(siblingPkg, 'utf8'));
|
|
159
|
+
if (p.name === pkg) {
|
|
160
|
+
return { installed: true, version: p.version || 'source', method: 'source' };
|
|
161
|
+
}
|
|
162
|
+
} catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { installed: false, version: null, method: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function escapeRegex(str) {
|
|
169
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function runDoctor() {
|
|
173
|
+
const nodeVersion = process.version;
|
|
174
|
+
let npmVersion = 'unknown';
|
|
175
|
+
try {
|
|
176
|
+
npmVersion = execSync('npm --version', { stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
177
|
+
} catch { /* ignore */ }
|
|
178
|
+
|
|
179
|
+
const checks = [];
|
|
180
|
+
|
|
181
|
+
// Environment checks
|
|
182
|
+
checks.push({
|
|
183
|
+
name: 'Node.js',
|
|
184
|
+
status: parseInt(nodeVersion.slice(1)) >= 18 ? 'pass' : 'warning',
|
|
185
|
+
detail: nodeVersion,
|
|
186
|
+
category: 'environment',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
checks.push({
|
|
190
|
+
name: 'npm',
|
|
191
|
+
status: npmVersion !== 'unknown' ? 'pass' : 'fail',
|
|
192
|
+
detail: `v${npmVersion}`,
|
|
193
|
+
category: 'environment',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Tool checks
|
|
197
|
+
const toolStatuses = [];
|
|
198
|
+
for (const tool of TOOLS) {
|
|
199
|
+
const result = detectTool(tool.pkg);
|
|
200
|
+
const status = result.installed ? 'pass' : (tool.category === 'core' ? 'warning' : 'info');
|
|
201
|
+
|
|
202
|
+
checks.push({
|
|
203
|
+
name: tool.name,
|
|
204
|
+
status,
|
|
205
|
+
detail: result.installed ? `v${result.version} (${result.method})` : 'not installed',
|
|
206
|
+
category: 'tools',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
toolStatuses.push({
|
|
210
|
+
...tool,
|
|
211
|
+
installed: result.installed,
|
|
212
|
+
version: result.version,
|
|
213
|
+
method: result.method,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const passCount = checks.filter(c => c.status === 'pass').length;
|
|
218
|
+
const warnCount = checks.filter(c => c.status === 'warning').length;
|
|
219
|
+
const failCount = checks.filter(c => c.status === 'fail').length;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
checks,
|
|
223
|
+
toolStatuses,
|
|
224
|
+
summary: { pass: passCount, warning: warnCount, fail: failCount, total: checks.length },
|
|
225
|
+
nodeVersion,
|
|
226
|
+
npmVersion,
|
|
227
|
+
timestamp: new Date().toISOString(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Ecosystem — aggregate status ──────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function getEcosystem() {
|
|
234
|
+
return TOOLS.map(tool => {
|
|
235
|
+
const result = detectTool(tool.pkg);
|
|
236
|
+
return {
|
|
237
|
+
...tool,
|
|
238
|
+
installed: result.installed,
|
|
239
|
+
version: result.version,
|
|
240
|
+
method: result.method,
|
|
241
|
+
url: `http://localhost:${tool.port}`,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Scaffold — create project directory ───────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function scaffold(targetDir, options = {}) {
|
|
249
|
+
const dir = resolve(targetDir);
|
|
250
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
251
|
+
return { ok: false, error: 'Directory already exists and is not empty' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
mkdirSync(dir, { recursive: true });
|
|
255
|
+
|
|
256
|
+
// claims.json (atomic write-then-rename)
|
|
257
|
+
const claimsData = JSON.stringify({
|
|
258
|
+
claims: [],
|
|
259
|
+
meta: { created: new Date().toISOString(), tool: 'grainulation' }
|
|
260
|
+
}, null, 2) + '\n';
|
|
261
|
+
const tmpClaims = join(dir, 'claims.json.tmp.' + process.pid);
|
|
262
|
+
writeFileSync(tmpClaims, claimsData);
|
|
263
|
+
renameSync(tmpClaims, join(dir, 'claims.json'));
|
|
264
|
+
|
|
265
|
+
// CLAUDE.md
|
|
266
|
+
const question = options.question || 'What should we build?';
|
|
267
|
+
writeFileSync(join(dir, 'CLAUDE.md'), `# Sprint\n\n**Question:** ${question}\n\n**Constraints:**\n- (add constraints here)\n\n**Done looks like:** (describe the output)\n`);
|
|
268
|
+
|
|
269
|
+
// orchard.json (if multi-sprint, atomic write-then-rename)
|
|
270
|
+
if (options.includeOrchard) {
|
|
271
|
+
const orchardData = JSON.stringify({
|
|
272
|
+
sprints: [],
|
|
273
|
+
settings: { sync_interval: 'manual' }
|
|
274
|
+
}, null, 2) + '\n';
|
|
275
|
+
const tmpOrchard = join(dir, 'orchard.json.tmp.' + process.pid);
|
|
276
|
+
writeFileSync(tmpOrchard, orchardData);
|
|
277
|
+
renameSync(tmpOrchard, join(dir, 'orchard.json'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { ok: true, path: dir, files: ['claims.json', 'CLAUDE.md'] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Refresh state ─────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function refreshState() {
|
|
286
|
+
state.ecosystem = getEcosystem();
|
|
287
|
+
state.doctorResults = runDoctor();
|
|
288
|
+
state.lastCheck = new Date().toISOString();
|
|
289
|
+
broadcast({ type: 'state', data: state });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
const MIME = {
|
|
295
|
+
'.html': 'text/html; charset=utf-8',
|
|
296
|
+
'.css': 'text/css; charset=utf-8',
|
|
297
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
298
|
+
'.json': 'application/json; charset=utf-8',
|
|
299
|
+
'.svg': 'image/svg+xml',
|
|
300
|
+
'.png': 'image/png',
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function json(res, data, status = 200) {
|
|
306
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
307
|
+
res.end(JSON.stringify(data));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readBody(req) {
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const chunks = [];
|
|
313
|
+
req.on('data', c => chunks.push(c));
|
|
314
|
+
req.on('end', () => {
|
|
315
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
316
|
+
catch { resolve({}); }
|
|
317
|
+
});
|
|
318
|
+
req.on('error', reject);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
const server = createServer(async (req, res) => {
|
|
325
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
326
|
+
|
|
327
|
+
// CORS (only when --cors is passed)
|
|
328
|
+
if (CORS_ORIGIN) {
|
|
329
|
+
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
330
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
331
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (req.method === 'OPTIONS' && CORS_ORIGIN) {
|
|
335
|
+
res.writeHead(204);
|
|
336
|
+
res.end();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
vlog('request', req.method, url.pathname);
|
|
341
|
+
|
|
342
|
+
// ── API: docs ──
|
|
343
|
+
if (req.method === 'GET' && url.pathname === '/api/docs') {
|
|
344
|
+
const html = `<!DOCTYPE html><html><head><title>grainulation API</title>
|
|
345
|
+
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
346
|
+
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
347
|
+
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
348
|
+
<body><h1>grainulation API</h1><p>${ROUTES.length} endpoints</p>
|
|
349
|
+
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
350
|
+
${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
|
|
351
|
+
</table></body></html>`;
|
|
352
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
353
|
+
res.end(html);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── SSE ──
|
|
358
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
359
|
+
res.writeHead(200, {
|
|
360
|
+
'Content-Type': 'text/event-stream',
|
|
361
|
+
'Cache-Control': 'no-cache',
|
|
362
|
+
'Connection': 'keep-alive',
|
|
363
|
+
});
|
|
364
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
|
|
365
|
+
const heartbeat = setInterval(() => {
|
|
366
|
+
try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
|
|
367
|
+
}, 15000);
|
|
368
|
+
sseClients.add(res);
|
|
369
|
+
vlog('sse', `client connected (${sseClients.size} total)`);
|
|
370
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── API: ecosystem ──
|
|
375
|
+
if (req.method === 'GET' && url.pathname === '/api/ecosystem') {
|
|
376
|
+
json(res, { tools: state.ecosystem, lastCheck: state.lastCheck });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── API: doctor ──
|
|
381
|
+
if (req.method === 'GET' && url.pathname === '/api/doctor') {
|
|
382
|
+
// Run fresh doctor check
|
|
383
|
+
const results = runDoctor();
|
|
384
|
+
json(res, results);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── API: tool detail ──
|
|
389
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/tools/')) {
|
|
390
|
+
const name = url.pathname.split('/').pop();
|
|
391
|
+
const tool = state.ecosystem.find(t => t.name === name);
|
|
392
|
+
if (!tool) {
|
|
393
|
+
json(res, { error: 'Tool not found' }, 404);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
json(res, tool);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── API: PM — start a tool ──
|
|
401
|
+
if (req.method === 'POST' && url.pathname === '/api/pm/start') {
|
|
402
|
+
const body = await readBody(req);
|
|
403
|
+
const toolName = body.tool;
|
|
404
|
+
if (!toolName) { json(res, { error: 'Missing tool name' }, 400); return; }
|
|
405
|
+
try {
|
|
406
|
+
const rootArgs = (body.root || SPRINT_ROOT) ? ['--root', resolve(body.root || SPRINT_ROOT)] : [];
|
|
407
|
+
const result = pm.startTool(toolName, rootArgs);
|
|
408
|
+
json(res, { ok: true, ...result });
|
|
409
|
+
setTimeout(refreshState, 1500);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
json(res, { ok: false, error: err.message }, 400);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── API: PM — stop a tool ──
|
|
417
|
+
if (req.method === 'POST' && url.pathname === '/api/pm/stop') {
|
|
418
|
+
const body = await readBody(req);
|
|
419
|
+
const toolName = body.tool;
|
|
420
|
+
if (!toolName) { json(res, { error: 'Missing tool name' }, 400); return; }
|
|
421
|
+
try {
|
|
422
|
+
const result = pm.stopTool(toolName);
|
|
423
|
+
json(res, { ok: true, ...result });
|
|
424
|
+
setTimeout(refreshState, 500);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
json(res, { ok: false, error: err.message }, 400);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── API: PM — start all tools ──
|
|
432
|
+
if (req.method === 'POST' && url.pathname === '/api/pm/up') {
|
|
433
|
+
try {
|
|
434
|
+
const rootArgs = SPRINT_ROOT ? ['--root', resolve(SPRINT_ROOT)] : [];
|
|
435
|
+
const results = pm.up(['all'], rootArgs);
|
|
436
|
+
json(res, { ok: true, results });
|
|
437
|
+
setTimeout(refreshState, 2000);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
json(res, { ok: false, error: err.message }, 500);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── API: PM — stop all tools ──
|
|
445
|
+
if (req.method === 'POST' && url.pathname === '/api/pm/down') {
|
|
446
|
+
try {
|
|
447
|
+
const results = pm.down();
|
|
448
|
+
json(res, { ok: true, results });
|
|
449
|
+
setTimeout(refreshState, 500);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
json(res, { ok: false, error: err.message }, 500);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── API: PM — ps (process status) ──
|
|
457
|
+
if (req.method === 'GET' && url.pathname === '/api/pm/ps') {
|
|
458
|
+
try {
|
|
459
|
+
const results = await pm.ps();
|
|
460
|
+
json(res, { ok: true, processes: results });
|
|
461
|
+
} catch (err) {
|
|
462
|
+
json(res, { ok: false, error: err.message }, 500);
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── API: scaffold ──
|
|
468
|
+
if (req.method === 'POST' && url.pathname === '/api/scaffold') {
|
|
469
|
+
const body = await readBody(req);
|
|
470
|
+
if (!body.path) {
|
|
471
|
+
json(res, { error: 'Missing path' }, 400);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const result = scaffold(body.path, body);
|
|
475
|
+
json(res, result, result.ok ? 200 : 400);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Static files ──
|
|
480
|
+
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
481
|
+
const resolved = resolve(PUBLIC_DIR, '.' + filePath);
|
|
482
|
+
|
|
483
|
+
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
484
|
+
res.writeHead(403);
|
|
485
|
+
res.end('forbidden');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (existsSync(resolved) && statSync(resolved).isFile()) {
|
|
490
|
+
const ext = extname(resolved);
|
|
491
|
+
let content = readFileSync(resolved);
|
|
492
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
493
|
+
res.end(content);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
res.writeHead(404);
|
|
498
|
+
res.end('not found');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
502
|
+
const shutdown = (signal) => {
|
|
503
|
+
console.log(`\ngrainulation: ${signal} received, shutting down...`);
|
|
504
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
505
|
+
sseClients.clear();
|
|
506
|
+
server.close(() => process.exit(0));
|
|
507
|
+
setTimeout(() => process.exit(1), 5000);
|
|
508
|
+
};
|
|
509
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
510
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
511
|
+
|
|
512
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
refreshState();
|
|
515
|
+
|
|
516
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
517
|
+
vlog('listen', `port=${PORT}`);
|
|
518
|
+
const installed = state.ecosystem.filter(t => t.installed).length;
|
|
519
|
+
console.log(`grainulation: serving on http://localhost:${PORT}`);
|
|
520
|
+
console.log(` tools: ${installed}/${TOOLS.length} installed`);
|
|
521
|
+
console.log(` doctor: ${state.doctorResults.summary.pass} pass, ${state.doctorResults.summary.warning} warn, ${state.doctorResults.summary.fail} fail`);
|
|
522
|
+
});
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('node:readline');
|
|
4
|
+
const { execSync } = require('node:child_process');
|
|
5
|
+
const { getInstallable, getCategories } = require('./ecosystem');
|
|
6
|
+
const { getVersion } = require('./doctor');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interactive setup wizard.
|
|
10
|
+
*
|
|
11
|
+
* Asks what you're trying to do, then recommends and installs
|
|
12
|
+
* the right subset of the ecosystem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ROLES = [
|
|
16
|
+
{
|
|
17
|
+
name: 'Researcher',
|
|
18
|
+
description: 'Run research sprints, grow evidence, write briefs',
|
|
19
|
+
tools: ['wheat'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Researcher + Dashboard',
|
|
23
|
+
description: 'Research sprints with real-time permission dashboard',
|
|
24
|
+
tools: ['wheat', 'farmer'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'Team Lead',
|
|
28
|
+
description: 'Coordinate multiple sprints, review analytics',
|
|
29
|
+
tools: ['wheat', 'farmer', 'orchard', 'harvest'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'Full Ecosystem',
|
|
33
|
+
description: 'Everything. All 7 tools.',
|
|
34
|
+
tools: ['wheat', 'farmer', 'barn', 'mill', 'silo', 'harvest', 'orchard'],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function ask(rl, question) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
rl.question(question, resolve);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function run() {
|
|
45
|
+
const rl = readline.createInterface({
|
|
46
|
+
input: process.stdin,
|
|
47
|
+
output: process.stdout,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(' \x1b[1;33mgrainulation setup\x1b[0m');
|
|
52
|
+
console.log(' What are you trying to do?');
|
|
53
|
+
console.log('');
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < ROLES.length; i++) {
|
|
56
|
+
const role = ROLES[i];
|
|
57
|
+
console.log(` \x1b[1m${i + 1}.\x1b[0m ${role.name}`);
|
|
58
|
+
console.log(` \x1b[2m${role.description}\x1b[0m`);
|
|
59
|
+
console.log(` Tools: ${role.tools.join(', ')}`);
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const answer = await ask(rl, ' Choose (1-4): ');
|
|
64
|
+
const choice = parseInt(answer, 10);
|
|
65
|
+
|
|
66
|
+
if (choice < 1 || choice > ROLES.length || isNaN(choice)) {
|
|
67
|
+
console.log('\n \x1b[31mInvalid choice.\x1b[0m\n');
|
|
68
|
+
rl.close();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const role = ROLES[choice - 1];
|
|
73
|
+
const toInstall = [];
|
|
74
|
+
|
|
75
|
+
console.log('');
|
|
76
|
+
console.log(` \x1b[1mSetting up: ${role.name}\x1b[0m`);
|
|
77
|
+
console.log('');
|
|
78
|
+
|
|
79
|
+
for (const toolName of role.tools) {
|
|
80
|
+
const tool = getInstallable().find((t) => t.name === toolName);
|
|
81
|
+
if (!tool) continue;
|
|
82
|
+
|
|
83
|
+
const version = getVersion(tool.package);
|
|
84
|
+
if (version) {
|
|
85
|
+
console.log(` \x1b[32m\u2713\x1b[0m ${tool.name} already installed (${version})`);
|
|
86
|
+
} else {
|
|
87
|
+
toInstall.push(tool);
|
|
88
|
+
console.log(` \x1b[33m+\x1b[0m ${tool.name} will be installed`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (toInstall.length === 0) {
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log(' \x1b[32mEverything is already installed.\x1b[0m');
|
|
95
|
+
console.log(' Run \x1b[1mnpx @grainulation/wheat init\x1b[0m to start a research sprint.');
|
|
96
|
+
rl.close();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
const confirm = await ask(
|
|
102
|
+
rl,
|
|
103
|
+
` Install ${toInstall.length} package(s)? (y/N): `
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (confirm.toLowerCase() !== 'y') {
|
|
107
|
+
console.log('\n \x1b[2mAborted.\x1b[0m\n');
|
|
108
|
+
rl.close();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
for (const tool of toInstall) {
|
|
114
|
+
console.log(` Installing ${tool.package}...`);
|
|
115
|
+
try {
|
|
116
|
+
execSync(`npm install -g ${tool.package}`, { stdio: 'pipe' });
|
|
117
|
+
console.log(` \x1b[32m\u2713\x1b[0m ${tool.name} installed`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.log(` \x1b[31m\u2717\x1b[0m ${tool.name} failed: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(' \x1b[32mSetup complete.\x1b[0m');
|
|
125
|
+
console.log(' Start with: npx @grainulation/wheat init');
|
|
126
|
+
console.log('');
|
|
127
|
+
rl.close();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { run, ROLES };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grainulation/grainulation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Structured research for decisions that satisfice",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"grainulation": "bin/grainulation.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./lib/ecosystem.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/ecosystem.js",
|
|
12
|
+
"./pm": "./lib/pm.js",
|
|
13
|
+
"./doctor": "./lib/doctor.js",
|
|
14
|
+
"./router": "./lib/router.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"lib/",
|
|
19
|
+
"public/"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test/basic.test.js",
|
|
23
|
+
"start": "node bin/grainulation.js"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"research",
|
|
27
|
+
"decision-making",
|
|
28
|
+
"evidence",
|
|
29
|
+
"sprint",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/grainulation/grainulation.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/grainulation/grainulation/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://grainulation.com",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"author": "grainulation contributors"
|
|
44
|
+
}
|