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