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 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
 
@@ -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 new Promise((resolve) => {
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.code === 0 && result.output === '200') {
47
+ if (result.success && result.statusCode === 200) {
25
48
  !silent && this.logger.success(`${url} is available`);
26
49
  return true;
27
50
  }
@@ -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);
@@ -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
- if (!startedByScript) {
222
- this.logger.verbose(
223
- `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
224
- );
225
- return;
226
- }
222
+ await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
223
+ },
224
+ );
227
225
 
228
- this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
226
+ await Promise.all(killPromises);
227
+ this.backgroundProcesses = [];
228
+ this.backgroundProcessesDetails = [];
229
+ }
229
230
 
230
- // Try custom kill command first if specified
231
- if (kill_command) {
232
- try {
233
- this.logger.verbose(`- Using custom kill command: npm run ${kill_command}`);
234
- const result = await this.runCommand({ cmd: kill_command, logFile: null, background: false });
235
- if (result.success) {
236
- this.logger.verbose(`- Successfully killed ${command} using custom command`);
237
- return;
238
- } else {
239
- this.logger.verbose('- Custom kill command failed, falling back to process signals');
240
- }
241
- } catch (error) {
242
- this.logger.verbose(`- Custom kill command error: ${error.message}, falling back`);
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(`- No kill_command specified for ${command}, using process signals`);
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
- // First try to kill the process group
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
- this.logger.verbose(
254
- `- Process ${command} (PGID: ${pgid}) already terminated`,
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
- // Try SIGTERM first
260
- process.kill(pgid, 'SIGTERM');
261
-
262
- await new Promise((resolve, reject) => {
263
- const timeout = setTimeout(() => {
264
- clearInterval(checkInterval);
265
- reject(new Error('Process termination timeout'));
266
- }, 5000);
267
-
268
- const checkInterval = setInterval(() => {
269
- try {
270
- process.kill(pgid, 0);
271
- } catch (error) {
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 urlObj = new URL(url);
289
- const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
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 curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
372
+ const findProcess = spawn(findPortCmd, findPortArgs);
292
373
  const result = await new Promise((resolve) => {
293
374
  let output = '';
294
- curl.stdout.on('data', (data) => {
375
+ findProcess.stdout.on('data', (data) => {
295
376
  output += data.toString();
296
377
  });
297
- curl.on('close', (code) => {
378
+ findProcess.on('close', (code) => {
298
379
  resolve({ code, output });
299
380
  });
300
381
  });
301
382
 
302
- if (result.code === 0 && result.output === '200') {
303
- this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
383
+ if (result.code === 0 && result.output.trim()) {
384
+ let pids = [];
304
385
 
305
- // Find and kill process using the port
306
- try {
307
- const lsof = spawn('lsof', ['-i', `:${port}`, '-t']);
308
- const result = await new Promise((resolve) => {
309
- let output = '';
310
- lsof.stdout.on('data', (data) => {
311
- output += data.toString();
312
- });
313
- lsof.on('close', (code) => {
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
- } catch (lsofError) {
332
- this.logger.error(`- Failed to find process using port ${port}: ${lsofError.message}`);
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 (error) {
336
- this.logger.verbose(`- URL check failed: ${error.message}`);
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
- await Promise.all(killPromises);
352
- this.backgroundProcesses = [];
353
- this.backgroundProcessesDetails = [];
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.1.0",
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",