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.
@@ -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.5",
3
+ "version": "1.0.7",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
- "cligr": "./dist/index.js"
6
+ "cligr": "dist/index.js"
7
7
  },
8
8
  "type": "module",
9
- "author": "zerr",
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 { exec } from 'child_process';
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
- async function runTests(verbose = false) {
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 verboseFlag = verbose ? '--verbose' : '';
109
- const command = `node --test ${verboseFlag} ${testFiles}`;
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 { stdout, stderr } = await execAsync(command, {
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
- return { stdout, stderr };
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
- await runTests(verbose);
149
+ runTests(verbose);
146
150
 
147
151
  // Clean up
148
152
  cleanupSourceModules();
@@ -29,7 +29,7 @@ export async function groupsCommand(verbose: boolean): Promise<number> {
29
29
  name,
30
30
  tool: group.tool || '(none)',
31
31
  restart: group.restart || '(none)',
32
- itemCount: group.items.length,
32
+ itemCount: Object.keys(group.items).length,
33
33
  });
34
34
  }
35
35
 
@@ -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
- for (const item of config.items) {
15
- // Show the full item string as-is in the output
16
- console.log(` - ${item}`);
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 (err) {
23
- console.error((err as Error).message);
24
- return 1;
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
+ }
@@ -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 items = config.items.map((itemStr, index) =>
20
- TemplateExpander.parseItem(tool, toolTemplate, itemStr, index, params)
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, items, config.restart);
24
+ manager.spawnGroup(groupName, processItems, config.restart);
25
25
 
26
- console.log(`Started group ${groupName} with ${items.length} process(es)`);
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