scripts-orchestrator 2.1.0 → 2.4.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 {
@@ -75,6 +76,16 @@ export class ProcessManager {
75
76
  const processGroupId = processInstance.pid;
76
77
  this.logger.verbose(`Background process spawned with PID: ${processGroupId}`);
77
78
 
79
+ // Track process exit for background processes
80
+ let processExited = false;
81
+ let processExitCode = null;
82
+
83
+ processInstance.on('exit', (code, signal) => {
84
+ processExited = true;
85
+ processExitCode = code;
86
+ this.logger.verbose(`Background process ${cmd} (PID: ${processGroupId}) exited with code: ${code}, signal: ${signal}`);
87
+ });
88
+
78
89
  processInstance.stdout.on('data', (data) => {
79
90
  try {
80
91
  fs.appendFileSync(LOG_FILE, data.toString());
@@ -97,10 +108,39 @@ export class ProcessManager {
97
108
 
98
109
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
99
110
  try {
111
+ // First check if the process has already exited with an error
112
+ if (processExited && processExitCode !== 0) {
113
+ this.logger.error(`Background process ${cmd} exited with code ${processExitCode}`);
114
+ let output = '';
115
+ try {
116
+ output = fs.readFileSync(LOG_FILE, 'utf8');
117
+ this.logger.verbose(`Process output: ${output}`);
118
+ } catch (error) {
119
+ this.logger.error(`Failed to read log file: ${error.message}`);
120
+ }
121
+ return { success: false, output };
122
+ }
123
+
100
124
  this.logger.verbose(`Verifying process ${processGroupId} (attempt ${attempt}/${maxAttempts})`);
101
125
  process.kill(processGroupId, 0);
102
126
  this.logger.verbose(`Process ${processGroupId} is running`);
103
127
 
128
+ // Wait a bit more to ensure the process doesn't exit immediately after verification
129
+ await new Promise((resolve) => setTimeout(resolve, 500));
130
+
131
+ // Check again if the process exited during our wait
132
+ if (processExited && processExitCode !== 0) {
133
+ this.logger.error(`Background process ${cmd} exited with code ${processExitCode} shortly after starting`);
134
+ let output = '';
135
+ try {
136
+ output = fs.readFileSync(LOG_FILE, 'utf8');
137
+ this.logger.verbose(`Process output: ${output}`);
138
+ } catch (error) {
139
+ this.logger.error(`Failed to read log file: ${error.message}`);
140
+ }
141
+ return { success: false, output };
142
+ }
143
+
104
144
  this.backgroundProcesses.push(processGroupId);
105
145
  this.backgroundProcessesDetails.push({
106
146
  command: cmd,
@@ -218,139 +258,235 @@ export class ProcessManager {
218
258
 
219
259
  const killPromises = this.backgroundProcessesDetails.map(
220
260
  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
- }
261
+ await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
262
+ },
263
+ );
227
264
 
228
- this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
265
+ await Promise.all(killPromises);
266
+ this.backgroundProcesses = [];
267
+ this.backgroundProcessesDetails = [];
268
+ }
229
269
 
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
- }
270
+ async cleanupCommand(commandName) {
271
+ this.logger.info(`\nCleaning up processes for command: ${commandName}`);
272
+
273
+ // Find processes for this specific command
274
+ const commandProcesses = this.backgroundProcessesDetails.filter(
275
+ ({ command }) => command === commandName
276
+ );
277
+
278
+ if (commandProcesses.length === 0) {
279
+ this.logger.verbose(`- No background processes found for command: ${commandName}`);
280
+ return;
281
+ }
282
+
283
+ this.logger.verbose(`- Found ${commandProcesses.length} background processes for command: ${commandName}`);
284
+
285
+ const killPromises = commandProcesses.map(
286
+ async ({ command, pgid, url, startedByScript, kill_command }) => {
287
+ await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
288
+ }
289
+ );
290
+
291
+ await Promise.all(killPromises);
292
+
293
+ // Remove the cleaned up processes from our tracking arrays
294
+ this.backgroundProcesses = this.backgroundProcesses.filter(pgid =>
295
+ !commandProcesses.some(proc => proc.pgid === pgid)
296
+ );
297
+ this.backgroundProcessesDetails = this.backgroundProcessesDetails.filter(
298
+ ({ command }) => command !== commandName
299
+ );
300
+ }
301
+
302
+ async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
303
+ if (!startedByScript) {
304
+ this.logger.verbose(
305
+ `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
306
+ );
307
+ return;
308
+ }
309
+
310
+ this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
311
+
312
+ // Try custom kill command first if specified
313
+ if (kill_command) {
314
+ try {
315
+ this.logger.verbose(`- Using custom kill command: npm run ${kill_command}`);
316
+ const result = await this.runCommand({ cmd: kill_command, logFile: null, background: false });
317
+ if (result.success) {
318
+ this.logger.verbose(`- Successfully killed ${command} using custom command`);
319
+ return;
244
320
  } else {
245
- this.logger.verbose(`- No kill_command specified for ${command}, using process signals`);
321
+ this.logger.verbose('- Custom kill command failed, falling back to process signals');
246
322
  }
323
+ } catch (error) {
324
+ this.logger.verbose(`- Custom kill command error: ${error.message}, falling back`);
325
+ }
326
+ } else {
327
+ this.logger.verbose(`- No kill_command specified for ${command}, using process signals`);
328
+ }
329
+
330
+ try {
331
+ // First try to kill the process group
332
+ try {
333
+ process.kill(pgid, 0);
334
+ } catch (error) {
335
+ this.logger.verbose(
336
+ `- Process ${command} (PGID: ${pgid}) already terminated`,
337
+ );
338
+ return;
339
+ }
247
340
 
341
+ // Cross-platform process termination
342
+ const isWindows = process.platform === 'win32';
343
+
344
+ if (isWindows) {
345
+ // Windows: use taskkill to terminate process tree
248
346
  try {
249
- // First try to kill the process group
347
+ const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
348
+ await new Promise((resolve) => {
349
+ killProcess.on('close', resolve);
350
+ });
351
+ this.logger.verbose(`- Terminated background process: ${command} (PID: ${pgid})`);
352
+ return;
353
+ } catch (killError) {
354
+ this.logger.verbose(`- Failed to use taskkill, falling back to process.kill: ${killError.message}`);
355
+ }
356
+ }
357
+
358
+ // Unix/Linux/macOS or Windows fallback: Try SIGTERM first
359
+ process.kill(pgid, 'SIGTERM');
360
+
361
+ await new Promise((resolve, reject) => {
362
+ const timeout = setTimeout(() => {
363
+ clearInterval(checkInterval);
364
+ reject(new Error('Process termination timeout'));
365
+ }, 5000);
366
+
367
+ const checkInterval = setInterval(() => {
250
368
  try {
251
369
  process.kill(pgid, 0);
252
370
  } catch (error) {
253
- this.logger.verbose(
254
- `- Process ${command} (PGID: ${pgid}) already terminated`,
255
- );
256
- return;
371
+ clearInterval(checkInterval);
372
+ clearTimeout(timeout);
373
+ resolve();
257
374
  }
375
+ }, 100);
376
+ });
377
+ this.logger.verbose(
378
+ `- Terminated background process: ${command} (PGID: ${pgid})`,
379
+ );
380
+ } catch (error) {
381
+ this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
382
+ }
258
383
 
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) {
384
+ // Check if the URL is still responding after termination attempt
385
+ if (url) {
386
+ try {
387
+ const urlObj = new URL(url);
388
+ const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
389
+
390
+ // Use shared HTTP utility for cross-platform compatibility
391
+ const urlResult = await HealthCheck.makeHttpRequest(url, 2000);
392
+
393
+ if (urlResult.success && urlResult.statusCode === 200) {
394
+ this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
395
+
396
+ // Find and kill process using the port - cross-platform approach
287
397
  try {
288
- const urlObj = new URL(url);
289
- const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
398
+ const isWindows = process.platform === 'win32';
399
+ let findPortCmd, findPortArgs;
290
400
 
291
- const curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
401
+ if (isWindows) {
402
+ // Windows: use netstat
403
+ findPortCmd = 'netstat';
404
+ findPortArgs = ['-ano'];
405
+ } else {
406
+ // Unix/Linux/macOS: use lsof
407
+ findPortCmd = 'lsof';
408
+ findPortArgs = ['-i', `:${port}`, '-t'];
409
+ }
410
+
411
+ const findProcess = spawn(findPortCmd, findPortArgs);
292
412
  const result = await new Promise((resolve) => {
293
413
  let output = '';
294
- curl.stdout.on('data', (data) => {
414
+ findProcess.stdout.on('data', (data) => {
295
415
  output += data.toString();
296
416
  });
297
- curl.on('close', (code) => {
417
+ findProcess.on('close', (code) => {
298
418
  resolve({ code, output });
299
419
  });
300
420
  });
301
421
 
302
- if (result.code === 0 && result.output === '200') {
303
- this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
422
+ if (result.code === 0 && result.output.trim()) {
423
+ let pids = [];
304
424
 
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
- }
425
+ if (isWindows) {
426
+ // Parse netstat output to find PIDs for the specific port
427
+ const lines = result.output.split('\n');
428
+ for (const line of lines) {
429
+ if (line.includes(`:${port} `) && line.includes('LISTENING')) {
430
+ const parts = line.trim().split(/\s+/);
431
+ const pid = parts[parts.length - 1];
432
+ if (pid && !isNaN(pid)) {
433
+ pids.push(pid);
328
434
  }
329
435
  }
330
436
  }
331
- } catch (lsofError) {
332
- this.logger.error(`- Failed to find process using port ${port}: ${lsofError.message}`);
437
+ } else {
438
+ // lsof output is already just PIDs
439
+ pids = result.output.trim().split('\n');
440
+ }
441
+
442
+ for (const pid of pids) {
443
+ try {
444
+ if (isWindows) {
445
+ // Windows: use taskkill
446
+ const killProcess = spawn('taskkill', ['/F', '/PID', pid]);
447
+ await new Promise((resolve) => {
448
+ killProcess.on('close', resolve);
449
+ });
450
+ } else {
451
+ // Unix/Linux/macOS: use process.kill
452
+ process.kill(parseInt(pid), 'SIGKILL');
453
+ }
454
+ this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
455
+ } catch (killError) {
456
+ if (killError.code !== 'ESRCH') {
457
+ this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
458
+ }
459
+ }
333
460
  }
334
461
  }
335
- } catch (error) {
336
- this.logger.verbose(`- URL check failed: ${error.message}`);
462
+ } catch (portError) {
463
+ this.logger.error(`- Failed to find process using port ${port}: ${portError.message}`);
337
464
  }
338
465
  }
466
+ } catch (error) {
467
+ this.logger.verbose(`- URL check failed: ${error.message}`);
468
+ }
469
+ }
339
470
 
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}`);
346
- }
347
- }
348
- },
349
- );
350
-
351
- await Promise.all(killPromises);
352
- this.backgroundProcesses = [];
353
- this.backgroundProcessesDetails = [];
471
+ // Final attempt to kill the process group
472
+ try {
473
+ const isWindows = process.platform === 'win32';
474
+
475
+ if (isWindows) {
476
+ // Windows: force kill with taskkill
477
+ const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
478
+ await new Promise((resolve) => {
479
+ killProcess.on('close', resolve);
480
+ });
481
+ } else {
482
+ // Unix/Linux/macOS: use SIGKILL
483
+ process.kill(pgid, 'SIGKILL');
484
+ }
485
+ } catch (error) {
486
+ if (error.code !== 'ESRCH') {
487
+ this.logger.error(`- Failed to kill process group: ${error.message}`);
488
+ }
489
+ }
354
490
  }
355
491
  }
356
492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.1.0",
3
+ "version": "2.4.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",