scripts-orchestrator 2.1.0 → 2.3.0
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 +0 -1
- package/lib/health-check.js +35 -12
- package/lib/orchestrator.js +10 -0
- package/lib/process-manager.js +200 -103
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -192,7 +192,6 @@ See [versions](./docs/versions.md)
|
|
|
192
192
|
## Roadmap
|
|
193
193
|
- Better UX to indicate what is happening
|
|
194
194
|
- Tests to avoid regression
|
|
195
|
-
- Retry should append to the log file
|
|
196
195
|
- Run any shell command rather than assume the command is specified in package.json (? tentative)
|
|
197
196
|
|
|
198
197
|
|
package/lib/health-check.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { log } from './logger.js';
|
|
3
2
|
|
|
4
3
|
export class HealthCheck {
|
|
@@ -6,22 +5,46 @@ export class HealthCheck {
|
|
|
6
5
|
this.logger = log;
|
|
7
6
|
}
|
|
8
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Static method to make a simple HTTP/HTTPS request
|
|
10
|
+
* @param {string} url - The URL to check
|
|
11
|
+
* @param {number} timeout - Request timeout in milliseconds (default: 5000)
|
|
12
|
+
* @returns {Promise<{statusCode: number|null, success: boolean, error?: string}>}
|
|
13
|
+
*/
|
|
14
|
+
static async makeHttpRequest(url, timeout = 5000) {
|
|
15
|
+
try {
|
|
16
|
+
// Use Node.js http/https module instead of curl for cross-platform compatibility
|
|
17
|
+
const urlObj = new URL(url);
|
|
18
|
+
const isHttps = urlObj.protocol === 'https:';
|
|
19
|
+
const httpModule = isHttps ? await import('https') : await import('http');
|
|
20
|
+
|
|
21
|
+
return await new Promise((resolve) => {
|
|
22
|
+
const req = httpModule.default.get(url, (res) => {
|
|
23
|
+
resolve({ statusCode: res.statusCode, success: true });
|
|
24
|
+
res.destroy(); // Close the response stream
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
req.on('error', (error) => {
|
|
28
|
+
resolve({ statusCode: null, success: false, error: error.message });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
req.setTimeout(timeout, () => {
|
|
32
|
+
req.destroy();
|
|
33
|
+
resolve({ statusCode: null, success: false, error: 'Timeout' });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { statusCode: null, success: false, error: error.message };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
9
41
|
async waitForUrl({url, maxAttempts = 20, interval = 2000, silent=false}) {
|
|
10
42
|
!silent && this.logger.info(`Waiting for ${url} to be available...`);
|
|
11
43
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
12
44
|
try {
|
|
13
|
-
const result = await
|
|
14
|
-
const curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
|
|
15
|
-
let output = '';
|
|
16
|
-
curl.stdout.on('data', (data) => {
|
|
17
|
-
output += data.toString();
|
|
18
|
-
});
|
|
19
|
-
curl.on('close', (code) => {
|
|
20
|
-
resolve({ code, output });
|
|
21
|
-
});
|
|
22
|
-
});
|
|
45
|
+
const result = await HealthCheck.makeHttpRequest(url, 5000);
|
|
23
46
|
|
|
24
|
-
if (result.
|
|
47
|
+
if (result.success && result.statusCode === 200) {
|
|
25
48
|
!silent && this.logger.success(`${url} is available`);
|
|
26
49
|
return true;
|
|
27
50
|
}
|
package/lib/orchestrator.js
CHANGED
|
@@ -179,6 +179,16 @@ export class Orchestrator {
|
|
|
179
179
|
|
|
180
180
|
if (commandFailed) {
|
|
181
181
|
this.failedCommands.push(command);
|
|
182
|
+
|
|
183
|
+
// Cleanup any background processes for this failed command
|
|
184
|
+
if (background) {
|
|
185
|
+
this.logger.warn(`Command ${command} failed after all attempts. Cleaning up background processes.`);
|
|
186
|
+
try {
|
|
187
|
+
await this.processManager.cleanupCommand(command);
|
|
188
|
+
} catch (cleanupError) {
|
|
189
|
+
this.logger.error(`Failed to cleanup processes for ${command}: ${cleanupError.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
this.commandTimings.set(command, Date.now() - startTime);
|
package/lib/process-manager.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { log } from './logger.js';
|
|
5
|
+
import { HealthCheck } from './health-check.js';
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
export class ProcessManager {
|
|
@@ -218,139 +219,235 @@ export class ProcessManager {
|
|
|
218
219
|
|
|
219
220
|
const killPromises = this.backgroundProcessesDetails.map(
|
|
220
221
|
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
222
|
+
await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
|
|
223
|
+
},
|
|
224
|
+
);
|
|
227
225
|
|
|
228
|
-
|
|
226
|
+
await Promise.all(killPromises);
|
|
227
|
+
this.backgroundProcesses = [];
|
|
228
|
+
this.backgroundProcessesDetails = [];
|
|
229
|
+
}
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
async cleanupCommand(commandName) {
|
|
232
|
+
this.logger.info(`\nCleaning up processes for command: ${commandName}`);
|
|
233
|
+
|
|
234
|
+
// Find processes for this specific command
|
|
235
|
+
const commandProcesses = this.backgroundProcessesDetails.filter(
|
|
236
|
+
({ command }) => command === commandName
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (commandProcesses.length === 0) {
|
|
240
|
+
this.logger.verbose(`- No background processes found for command: ${commandName}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.logger.verbose(`- Found ${commandProcesses.length} background processes for command: ${commandName}`);
|
|
245
|
+
|
|
246
|
+
const killPromises = commandProcesses.map(
|
|
247
|
+
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
248
|
+
await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await Promise.all(killPromises);
|
|
253
|
+
|
|
254
|
+
// Remove the cleaned up processes from our tracking arrays
|
|
255
|
+
this.backgroundProcesses = this.backgroundProcesses.filter(pgid =>
|
|
256
|
+
!commandProcesses.some(proc => proc.pgid === pgid)
|
|
257
|
+
);
|
|
258
|
+
this.backgroundProcessesDetails = this.backgroundProcessesDetails.filter(
|
|
259
|
+
({ command }) => command !== commandName
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
|
|
264
|
+
if (!startedByScript) {
|
|
265
|
+
this.logger.verbose(
|
|
266
|
+
`- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
|
|
272
|
+
|
|
273
|
+
// Try custom kill command first if specified
|
|
274
|
+
if (kill_command) {
|
|
275
|
+
try {
|
|
276
|
+
this.logger.verbose(`- Using custom kill command: npm run ${kill_command}`);
|
|
277
|
+
const result = await this.runCommand({ cmd: kill_command, logFile: null, background: false });
|
|
278
|
+
if (result.success) {
|
|
279
|
+
this.logger.verbose(`- Successfully killed ${command} using custom command`);
|
|
280
|
+
return;
|
|
244
281
|
} else {
|
|
245
|
-
this.logger.verbose(
|
|
282
|
+
this.logger.verbose('- Custom kill command failed, falling back to process signals');
|
|
246
283
|
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.logger.verbose(`- Custom kill command error: ${error.message}, falling back`);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
this.logger.verbose(`- No kill_command specified for ${command}, using process signals`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// First try to kill the process group
|
|
293
|
+
try {
|
|
294
|
+
process.kill(pgid, 0);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.logger.verbose(
|
|
297
|
+
`- Process ${command} (PGID: ${pgid}) already terminated`,
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
247
301
|
|
|
302
|
+
// Cross-platform process termination
|
|
303
|
+
const isWindows = process.platform === 'win32';
|
|
304
|
+
|
|
305
|
+
if (isWindows) {
|
|
306
|
+
// Windows: use taskkill to terminate process tree
|
|
248
307
|
try {
|
|
249
|
-
|
|
308
|
+
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
|
|
309
|
+
await new Promise((resolve) => {
|
|
310
|
+
killProcess.on('close', resolve);
|
|
311
|
+
});
|
|
312
|
+
this.logger.verbose(`- Terminated background process: ${command} (PID: ${pgid})`);
|
|
313
|
+
return;
|
|
314
|
+
} catch (killError) {
|
|
315
|
+
this.logger.verbose(`- Failed to use taskkill, falling back to process.kill: ${killError.message}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Unix/Linux/macOS or Windows fallback: Try SIGTERM first
|
|
320
|
+
process.kill(pgid, 'SIGTERM');
|
|
321
|
+
|
|
322
|
+
await new Promise((resolve, reject) => {
|
|
323
|
+
const timeout = setTimeout(() => {
|
|
324
|
+
clearInterval(checkInterval);
|
|
325
|
+
reject(new Error('Process termination timeout'));
|
|
326
|
+
}, 5000);
|
|
327
|
+
|
|
328
|
+
const checkInterval = setInterval(() => {
|
|
250
329
|
try {
|
|
251
330
|
process.kill(pgid, 0);
|
|
252
331
|
} catch (error) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
);
|
|
256
|
-
return;
|
|
332
|
+
clearInterval(checkInterval);
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
resolve();
|
|
257
335
|
}
|
|
336
|
+
}, 100);
|
|
337
|
+
});
|
|
338
|
+
this.logger.verbose(
|
|
339
|
+
`- Terminated background process: ${command} (PGID: ${pgid})`,
|
|
340
|
+
);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
|
|
343
|
+
}
|
|
258
344
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
clearInterval(checkInterval);
|
|
273
|
-
clearTimeout(timeout);
|
|
274
|
-
resolve();
|
|
275
|
-
}
|
|
276
|
-
}, 100);
|
|
277
|
-
});
|
|
278
|
-
this.logger.verbose(
|
|
279
|
-
`- Terminated background process: ${command} (PGID: ${pgid})`,
|
|
280
|
-
);
|
|
281
|
-
} catch (error) {
|
|
282
|
-
this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Check if the URL is still responding after termination attempt
|
|
286
|
-
if (url) {
|
|
345
|
+
// Check if the URL is still responding after termination attempt
|
|
346
|
+
if (url) {
|
|
347
|
+
try {
|
|
348
|
+
const urlObj = new URL(url);
|
|
349
|
+
const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
|
|
350
|
+
|
|
351
|
+
// Use shared HTTP utility for cross-platform compatibility
|
|
352
|
+
const urlResult = await HealthCheck.makeHttpRequest(url, 2000);
|
|
353
|
+
|
|
354
|
+
if (urlResult.success && urlResult.statusCode === 200) {
|
|
355
|
+
this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
|
|
356
|
+
|
|
357
|
+
// Find and kill process using the port - cross-platform approach
|
|
287
358
|
try {
|
|
288
|
-
const
|
|
289
|
-
|
|
359
|
+
const isWindows = process.platform === 'win32';
|
|
360
|
+
let findPortCmd, findPortArgs;
|
|
361
|
+
|
|
362
|
+
if (isWindows) {
|
|
363
|
+
// Windows: use netstat
|
|
364
|
+
findPortCmd = 'netstat';
|
|
365
|
+
findPortArgs = ['-ano'];
|
|
366
|
+
} else {
|
|
367
|
+
// Unix/Linux/macOS: use lsof
|
|
368
|
+
findPortCmd = 'lsof';
|
|
369
|
+
findPortArgs = ['-i', `:${port}`, '-t'];
|
|
370
|
+
}
|
|
290
371
|
|
|
291
|
-
const
|
|
372
|
+
const findProcess = spawn(findPortCmd, findPortArgs);
|
|
292
373
|
const result = await new Promise((resolve) => {
|
|
293
374
|
let output = '';
|
|
294
|
-
|
|
375
|
+
findProcess.stdout.on('data', (data) => {
|
|
295
376
|
output += data.toString();
|
|
296
377
|
});
|
|
297
|
-
|
|
378
|
+
findProcess.on('close', (code) => {
|
|
298
379
|
resolve({ code, output });
|
|
299
380
|
});
|
|
300
381
|
});
|
|
301
382
|
|
|
302
|
-
if (result.code === 0 && result.output
|
|
303
|
-
|
|
383
|
+
if (result.code === 0 && result.output.trim()) {
|
|
384
|
+
let pids = [];
|
|
304
385
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
resolve({ code, output });
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
if (result.code === 0 && result.output.trim()) {
|
|
319
|
-
const pids = result.output.trim().split('\n');
|
|
320
|
-
for (const pid of pids) {
|
|
321
|
-
try {
|
|
322
|
-
process.kill(parseInt(pid), 'SIGKILL');
|
|
323
|
-
this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
|
|
324
|
-
} catch (killError) {
|
|
325
|
-
if (killError.code !== 'ESRCH') {
|
|
326
|
-
this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
|
|
327
|
-
}
|
|
386
|
+
if (isWindows) {
|
|
387
|
+
// Parse netstat output to find PIDs for the specific port
|
|
388
|
+
const lines = result.output.split('\n');
|
|
389
|
+
for (const line of lines) {
|
|
390
|
+
if (line.includes(`:${port} `) && line.includes('LISTENING')) {
|
|
391
|
+
const parts = line.trim().split(/\s+/);
|
|
392
|
+
const pid = parts[parts.length - 1];
|
|
393
|
+
if (pid && !isNaN(pid)) {
|
|
394
|
+
pids.push(pid);
|
|
328
395
|
}
|
|
329
396
|
}
|
|
330
397
|
}
|
|
331
|
-
}
|
|
332
|
-
|
|
398
|
+
} else {
|
|
399
|
+
// lsof output is already just PIDs
|
|
400
|
+
pids = result.output.trim().split('\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const pid of pids) {
|
|
404
|
+
try {
|
|
405
|
+
if (isWindows) {
|
|
406
|
+
// Windows: use taskkill
|
|
407
|
+
const killProcess = spawn('taskkill', ['/F', '/PID', pid]);
|
|
408
|
+
await new Promise((resolve) => {
|
|
409
|
+
killProcess.on('close', resolve);
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
// Unix/Linux/macOS: use process.kill
|
|
413
|
+
process.kill(parseInt(pid), 'SIGKILL');
|
|
414
|
+
}
|
|
415
|
+
this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
|
|
416
|
+
} catch (killError) {
|
|
417
|
+
if (killError.code !== 'ESRCH') {
|
|
418
|
+
this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
333
421
|
}
|
|
334
422
|
}
|
|
335
|
-
} catch (
|
|
336
|
-
this.logger.
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Final attempt to kill the process group with SIGKILL
|
|
341
|
-
try {
|
|
342
|
-
process.kill(pgid, 'SIGKILL');
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (error.code !== 'ESRCH') {
|
|
345
|
-
this.logger.error(`- Failed to kill process group with SIGKILL: ${error.message}`);
|
|
423
|
+
} catch (portError) {
|
|
424
|
+
this.logger.error(`- Failed to find process using port ${port}: ${portError.message}`);
|
|
346
425
|
}
|
|
347
426
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
427
|
+
} catch (error) {
|
|
428
|
+
this.logger.verbose(`- URL check failed: ${error.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
350
431
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
432
|
+
// Final attempt to kill the process group
|
|
433
|
+
try {
|
|
434
|
+
const isWindows = process.platform === 'win32';
|
|
435
|
+
|
|
436
|
+
if (isWindows) {
|
|
437
|
+
// Windows: force kill with taskkill
|
|
438
|
+
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
|
|
439
|
+
await new Promise((resolve) => {
|
|
440
|
+
killProcess.on('close', resolve);
|
|
441
|
+
});
|
|
442
|
+
} else {
|
|
443
|
+
// Unix/Linux/macOS: use SIGKILL
|
|
444
|
+
process.kill(pgid, 'SIGKILL');
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error.code !== 'ESRCH') {
|
|
448
|
+
this.logger.error(`- Failed to kill process group: ${error.message}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
354
451
|
}
|
|
355
452
|
}
|
|
356
453
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scripts-orchestrator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"type": "module",
|