@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/pm.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process Manager — start, stop, and monitor grainulation tools.
|
|
5
|
+
*
|
|
6
|
+
* Each tool runs its own HTTP server on a known port.
|
|
7
|
+
* This module spawns/kills them and probes ports for health.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { spawn, execSync } = require('node:child_process');
|
|
11
|
+
const { existsSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
|
|
12
|
+
const { join } = require('node:path');
|
|
13
|
+
const http = require('node:http');
|
|
14
|
+
const { getAll, getByName, getInstallable } = require('./ecosystem');
|
|
15
|
+
|
|
16
|
+
// PID tracking directory
|
|
17
|
+
const PM_DIR = join(require('node:os').homedir(), '.grainulation');
|
|
18
|
+
const PID_DIR = join(PM_DIR, 'pids');
|
|
19
|
+
const CONFIG_FILE = join(PM_DIR, 'config.json');
|
|
20
|
+
|
|
21
|
+
function loadConfig() {
|
|
22
|
+
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureDirs() {
|
|
26
|
+
if (!existsSync(PM_DIR)) mkdirSync(PM_DIR, { recursive: true });
|
|
27
|
+
if (!existsSync(PID_DIR)) mkdirSync(PID_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pidFile(toolName) {
|
|
31
|
+
return join(PID_DIR, `${toolName}.pid`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPid(toolName) {
|
|
35
|
+
const f = pidFile(toolName);
|
|
36
|
+
if (!existsSync(f)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const pid = parseInt(readFileSync(f, 'utf8').trim(), 10);
|
|
39
|
+
if (isNaN(pid)) return null;
|
|
40
|
+
// Check if process is alive
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return pid;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writePid(toolName, pid) {
|
|
49
|
+
ensureDirs();
|
|
50
|
+
writeFileSync(pidFile(toolName), String(pid));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function removePid(toolName) {
|
|
54
|
+
const f = pidFile(toolName);
|
|
55
|
+
try { require('node:fs').unlinkSync(f); } catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Probe a port to check if a tool is responding.
|
|
60
|
+
* Returns a promise that resolves to { alive, statusCode, latencyMs } or { alive: false }.
|
|
61
|
+
*/
|
|
62
|
+
function probe(port, timeoutMs = 2000) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
const req = http.get({ hostname: '127.0.0.1', port, path: '/health', timeout: timeoutMs }, (res) => {
|
|
66
|
+
const latencyMs = Date.now() - start;
|
|
67
|
+
// Consume body
|
|
68
|
+
res.resume();
|
|
69
|
+
resolve({ alive: true, statusCode: res.statusCode, latencyMs });
|
|
70
|
+
});
|
|
71
|
+
req.on('error', () => resolve({ alive: false }));
|
|
72
|
+
req.on('timeout', () => { req.destroy(); resolve({ alive: false }); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find the bin path for a tool — prefers source checkout, falls back to npx.
|
|
78
|
+
*/
|
|
79
|
+
function findBin(tool) {
|
|
80
|
+
const shortName = tool.package.replace(/^@[^/]+\//, '');
|
|
81
|
+
const candidates = [
|
|
82
|
+
join(__dirname, '..', '..', shortName),
|
|
83
|
+
join(process.cwd(), '..', shortName),
|
|
84
|
+
];
|
|
85
|
+
for (const dir of candidates) {
|
|
86
|
+
try {
|
|
87
|
+
const pkgPath = join(dir, 'package.json');
|
|
88
|
+
if (!existsSync(pkgPath)) continue;
|
|
89
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
90
|
+
if (pkg.name !== tool.package) continue;
|
|
91
|
+
if (pkg.bin) {
|
|
92
|
+
const binFile = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin)[0];
|
|
93
|
+
if (binFile) {
|
|
94
|
+
const binPath = require('node:path').resolve(dir, binFile);
|
|
95
|
+
if (existsSync(binPath)) return { cmd: process.execPath, args: [binPath] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
return { cmd: 'npx', args: [tool.package], shell: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start a tool's server. Returns { pid, port } or throws.
|
|
105
|
+
*/
|
|
106
|
+
function startTool(toolName, extraArgs = []) {
|
|
107
|
+
const tool = getByName(toolName);
|
|
108
|
+
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
|
109
|
+
if (toolName === 'grainulation') throw new Error('Use "grainulation serve" directly');
|
|
110
|
+
|
|
111
|
+
// Check if already running
|
|
112
|
+
const existing = readPid(toolName);
|
|
113
|
+
if (existing) {
|
|
114
|
+
return { pid: existing, port: tool.port, alreadyRunning: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Auto-inject --root and --dir from config if not explicitly provided
|
|
118
|
+
const hasRoot = extraArgs.includes('--root') || extraArgs.includes('--dir');
|
|
119
|
+
let rootArgs = [];
|
|
120
|
+
if (!hasRoot) {
|
|
121
|
+
const cfg = loadConfig();
|
|
122
|
+
if (cfg.sprintRoot) rootArgs = ['--root', cfg.sprintRoot, '--dir', cfg.sprintRoot];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bin = findBin(tool);
|
|
126
|
+
const args = [...bin.args, ...tool.serveCmd, '--port', String(tool.port), ...rootArgs, ...extraArgs];
|
|
127
|
+
|
|
128
|
+
const child = spawn(bin.cmd, args, {
|
|
129
|
+
stdio: 'ignore',
|
|
130
|
+
detached: true,
|
|
131
|
+
shell: bin.shell || false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
child.unref();
|
|
135
|
+
writePid(toolName, child.pid);
|
|
136
|
+
|
|
137
|
+
return { pid: child.pid, port: tool.port, alreadyRunning: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find a process listening on a port by checking lsof (macOS/Linux).
|
|
142
|
+
* Returns the PID or null.
|
|
143
|
+
*/
|
|
144
|
+
function findPidByPort(port) {
|
|
145
|
+
try {
|
|
146
|
+
const out = execSync(`lsof -ti :${port}`, { timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
147
|
+
const pids = out.toString().trim().split('\n').map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n > 0);
|
|
148
|
+
// Return the first PID that isn't our own process
|
|
149
|
+
return pids.find(p => p !== process.pid) || null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stop a tool by killing its PID.
|
|
157
|
+
* Falls back to finding the process by port if no PID file exists.
|
|
158
|
+
*/
|
|
159
|
+
function stopTool(toolName) {
|
|
160
|
+
let pid = readPid(toolName);
|
|
161
|
+
|
|
162
|
+
// Fallback: find process by port if no PID file
|
|
163
|
+
if (!pid) {
|
|
164
|
+
const tool = getByName(toolName);
|
|
165
|
+
if (tool) pid = findPidByPort(tool.port);
|
|
166
|
+
if (!pid) return { stopped: false, reason: 'not running' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
process.kill(pid, 'SIGTERM');
|
|
171
|
+
removePid(toolName);
|
|
172
|
+
return { stopped: true, pid };
|
|
173
|
+
} catch {
|
|
174
|
+
removePid(toolName);
|
|
175
|
+
return { stopped: false, reason: 'process already dead' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get status of all tools — probes ports in parallel.
|
|
181
|
+
*/
|
|
182
|
+
async function ps() {
|
|
183
|
+
const tools = getInstallable();
|
|
184
|
+
const results = await Promise.all(tools.map(async (tool) => {
|
|
185
|
+
const pid = readPid(tool.name);
|
|
186
|
+
const health = await probe(tool.port);
|
|
187
|
+
return {
|
|
188
|
+
name: tool.name,
|
|
189
|
+
port: tool.port,
|
|
190
|
+
role: tool.role,
|
|
191
|
+
pid: pid || null,
|
|
192
|
+
alive: health.alive,
|
|
193
|
+
latencyMs: health.latencyMs || null,
|
|
194
|
+
statusCode: health.statusCode || null,
|
|
195
|
+
};
|
|
196
|
+
}));
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Start multiple tools. Default set: wheat + farmer.
|
|
202
|
+
* 'all' starts everything except grainulation itself.
|
|
203
|
+
*/
|
|
204
|
+
function up(toolNames, extraArgs = []) {
|
|
205
|
+
const defaults = ['farmer', 'wheat'];
|
|
206
|
+
const names = (!toolNames || toolNames.length === 0) ? defaults :
|
|
207
|
+
(toolNames[0] === 'all' ? getInstallable().map(t => t.name) : toolNames);
|
|
208
|
+
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
try {
|
|
212
|
+
const r = startTool(name, extraArgs);
|
|
213
|
+
results.push({ name, ...r });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
results.push({ name, error: err.message });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Stop multiple tools. Default: stop all running.
|
|
223
|
+
*/
|
|
224
|
+
function down(toolNames) {
|
|
225
|
+
const names = (!toolNames || toolNames.length === 0)
|
|
226
|
+
? getInstallable().map(t => t.name)
|
|
227
|
+
: toolNames;
|
|
228
|
+
|
|
229
|
+
const results = [];
|
|
230
|
+
for (const name of names) {
|
|
231
|
+
const r = stopTool(name);
|
|
232
|
+
results.push({ name, ...r });
|
|
233
|
+
}
|
|
234
|
+
return results;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { startTool, stopTool, ps, up, down, probe, readPid };
|