cligr 1.0.6 → 1.0.8

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.
Files changed (43) hide show
  1. package/.claude/worktrees/agent-ac25cfb2/README.md +65 -0
  2. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-13-named-params-support.md +391 -0
  3. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-design.md +164 -0
  4. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-implementation.md +460 -0
  5. package/.claude/worktrees/agent-ac25cfb2/package-lock.json +554 -0
  6. package/.claude/worktrees/agent-ac25cfb2/package.json +27 -0
  7. package/.claude/worktrees/agent-ac25cfb2/scripts/build.js +20 -0
  8. package/.claude/worktrees/agent-ac25cfb2/scripts/test.js +168 -0
  9. package/.claude/worktrees/agent-ac25cfb2/src/commands/config.ts +121 -0
  10. package/.claude/worktrees/agent-ac25cfb2/src/commands/groups.ts +68 -0
  11. package/.claude/worktrees/agent-ac25cfb2/src/commands/ls.ts +25 -0
  12. package/.claude/worktrees/agent-ac25cfb2/src/commands/up.ts +49 -0
  13. package/.claude/worktrees/agent-ac25cfb2/src/config/loader.ts +148 -0
  14. package/.claude/worktrees/agent-ac25cfb2/src/config/types.ts +26 -0
  15. package/.claude/worktrees/agent-ac25cfb2/src/index.ts +97 -0
  16. package/.claude/worktrees/agent-ac25cfb2/src/process/manager.ts +270 -0
  17. package/.claude/worktrees/agent-ac25cfb2/src/process/pid-store.ts +203 -0
  18. package/.claude/worktrees/agent-ac25cfb2/src/process/template.ts +87 -0
  19. package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes-fixed.test.ts +255 -0
  20. package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes.test.ts +497 -0
  21. package/.claude/worktrees/agent-ac25cfb2/tests/integration/commands.test.ts +648 -0
  22. package/.claude/worktrees/agent-ac25cfb2/tests/integration/config-loader.test.ts +426 -0
  23. package/.claude/worktrees/agent-ac25cfb2/tests/integration/process-manager.test.ts +394 -0
  24. package/.claude/worktrees/agent-ac25cfb2/tests/integration/template-expander.test.ts +454 -0
  25. package/.claude/worktrees/agent-ac25cfb2/tsconfig.json +15 -0
  26. package/.claude/worktrees/agent-ac25cfb2/usage.md +9 -0
  27. package/dist/index.js +247 -39
  28. package/docs/superpowers/plans/2026-04-13-improve-web-ui-console.md +256 -0
  29. package/docs/superpowers/plans/2026-04-13-serve-command.md +1299 -0
  30. package/docs/superpowers/specs/2026-04-13-improve-web-ui-console-design.md +38 -0
  31. package/docs/superpowers/specs/2026-04-13-serve-command-design.md +93 -0
  32. package/package.json +1 -1
  33. package/src/commands/ls.ts +11 -6
  34. package/src/commands/serve.ts +417 -0
  35. package/src/config/loader.ts +71 -2
  36. package/src/config/types.ts +1 -0
  37. package/src/index.ts +10 -3
  38. package/src/process/manager.ts +36 -2
  39. package/tests/integration/commands.test.ts +24 -0
  40. package/tests/integration/config-loader.test.ts +110 -0
  41. package/tests/integration/process-manager.test.ts +103 -0
  42. package/tests/integration/serve.test.ts +245 -0
  43. /package/.claude/{settings.local.json → worktrees/agent-ac25cfb2/.claude/settings.local.json} +0 -0
@@ -11,6 +11,7 @@ export interface GroupConfig {
11
11
  tool: string;
12
12
  restart?: 'yes' | 'no' | 'unless-stopped';
13
13
  params?: Record<string, string>;
14
+ disabledItems?: string[];
14
15
  items: Record<string, string>;
15
16
  }
16
17
 
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] List all groups
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
 
@@ -1,4 +1,5 @@
1
1
  import { spawn, ChildProcess } from 'child_process';
2
+ import { EventEmitter } from 'events';
2
3
  import type { GroupConfig, ProcessItem } from '../config/types.js';
3
4
  import { PidStore, type PidEntry } from './pid-store.js';
4
5
 
@@ -12,7 +13,7 @@ export class ManagedProcess {
12
13
  ) {}
13
14
  }
14
15
 
15
- export class ProcessManager {
16
+ export class ProcessManager extends EventEmitter {
16
17
  private groups = new Map<string, ManagedProcess[]>();
17
18
  private restartTimestamps = new Map<string, number[]>();
18
19
  private readonly maxRestarts = 3;
@@ -32,6 +33,14 @@ export class ProcessManager {
32
33
  }
33
34
 
34
35
  this.groups.set(groupName, processes);
36
+ this.emit('group-started', groupName);
37
+ }
38
+
39
+ async restartGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): Promise<void> {
40
+ if (this.isGroupRunning(groupName)) {
41
+ await this.killGroup(groupName);
42
+ }
43
+ this.spawnGroup(groupName, items, restartPolicy);
35
44
  }
36
45
 
37
46
  private spawnProcess(item: ProcessItem, groupName: string, restartPolicy: GroupConfig['restart']): ChildProcess {
@@ -66,16 +75,28 @@ export class ProcessManager {
66
75
  });
67
76
  }
68
77
 
69
- // Prefix output with item name
78
+ // Prefix output with item name and emit events
79
+ const emitLines = (data: Buffer, isError: boolean) => {
80
+ const text = data.toString('utf-8');
81
+ const lines = text.split('\n');
82
+ for (const line of lines) {
83
+ if (line.length > 0) {
84
+ this.emit('process-log', groupName, item.name, line, isError);
85
+ }
86
+ }
87
+ };
88
+
70
89
  if (proc.stdout) {
71
90
  proc.stdout.on('data', (data) => {
72
91
  process.stdout.write(`[${item.name}] ${data}`);
92
+ emitLines(data, false);
73
93
  });
74
94
  }
75
95
 
76
96
  if (proc.stderr) {
77
97
  proc.stderr.on('data', (data) => {
78
98
  process.stderr.write(`[${item.name}] ${data}`);
99
+ emitLines(data, true);
79
100
  });
80
101
  }
81
102
 
@@ -122,6 +143,13 @@ export class ProcessManager {
122
143
  }
123
144
 
124
145
  private handleExit(groupName: string, item: ProcessItem, restartPolicy: GroupConfig['restart'], code: number | null, signal: NodeJS.Signals | null): void {
146
+ // If killed by cligr via killGroup, the group is removed from the map before SIGTERM is sent.
147
+ // Don't restart processes that were intentionally stopped.
148
+ if (signal === 'SIGTERM' && !this.groups.has(groupName)) {
149
+ this.pidStore.deletePid(groupName, item.name).catch(() => {});
150
+ return;
151
+ }
152
+
125
153
  // Check if killed by cligr (don't restart if unless-stopped)
126
154
  // SIGTERM works on both Unix and Windows in Node.js
127
155
  if (restartPolicy === 'unless-stopped' && signal === 'SIGTERM') {
@@ -158,6 +186,7 @@ export class ProcessManager {
158
186
  setTimeout(() => {
159
187
  console.log(`[${item.name}] Restarting... (exit code: ${code})`);
160
188
  const newProc = this.spawnProcess(item, groupName, restartPolicy);
189
+ this.emit('item-restarted', groupName, item.name);
161
190
 
162
191
  // Update the ManagedProcess in the groups Map with the new process handle
163
192
  const processes = this.groups.get(groupName);
@@ -181,6 +210,7 @@ export class ProcessManager {
181
210
  // Clean up PID files after killing
182
211
  return Promise.all(killPromises).then(async () => {
183
212
  await this.pidStore.deleteGroupPids(groupName);
213
+ this.emit('group-stopped', groupName);
184
214
  });
185
215
  }
186
216
 
@@ -253,6 +283,10 @@ export class ProcessManager {
253
283
  return Promise.all(killPromises).then(() => {});
254
284
  }
255
285
 
286
+ async cleanupStalePids(): Promise<void> {
287
+ await this.pidStore.cleanupStalePids();
288
+ }
289
+
256
290
  getGroupStatus(groupName: string): ProcessStatus[] {
257
291
  const processes = this.groups.get(groupName);
258
292
  if (!processes) return [];
@@ -292,6 +292,30 @@ groups:
292
292
  const output = getLogOutput();
293
293
  assert.ok(output.includes('Tool: node'));
294
294
  });
295
+
296
+ it('should mark disabled items in ls output', async () => {
297
+ const configContent = `
298
+ groups:
299
+ mixed:
300
+ tool: echo
301
+ restart: no
302
+ disabledItems:
303
+ - service2
304
+ items:
305
+ service1: service1
306
+ service2: service2
307
+ `;
308
+ fs.writeFileSync(testConfigPath, configContent);
309
+ resetOutput();
310
+
311
+ const exitCode = await lsCommand('mixed');
312
+
313
+ assert.strictEqual(exitCode, 0);
314
+ const output = getLogOutput();
315
+ assert.ok(output.includes('service1: service1'));
316
+ assert.ok(!output.includes('service1: service1 [disabled]'));
317
+ assert.ok(output.includes('service2: service2 [disabled]'));
318
+ });
295
319
  });
296
320
 
297
321
  describe('Command integration scenarios', () => {
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
16
  import { ConfigLoader, ConfigError } from '../../src/config/loader.js';
17
+ import yaml from 'js-yaml';
17
18
 
18
19
  describe('ConfigLoader Integration Tests', () => {
19
20
  let testConfigDir: string;
@@ -311,6 +312,115 @@ groups:
311
312
  });
312
313
  });
313
314
 
315
+ describe('saveConfig()', () => {
316
+ it('should save config back to file', () => {
317
+ const configContent = `
318
+ groups:
319
+ test1:
320
+ tool: echo
321
+ restart: no
322
+ items:
323
+ hello: hello
324
+ `;
325
+ fs.writeFileSync(testConfigPath, configContent);
326
+
327
+ const loader = new ConfigLoader();
328
+ const config = loader.load();
329
+ config.groups.test1.restart = 'yes';
330
+
331
+ loader.saveConfig(config);
332
+
333
+ const saved = fs.readFileSync(testConfigPath, 'utf-8');
334
+ const parsed = yaml.load(saved) as any;
335
+ assert.strictEqual(parsed.groups.test1.restart, 'yes');
336
+ });
337
+ });
338
+
339
+ describe('toggleItem()', () => {
340
+ it('should add item to disabledItems when disabling', () => {
341
+ const configContent = `
342
+ groups:
343
+ test1:
344
+ tool: echo
345
+ restart: no
346
+ items:
347
+ hello: hello
348
+ world: world
349
+ `;
350
+ fs.writeFileSync(testConfigPath, configContent);
351
+
352
+ const loader = new ConfigLoader();
353
+ loader.toggleItem('test1', 'hello', false);
354
+
355
+ const reloaded = new ConfigLoader().load();
356
+ assert.deepStrictEqual(reloaded.groups.test1.disabledItems, ['hello']);
357
+ });
358
+
359
+ it('should remove item from disabledItems when enabling', () => {
360
+ const configContent = `
361
+ groups:
362
+ test1:
363
+ tool: echo
364
+ restart: no
365
+ disabledItems:
366
+ - hello
367
+ items:
368
+ hello: hello
369
+ world: world
370
+ `;
371
+ fs.writeFileSync(testConfigPath, configContent);
372
+
373
+ const loader = new ConfigLoader();
374
+ loader.toggleItem('test1', 'hello', true);
375
+
376
+ const reloaded = new ConfigLoader().load();
377
+ assert.strictEqual(reloaded.groups.test1.disabledItems, undefined);
378
+ });
379
+ });
380
+
381
+ describe('disabledItems filtering', () => {
382
+ it('should filter disabled items from getGroup result', () => {
383
+ const configContent = `
384
+ groups:
385
+ test1:
386
+ tool: echo
387
+ restart: no
388
+ disabledItems:
389
+ - hello
390
+ items:
391
+ hello: hello
392
+ world: world
393
+ `;
394
+ fs.writeFileSync(testConfigPath, configContent);
395
+
396
+ const loader = new ConfigLoader();
397
+ const result = loader.getGroup('test1');
398
+
399
+ assert.strictEqual(result.items.length, 1);
400
+ assert.strictEqual(result.items[0].name, 'world');
401
+ assert.strictEqual(result.config.disabledItems?.length, 1);
402
+ });
403
+
404
+ it('should include all items when disabledItems is empty', () => {
405
+ const configContent = `
406
+ groups:
407
+ test1:
408
+ tool: echo
409
+ restart: no
410
+ disabledItems: []
411
+ items:
412
+ hello: hello
413
+ world: world
414
+ `;
415
+ fs.writeFileSync(testConfigPath, configContent);
416
+
417
+ const loader = new ConfigLoader();
418
+ const result = loader.getGroup('test1');
419
+
420
+ assert.strictEqual(result.items.length, 2);
421
+ });
422
+ });
423
+
314
424
  describe('Config precedence', () => {
315
425
  it('should prefer home directory config over current directory', () => {
316
426
  const homeConfigPath = path.join(testConfigDir, '.cligr.yml');
@@ -391,4 +391,107 @@ describe('ProcessManager Integration Tests', () => {
391
391
  await manager.killGroup('unix-test');
392
392
  });
393
393
  });
394
+
395
+ describe('restartGroup()', () => {
396
+ it('should restart a running group', async () => {
397
+ const sleepCmd = process.platform === 'win32' ? 'timeout' : 'sleep';
398
+ const sleepFlag = process.platform === 'win32' ? '/t' : '';
399
+
400
+ const items: ProcessItem[] = [
401
+ { name: 'p1', args: ['5'], fullCmd: `${sleepCmd} ${sleepFlag} 5` }
402
+ ];
403
+
404
+ manager.spawnGroup('restart-group', items, 'no');
405
+ assert.strictEqual(manager.isGroupRunning('restart-group'), true);
406
+
407
+ await manager.restartGroup('restart-group', items, 'no');
408
+ assert.strictEqual(manager.isGroupRunning('restart-group'), true);
409
+
410
+ await manager.killGroup('restart-group');
411
+ });
412
+ });
413
+
414
+ describe('events', () => {
415
+ it('should emit group-started when spawning', async () => {
416
+ let emitted = false;
417
+ manager.once('group-started', (name) => {
418
+ assert.strictEqual(name, 'event-group');
419
+ emitted = true;
420
+ });
421
+
422
+ const items: ProcessItem[] = [
423
+ { name: 'p1', args: [], fullCmd: 'echo hello' }
424
+ ];
425
+
426
+ manager.spawnGroup('event-group', items, 'no');
427
+ assert.strictEqual(emitted, true);
428
+
429
+ await manager.killGroup('event-group');
430
+ });
431
+
432
+ it('should emit group-stopped when killing', async () => {
433
+ const items: ProcessItem[] = [
434
+ { name: 'p1', args: ['5'], fullCmd: process.platform === 'win32' ? 'timeout /t 5' : 'sleep 5' }
435
+ ];
436
+
437
+ manager.spawnGroup('stop-group', items, 'no');
438
+
439
+ let emitted = false;
440
+ manager.once('group-stopped', (name) => {
441
+ assert.strictEqual(name, 'stop-group');
442
+ emitted = true;
443
+ });
444
+
445
+ await manager.killGroup('stop-group');
446
+ assert.strictEqual(emitted, true);
447
+ });
448
+
449
+ it('should emit process-log events', async () => {
450
+ const items: ProcessItem[] = [
451
+ { name: 'logger', args: [], fullCmd: 'echo test-log' }
452
+ ];
453
+
454
+ const logs: Array<{ group: string; item: string; line: string; isError: boolean }> = [];
455
+ const handler = (group: string, item: string, line: string, isError: boolean) => {
456
+ logs.push({ group, item, line, isError });
457
+ };
458
+ manager.once('process-log', handler);
459
+
460
+ manager.spawnGroup('log-group', items, 'no');
461
+
462
+ // Wait for process-log event or a small timeout
463
+ await Promise.race([
464
+ new Promise<void>(resolve => manager.once('process-log', () => resolve())),
465
+ new Promise<void>(resolve => setTimeout(resolve, 200))
466
+ ]);
467
+
468
+ assert.ok(logs.some(l => l.group === 'log-group' && l.item === 'logger' && l.line.includes('test-log')));
469
+
470
+ manager.off('process-log', handler);
471
+ await manager.killGroup('log-group');
472
+ });
473
+
474
+ it('should emit item-restarted when a process restarts', { timeout: 3000 }, async () => {
475
+ const items: ProcessItem[] = [
476
+ { name: 'quick-exit', args: [], fullCmd: 'node -e "process.exit(0)"' }
477
+ ];
478
+
479
+ let restartedGroup = '';
480
+ let restartedItem = '';
481
+ manager.once('item-restarted', (groupName, itemName) => {
482
+ restartedGroup = groupName;
483
+ restartedItem = itemName;
484
+ });
485
+
486
+ manager.spawnGroup('restart-event-group', items, 'yes');
487
+
488
+ // Wait for the process to exit and restart (1-second delay + margin)
489
+ await new Promise(resolve => setTimeout(resolve, 2500));
490
+
491
+ assert.strictEqual(restartedGroup, 'restart-event-group');
492
+ assert.strictEqual(restartedItem, 'quick-exit');
493
+
494
+ await manager.killGroup('restart-event-group');
495
+ });
496
+ });
394
497
  });
@@ -0,0 +1,245 @@
1
+ import { describe, it, before, after, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import http from 'node:http';
7
+ import net from 'node:net';
8
+
9
+ let serverProcess: import('child_process').ChildProcess | null = null;
10
+
11
+ describe('serve command integration tests', () => {
12
+ let testConfigDir: string;
13
+ let testConfigPath: string;
14
+ let originalHomeDir: string;
15
+
16
+ before(async () => {
17
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cligr-serve-test-'));
18
+ testConfigPath = path.join(testConfigDir, '.cligr.yml');
19
+ originalHomeDir = os.homedir();
20
+ mock.method(os, 'homedir', () => testConfigDir);
21
+
22
+ // Build dist/index.js
23
+ const { spawnSync } = await import('child_process');
24
+ const result = spawnSync('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', shell: true });
25
+ if (result.status !== 0) {
26
+ throw new Error('Build failed: ' + result.stderr?.toString());
27
+ }
28
+ });
29
+
30
+ after(async () => {
31
+ mock.method(os, 'homedir', () => originalHomeDir);
32
+ if (fs.existsSync(testConfigDir)) {
33
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
34
+ }
35
+ await stopServer();
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await stopServer();
40
+ });
41
+
42
+ async function stopServer() {
43
+ const proc = serverProcess;
44
+ if (!proc) return;
45
+ serverProcess = null;
46
+ await new Promise<void>((resolve) => {
47
+ proc.once('exit', () => resolve());
48
+ proc.kill();
49
+ const timeout = setTimeout(() => {
50
+ if (!proc.killed) {
51
+ proc.kill('SIGKILL');
52
+ }
53
+ resolve();
54
+ }, 500);
55
+ proc.once('exit', () => clearTimeout(timeout));
56
+ });
57
+ }
58
+
59
+ async function getFreePort(): Promise<number> {
60
+ return new Promise((resolve, reject) => {
61
+ const srv = net.createServer();
62
+ srv.listen(0, () => {
63
+ const addr = srv.address() as net.AddressInfo;
64
+ srv.close(() => resolve(addr.port));
65
+ });
66
+ srv.on('error', reject);
67
+ });
68
+ }
69
+
70
+ function writeConfig() {
71
+ const configContent = `
72
+ tools:
73
+ node:
74
+ cmd: node -e "console.log('$1')"
75
+ groups:
76
+ web:
77
+ tool: node
78
+ restart: no
79
+ disabledItems:
80
+ - worker
81
+ items:
82
+ server: server
83
+ worker: worker
84
+ `;
85
+ fs.writeFileSync(testConfigPath, configContent);
86
+ }
87
+
88
+ async function startServer(port: number) {
89
+ const { spawn } = await import('child_process');
90
+ const env = { ...process.env, USERPROFILE: testConfigDir, HOME: testConfigDir };
91
+ serverProcess = spawn('node', ['dist/index.js', 'serve', String(port)], {
92
+ cwd: process.cwd(),
93
+ stdio: 'pipe',
94
+ env
95
+ });
96
+
97
+ // Wait for server to be ready
98
+ await new Promise<void>((resolve, reject) => {
99
+ let output = '';
100
+ const onData = (data: Buffer) => {
101
+ output += data.toString();
102
+ if (output.includes('cligr serve running at')) {
103
+ cleanup();
104
+ resolve();
105
+ }
106
+ };
107
+ const onError = (data: Buffer) => {
108
+ output += data.toString();
109
+ };
110
+ serverProcess!.stdout!.on('data', onData);
111
+ serverProcess!.stderr!.on('data', onError);
112
+
113
+ const timeout = setTimeout(() => {
114
+ cleanup();
115
+ reject(new Error('Server startup timeout. Output: ' + output));
116
+ }, 5000);
117
+
118
+ const cleanup = () => {
119
+ clearTimeout(timeout);
120
+ serverProcess!.stdout!.off('data', onData);
121
+ serverProcess!.stderr!.off('data', onError);
122
+ };
123
+ });
124
+ }
125
+
126
+ async function httpGet(url: string): Promise<{ status: number; body: string }> {
127
+ return new Promise((resolve, reject) => {
128
+ const req = http.get(url, (res) => {
129
+ let body = '';
130
+ res.on('data', (chunk) => body += chunk);
131
+ res.on('end', () => resolve({ status: res.statusCode || 0, body }));
132
+ });
133
+ req.on('error', reject);
134
+ req.setTimeout(2000, () => reject(new Error('HTTP timeout')));
135
+ });
136
+ }
137
+
138
+ async function httpPost(url: string, body: object): Promise<{ status: number; body: string }> {
139
+ return new Promise((resolve, reject) => {
140
+ const data = JSON.stringify(body);
141
+ const req = http.request(url, {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
144
+ }, (res) => {
145
+ let responseBody = '';
146
+ res.on('data', (chunk) => responseBody += chunk);
147
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: responseBody }));
148
+ });
149
+ req.on('error', reject);
150
+ req.write(data);
151
+ req.end();
152
+ });
153
+ }
154
+
155
+ it('should serve the HTML UI', { timeout: 15000 }, async () => {
156
+ writeConfig();
157
+ const port = await getFreePort();
158
+ await startServer(port);
159
+
160
+ const res = await httpGet(`http://localhost:${port}/`);
161
+ assert.strictEqual(res.status, 200);
162
+ assert.ok(res.body.includes('cligr serve'));
163
+ });
164
+
165
+ it('should return groups via API', { timeout: 15000 }, async () => {
166
+ writeConfig();
167
+ const port = await getFreePort();
168
+ await startServer(port);
169
+
170
+ const res = await httpGet(`http://localhost:${port}/api/groups`);
171
+ assert.strictEqual(res.status, 200);
172
+ const data = JSON.parse(res.body);
173
+ assert.ok(Array.isArray(data.groups));
174
+ assert.strictEqual(data.groups.length, 1);
175
+ assert.strictEqual(data.groups[0].name, 'web');
176
+ assert.strictEqual(data.groups[0].running, false);
177
+ assert.strictEqual(data.groups[0].items.length, 2);
178
+ const serverItem = data.groups[0].items.find((i: any) => i.name === 'server');
179
+ const workerItem = data.groups[0].items.find((i: any) => i.name === 'worker');
180
+ assert.strictEqual(serverItem.enabled, true);
181
+ assert.strictEqual(workerItem.enabled, false);
182
+ });
183
+
184
+ it('should toggle group via API', { timeout: 15000 }, async () => {
185
+ writeConfig();
186
+ const port = await getFreePort();
187
+ await startServer(port);
188
+
189
+ const postRes = await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true });
190
+ assert.strictEqual(postRes.status, 200);
191
+
192
+ await new Promise(r => setTimeout(r, 300));
193
+
194
+ const getRes = await httpGet(`http://localhost:${port}/api/groups`);
195
+ const data = JSON.parse(getRes.body);
196
+ const web = data.groups.find((g: any) => g.name === 'web');
197
+ assert.strictEqual(web.running, true);
198
+
199
+ await httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: false });
200
+ });
201
+
202
+ it('should toggle item via API', { timeout: 15000 }, async () => {
203
+ writeConfig();
204
+ const port = await getFreePort();
205
+ await startServer(port);
206
+
207
+ const postRes = await httpPost(`http://localhost:${port}/api/groups/web/items/worker/toggle`, { enabled: true });
208
+ assert.strictEqual(postRes.status, 200);
209
+
210
+ const getRes = await httpGet(`http://localhost:${port}/api/groups`);
211
+ const data = JSON.parse(getRes.body);
212
+ const web = data.groups.find((g: any) => g.name === 'web');
213
+ const worker = web.items.find((i: any) => i.name === 'worker');
214
+ assert.strictEqual(worker.enabled, true);
215
+ });
216
+
217
+ it('should stream SSE events', { timeout: 15000 }, async () => {
218
+ writeConfig();
219
+ const port = await getFreePort();
220
+ await startServer(port);
221
+
222
+ return new Promise<void>((resolve, reject) => {
223
+ const req = http.get(`http://localhost:${port}/api/events`, (res) => {
224
+ let buffer = '';
225
+ res.on('data', (chunk) => {
226
+ buffer += chunk.toString();
227
+ if (buffer.includes('event: status')) {
228
+ req.destroy();
229
+ resolve();
230
+ }
231
+ });
232
+ });
233
+ req.on('error', reject);
234
+
235
+ setTimeout(() => {
236
+ httpPost(`http://localhost:${port}/api/groups/web/toggle`, { enabled: true }).catch(() => {});
237
+ }, 200);
238
+
239
+ setTimeout(() => {
240
+ req.destroy();
241
+ reject(new Error('SSE timeout'));
242
+ }, 3000);
243
+ });
244
+ });
245
+ });