cligr 1.0.6 → 1.0.7
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/dist/index.js +190 -39
- package/docs/superpowers/plans/2026-04-13-serve-command.md +1299 -0
- package/docs/superpowers/specs/2026-04-13-serve-command-design.md +93 -0
- package/package.json +1 -1
- package/src/commands/ls.ts +11 -6
- package/src/commands/serve.ts +360 -0
- package/src/config/loader.ts +71 -2
- package/src/config/types.ts +1 -0
- package/src/index.ts +10 -3
- package/src/process/manager.ts +36 -2
- package/tests/integration/commands.test.ts +24 -0
- package/tests/integration/config-loader.test.ts +110 -0
- package/tests/integration/process-manager.test.ts +103 -0
- package/tests/integration/serve.test.ts +245 -0
- package/.claude/settings.local.json +0 -30
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Design: `cligr serve` Command
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Add a `serve` command that starts an HTTP server with a web UI for toggling groups and individual items in `.cligr.yml`. Toggling an item changes a `disabledItems` list in config and restarts the running group. The UI receives real-time updates via Server-Sent Events (SSE).
|
|
5
|
+
|
|
6
|
+
## 1. Architecture Overview
|
|
7
|
+
|
|
8
|
+
Running `cligr serve` starts an HTTP server on a default port (`7373`, configurable via `--port`). The UI shows all groups from `.cligr.yml`, each with:
|
|
9
|
+
- A master checkbox to start/stop the entire group.
|
|
10
|
+
- Per-item checkboxes to enable/disable individual items.
|
|
11
|
+
|
|
12
|
+
### Core Components
|
|
13
|
+
- **`ConfigLoader`**: Gains `saveConfig()` and `toggleItem()` to persist `disabledItems` changes back to `.cligr.yml`.
|
|
14
|
+
- **`ProcessManager`**: Extends `EventEmitter` to emit events (`group-started`, `group-stopped`, `item-restarted`, `process-log`). Gains `restartGroup()`.
|
|
15
|
+
- **`serve.ts` command**: Sets up HTTP + SSE server; wires UI actions to `ConfigLoader` and `ProcessManager`.
|
|
16
|
+
- **UI**: Single-page HTML served inline from memory (no extra files needed).
|
|
17
|
+
|
|
18
|
+
## 2. Config Changes: `disabledItems`
|
|
19
|
+
|
|
20
|
+
Each `GroupConfig` gains an optional `disabledItems` string array:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
groups:
|
|
24
|
+
myapp:
|
|
25
|
+
tool: kubefwd
|
|
26
|
+
restart: yes
|
|
27
|
+
disabledItems:
|
|
28
|
+
- service2
|
|
29
|
+
items:
|
|
30
|
+
service1: "8080,80"
|
|
31
|
+
service2: "8081,80"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### ConfigLoader API Additions
|
|
35
|
+
- `saveConfig(config: CliGrConfig): void` — writes the full config back to the YAML file while preserving structure.
|
|
36
|
+
- `toggleItem(groupName: string, itemName: string, enabled: boolean): void` — adds/removes the item from `disabledItems` and calls `saveConfig()`.
|
|
37
|
+
|
|
38
|
+
### Filtering
|
|
39
|
+
When loading a group for execution (`upCommand` or `serve`), items present in `disabledItems` are filtered out before building `ProcessItem`s.
|
|
40
|
+
|
|
41
|
+
## 3. HTTP API & SSE
|
|
42
|
+
|
|
43
|
+
| Method | Endpoint | Body | Description |
|
|
44
|
+
|--------|----------|------|-------------|
|
|
45
|
+
| GET | `/` | — | Serves the HTML UI |
|
|
46
|
+
| GET | `/api/groups` | — | Returns all groups, items, running state, and `disabledItems` |
|
|
47
|
+
| POST | `/api/groups/:name/toggle` | `{ enabled: boolean }` | Starts or stops a group |
|
|
48
|
+
| POST | `/api/groups/:name/items/:item/toggle` | `{ enabled: boolean }` | Enables/disables an item |
|
|
49
|
+
| GET | `/api/events` | — | SSE stream for live updates |
|
|
50
|
+
|
|
51
|
+
### SSE Event Types
|
|
52
|
+
- `status` — Sent whenever a group starts/stops or an item is toggled. Payload includes current state of all groups.
|
|
53
|
+
- `log` — Sent when a managed process writes to stdout/stderr. Payload: `{ group, item, line, isError }`.
|
|
54
|
+
|
|
55
|
+
## 4. UI Behavior
|
|
56
|
+
|
|
57
|
+
- **Group header**: Shows group name, tool, and a checkbox. Checked = running; unchecked = stopped.
|
|
58
|
+
- **Item list**: Each item has a checkbox. Checked = enabled; unchecked = disabled.
|
|
59
|
+
- **Live log panel**: Scrollable panel showing the last ~500 lines, prefixed `[group/item]`.
|
|
60
|
+
|
|
61
|
+
### Interaction Rules
|
|
62
|
+
- **Group checkbox toggle**: Checking starts the group with currently enabled items. Unchecking stops the whole group via `ProcessManager.killGroup()`.
|
|
63
|
+
- **Item checkbox toggle**: If the group is running, persist the `disabledItems` change, then call `ProcessManager.restartGroup()` (kill previous processes, respawn with the new enabled set). If the group is not running, only persist the config change.
|
|
64
|
+
- **Log auto-scroll**: New lines scroll to bottom unless the user has manually scrolled up.
|
|
65
|
+
|
|
66
|
+
## 5. ProcessManager Changes
|
|
67
|
+
|
|
68
|
+
`ProcessManager` will extend Node.js `EventEmitter`.
|
|
69
|
+
|
|
70
|
+
### New Methods
|
|
71
|
+
- `restartGroup(groupName, items, restartPolicy)` — kills existing processes, removes the group from the map, then re-spawns with the given items.
|
|
72
|
+
|
|
73
|
+
### Emitted Events
|
|
74
|
+
- `group-started` `(groupName)`
|
|
75
|
+
- `group-stopped` `(groupName)`
|
|
76
|
+
- `item-restarted` `(groupName, itemName)`
|
|
77
|
+
- `process-log` `(groupName, itemName, line: string, isError: boolean)` — stdout/stderr data is split on newlines so only complete lines are emitted
|
|
78
|
+
|
|
79
|
+
The `serve` command subscribes to these events and forwards them over SSE.
|
|
80
|
+
|
|
81
|
+
## 6. Error Handling
|
|
82
|
+
|
|
83
|
+
- **Config write failure**: Return HTTP 500. The UI re-fetches status on error and reverts the checkbox.
|
|
84
|
+
- **Group spawn failure**: Emit a `status` event with an `error` field; UI shows an error banner.
|
|
85
|
+
- **Port in use**: Print `Port 7373 is already in use` and exit with code 1.
|
|
86
|
+
- **Process crash loop**: Existing `ProcessManager` behavior (stop restarting after 3 crashes in 10s). Logged to UI via `process-log` events.
|
|
87
|
+
|
|
88
|
+
## 7. Testing Approach
|
|
89
|
+
|
|
90
|
+
- `ConfigLoader`: test `saveConfig()` round-trip and `disabledItems` filtering.
|
|
91
|
+
- `serve` command: test that the HTTP server starts and `/api/groups` returns the expected JSON.
|
|
92
|
+
- SSE endpoint: test that `ProcessManager` events are forwarded as SSE messages.
|
|
93
|
+
- No browser-based tests; test HTTP and SSE semantics directly via HTTP clients.
|
package/package.json
CHANGED
package/src/commands/ls.ts
CHANGED
|
@@ -4,22 +4,27 @@ export async function lsCommand(groupName: string): Promise<number> {
|
|
|
4
4
|
const loader = new ConfigLoader();
|
|
5
5
|
|
|
6
6
|
try {
|
|
7
|
-
const { config
|
|
7
|
+
const { config } = loader.getGroup(groupName);
|
|
8
8
|
|
|
9
9
|
console.log(`\nGroup: ${groupName}`);
|
|
10
10
|
console.log(`Tool: ${config.tool}`);
|
|
11
11
|
console.log(`Restart: ${config.restart}`);
|
|
12
12
|
console.log('\nItems:');
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const disabled = new Set(config.disabledItems || []);
|
|
15
|
+
for (const [name, value] of Object.entries(config.items)) {
|
|
16
|
+
const marker = disabled.has(name) ? ' [disabled]' : '';
|
|
17
|
+
console.log(` ${name}: ${value}${marker}`);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
console.log('');
|
|
19
21
|
|
|
20
22
|
return 0;
|
|
21
|
-
} catch (
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error instanceof Error && error.name === 'ConfigError') {
|
|
25
|
+
console.error(error.message);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
24
29
|
}
|
|
25
30
|
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
3
|
+
import { ProcessManager } from '../process/manager.js';
|
|
4
|
+
import { TemplateExpander } from '../process/template.js';
|
|
5
|
+
|
|
6
|
+
export async function serveCommand(portArg?: string): Promise<number> {
|
|
7
|
+
const port = portArg ? parseInt(portArg, 10) : 7373;
|
|
8
|
+
const loader = new ConfigLoader();
|
|
9
|
+
const manager = new ProcessManager();
|
|
10
|
+
|
|
11
|
+
// Clean up any stale PID files on startup
|
|
12
|
+
await manager.cleanupStalePids();
|
|
13
|
+
|
|
14
|
+
const clients: http.ServerResponse[] = [];
|
|
15
|
+
|
|
16
|
+
const sendEvent = (event: string, data: unknown) => {
|
|
17
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
18
|
+
for (let i = clients.length - 1; i >= 0; i--) {
|
|
19
|
+
const client = clients[i];
|
|
20
|
+
try {
|
|
21
|
+
client.write(payload);
|
|
22
|
+
} catch {
|
|
23
|
+
clients.splice(i, 1);
|
|
24
|
+
try { client.end(); } catch { /* ignore */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
manager.on('group-started', (groupName) => {
|
|
30
|
+
sendEvent('status', { type: 'group-started', groupName });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
manager.on('group-stopped', (groupName) => {
|
|
34
|
+
sendEvent('status', { type: 'group-stopped', groupName });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
manager.on('item-restarted', (groupName, itemName) => {
|
|
38
|
+
sendEvent('status', { type: 'item-restarted', groupName, itemName });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
manager.on('process-log', (groupName, itemName, line, isError) => {
|
|
42
|
+
sendEvent('log', { group: groupName, item: itemName, line, isError });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const server = http.createServer((req, res) => {
|
|
46
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
47
|
+
|
|
48
|
+
// CORS headers
|
|
49
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
50
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
51
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
52
|
+
|
|
53
|
+
if (req.method === 'OPTIONS') {
|
|
54
|
+
res.writeHead(204);
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (url.pathname === '/') {
|
|
60
|
+
res.setHeader('Content-Type', 'text/html');
|
|
61
|
+
res.writeHead(200);
|
|
62
|
+
res.end(serveHtml());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (url.pathname === '/api/groups') {
|
|
67
|
+
try {
|
|
68
|
+
const config = loader.load();
|
|
69
|
+
const groups = Object.entries(config.groups).map(([name, group]) => ({
|
|
70
|
+
name,
|
|
71
|
+
tool: group.tool,
|
|
72
|
+
restart: group.restart,
|
|
73
|
+
items: Object.entries(group.items).map(([itemName, value]) => ({
|
|
74
|
+
name: itemName,
|
|
75
|
+
value,
|
|
76
|
+
enabled: !(group.disabledItems || []).includes(itemName),
|
|
77
|
+
})),
|
|
78
|
+
running: manager.isGroupRunning(name),
|
|
79
|
+
}));
|
|
80
|
+
res.setHeader('Content-Type', 'application/json');
|
|
81
|
+
res.writeHead(200);
|
|
82
|
+
res.end(JSON.stringify({ groups }));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
res.setHeader('Content-Type', 'application/json');
|
|
85
|
+
res.writeHead(500);
|
|
86
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (url.pathname === '/api/events') {
|
|
92
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
93
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
94
|
+
res.setHeader('Connection', 'keep-alive');
|
|
95
|
+
res.writeHead(200);
|
|
96
|
+
res.write(':ok\n\n');
|
|
97
|
+
clients.push(res);
|
|
98
|
+
req.on('close', () => {
|
|
99
|
+
const index = clients.indexOf(res);
|
|
100
|
+
if (index !== -1) clients.splice(index, 1);
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const toggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/toggle$/);
|
|
106
|
+
if (toggleMatch && req.method === 'POST') {
|
|
107
|
+
const groupName = decodeURIComponent(toggleMatch[1]);
|
|
108
|
+
let body = '';
|
|
109
|
+
req.on('data', (chunk) => body += chunk);
|
|
110
|
+
req.on('end', async () => {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(body);
|
|
114
|
+
} catch {
|
|
115
|
+
res.writeHead(400);
|
|
116
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { enabled } = parsed;
|
|
120
|
+
try {
|
|
121
|
+
if (enabled) {
|
|
122
|
+
const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
|
|
123
|
+
const processItems = items.map((item, index) =>
|
|
124
|
+
TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
|
|
125
|
+
);
|
|
126
|
+
manager.spawnGroup(groupName, processItems, config.restart);
|
|
127
|
+
} else {
|
|
128
|
+
await manager.killGroup(groupName);
|
|
129
|
+
}
|
|
130
|
+
res.setHeader('Content-Type', 'application/json');
|
|
131
|
+
res.writeHead(200);
|
|
132
|
+
res.end(JSON.stringify({ success: true }));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.setHeader('Content-Type', 'application/json');
|
|
135
|
+
res.writeHead(500);
|
|
136
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const itemToggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/items\/([^/]+)\/toggle$/);
|
|
143
|
+
if (itemToggleMatch && req.method === 'POST') {
|
|
144
|
+
const groupName = decodeURIComponent(itemToggleMatch[1]);
|
|
145
|
+
const itemName = decodeURIComponent(itemToggleMatch[2]);
|
|
146
|
+
let body = '';
|
|
147
|
+
req.on('data', (chunk) => body += chunk);
|
|
148
|
+
req.on('end', async () => {
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(body);
|
|
152
|
+
} catch {
|
|
153
|
+
res.writeHead(400);
|
|
154
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const { enabled } = parsed;
|
|
158
|
+
try {
|
|
159
|
+
loader.toggleItem(groupName, itemName, enabled);
|
|
160
|
+
|
|
161
|
+
if (manager.isGroupRunning(groupName)) {
|
|
162
|
+
const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
|
|
163
|
+
const processItems = items.map((item, index) =>
|
|
164
|
+
TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
|
|
165
|
+
);
|
|
166
|
+
await manager.restartGroup(groupName, processItems, config.restart);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
res.setHeader('Content-Type', 'application/json');
|
|
170
|
+
res.writeHead(200);
|
|
171
|
+
res.end(JSON.stringify({ success: true }));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
res.setHeader('Content-Type', 'application/json');
|
|
174
|
+
res.writeHead(500);
|
|
175
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
res.writeHead(404);
|
|
182
|
+
res.end('Not found');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
186
|
+
if (err.code === 'EADDRINUSE') {
|
|
187
|
+
console.error(`Port ${port} is already in use`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
} else {
|
|
190
|
+
console.error('Server error:', err);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
let resolveCommand: (value: number) => void;
|
|
196
|
+
const commandPromise = new Promise<number>((resolve) => {
|
|
197
|
+
resolveCommand = resolve;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const shutdown = async () => {
|
|
201
|
+
console.log('\nShutting down...');
|
|
202
|
+
server.close();
|
|
203
|
+
await manager.killAll();
|
|
204
|
+
resolveCommand(0);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
process.on('SIGINT', shutdown);
|
|
208
|
+
process.on('SIGTERM', shutdown);
|
|
209
|
+
|
|
210
|
+
server.listen(port, () => {
|
|
211
|
+
console.log(`cligr serve running at http://localhost:${port}`);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return commandPromise;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function serveHtml(): string {
|
|
218
|
+
return `<!DOCTYPE html>
|
|
219
|
+
<html>
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8">
|
|
222
|
+
<title>cligr serve</title>
|
|
223
|
+
<style>
|
|
224
|
+
* { box-sizing: border-box; }
|
|
225
|
+
html, body { height: 100%; margin: 0; }
|
|
226
|
+
body { font-family: system-ui, sans-serif; display: flex; flex-direction: column; }
|
|
227
|
+
h1 { font-size: 1.25rem; margin: 0; padding: 0.75rem 1rem; border-bottom: 1px solid #ccc; background: #f8f8f8; }
|
|
228
|
+
.container { display: flex; flex: 1; overflow: hidden; }
|
|
229
|
+
.sidebar { width: 320px; min-width: 180px; max-width: 60vw; border-right: 1px solid #ccc; padding: 1rem; overflow-y: auto; background: #fafafa; }
|
|
230
|
+
.resizer { width: 6px; background: #e0e0e0; cursor: col-resize; flex-shrink: 0; }
|
|
231
|
+
.resizer:hover { background: #bbb; }
|
|
232
|
+
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
233
|
+
.main h2 { font-size: 1rem; margin: 0; padding: 0.5rem 1rem; border-bottom: 1px solid #ccc; background: #f0f0f0; }
|
|
234
|
+
.group { border: 1px solid #ccc; border-radius: 6px; padding: 0.75rem; margin: 0 0 0.75rem 0; background: #fff; }
|
|
235
|
+
.group-header { display: flex; align-items: center; gap: 0.5rem; font-weight: bold; font-size: 1rem; }
|
|
236
|
+
.items { margin: 0.5rem 0 0 1.25rem; }
|
|
237
|
+
.item { display: flex; align-items: center; gap: 0.4rem; margin: 0.2rem 0; font-size: 0.9rem; }
|
|
238
|
+
.logs { flex: 1; background: #111; color: #0f0; font-family: monospace; font-size: 0.85rem; overflow-y: auto; padding: 0.75rem; white-space: pre-wrap; }
|
|
239
|
+
.error { color: #f55; }
|
|
240
|
+
</style>
|
|
241
|
+
</head>
|
|
242
|
+
<body>
|
|
243
|
+
<h1>cligr serve</h1>
|
|
244
|
+
<div class="container">
|
|
245
|
+
<div class="sidebar" id="groups"></div>
|
|
246
|
+
<div class="resizer" id="resizer"></div>
|
|
247
|
+
<div class="main">
|
|
248
|
+
<h2>Console</h2>
|
|
249
|
+
<div class="logs" id="logs"></div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<script>
|
|
254
|
+
const groupsEl = document.getElementById('groups');
|
|
255
|
+
const logsEl = document.getElementById('logs');
|
|
256
|
+
const resizer = document.getElementById('resizer');
|
|
257
|
+
let autoScroll = true;
|
|
258
|
+
|
|
259
|
+
resizer.addEventListener('mousedown', (e) => {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
document.body.style.cursor = 'col-resize';
|
|
262
|
+
const startX = e.clientX;
|
|
263
|
+
const startWidth = groupsEl.offsetWidth;
|
|
264
|
+
|
|
265
|
+
const onMove = (ev) => {
|
|
266
|
+
const newWidth = startWidth + (ev.clientX - startX);
|
|
267
|
+
groupsEl.style.width = Math.max(180, Math.min(newWidth, window.innerWidth * 0.6)) + 'px';
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onUp = () => {
|
|
271
|
+
document.removeEventListener('mousemove', onMove);
|
|
272
|
+
document.removeEventListener('mouseup', onUp);
|
|
273
|
+
document.body.style.cursor = '';
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
document.addEventListener('mousemove', onMove);
|
|
277
|
+
document.addEventListener('mouseup', onUp);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
async function fetchGroups() {
|
|
281
|
+
const res = await fetch('/api/groups');
|
|
282
|
+
const data = await res.json();
|
|
283
|
+
renderGroups(data.groups);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderGroups(groups) {
|
|
287
|
+
groupsEl.innerHTML = '';
|
|
288
|
+
for (const g of groups) {
|
|
289
|
+
const div = document.createElement('div');
|
|
290
|
+
div.className = 'group';
|
|
291
|
+
|
|
292
|
+
const header = document.createElement('div');
|
|
293
|
+
header.className = 'group-header';
|
|
294
|
+
const checkbox = document.createElement('input');
|
|
295
|
+
checkbox.type = 'checkbox';
|
|
296
|
+
checkbox.checked = g.running;
|
|
297
|
+
checkbox.onchange = async () => {
|
|
298
|
+
await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/toggle\`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'Content-Type': 'application/json' },
|
|
301
|
+
body: JSON.stringify({ enabled: checkbox.checked })
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
header.appendChild(checkbox);
|
|
305
|
+
header.appendChild(document.createTextNode(g.name + ' (' + g.tool + ')' + (g.running ? ' - running' : '')));
|
|
306
|
+
div.appendChild(header);
|
|
307
|
+
|
|
308
|
+
const itemsDiv = document.createElement('div');
|
|
309
|
+
itemsDiv.className = 'items';
|
|
310
|
+
for (const item of g.items) {
|
|
311
|
+
const itemDiv = document.createElement('div');
|
|
312
|
+
itemDiv.className = 'item';
|
|
313
|
+
const itemCb = document.createElement('input');
|
|
314
|
+
itemCb.type = 'checkbox';
|
|
315
|
+
itemCb.checked = item.enabled;
|
|
316
|
+
itemCb.onchange = async () => {
|
|
317
|
+
await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/items/\${encodeURIComponent(item.name)}/toggle\`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: { 'Content-Type': 'application/json' },
|
|
320
|
+
body: JSON.stringify({ enabled: itemCb.checked })
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
itemDiv.appendChild(itemCb);
|
|
324
|
+
itemDiv.appendChild(document.createTextNode(item.name + ': ' + item.value));
|
|
325
|
+
itemsDiv.appendChild(itemDiv);
|
|
326
|
+
}
|
|
327
|
+
div.appendChild(itemsDiv);
|
|
328
|
+
groupsEl.appendChild(div);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
logsEl.addEventListener('scroll', () => {
|
|
333
|
+
autoScroll = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 10;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
function appendLog(line, isError) {
|
|
337
|
+
const span = document.createElement('div');
|
|
338
|
+
span.textContent = line;
|
|
339
|
+
if (isError) span.className = 'error';
|
|
340
|
+
logsEl.appendChild(span);
|
|
341
|
+
if (autoScroll) logsEl.scrollTop = logsEl.scrollHeight;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const evtSource = new EventSource('/api/events');
|
|
345
|
+
evtSource.addEventListener('status', (e) => {
|
|
346
|
+
fetchGroups();
|
|
347
|
+
});
|
|
348
|
+
evtSource.addEventListener('log', (e) => {
|
|
349
|
+
const data = JSON.parse(e.data);
|
|
350
|
+
appendLog(\`[\${data.group}/\${data.item}] \${data.line}\`, data.isError);
|
|
351
|
+
});
|
|
352
|
+
evtSource.onerror = () => {
|
|
353
|
+
appendLog('[SSE connection error]', true);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
fetchGroups();
|
|
357
|
+
</script>
|
|
358
|
+
</body>
|
|
359
|
+
</html>`;
|
|
360
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -73,6 +73,7 @@ export class ConfigLoader {
|
|
|
73
73
|
if (group && typeof group === 'object') {
|
|
74
74
|
const groupObj = group as Record<string, unknown>;
|
|
75
75
|
this.validateItems(groupObj.items, groupName);
|
|
76
|
+
this.validateDisabledItems(groupObj.items, groupObj.disabledItems, groupName);
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -105,6 +106,36 @@ export class ConfigLoader {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
private validateDisabledItems(items: unknown, disabledItems: unknown, groupName: string): void {
|
|
110
|
+
if (disabledItems === undefined || disabledItems === null) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!Array.isArray(disabledItems)) {
|
|
115
|
+
throw new ConfigError(`Group "${groupName}": disabledItems must be an array of strings`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
const itemKeys = items && typeof items === 'object' && !Array.isArray(items)
|
|
120
|
+
? new Set(Object.keys(items as Record<string, unknown>))
|
|
121
|
+
: new Set<string>();
|
|
122
|
+
|
|
123
|
+
for (const entry of disabledItems) {
|
|
124
|
+
if (typeof entry !== 'string') {
|
|
125
|
+
throw new ConfigError(`Group "${groupName}": disabledItems must be an array of strings`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (seen.has(entry)) {
|
|
129
|
+
throw new ConfigError(`Group "${groupName}": disabledItems contains duplicate "${entry}"`);
|
|
130
|
+
}
|
|
131
|
+
seen.add(entry);
|
|
132
|
+
|
|
133
|
+
if (!itemKeys.has(entry)) {
|
|
134
|
+
throw new ConfigError(`Group "${groupName}": disabledItems entry "${entry}" does not match any item`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
108
139
|
private normalizeItems(items: Record<string, string>): ItemEntry[] {
|
|
109
140
|
return Object.entries(items).map(([name, value]) => ({
|
|
110
141
|
name,
|
|
@@ -121,8 +152,14 @@ export class ConfigLoader {
|
|
|
121
152
|
throw new ConfigError(`Unknown group: ${name}. Available: ${available}`);
|
|
122
153
|
}
|
|
123
154
|
|
|
124
|
-
|
|
125
|
-
const
|
|
155
|
+
const disabled = new Set(group.disabledItems || []);
|
|
156
|
+
const enabledItems: Record<string, string> = {};
|
|
157
|
+
for (const [name, value] of Object.entries(group.items)) {
|
|
158
|
+
if (!disabled.has(name)) {
|
|
159
|
+
enabledItems[name] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const items = this.normalizeItems(enabledItems);
|
|
126
163
|
|
|
127
164
|
// Resolve tool
|
|
128
165
|
let toolTemplate: string | null = null;
|
|
@@ -141,6 +178,38 @@ export class ConfigLoader {
|
|
|
141
178
|
return { config: group, items, tool, toolTemplate, params };
|
|
142
179
|
}
|
|
143
180
|
|
|
181
|
+
saveConfig(config: CliGrConfig): void {
|
|
182
|
+
const yamlContent = yaml.dump(config, { indent: 2, lineWidth: -1 });
|
|
183
|
+
fs.writeFileSync(this.configPath, yamlContent, 'utf-8');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
toggleItem(groupName: string, itemName: string, enabled: boolean): void {
|
|
187
|
+
const config = this.load();
|
|
188
|
+
const group = config.groups[groupName];
|
|
189
|
+
if (!group) {
|
|
190
|
+
throw new ConfigError(`Unknown group: ${groupName}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!Object.hasOwn(group.items || {}, itemName)) {
|
|
194
|
+
throw new ConfigError(`Item "${itemName}" not found in group "${groupName}"`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const disabled = new Set(group.disabledItems || []);
|
|
198
|
+
if (enabled) {
|
|
199
|
+
disabled.delete(itemName);
|
|
200
|
+
} else {
|
|
201
|
+
disabled.add(itemName);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (disabled.size === 0) {
|
|
205
|
+
delete (group as unknown as Record<string, unknown>).disabledItems;
|
|
206
|
+
} else {
|
|
207
|
+
group.disabledItems = Array.from(disabled);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.saveConfig(config);
|
|
211
|
+
}
|
|
212
|
+
|
|
144
213
|
listGroups(): string[] {
|
|
145
214
|
const config = this.load();
|
|
146
215
|
return Object.keys(config.groups);
|
package/src/config/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { upCommand } from './commands/up.js';
|
|
|
4
4
|
import { lsCommand } from './commands/ls.js';
|
|
5
5
|
import { configCommand } from './commands/config.js';
|
|
6
6
|
import { groupsCommand } from './commands/groups.js';
|
|
7
|
+
import { serveCommand } from './commands/serve.js';
|
|
7
8
|
|
|
8
9
|
async function main(): Promise<void> {
|
|
9
10
|
const args = process.argv.slice(2);
|
|
@@ -18,7 +19,7 @@ async function main(): Promise<void> {
|
|
|
18
19
|
let verbose = false;
|
|
19
20
|
|
|
20
21
|
// Check if this is a known command
|
|
21
|
-
const knownCommands = ['config', 'up', 'ls', 'groups'];
|
|
22
|
+
const knownCommands = ['config', 'up', 'ls', 'groups', 'serve'];
|
|
22
23
|
|
|
23
24
|
if (knownCommands.includes(firstArg)) {
|
|
24
25
|
// It's a command
|
|
@@ -38,7 +39,7 @@ async function main(): Promise<void> {
|
|
|
38
39
|
groupName = rest[0];
|
|
39
40
|
|
|
40
41
|
// config and groups commands don't require group name
|
|
41
|
-
if (command !== 'config' && command !== 'groups' && !groupName) {
|
|
42
|
+
if (command !== 'config' && command !== 'groups' && command !== 'serve' && !groupName) {
|
|
42
43
|
console.error('Error: group name required');
|
|
43
44
|
printUsage();
|
|
44
45
|
process.exit(1);
|
|
@@ -60,6 +61,9 @@ async function main(): Promise<void> {
|
|
|
60
61
|
case 'groups':
|
|
61
62
|
exitCode = await groupsCommand(verbose);
|
|
62
63
|
break;
|
|
64
|
+
case 'serve':
|
|
65
|
+
exitCode = await serveCommand(rest[0]);
|
|
66
|
+
break;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
process.exit(exitCode);
|
|
@@ -77,7 +81,8 @@ Usage: cligr <group> | <command> [options]
|
|
|
77
81
|
Commands:
|
|
78
82
|
config Open config file in editor
|
|
79
83
|
ls <group> List all items in the group
|
|
80
|
-
groups [-v|--verbose]
|
|
84
|
+
groups [-v|--verbose] List all groups
|
|
85
|
+
serve [port] Start web UI server (default port 7373)
|
|
81
86
|
|
|
82
87
|
Options:
|
|
83
88
|
-v, --verbose Show detailed group information
|
|
@@ -88,6 +93,8 @@ Examples:
|
|
|
88
93
|
cligr ls test1
|
|
89
94
|
cligr groups
|
|
90
95
|
cligr groups -v
|
|
96
|
+
cligr serve
|
|
97
|
+
cligr serve 8080
|
|
91
98
|
`);
|
|
92
99
|
}
|
|
93
100
|
|