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.
- package/.claude/worktrees/agent-ac25cfb2/README.md +65 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-13-named-params-support.md +391 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-design.md +164 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-implementation.md +460 -0
- package/.claude/worktrees/agent-ac25cfb2/package-lock.json +554 -0
- package/.claude/worktrees/agent-ac25cfb2/package.json +27 -0
- package/.claude/worktrees/agent-ac25cfb2/scripts/build.js +20 -0
- package/.claude/worktrees/agent-ac25cfb2/scripts/test.js +168 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/config.ts +121 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/groups.ts +68 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/ls.ts +25 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/up.ts +49 -0
- package/.claude/worktrees/agent-ac25cfb2/src/config/loader.ts +148 -0
- package/.claude/worktrees/agent-ac25cfb2/src/config/types.ts +26 -0
- package/.claude/worktrees/agent-ac25cfb2/src/index.ts +97 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/manager.ts +270 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/pid-store.ts +203 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/template.ts +87 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes-fixed.test.ts +255 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes.test.ts +497 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/commands.test.ts +648 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/config-loader.test.ts +426 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/process-manager.test.ts +394 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/template-expander.test.ts +454 -0
- package/.claude/worktrees/agent-ac25cfb2/tsconfig.json +15 -0
- package/.claude/worktrees/agent-ac25cfb2/usage.md +9 -0
- package/dist/index.js +247 -39
- package/docs/superpowers/plans/2026-04-13-improve-web-ui-console.md +256 -0
- package/docs/superpowers/plans/2026-04-13-serve-command.md +1299 -0
- package/docs/superpowers/specs/2026-04-13-improve-web-ui-console-design.md +38 -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 +417 -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 → worktrees/agent-ac25cfb2/.claude/settings.local.json} +0 -0
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
|
|
package/src/process/manager.ts
CHANGED
|
@@ -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
|
+
});
|
/package/.claude/{settings.local.json → worktrees/agent-ac25cfb2/.claude/settings.local.json}
RENAMED
|
File without changes
|