cligr 1.0.5 → 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/README.md +1 -1
- package/dist/index.js +192 -39
- package/docs/plans/2026-02-25-named-items-design.md +164 -0
- package/docs/plans/2026-02-25-named-items-implementation.md +460 -0
- 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 +3 -3
- package/scripts/test.js +17 -13
- package/src/commands/groups.ts +1 -1
- package/src/commands/ls.ts +10 -6
- package/src/commands/serve.ts +360 -0
- package/src/commands/up.ts +5 -5
- package/src/config/loader.ts +116 -5
- package/src/config/types.ts +7 -1
- package/src/index.ts +10 -3
- package/src/process/manager.ts +36 -2
- package/src/process/template.ts +13 -24
- package/tests/integration/commands.test.ts +65 -41
- package/tests/integration/config-loader.test.ts +143 -33
- package/tests/integration/process-manager.test.ts +107 -1
- package/tests/integration/serve.test.ts +245 -0
- package/tests/integration/template-expander.test.ts +101 -93
- package/.claude/settings.local.json +0 -13
- package/dist/commands/config.js +0 -102
- package/dist/commands/down.js +0 -26
- package/dist/commands/groups.js +0 -43
- package/dist/commands/ls.js +0 -23
- package/dist/commands/up.js +0 -39
- package/dist/config/loader.js +0 -82
- package/dist/config/types.js +0 -0
- package/dist/process/manager.js +0 -226
- package/dist/process/pid-store.js +0 -141
- package/dist/process/template.js +0 -53
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
# `cligr serve` Command Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a `serve` command that starts an HTTP server with a web UI for toggling groups and items. Toggling updates `disabledItems` in `.cligr.yml` and restarts running groups. UI updates via Server-Sent Events.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Extend `ConfigLoader` to persist `disabledItems`, extend `ProcessManager` with `EventEmitter` for real-time logs/status, and add a lightweight HTTP/SSE server in a new `serve.ts` command with an inline HTML UI.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Node.js built-in `http`, `events` (EventEmitter), `js-yaml`, Node.js built-in test runner.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
| File | Action | Responsibility |
|
|
16
|
+
|------|--------|----------------|
|
|
17
|
+
| `src/config/types.ts` | Modify | Add `disabledItems?: string[]` to `GroupConfig` |
|
|
18
|
+
| `src/config/loader.ts` | Modify | Add `saveConfig()`, `toggleItem()`, filter disabled items in `getGroup()` |
|
|
19
|
+
| `src/process/manager.ts` | Modify | Extend `EventEmitter`, add `restartGroup()`, emit `process-log`/`group-started`/`group-stopped`/`item-restarted` |
|
|
20
|
+
| `src/commands/serve.ts` | Create | HTTP server, SSE endpoint, inline HTML UI, API handlers |
|
|
21
|
+
| `src/index.ts` | Modify | Register `serve` command and `--port` flag parsing |
|
|
22
|
+
| `tests/integration/config-loader.test.ts` | Modify | Tests for `saveConfig`, `toggleItem`, `disabledItems` filtering |
|
|
23
|
+
| `tests/integration/process-manager.test.ts` | Modify | Tests for `restartGroup()` and emitted events |
|
|
24
|
+
| `tests/integration/serve.test.ts` | Create | Tests for HTTP API, SSE stream, and command registration |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Task 1: Add `disabledItems` support to config types and loader
|
|
29
|
+
|
|
30
|
+
**Files:**
|
|
31
|
+
- Modify: `src/config/types.ts`
|
|
32
|
+
- Modify: `src/config/loader.ts`
|
|
33
|
+
- Test: `tests/integration/config-loader.test.ts`
|
|
34
|
+
|
|
35
|
+
- [ ] **Step 1: Update `GroupConfig` type**
|
|
36
|
+
|
|
37
|
+
Modify `src/config/types.ts`:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
export interface GroupConfig {
|
|
41
|
+
tool: string;
|
|
42
|
+
restart?: 'yes' | 'no' | 'unless-stopped';
|
|
43
|
+
params?: Record<string, string>;
|
|
44
|
+
disabledItems?: string[];
|
|
45
|
+
items: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 2: Write failing tests for new loader features**
|
|
50
|
+
|
|
51
|
+
Add to `tests/integration/config-loader.test.ts` inside the `describe('ConfigLoader Integration Tests', () => { ... })` block, after the existing `describe('Constructor with explicit path', ...)`:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
describe('saveConfig()', () => {
|
|
55
|
+
it('should save config back to file', () => {
|
|
56
|
+
const configContent = `
|
|
57
|
+
groups:
|
|
58
|
+
test1:
|
|
59
|
+
tool: echo
|
|
60
|
+
restart: no
|
|
61
|
+
items:
|
|
62
|
+
hello: hello
|
|
63
|
+
`;
|
|
64
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
65
|
+
|
|
66
|
+
const loader = new ConfigLoader();
|
|
67
|
+
const config = loader.load();
|
|
68
|
+
config.groups.test1.restart = 'yes';
|
|
69
|
+
|
|
70
|
+
loader.saveConfig(config);
|
|
71
|
+
|
|
72
|
+
const saved = fs.readFileSync(testConfigPath, 'utf-8');
|
|
73
|
+
assert.ok(saved.includes('restart: yes'));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('toggleItem()', () => {
|
|
78
|
+
it('should add item to disabledItems when disabling', () => {
|
|
79
|
+
const configContent = `
|
|
80
|
+
groups:
|
|
81
|
+
test1:
|
|
82
|
+
tool: echo
|
|
83
|
+
restart: no
|
|
84
|
+
items:
|
|
85
|
+
hello: hello
|
|
86
|
+
world: world
|
|
87
|
+
`;
|
|
88
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
89
|
+
|
|
90
|
+
const loader = new ConfigLoader();
|
|
91
|
+
loader.toggleItem('test1', 'hello', false);
|
|
92
|
+
|
|
93
|
+
const reloaded = new ConfigLoader().load();
|
|
94
|
+
assert.deepStrictEqual(reloaded.groups.test1.disabledItems, ['hello']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should remove item from disabledItems when enabling', () => {
|
|
98
|
+
const configContent = `
|
|
99
|
+
groups:
|
|
100
|
+
test1:
|
|
101
|
+
tool: echo
|
|
102
|
+
restart: no
|
|
103
|
+
disabledItems:
|
|
104
|
+
- hello
|
|
105
|
+
items:
|
|
106
|
+
hello: hello
|
|
107
|
+
world: world
|
|
108
|
+
`;
|
|
109
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
110
|
+
|
|
111
|
+
const loader = new ConfigLoader();
|
|
112
|
+
loader.toggleItem('test1', 'hello', true);
|
|
113
|
+
|
|
114
|
+
const reloaded = new ConfigLoader().load();
|
|
115
|
+
assert.strictEqual(reloaded.groups.test1.disabledItems, undefined);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('disabledItems filtering', () => {
|
|
120
|
+
it('should filter disabled items from getGroup result', () => {
|
|
121
|
+
const configContent = `
|
|
122
|
+
groups:
|
|
123
|
+
test1:
|
|
124
|
+
tool: echo
|
|
125
|
+
restart: no
|
|
126
|
+
disabledItems:
|
|
127
|
+
- hello
|
|
128
|
+
items:
|
|
129
|
+
hello: hello
|
|
130
|
+
world: world
|
|
131
|
+
`;
|
|
132
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
133
|
+
|
|
134
|
+
const loader = new ConfigLoader();
|
|
135
|
+
const result = loader.getGroup('test1');
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(result.items.length, 1);
|
|
138
|
+
assert.strictEqual(result.items[0].name, 'world');
|
|
139
|
+
assert.strictEqual(result.config.disabledItems?.length, 1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should include all items when disabledItems is empty', () => {
|
|
143
|
+
const configContent = `
|
|
144
|
+
groups:
|
|
145
|
+
test1:
|
|
146
|
+
tool: echo
|
|
147
|
+
restart: no
|
|
148
|
+
disabledItems: []
|
|
149
|
+
items:
|
|
150
|
+
hello: hello
|
|
151
|
+
world: world
|
|
152
|
+
`;
|
|
153
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
154
|
+
|
|
155
|
+
const loader = new ConfigLoader();
|
|
156
|
+
const result = loader.getGroup('test1');
|
|
157
|
+
|
|
158
|
+
assert.strictEqual(result.items.length, 2);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
164
|
+
|
|
165
|
+
Run: `npm test`
|
|
166
|
+
|
|
167
|
+
Expected: FAIL with `saveConfig is not a function`, `toggleItem is not a function`, etc.
|
|
168
|
+
|
|
169
|
+
- [ ] **Step 4: Implement `saveConfig`, `toggleItem`, and filtering**
|
|
170
|
+
|
|
171
|
+
Modify `src/config/loader.ts`:
|
|
172
|
+
|
|
173
|
+
1. Add import for `fs` at the top (if not already — it currently imports `promises as fs`, but we need sync methods; check current imports):
|
|
174
|
+
|
|
175
|
+
Current imports are:
|
|
176
|
+
```typescript
|
|
177
|
+
import fs from 'fs';
|
|
178
|
+
import os from 'os';
|
|
179
|
+
import path from 'path';
|
|
180
|
+
import yaml from 'js-yaml';
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Good, it imports the full `fs` module (sync methods available).
|
|
184
|
+
|
|
185
|
+
2. Add methods to `ConfigLoader` class after `listGroups()`:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
saveConfig(config: CliGrConfig): void {
|
|
189
|
+
const yamlContent = yaml.dump(config, { indent: 2, lineWidth: -1 });
|
|
190
|
+
fs.writeFileSync(this.configPath, yamlContent, 'utf-8');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
toggleItem(groupName: string, itemName: string, enabled: boolean): void {
|
|
194
|
+
const config = this.load();
|
|
195
|
+
const group = config.groups[groupName];
|
|
196
|
+
if (!group) {
|
|
197
|
+
throw new ConfigError(`Unknown group: ${groupName}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const disabled = new Set(group.disabledItems || []);
|
|
201
|
+
if (enabled) {
|
|
202
|
+
disabled.delete(itemName);
|
|
203
|
+
} else {
|
|
204
|
+
disabled.add(itemName);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (disabled.size === 0) {
|
|
208
|
+
delete group.disabledItems;
|
|
209
|
+
} else {
|
|
210
|
+
group.disabledItems = Array.from(disabled);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.saveConfig(config);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
3. Update `getGroup()` to filter disabled items. Find the line:
|
|
218
|
+
```typescript
|
|
219
|
+
const items = this.normalizeItems(group.items);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Replace with:
|
|
223
|
+
```typescript
|
|
224
|
+
const disabled = new Set(group.disabledItems || []);
|
|
225
|
+
const enabledItems: Record<string, string> = {};
|
|
226
|
+
for (const [name, value] of Object.entries(group.items)) {
|
|
227
|
+
if (!disabled.has(name)) {
|
|
228
|
+
enabledItems[name] = value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const items = this.normalizeItems(enabledItems);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
235
|
+
|
|
236
|
+
Run: `npm test`
|
|
237
|
+
|
|
238
|
+
Expected: PASS for all config-loader tests.
|
|
239
|
+
|
|
240
|
+
- [ ] **Step 6: Commit**
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
git add src/config/types.ts src/config/loader.ts tests/integration/config-loader.test.ts
|
|
244
|
+
git commit -m "feat(config): add disabledItems support with save and toggle"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Task 2: Update CLI commands to respect `disabledItems`
|
|
250
|
+
|
|
251
|
+
**Files:**
|
|
252
|
+
- Modify: `src/commands/up.ts`
|
|
253
|
+
- Modify: `src/commands/ls.ts`
|
|
254
|
+
- Test: `tests/integration/commands.test.ts`
|
|
255
|
+
|
|
256
|
+
- [ ] **Step 1: Update `upCommand` to pass disabled state info**
|
|
257
|
+
|
|
258
|
+
`src/commands/up.ts` already uses `loader.getGroup()`, which now filters disabled items automatically. No code change needed here — but verify the existing code still works.
|
|
259
|
+
|
|
260
|
+
- [ ] **Step 2: Update `lsCommand` to show disabled status**
|
|
261
|
+
|
|
262
|
+
Modify `src/commands/ls.ts`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
266
|
+
|
|
267
|
+
export async function lsCommand(groupName: string): Promise<number> {
|
|
268
|
+
const loader = new ConfigLoader();
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const { config, items } = loader.getGroup(groupName);
|
|
272
|
+
|
|
273
|
+
console.log(`\nGroup: ${groupName}`);
|
|
274
|
+
console.log(`Tool: ${config.tool}`);
|
|
275
|
+
console.log(`Restart: ${config.restart}`);
|
|
276
|
+
console.log('\nItems:');
|
|
277
|
+
|
|
278
|
+
const disabled = new Set(config.disabledItems || []);
|
|
279
|
+
for (const item of items) {
|
|
280
|
+
const marker = disabled.has(item.name) ? ' [disabled]' : '';
|
|
281
|
+
console.log(` ${item.name}: ${item.value}${marker}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (disabled.size > 0) {
|
|
285
|
+
console.log(`\nDisabled items: ${Array.from(disabled).join(', ')}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log('');
|
|
289
|
+
|
|
290
|
+
return 0;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error((err as Error).message);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Wait — that's wrong. `items` returned from `getGroup()` are already filtered. We want to show ALL items with their status. We need to load the raw items separately.
|
|
299
|
+
|
|
300
|
+
Correct approach:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
304
|
+
|
|
305
|
+
export async function lsCommand(groupName: string): Promise<number> {
|
|
306
|
+
const loader = new ConfigLoader();
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const config = loader.load().groups[groupName];
|
|
310
|
+
if (!config) {
|
|
311
|
+
throw new Error(`Unknown group: ${groupName}. Available: ${loader.listGroups().join(', ')}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log(`\nGroup: ${groupName}`);
|
|
315
|
+
console.log(`Tool: ${config.tool}`);
|
|
316
|
+
console.log(`Restart: ${config.restart}`);
|
|
317
|
+
console.log('\nItems:');
|
|
318
|
+
|
|
319
|
+
const disabled = new Set(config.disabledItems || []);
|
|
320
|
+
for (const [name, value] of Object.entries(config.items)) {
|
|
321
|
+
const marker = disabled.has(name) ? ' [disabled]' : '';
|
|
322
|
+
console.log(` ${name}: ${value}${marker}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log('');
|
|
326
|
+
|
|
327
|
+
return 0;
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.error((err as Error).message);
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 3: Write failing test for `lsCommand` with disabled items**
|
|
336
|
+
|
|
337
|
+
Add to `tests/integration/commands.test.ts` inside `describe('lsCommand', () => { ... })`:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
it('should mark disabled items in ls output', async () => {
|
|
341
|
+
const configContent = `
|
|
342
|
+
groups:
|
|
343
|
+
mixed:
|
|
344
|
+
tool: echo
|
|
345
|
+
restart: no
|
|
346
|
+
disabledItems:
|
|
347
|
+
- service2
|
|
348
|
+
items:
|
|
349
|
+
service1: service1
|
|
350
|
+
service2: service2
|
|
351
|
+
`;
|
|
352
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
353
|
+
resetOutput();
|
|
354
|
+
|
|
355
|
+
const exitCode = await lsCommand('mixed');
|
|
356
|
+
|
|
357
|
+
assert.strictEqual(exitCode, 0);
|
|
358
|
+
const output = getLogOutput();
|
|
359
|
+
assert.ok(output.includes('service1: service1'));
|
|
360
|
+
assert.ok(output.includes('service2: service2 [disabled]'));
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
365
|
+
|
|
366
|
+
Run: `npm test`
|
|
367
|
+
|
|
368
|
+
Expected: PASS for commands tests.
|
|
369
|
+
|
|
370
|
+
- [ ] **Step 5: Commit**
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
git add src/commands/ls.ts tests/integration/commands.test.ts
|
|
374
|
+
git commit -m "feat(ls): show disabled items status"
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Task 3: Extend `ProcessManager` with events and `restartGroup`
|
|
380
|
+
|
|
381
|
+
**Files:**
|
|
382
|
+
- Modify: `src/process/manager.ts`
|
|
383
|
+
- Test: `tests/integration/process-manager.test.ts`
|
|
384
|
+
|
|
385
|
+
- [ ] **Step 1: Make `ProcessManager` extend `EventEmitter`**
|
|
386
|
+
|
|
387
|
+
At the top of `src/process/manager.ts`, add:
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
import { EventEmitter } from 'events';
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Change the class definition from:
|
|
394
|
+
```typescript
|
|
395
|
+
export class ProcessManager {
|
|
396
|
+
```
|
|
397
|
+
to:
|
|
398
|
+
```typescript
|
|
399
|
+
export class ProcessManager extends EventEmitter {
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- [ ] **Step 2: Add `restartGroup()` method**
|
|
403
|
+
|
|
404
|
+
Add after `spawnGroup()`:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
async restartGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): Promise<void> {
|
|
408
|
+
await this.killGroup(groupName);
|
|
409
|
+
this.spawnGroup(groupName, items, restartPolicy);
|
|
410
|
+
this.emit('item-restarted', groupName, items.map(i => i.name).join(', '));
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Actually, `item-restarted` in the design is for individual item restarts. For a full group restart, we should emit `group-started`. Let's adjust:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
async restartGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): Promise<void> {
|
|
418
|
+
await this.killGroup(groupName);
|
|
419
|
+
this.spawnGroup(groupName, items, restartPolicy);
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`spawnGroup` already emits `group-started`, and `killGroup` emits `group-stopped`.
|
|
424
|
+
|
|
425
|
+
- [ ] **Step 3: Emit events in `spawnGroup`, `killGroup`, and line-buffer logs**
|
|
426
|
+
|
|
427
|
+
1. In `spawnGroup()`, after setting the map, emit:
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
this.groups.set(groupName, processes);
|
|
431
|
+
this.emit('group-started', groupName);
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
2. In `killGroup()`, after cleanup, emit:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
return Promise.all(killPromises).then(async () => {
|
|
438
|
+
await this.pidStore.deleteGroupPids(groupName);
|
|
439
|
+
this.emit('group-stopped', groupName);
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
3. For line-buffered log emission, modify the stdout/stderr handlers in `spawnProcess()`. Replace the existing handlers with:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
const emitLines = (data: Buffer, isError: boolean) => {
|
|
447
|
+
const text = data.toString('utf-8');
|
|
448
|
+
const lines = text.split('\n');
|
|
449
|
+
for (const line of lines) {
|
|
450
|
+
if (line.length > 0 || lines.length > 1) {
|
|
451
|
+
this.emit('process-log', groupName, item.name, line, isError);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Prefix output with item name and emit events
|
|
457
|
+
if (proc.stdout) {
|
|
458
|
+
proc.stdout.on('data', (data) => {
|
|
459
|
+
process.stdout.write(`[${item.name}] ${data}`);
|
|
460
|
+
emitLines(data, false);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (proc.stderr) {
|
|
465
|
+
proc.stderr.on('data', (data) => {
|
|
466
|
+
process.stderr.write(`[${item.name}] ${data}`);
|
|
467
|
+
emitLines(data, true);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Wait, this will emit events for incomplete lines at end of buffer too. That's acceptable for this design. The alternative is to keep a line buffer per stream, but for simplicity we split on newlines.
|
|
473
|
+
|
|
474
|
+
Also, in `handleExit()` where restarts happen, emit `item-restarted`:
|
|
475
|
+
|
|
476
|
+
In `handleExit()`, inside the `setTimeout` callback where restart happens:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// Restart after delay
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
console.log(`[${item.name}] Restarting... (exit code: ${code})`);
|
|
482
|
+
const newProc = this.spawnProcess(item, groupName, restartPolicy);
|
|
483
|
+
this.emit('item-restarted', groupName, item.name);
|
|
484
|
+
|
|
485
|
+
// Update the ManagedProcess in the groups Map with the new process handle
|
|
486
|
+
...
|
|
487
|
+
}, 1000);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 4: Write failing tests for new ProcessManager features**
|
|
491
|
+
|
|
492
|
+
Add to `tests/integration/process-manager.test.ts` inside the main describe block, after `describe('Cross-platform compatibility', ...)`:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
describe('restartGroup()', () => {
|
|
496
|
+
it('should restart a running group', async () => {
|
|
497
|
+
const sleepCmd = process.platform === 'win32' ? 'timeout' : 'sleep';
|
|
498
|
+
const sleepFlag = process.platform === 'win32' ? '/t' : '';
|
|
499
|
+
|
|
500
|
+
const items: ProcessItem[] = [
|
|
501
|
+
{ name: 'p1', args: ['5'], fullCmd: `${sleepCmd} ${sleepFlag} 5` }
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
manager.spawnGroup('restart-group', items, 'no');
|
|
505
|
+
assert.strictEqual(manager.isGroupRunning('restart-group'), true);
|
|
506
|
+
|
|
507
|
+
await manager.restartGroup('restart-group', items, 'no');
|
|
508
|
+
assert.strictEqual(manager.isGroupRunning('restart-group'), true);
|
|
509
|
+
|
|
510
|
+
await manager.killGroup('restart-group');
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('events', () => {
|
|
515
|
+
it('should emit group-started when spawning', async () => {
|
|
516
|
+
let emitted = false;
|
|
517
|
+
manager.once('group-started', (name) => {
|
|
518
|
+
assert.strictEqual(name, 'event-group');
|
|
519
|
+
emitted = true;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const items: ProcessItem[] = [
|
|
523
|
+
{ name: 'p1', args: [], fullCmd: 'echo hello' }
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
manager.spawnGroup('event-group', items, 'no');
|
|
527
|
+
assert.strictEqual(emitted, true);
|
|
528
|
+
|
|
529
|
+
await manager.killGroup('event-group');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should emit group-stopped when killing', async () => {
|
|
533
|
+
const items: ProcessItem[] = [
|
|
534
|
+
{ name: 'p1', args: ['5'], fullCmd: process.platform === 'win32' ? 'timeout /t 5' : 'sleep 5' }
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
manager.spawnGroup('stop-group', items, 'no');
|
|
538
|
+
|
|
539
|
+
let emitted = false;
|
|
540
|
+
manager.once('group-stopped', (name) => {
|
|
541
|
+
assert.strictEqual(name, 'stop-group');
|
|
542
|
+
emitted = true;
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await manager.killGroup('stop-group');
|
|
546
|
+
assert.strictEqual(emitted, true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('should emit process-log events', async () => {
|
|
550
|
+
const items: ProcessItem[] = [
|
|
551
|
+
{ name: 'logger', args: [], fullCmd: 'echo test-log' }
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
const logs: Array<{ group: string; item: string; line: string; isError: boolean }> = [];
|
|
555
|
+
manager.on('process-log', (group, item, line, isError) => {
|
|
556
|
+
logs.push({ group, item, line, isError });
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
manager.spawnGroup('log-group', items, 'no');
|
|
560
|
+
|
|
561
|
+
// Wait for process to run
|
|
562
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
563
|
+
|
|
564
|
+
assert.ok(logs.some(l => l.group === 'log-group' && l.item === 'logger' && l.line.includes('test-log')));
|
|
565
|
+
|
|
566
|
+
await manager.killGroup('log-group');
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
572
|
+
|
|
573
|
+
Run: `npm test`
|
|
574
|
+
|
|
575
|
+
Expected: PASS for all process-manager tests.
|
|
576
|
+
|
|
577
|
+
- [ ] **Step 6: Commit**
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
git add src/process/manager.ts tests/integration/process-manager.test.ts
|
|
581
|
+
git commit -m "feat(process): add EventEmitter, restartGroup, and process-log events"
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Task 4: Create the `serve` command
|
|
587
|
+
|
|
588
|
+
**Files:**
|
|
589
|
+
- Create: `src/commands/serve.ts`
|
|
590
|
+
- Modify: `src/index.ts`
|
|
591
|
+
- Test: `tests/integration/serve.test.ts`
|
|
592
|
+
|
|
593
|
+
- [ ] **Step 1: Create `serve.ts` command**
|
|
594
|
+
|
|
595
|
+
Create `src/commands/serve.ts`:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
import http from 'http';
|
|
599
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
600
|
+
import { ProcessManager } from '../process/manager.js';
|
|
601
|
+
import { TemplateExpander } from '../process/template.js';
|
|
602
|
+
|
|
603
|
+
export async function serveCommand(portArg?: string): Promise<number> {
|
|
604
|
+
const port = portArg ? parseInt(portArg, 10) : 7373;
|
|
605
|
+
const loader = new ConfigLoader();
|
|
606
|
+
const manager = new ProcessManager();
|
|
607
|
+
|
|
608
|
+
// Clean up any stale PID files on startup
|
|
609
|
+
await manager.cleanupStalePids();
|
|
610
|
+
|
|
611
|
+
const clients: http.ServerResponse[] = [];
|
|
612
|
+
|
|
613
|
+
const sendEvent = (event: string, data: unknown) => {
|
|
614
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
615
|
+
for (const client of clients) {
|
|
616
|
+
client.write(payload);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
manager.on('group-started', (groupName) => {
|
|
621
|
+
sendEvent('status', { type: 'group-started', groupName });
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
manager.on('group-stopped', (groupName) => {
|
|
625
|
+
sendEvent('status', { type: 'group-stopped', groupName });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
manager.on('item-restarted', (groupName, itemName) => {
|
|
629
|
+
sendEvent('status', { type: 'item-restarted', groupName, itemName });
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
manager.on('process-log', (groupName, itemName, line, isError) => {
|
|
633
|
+
sendEvent('log', { group: groupName, item: itemName, line, isError });
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const server = http.createServer((req, res) => {
|
|
637
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
638
|
+
|
|
639
|
+
// CORS headers
|
|
640
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
641
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
642
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
643
|
+
|
|
644
|
+
if (req.method === 'OPTIONS') {
|
|
645
|
+
res.writeHead(204);
|
|
646
|
+
res.end();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (url.pathname === '/') {
|
|
651
|
+
res.setHeader('Content-Type', 'text/html');
|
|
652
|
+
res.writeHead(200);
|
|
653
|
+
res.end(serveHtml());
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (url.pathname === '/api/groups') {
|
|
658
|
+
try {
|
|
659
|
+
const config = loader.load();
|
|
660
|
+
const groups = Object.entries(config.groups).map(([name, group]) => ({
|
|
661
|
+
name,
|
|
662
|
+
tool: group.tool,
|
|
663
|
+
restart: group.restart,
|
|
664
|
+
items: Object.entries(group.items).map(([itemName, value]) => ({
|
|
665
|
+
name: itemName,
|
|
666
|
+
value,
|
|
667
|
+
enabled: !(group.disabledItems || []).includes(itemName),
|
|
668
|
+
})),
|
|
669
|
+
running: manager.isGroupRunning(name),
|
|
670
|
+
}));
|
|
671
|
+
res.setHeader('Content-Type', 'application/json');
|
|
672
|
+
res.writeHead(200);
|
|
673
|
+
res.end(JSON.stringify({ groups }));
|
|
674
|
+
} catch (err) {
|
|
675
|
+
res.setHeader('Content-Type', 'application/json');
|
|
676
|
+
res.writeHead(500);
|
|
677
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
678
|
+
}
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (url.pathname === '/api/events') {
|
|
683
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
684
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
685
|
+
res.setHeader('Connection', 'keep-alive');
|
|
686
|
+
res.writeHead(200);
|
|
687
|
+
res.write(':ok\n\n');
|
|
688
|
+
clients.push(res);
|
|
689
|
+
req.on('close', () => {
|
|
690
|
+
const index = clients.indexOf(res);
|
|
691
|
+
if (index !== -1) clients.splice(index, 1);
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const toggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/toggle$/);
|
|
697
|
+
if (toggleMatch && req.method === 'POST') {
|
|
698
|
+
const groupName = decodeURIComponent(toggleMatch[1]);
|
|
699
|
+
let body = '';
|
|
700
|
+
req.on('data', (chunk) => body += chunk);
|
|
701
|
+
req.on('end', async () => {
|
|
702
|
+
try {
|
|
703
|
+
const { enabled } = JSON.parse(body);
|
|
704
|
+
if (enabled) {
|
|
705
|
+
const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
|
|
706
|
+
const processItems = items.map((item, index) =>
|
|
707
|
+
TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
|
|
708
|
+
);
|
|
709
|
+
manager.spawnGroup(groupName, processItems, config.restart);
|
|
710
|
+
} else {
|
|
711
|
+
await manager.killGroup(groupName);
|
|
712
|
+
}
|
|
713
|
+
res.setHeader('Content-Type', 'application/json');
|
|
714
|
+
res.writeHead(200);
|
|
715
|
+
res.end(JSON.stringify({ success: true }));
|
|
716
|
+
} catch (err) {
|
|
717
|
+
res.setHeader('Content-Type', 'application/json');
|
|
718
|
+
res.writeHead(500);
|
|
719
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const itemToggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/items\/([^/]+)\/toggle$/);
|
|
726
|
+
if (itemToggleMatch && req.method === 'POST') {
|
|
727
|
+
const groupName = decodeURIComponent(itemToggleMatch[1]);
|
|
728
|
+
const itemName = decodeURIComponent(itemToggleMatch[2]);
|
|
729
|
+
let body = '';
|
|
730
|
+
req.on('data', (chunk) => body += chunk);
|
|
731
|
+
req.on('end', async () => {
|
|
732
|
+
try {
|
|
733
|
+
const { enabled } = JSON.parse(body);
|
|
734
|
+
loader.toggleItem(groupName, itemName, enabled);
|
|
735
|
+
|
|
736
|
+
if (manager.isGroupRunning(groupName)) {
|
|
737
|
+
const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
|
|
738
|
+
const processItems = items.map((item, index) =>
|
|
739
|
+
TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
|
|
740
|
+
);
|
|
741
|
+
await manager.restartGroup(groupName, processItems, config.restart);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
res.setHeader('Content-Type', 'application/json');
|
|
745
|
+
res.writeHead(200);
|
|
746
|
+
res.end(JSON.stringify({ success: true }));
|
|
747
|
+
} catch (err) {
|
|
748
|
+
res.setHeader('Content-Type', 'application/json');
|
|
749
|
+
res.writeHead(500);
|
|
750
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
res.writeHead(404);
|
|
757
|
+
res.end('Not found');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
761
|
+
if (err.code === 'EADDRINUSE') {
|
|
762
|
+
console.error(`Port ${port} is already in use`);
|
|
763
|
+
process.exit(1);
|
|
764
|
+
} else {
|
|
765
|
+
console.error('Server error:', err);
|
|
766
|
+
process.exit(2);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
server.listen(port, () => {
|
|
771
|
+
console.log(`cligr serve running at http://localhost:${port}`);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Keep process alive
|
|
775
|
+
return new Promise(() => {});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function serveHtml(): string {
|
|
779
|
+
return `<!DOCTYPE html>
|
|
780
|
+
<html>
|
|
781
|
+
<head>
|
|
782
|
+
<meta charset="utf-8">
|
|
783
|
+
<title>cligr serve</title>
|
|
784
|
+
<style>
|
|
785
|
+
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
|
786
|
+
h1 { font-size: 1.5rem; }
|
|
787
|
+
.group { border: 1px solid #ccc; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
|
|
788
|
+
.group-header { display: flex; align-items: center; gap: 0.5rem; font-weight: bold; font-size: 1.1rem; }
|
|
789
|
+
.items { margin: 0.5rem 0 0 1.5rem; }
|
|
790
|
+
.item { display: flex; align-items: center; gap: 0.4rem; margin: 0.25rem 0; }
|
|
791
|
+
.logs { background: #111; color: #0f0; font-family: monospace; font-size: 0.85rem; height: 300px; overflow-y: auto; padding: 0.75rem; border-radius: 4px; white-space: pre-wrap; }
|
|
792
|
+
.error { color: #f55; }
|
|
793
|
+
</style>
|
|
794
|
+
</head>
|
|
795
|
+
<body>
|
|
796
|
+
<h1>cligr serve</h1>
|
|
797
|
+
<div id="groups"></div>
|
|
798
|
+
<h2>Logs</h2>
|
|
799
|
+
<div class="logs" id="logs"></div>
|
|
800
|
+
|
|
801
|
+
<script>
|
|
802
|
+
const groupsEl = document.getElementById('groups');
|
|
803
|
+
const logsEl = document.getElementById('logs');
|
|
804
|
+
let autoScroll = true;
|
|
805
|
+
|
|
806
|
+
async function fetchGroups() {
|
|
807
|
+
const res = await fetch('/api/groups');
|
|
808
|
+
const data = await res.json();
|
|
809
|
+
renderGroups(data.groups);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function renderGroups(groups) {
|
|
813
|
+
groupsEl.innerHTML = '';
|
|
814
|
+
for (const g of groups) {
|
|
815
|
+
const div = document.createElement('div');
|
|
816
|
+
div.className = 'group';
|
|
817
|
+
|
|
818
|
+
const header = document.createElement('div');
|
|
819
|
+
header.className = 'group-header';
|
|
820
|
+
const checkbox = document.createElement('input');
|
|
821
|
+
checkbox.type = 'checkbox';
|
|
822
|
+
checkbox.checked = g.running;
|
|
823
|
+
checkbox.onchange = async () => {
|
|
824
|
+
await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/toggle\`, {
|
|
825
|
+
method: 'POST',
|
|
826
|
+
headers: { 'Content-Type': 'application/json' },
|
|
827
|
+
body: JSON.stringify({ enabled: checkbox.checked })
|
|
828
|
+
});
|
|
829
|
+
};
|
|
830
|
+
header.appendChild(checkbox);
|
|
831
|
+
header.appendChild(document.createTextNode(g.name + ' (' + g.tool + ')' + (g.running ? ' - running' : '')));
|
|
832
|
+
div.appendChild(header);
|
|
833
|
+
|
|
834
|
+
const itemsDiv = document.createElement('div');
|
|
835
|
+
itemsDiv.className = 'items';
|
|
836
|
+
for (const item of g.items) {
|
|
837
|
+
const itemDiv = document.createElement('div');
|
|
838
|
+
itemDiv.className = 'item';
|
|
839
|
+
const itemCb = document.createElement('input');
|
|
840
|
+
itemCb.type = 'checkbox';
|
|
841
|
+
itemCb.checked = item.enabled;
|
|
842
|
+
itemCb.onchange = async () => {
|
|
843
|
+
await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/items/\${encodeURIComponent(item.name)}/toggle\`, {
|
|
844
|
+
method: 'POST',
|
|
845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
846
|
+
body: JSON.stringify({ enabled: itemCb.checked })
|
|
847
|
+
});
|
|
848
|
+
};
|
|
849
|
+
itemDiv.appendChild(itemCb);
|
|
850
|
+
itemDiv.appendChild(document.createTextNode(item.name + ': ' + item.value));
|
|
851
|
+
itemsDiv.appendChild(itemDiv);
|
|
852
|
+
}
|
|
853
|
+
div.appendChild(itemsDiv);
|
|
854
|
+
groupsEl.appendChild(div);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
logsEl.addEventListener('scroll', () => {
|
|
859
|
+
autoScroll = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 10;
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
function appendLog(line, isError) {
|
|
863
|
+
const span = document.createElement('div');
|
|
864
|
+
span.textContent = line;
|
|
865
|
+
if (isError) span.className = 'error';
|
|
866
|
+
logsEl.appendChild(span);
|
|
867
|
+
if (autoScroll) logsEl.scrollTop = logsEl.scrollHeight;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const evtSource = new EventSource('/api/events');
|
|
871
|
+
evtSource.addEventListener('status', (e) => {
|
|
872
|
+
fetchGroups();
|
|
873
|
+
});
|
|
874
|
+
evtSource.addEventListener('log', (e) => {
|
|
875
|
+
const data = JSON.parse(e.data);
|
|
876
|
+
appendLog(\`[\${data.group}/\${data.item}] \${data.line}\`, data.isError);
|
|
877
|
+
});
|
|
878
|
+
evtSource.onerror = () => {
|
|
879
|
+
appendLog('[SSE connection error]', true);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
fetchGroups();
|
|
883
|
+
</script>
|
|
884
|
+
</body>
|
|
885
|
+
</html>`;
|
|
886
|
+
}
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
Note: The `manager['pidStore']` private access is a bit hacky. A better approach is to expose a public method on `ProcessManager` for cleanup. Let's add that in Task 3. In `src/process/manager.ts`, add:
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
async cleanupStalePids(): Promise<void> {
|
|
893
|
+
await this.pidStore.cleanupStalePids();
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
Then in `serve.ts`, call `await manager.cleanupStalePids();` instead.
|
|
898
|
+
|
|
899
|
+
- [ ] **Step 2: Add `cleanupStalePids` wrapper to `ProcessManager`**
|
|
900
|
+
|
|
901
|
+
Add to `src/process/manager.ts` after `killAll()`:
|
|
902
|
+
|
|
903
|
+
```typescript
|
|
904
|
+
async cleanupStalePids(): Promise<void> {
|
|
905
|
+
await this.pidStore.cleanupStalePids();
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
- [ ] **Step 3: Register `serve` in `src/index.ts`**
|
|
910
|
+
|
|
911
|
+
Modify `src/index.ts`:
|
|
912
|
+
|
|
913
|
+
1. Add import:
|
|
914
|
+
```typescript
|
|
915
|
+
import { serveCommand } from './commands/serve.js';
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
2. Update `knownCommands` array:
|
|
919
|
+
```typescript
|
|
920
|
+
const knownCommands = ['config', 'up', 'ls', 'groups', 'serve'];
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
3. In the switch statement, add:
|
|
924
|
+
```typescript
|
|
925
|
+
case 'serve':
|
|
926
|
+
exitCode = await serveCommand(rest[0]);
|
|
927
|
+
break;
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
4. Update `printUsage`:
|
|
931
|
+
```typescript
|
|
932
|
+
console.log(`
|
|
933
|
+
Usage: cligr <group> | <command> [options]
|
|
934
|
+
|
|
935
|
+
Commands:
|
|
936
|
+
config Open config file in editor
|
|
937
|
+
ls <group> List all items in the group
|
|
938
|
+
groups [-v|--verbose] List all groups
|
|
939
|
+
serve [port] Start web UI server (default port 7373)
|
|
940
|
+
|
|
941
|
+
Options:
|
|
942
|
+
-v, --verbose Show detailed group information
|
|
943
|
+
|
|
944
|
+
Examples:
|
|
945
|
+
cligr test1 Start all processes in test1 group
|
|
946
|
+
cligr config
|
|
947
|
+
cligr ls test1
|
|
948
|
+
cligr groups
|
|
949
|
+
cligr groups -v
|
|
950
|
+
cligr serve
|
|
951
|
+
cligr serve 8080
|
|
952
|
+
`);
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
- [ ] **Step 4: Write failing tests for `serve` command**
|
|
956
|
+
|
|
957
|
+
Create `tests/integration/serve.test.ts`:
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
import { describe, it, before, after, mock } from 'node:test';
|
|
961
|
+
import assert from 'node:assert';
|
|
962
|
+
import fs from 'node:fs';
|
|
963
|
+
import path from 'node:path';
|
|
964
|
+
import os from 'node:os';
|
|
965
|
+
import http from 'node:http';
|
|
966
|
+
|
|
967
|
+
let serverProcess: import('child_process').ChildProcess | null = null;
|
|
968
|
+
|
|
969
|
+
describe('serve command integration tests', () => {
|
|
970
|
+
let testConfigDir: string;
|
|
971
|
+
let testConfigPath: string;
|
|
972
|
+
let originalHomeDir: string;
|
|
973
|
+
|
|
974
|
+
before(() => {
|
|
975
|
+
testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cligr-serve-test-'));
|
|
976
|
+
testConfigPath = path.join(testConfigDir, '.cligr.yml');
|
|
977
|
+
originalHomeDir = os.homedir();
|
|
978
|
+
mock.method(os, 'homedir', () => testConfigDir);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
after(() => {
|
|
982
|
+
mock.method(os, 'homedir', () => originalHomeDir);
|
|
983
|
+
if (fs.existsSync(testConfigDir)) {
|
|
984
|
+
fs.rmSync(testConfigDir, { recursive: true, force: true });
|
|
985
|
+
}
|
|
986
|
+
if (serverProcess) {
|
|
987
|
+
serverProcess.kill();
|
|
988
|
+
serverProcess = null;
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
function writeConfig() {
|
|
993
|
+
const configContent = `
|
|
994
|
+
groups:
|
|
995
|
+
web:
|
|
996
|
+
tool: echo
|
|
997
|
+
restart: no
|
|
998
|
+
disabledItems:
|
|
999
|
+
- worker
|
|
1000
|
+
items:
|
|
1001
|
+
server: server
|
|
1002
|
+
worker: worker
|
|
1003
|
+
`;
|
|
1004
|
+
fs.writeFileSync(testConfigPath, configContent);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function startServer(port: number) {
|
|
1008
|
+
const { spawn } = await import('child_process');
|
|
1009
|
+
serverProcess = spawn('node', ['dist/index.js', 'serve', String(port)], {
|
|
1010
|
+
cwd: process.cwd(),
|
|
1011
|
+
stdio: 'pipe'
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// Wait for server to be ready
|
|
1015
|
+
await new Promise<void>((resolve, reject) => {
|
|
1016
|
+
let output = '';
|
|
1017
|
+
const onData = (data: Buffer) => {
|
|
1018
|
+
output += data.toString();
|
|
1019
|
+
if (output.includes('cligr serve running at')) {
|
|
1020
|
+
cleanup();
|
|
1021
|
+
resolve();
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
const onError = (data: Buffer) => {
|
|
1025
|
+
output += data.toString();
|
|
1026
|
+
};
|
|
1027
|
+
serverProcess!.stdout!.on('data', onData);
|
|
1028
|
+
serverProcess!.stderr!.on('data', onError);
|
|
1029
|
+
|
|
1030
|
+
const timeout = setTimeout(() => {
|
|
1031
|
+
cleanup();
|
|
1032
|
+
reject(new Error('Server startup timeout'));
|
|
1033
|
+
}, 5000);
|
|
1034
|
+
|
|
1035
|
+
const cleanup = () => {
|
|
1036
|
+
clearTimeout(timeout);
|
|
1037
|
+
serverProcess!.stdout!.off('data', onData);
|
|
1038
|
+
serverProcess!.stderr!.off('data', onError);
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function httpGet(url: string): Promise<{ status: number; body: string }> {
|
|
1044
|
+
return new Promise((resolve, reject) => {
|
|
1045
|
+
const req = http.get(url, (res) => {
|
|
1046
|
+
let body = '';
|
|
1047
|
+
res.on('data', (chunk) => body += chunk);
|
|
1048
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body }));
|
|
1049
|
+
});
|
|
1050
|
+
req.on('error', reject);
|
|
1051
|
+
req.setTimeout(2000, () => reject(new Error('HTTP timeout')));
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function httpPost(url: string, body: object): Promise<{ status: number; body: string }> {
|
|
1056
|
+
return new Promise((resolve, reject) => {
|
|
1057
|
+
const data = JSON.stringify(body);
|
|
1058
|
+
const req = http.request(url, {
|
|
1059
|
+
method: 'POST',
|
|
1060
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
|
|
1061
|
+
}, (res) => {
|
|
1062
|
+
let responseBody = '';
|
|
1063
|
+
res.on('data', (chunk) => responseBody += chunk);
|
|
1064
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body: responseBody }));
|
|
1065
|
+
});
|
|
1066
|
+
req.on('error', reject);
|
|
1067
|
+
req.write(data);
|
|
1068
|
+
req.end();
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
it('should serve the HTML UI', { timeout: 10000 }, async () => {
|
|
1073
|
+
writeConfig();
|
|
1074
|
+
const port = 17373;
|
|
1075
|
+
await startServer(port);
|
|
1076
|
+
|
|
1077
|
+
const res = await httpGet(`http://localhost:${port}/`);
|
|
1078
|
+
assert.strictEqual(res.status, 200);
|
|
1079
|
+
assert.ok(res.body.includes('cligr serve'));
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it('should return groups via API', { timeout: 10000 }, async () => {
|
|
1083
|
+
writeConfig();
|
|
1084
|
+
const port = 17374;
|
|
1085
|
+
await startServer(port);
|
|
1086
|
+
|
|
1087
|
+
const res = await httpGet(`http://localhost:${port}/api/groups`);
|
|
1088
|
+
assert.strictEqual(res.status, 200);
|
|
1089
|
+
const data = JSON.parse(res.body);
|
|
1090
|
+
assert.ok(Array.isArray(data.groups));
|
|
1091
|
+
assert.strictEqual(data.groups.length, 1);
|
|
1092
|
+
assert.strictEqual(data.groups[0].name, 'web');
|
|
1093
|
+
assert.strictEqual(data.groups[0].running, false);
|
|
1094
|
+
assert.strictEqual(data.groups[0].items.length, 2);
|
|
1095
|
+
const serverItem = data.groups[0].items.find((i: any) => i.name === 'server');
|
|
1096
|
+
const workerItem = data.groups[0].items.find((i: any) => i.name === 'worker');
|
|
1097
|
+
assert.strictEqual(serverItem.enabled, true);
|
|
1098
|
+
assert.strictEqual(workerItem.enabled, false);
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it('should toggle group via API', { timeout: 10000 }, async () => {
|
|
1102
|
+
writeConfig();
|
|
1103
|
+
const port = 17375;
|
|
1104
|
+
await startServer(port);
|
|
1105
|
+
|
|
1106
|
+
const postRes = await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true });
|
|
1107
|
+
assert.strictEqual(postRes.status, 200);
|
|
1108
|
+
|
|
1109
|
+
// Give it a moment to start
|
|
1110
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1111
|
+
|
|
1112
|
+
const getRes = await httpGet(`http://localhost:${port}/api/groups`);
|
|
1113
|
+
const data = JSON.parse(getRes.body);
|
|
1114
|
+
const web = data.groups.find((g: any) => g.name === 'web');
|
|
1115
|
+
assert.strictEqual(web.running, true);
|
|
1116
|
+
|
|
1117
|
+
// Stop it
|
|
1118
|
+
await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: false });
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('should toggle item via API', { timeout: 10000 }, async () => {
|
|
1122
|
+
writeConfig();
|
|
1123
|
+
const port = 17376;
|
|
1124
|
+
await startServer(port);
|
|
1125
|
+
|
|
1126
|
+
const postRes = await httpPost(`http://localhost:${port}/api/groups/web/items/worker/toggle`, { enabled: true });
|
|
1127
|
+
assert.strictEqual(postRes.status, 200);
|
|
1128
|
+
|
|
1129
|
+
const getRes = await httpGet(`http://localhost:${port}/api/groups`);
|
|
1130
|
+
const data = JSON.parse(getRes.body);
|
|
1131
|
+
const web = data.groups.find((g: any) => g.name === 'web');
|
|
1132
|
+
const worker = web.items.find((i: any) => i.name === 'worker');
|
|
1133
|
+
assert.strictEqual(worker.enabled, true);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('should stream SSE events', { timeout: 10000 }, async () => {
|
|
1137
|
+
writeConfig();
|
|
1138
|
+
const port = 17377;
|
|
1139
|
+
await startServer(port);
|
|
1140
|
+
|
|
1141
|
+
return new Promise<void>((resolve, reject) => {
|
|
1142
|
+
const req = http.get(`http://localhost:${port}/api/events`, (res) => {
|
|
1143
|
+
let buffer = '';
|
|
1144
|
+
res.on('data', (chunk) => {
|
|
1145
|
+
buffer += chunk.toString();
|
|
1146
|
+
if (buffer.includes('event: status')) {
|
|
1147
|
+
req.destroy();
|
|
1148
|
+
resolve();
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
req.on('error', reject);
|
|
1153
|
+
|
|
1154
|
+
// Trigger a status event by toggling a group
|
|
1155
|
+
setTimeout(() => {
|
|
1156
|
+
httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true }).catch(() => {});
|
|
1157
|
+
}, 200);
|
|
1158
|
+
|
|
1159
|
+
setTimeout(() => {
|
|
1160
|
+
req.destroy();
|
|
1161
|
+
reject(new Error('SSE timeout'));
|
|
1162
|
+
}, 3000);
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
1169
|
+
|
|
1170
|
+
Run: `npm test -- --include-blocking`
|
|
1171
|
+
Actually, these aren't blocking tests. The serve tests need the built dist. The test script builds source to `.js` but `serve.test.ts` imports `dist/index.js`. We need to ensure the project is built first.
|
|
1172
|
+
|
|
1173
|
+
Actually, looking at the test script, it builds `src/` files to `.js` alongside `.ts`. But `dist/index.js` is the packaged output. We should either:
|
|
1174
|
+
1. Build the full project with `npm run build` before tests, or
|
|
1175
|
+
2. Not test `dist/index.js` and instead test the `serveCommand` function directly.
|
|
1176
|
+
|
|
1177
|
+
Better approach: test `serveCommand` directly and spawn it as a subprocess from the built `dist/index.js`. Add a build step before running serve tests.
|
|
1178
|
+
|
|
1179
|
+
For the test, modify to build first:
|
|
1180
|
+
|
|
1181
|
+
Actually, the project has a `build` script that uses `scripts/build.js`. Let's check what it does.
|
|
1182
|
+
|
|
1183
|
+
Let's simplify: instead of spawning `dist/index.js`, we can spawn `node --import ...` or just test the HTTP server function directly by importing `serveCommand` and running it on a free port, then stopping the server.
|
|
1184
|
+
|
|
1185
|
+
But `serveCommand` returns a never-resolving promise. We'd need to export the server or close it somehow. Better yet, we can test the API by importing the server setup logic.
|
|
1186
|
+
|
|
1187
|
+
Actually, let's keep it simple: spawn the built CLI. The test runner can call `npm run build` before starting.
|
|
1188
|
+
|
|
1189
|
+
In the test file, add a build step in `before()` or assume `dist/index.js` exists.
|
|
1190
|
+
|
|
1191
|
+
Let me check `scripts/build.js`:
|
|
1192
|
+
|
|
1193
|
+
```typescript
|
|
1194
|
+
import { build } from 'esbuild';
|
|
1195
|
+
import fs from 'fs';
|
|
1196
|
+
import path from 'path';
|
|
1197
|
+
|
|
1198
|
+
const srcDir = 'src';
|
|
1199
|
+
const outDir = 'dist';
|
|
1200
|
+
|
|
1201
|
+
async function buildAll() {
|
|
1202
|
+
if (!fs.existsSync(outDir)) {
|
|
1203
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const entries = fs.readdirSync(srcDir)
|
|
1207
|
+
.filter(f => f.endsWith('.ts'))
|
|
1208
|
+
.map(f => path.join(srcDir, f));
|
|
1209
|
+
|
|
1210
|
+
await build({
|
|
1211
|
+
entryPoints: entries,
|
|
1212
|
+
bundle: true,
|
|
1213
|
+
platform: 'node',
|
|
1214
|
+
target: 'es2022',
|
|
1215
|
+
format: 'esm',
|
|
1216
|
+
outdir: outDir,
|
|
1217
|
+
external: [],
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
buildAll().catch(err => {
|
|
1222
|
+
console.error(err);
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
});
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
So `npm run build` creates `dist/index.js` from `src/index.ts`.
|
|
1228
|
+
|
|
1229
|
+
For the serve test, we can build the project first in a `before` block. But that's slow. Alternatively, we can test by importing `serveCommand` directly and pass it a port. But we need a way to stop it.
|
|
1230
|
+
|
|
1231
|
+
Let's modify `serveCommand` slightly to return a cleanup function or store the server. Actually, we can export the server instance. But that's not needed for the command usage.
|
|
1232
|
+
|
|
1233
|
+
For testing, the simplest reliable approach is:
|
|
1234
|
+
1. Build with `npm run build`
|
|
1235
|
+
2. Spawn `node dist/index.js serve <port>`
|
|
1236
|
+
3. Test HTTP endpoints
|
|
1237
|
+
4. Kill the child process
|
|
1238
|
+
|
|
1239
|
+
We can add a `build` call in the test's `before` hook.
|
|
1240
|
+
|
|
1241
|
+
Actually, the test file already has async code. Let's add:
|
|
1242
|
+
|
|
1243
|
+
```typescript
|
|
1244
|
+
before(async () => {
|
|
1245
|
+
testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cligr-serve-test-'));
|
|
1246
|
+
testConfigPath = path.join(testConfigDir, '.cligr.yml');
|
|
1247
|
+
originalHomeDir = os.homedir();
|
|
1248
|
+
mock.method(os, 'homedir', () => testConfigDir);
|
|
1249
|
+
|
|
1250
|
+
// Build dist/index.js
|
|
1251
|
+
const { spawnSync } = await import('child_process');
|
|
1252
|
+
const result = spawnSync('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe' });
|
|
1253
|
+
if (result.status !== 0) {
|
|
1254
|
+
throw new Error('Build failed: ' + result.stderr?.toString());
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
- [ ] **Step 6: Run tests to verify they pass**
|
|
1260
|
+
|
|
1261
|
+
Run: `npm test`
|
|
1262
|
+
|
|
1263
|
+
Expected: PASS for all tests, including new `serve.test.ts`.
|
|
1264
|
+
|
|
1265
|
+
- [ ] **Step 7: Commit**
|
|
1266
|
+
|
|
1267
|
+
```bash
|
|
1268
|
+
git add src/commands/serve.ts src/process/manager.ts src/index.ts tests/integration/serve.test.ts
|
|
1269
|
+
git commit -m "feat(serve): add HTTP server with SSE and web UI for managing groups"
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
---
|
|
1273
|
+
|
|
1274
|
+
## Self-Review
|
|
1275
|
+
|
|
1276
|
+
**Spec coverage check:**
|
|
1277
|
+
- `disabledItems` config persistence → Task 1
|
|
1278
|
+
- Config filtering in `getGroup()` → Task 1
|
|
1279
|
+
- `ProcessManager` EventEmitter and events → Task 3
|
|
1280
|
+
- `restartGroup()` → Task 3
|
|
1281
|
+
- HTTP API (`/api/groups`, `/api/groups/:name/toggle`, `/api/groups/:name/items/:item/toggle`) → Task 4
|
|
1282
|
+
- SSE endpoint (`/api/events`) → Task 4
|
|
1283
|
+
- Inline HTML UI → Task 4
|
|
1284
|
+
- Port configuration → Task 4 (`serve [port]`)
|
|
1285
|
+
- Error handling (port in use, config errors) → Task 4
|
|
1286
|
+
- Tests for all components → Each task
|
|
1287
|
+
|
|
1288
|
+
**Placeholder scan:**
|
|
1289
|
+
- No "TBD", "TODO", or vague requirements found.
|
|
1290
|
+
- All code snippets are complete.
|
|
1291
|
+
- All commands are exact.
|
|
1292
|
+
|
|
1293
|
+
**Type consistency check:**
|
|
1294
|
+
- `disabledItems?: string[]` used consistently
|
|
1295
|
+
- `saveConfig(config: CliGrConfig)` used consistently
|
|
1296
|
+
- `toggleItem(groupName, itemName, enabled)` used consistently
|
|
1297
|
+
- Event names match design: `group-started`, `group-stopped`, `item-restarted`, `process-log`
|
|
1298
|
+
|
|
1299
|
+
No gaps found. Plan is ready for execution.
|