scripts-orchestrator 1.0.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 ADDED
@@ -0,0 +1,131 @@
1
+ # Scripts Orchestrator
2
+
3
+ A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks. Perfect for CI/CD pipelines and automated testing workflows.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Install as a development dependency
9
+ npm install --save-dev scripts-orchestrator
10
+
11
+ # Or install globally
12
+ npm install -g scripts-orchestrator
13
+ ```
14
+
15
+ ## Features
16
+
17
+ - **Parallel Execution**: Runs multiple commands concurrently for faster execution
18
+ - **Dependency Management**: Handles command dependencies and ensures proper execution order
19
+ - **Background Processes**: Supports running commands in the background with health checks
20
+ - **Retry Mechanism**: Configurable retry attempts for failed commands
21
+ - **Process Management**: Proper cleanup of background processes
22
+ - **Health Checks**: Verifies service availability before proceeding
23
+ - **Comprehensive Logging**: Detailed logging of command execution and results
24
+
25
+ ## Configuration
26
+
27
+ Create a configuration file (default: `scripts-orchestrator.config.js`) that defines an array of commands to execute. Each command can have the following properties:
28
+
29
+ ```javascript
30
+ {
31
+ command: 'command_name', // The npm script to run
32
+ description: 'Description', // Optional description
33
+ status: 'enabled', // 'enabled' or 'disabled'
34
+ attempts: 1, // Number of retry attempts
35
+ dependencies: [], // Array of dependent commands
36
+ background: false, // Whether to run in background
37
+ health_check: { // Health check configuration
38
+ url: 'http://localhost:port',
39
+ max_attempts: 20,
40
+ interval: 2000
41
+ },
42
+ should_retry: (output) => { // Custom retry logic
43
+ // Return true to retry, false to skip
44
+ }
45
+ }
46
+ ```
47
+
48
+ ## Command Types
49
+
50
+ The orchestrator is completely agnostic to what commands it runs. It can execute any npm scripts or shell commands. Common use cases include:
51
+
52
+ 1. **Build Processes**: Compile, bundle, or build your project
53
+ 2. **Testing**: Run unit tests, integration tests, or end-to-end tests
54
+ 3. **Code Quality**: Run linters, formatters, or static analysis tools
55
+ 4. **Documentation**: Generate documentation or run documentation tests
56
+ 5. **Deployment**: Run deployment scripts or environment checks
57
+ 6. **Custom Scripts**: Execute any custom npm scripts or shell commands
58
+
59
+ The orchestrator doesn't care what the commands do - it just ensures they run in the correct order, handles dependencies, manages background processes, and provides proper logging and error handling.
60
+
61
+ ## Usage
62
+
63
+ ### Local Installation
64
+
65
+ 1. Create a configuration file (e.g., `scripts-orchestrator.config.js`) in your project root
66
+ 2. Configure your commands in the config file
67
+ 3. Add a script to your package.json:
68
+ ```json
69
+ {
70
+ "scripts": {
71
+ "scripts-orchestrator": "npx scripts-orchestrator"
72
+ }
73
+ }
74
+ ```
75
+ 4. Run the orchestrator:
76
+ ```bash
77
+ # Using default config file (scripts-orchestrator.config.js)
78
+ npm run scripts-orchestrator
79
+
80
+ # Or specify a custom config file
81
+ npm run scripts-orchestrator -- ./path/to/your/config.js
82
+ ```
83
+
84
+ ### Global Installation
85
+
86
+ 1. Create a configuration file in your project root
87
+ 2. Configure your commands in the config file
88
+ 3. Run the orchestrator:
89
+ ```bash
90
+ # Using default config file (scripts-orchestrator.config.js)
91
+ scripts-orchestrator
92
+
93
+ # Or specify a custom config file
94
+ scripts-orchestrator ./path/to/your/config.js
95
+ ```
96
+
97
+ ## Error Handling
98
+
99
+ - The script tracks failed and skipped commands
100
+ - Provides detailed error messages and logs
101
+ - Handles process cleanup on script termination
102
+ - Manages background processes and ensures proper cleanup
103
+
104
+ ## Logging
105
+
106
+ - Each command's output is logged to `logs/scripts-orchestrator_<command>.log`
107
+ - Provides real-time status updates during execution
108
+ - Summarizes results at the end of execution
109
+
110
+ ## Exit Codes
111
+
112
+ - `0`: All commands executed successfully
113
+ - `1`: One or more commands failed or were skipped
114
+
115
+ ## Signal Handling
116
+
117
+ The script properly handles various termination signals:
118
+ - SIGINT (Ctrl+C)
119
+ - SIGTERM
120
+ - SIGQUIT
121
+ - SIGHUP
122
+ - Uncaught exceptions
123
+ - Unhandled rejections
124
+
125
+ ## Contributing
126
+
127
+ Contributions are welcome! Please feel free to submit a Pull Request.
128
+
129
+ ## License
130
+
131
+ MIT © Vivek Kodira
package/index.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file index.js
5
+ * @description CLI entry point for the scripts-orchestrator package
6
+ */
7
+
8
+ import path from 'path';
9
+ import fs from 'fs';
10
+ import { Orchestrator } from './lib/index.js';
11
+ import { log } from './lib/logger.js';
12
+
13
+ // Get config file path from command line arguments
14
+ const configPath = process.argv[2] || './scripts-orchestrator.config.js';
15
+
16
+ // Validate config file exists
17
+ if (!fs.existsSync(configPath)) {
18
+ log.error(`Error: Config file not found at ${configPath}`);
19
+ log.error('Usage: scripts-orchestrator [path-to-config-file]');
20
+ process.exit(1);
21
+ }
22
+
23
+ // Import the config file
24
+ const commandsConfig = (await import(path.resolve(process.cwd(), configPath))).default;
25
+
26
+ // Create and run the orchestrator
27
+ const orchestrator = new Orchestrator(commandsConfig);
28
+
29
+ // Enhanced signal handlers
30
+ const handleSignal = async (signal) => {
31
+ log.warn(`\nReceived ${signal} signal. Cleaning up...`);
32
+ try {
33
+ await orchestrator.processManager.cleanup();
34
+ } catch (error) {
35
+ log.error(`Cleanup failed: ${error.message}`);
36
+ }
37
+ process.exit(1);
38
+ };
39
+
40
+ // Attach handlers for various signals
41
+ process.on('SIGINT', () => handleSignal('interrupt'));
42
+ process.on('SIGTERM', () => handleSignal('termination'));
43
+ process.on('SIGQUIT', () => handleSignal('quit'));
44
+ process.on('SIGHUP', () => handleSignal('hangup'));
45
+
46
+ // Handle uncaught exceptions and rejections
47
+ process.on('uncaughtException', async (error) => {
48
+ log.error(`Uncaught Exception: ${error.message}`);
49
+ await handleSignal('exception');
50
+ });
51
+
52
+ process.on('unhandledRejection', async (reason, promise) => {
53
+ log.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);
54
+ await handleSignal('rejection');
55
+ });
56
+
57
+ // Run the orchestrator
58
+ orchestrator.run();
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'child_process';
2
+ import { log } from './logger.js';
3
+
4
+ export class HealthCheck {
5
+ constructor() {
6
+ this.logger = log;
7
+ }
8
+
9
+ async waitForUrl({url, maxAttempts = 20, interval = 2000, silent=false}) {
10
+ !silent && this.logger.info(`Waiting for ${url} to be available...`);
11
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
12
+ 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
+ });
23
+
24
+ if (result.code === 0 && result.output === '200') {
25
+ !silent && this.logger.success(`${url} is available`);
26
+ return true;
27
+ }
28
+ } catch (error) {
29
+ !silent && this.logger.verbose(`Attempt ${attempt}/${maxAttempts} failed: ${error.message}`);
30
+ }
31
+
32
+ if (attempt < maxAttempts) {
33
+ await new Promise((resolve) => setTimeout(resolve, interval));
34
+ }
35
+ }
36
+
37
+ !silent && this.logger.error(`Failed to connect to ${url} after ${maxAttempts} attempts`);
38
+
39
+ return false;
40
+ }
41
+ }
42
+
43
+ // For backward compatibility
44
+ export const healthCheck = new HealthCheck();
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { Orchestrator } from './orchestrator.js';
2
+ import { ProcessManager } from './process-manager.js';
3
+ import { HealthCheck } from './health-check.js';
4
+ import { Logger } from './logger.js';
5
+
6
+ export { Orchestrator, ProcessManager, HealthCheck, Logger };
7
+ export default Orchestrator;
package/lib/logger.js ADDED
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk';
2
+ import yargs from 'yargs';
3
+ import { hideBin } from 'yargs/helpers';
4
+
5
+ const argv = yargs(hideBin(process.argv))
6
+ .option('verbose', {
7
+ alias: 'v',
8
+ type: 'boolean',
9
+ description: 'Run with verbose logging',
10
+ })
11
+ .parse();
12
+
13
+ class Logger {
14
+ constructor() {
15
+ this.isVerbose = argv.verbose;
16
+ }
17
+
18
+ info(message) {
19
+ console.log(chalk.blue(`[INFO] ${message}`));
20
+ }
21
+
22
+ success(message) {
23
+ console.log(chalk.green(`[SUCCESS] ${message}`));
24
+ }
25
+
26
+ error(message) {
27
+ console.error(chalk.red(`[ERROR] ${message}`));
28
+ }
29
+
30
+ warn(message) {
31
+ console.warn(chalk.yellow(`[WARN] ${message}`));
32
+ }
33
+
34
+ verbose(message) {
35
+ if (this.isVerbose) {
36
+ console.log(chalk.gray(`[VERBOSE] ${message}`));
37
+ }
38
+ }
39
+ }
40
+
41
+ // Create a single instance
42
+ const logger = new Logger();
43
+
44
+ // Export both the class and the instance
45
+ export { Logger, logger as log };
@@ -0,0 +1,181 @@
1
+ import { processManager } from './process-manager.js';
2
+ import { healthCheck } from './health-check.js';
3
+ import { log } from './logger.js';
4
+
5
+ export class Orchestrator {
6
+ constructor(config) {
7
+ this.config = config;
8
+ this.processManager = processManager;
9
+ this.healthCheck = healthCheck;
10
+ this.logger = log;
11
+ this.failedCommands = [];
12
+ this.skippedCommands = [];
13
+ }
14
+
15
+ async executeCommand(commandConfig, visited = new Set()) {
16
+ const {
17
+ command,
18
+ dependencies = [],
19
+ background = false,
20
+ status = 'enabled',
21
+ logFile,
22
+ attempts = 1,
23
+ retry_command,
24
+ should_retry,
25
+ process_tracking = false,
26
+ health_check,
27
+ } = commandConfig;
28
+
29
+ // Check for circular dependencies
30
+ if (visited.has(command)) {
31
+ this.logger.error(
32
+ `Circular dependency detected: ${Array.from(visited).join(' -> ')} -> ${command}`,
33
+ );
34
+ this.failedCommands.push(command);
35
+ return false;
36
+ }
37
+ visited.add(command);
38
+
39
+ // Skip execution if the command is disabled
40
+ if (status === 'disabled') {
41
+ this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
42
+ this.skippedCommands.push(command);
43
+ visited.delete(command);
44
+ return true;
45
+ }
46
+
47
+ const checkUrl = health_check?.url;
48
+ if (checkUrl) {
49
+ this.logger.info(`Checking if ${checkUrl} is already available...`);
50
+ const urlAvailable = await this.healthCheck.waitForUrl({url: checkUrl, maxAttempts: 1, silent:true});
51
+ if (urlAvailable) {
52
+ this.logger.verbose(`${checkUrl} is already available. Skipping ${command} start.`);
53
+ this.processManager.addBackgroundProcess({
54
+ command,
55
+ url: checkUrl,
56
+ startedByScript: false,
57
+ process_tracking,
58
+ });
59
+ visited.delete(command);
60
+ return true;
61
+ }
62
+ }
63
+
64
+ // Execute dependencies first
65
+ for (const dependency of dependencies) {
66
+ const dependencySuccess = await this.executeCommand(dependency, visited);
67
+ if (!dependencySuccess) {
68
+ this.logger.error(`Skipping ${command} due to failed dependency`);
69
+ this.skippedCommands.push(command);
70
+ visited.delete(command);
71
+ return false;
72
+ }
73
+
74
+ if (dependency.health_check?.url) {
75
+ this.logger.info(`Waiting for ${dependency.health_check.url} to be available...`);
76
+ const urlAvailable = await this.healthCheck.waitForUrl({
77
+ url: dependency.health_check.url,
78
+ maxAttempts: dependency.health_check?.max_attempts || 20,
79
+ interval: dependency.health_check?.interval || 2000,
80
+ });
81
+ if (!urlAvailable) {
82
+ this.logger.error(
83
+ `URL ${dependency.health_check.url} is not available after maximum attempts`,
84
+ );
85
+ this.skippedCommands.push(command);
86
+ visited.delete(command);
87
+ return false;
88
+ }
89
+ if (dependency.wait) {
90
+ this.logger.verbose(`Waiting ${dependency.wait}ms`);
91
+ await new Promise((resolve) => {
92
+ setTimeout(() => {
93
+ this.logger.verbose(`Resolving after a wait of ${dependency.wait}ms`);
94
+ resolve(true);
95
+ }, dependency.wait);
96
+ });
97
+ }
98
+ }
99
+ }
100
+
101
+ // Execute the main command with retries
102
+ let result = false;
103
+ let commandOutput = '';
104
+ for (let attempt = 1; attempt <= attempts; attempt++) {
105
+ if (attempt > 1) {
106
+ this.logger.warn(`Retrying ${command} (attempt ${attempt}/${attempts})`);
107
+ await new Promise((resolve) => setTimeout(resolve, 1000));
108
+ }
109
+
110
+ const { success, output } = await this.processManager.runCommand(
111
+ attempt === 1 ? command : retry_command || command,
112
+ logFile,
113
+ background,
114
+ health_check,
115
+ );
116
+ commandOutput = output;
117
+ result = success;
118
+
119
+ if (result) {
120
+ this.failedCommands = this.failedCommands.filter(cmd => cmd !== command);
121
+ break;
122
+ } else if (attempt < attempts) {
123
+ if (should_retry && !should_retry(commandOutput)) {
124
+ this.logger.warn(
125
+ `${command} failed but doesn't meet retry criteria. Skipping retry.`,
126
+ );
127
+ break;
128
+ }
129
+ this.logger.error(`Attempt ${attempt}/${attempts} failed for ${command}`);
130
+ }
131
+ }
132
+
133
+ visited.delete(command);
134
+ return result;
135
+ }
136
+
137
+ summarizeResults() {
138
+ this.logger.info('\nCommand Summary:');
139
+ this.config.forEach(({ command }) => {
140
+ if (this.failedCommands.includes(command)) {
141
+ this.logger.error(`- ${command}: ❌ (See logs/scripts-orchestrator_${command}.log)`);
142
+ } else if (this.skippedCommands.includes(command)) {
143
+ this.logger.warn(`- ${command}: ⚠️ (Skipped due to failed dependency)`);
144
+ } else {
145
+ this.logger.success(`- ${command}: ✅`);
146
+ }
147
+ });
148
+
149
+ if (this.failedCommands.length > 0 || this.skippedCommands.length > 0) {
150
+ this.logger.error('\n❌ Some commands failed or were skipped. See details above.');
151
+ } else {
152
+ this.logger.success('\n🎉 All commands executed successfully!');
153
+ }
154
+ }
155
+
156
+ async run() {
157
+ try {
158
+ // Run top-level commands in parallel
159
+ const tasks = this.config.map((commandConfig) =>
160
+ this.executeCommand(commandConfig),
161
+ );
162
+
163
+ // Wait for all top-level commands to complete
164
+ await Promise.all(tasks);
165
+
166
+ // Add a small delay to ensure all processes have finished
167
+ await new Promise((resolve) => setTimeout(resolve, 1000));
168
+
169
+ this.summarizeResults();
170
+ } finally {
171
+ try {
172
+ await this.processManager.cleanup();
173
+ } catch (error) {
174
+ this.logger.error(`Cleanup failed: ${error.message}`);
175
+ }
176
+ if (this.failedCommands.length > 0 || this.skippedCommands.length > 0) {
177
+ process.exit(1);
178
+ }
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,295 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { log } from './logger.js';
5
+
6
+
7
+ export class ProcessManager {
8
+ constructor() {
9
+ this.logger = log;
10
+ this.backgroundProcesses = [];
11
+ this.backgroundProcessesDetails = [];
12
+ }
13
+
14
+ addBackgroundProcess({ command, url, startedByScript, process_tracking }) {
15
+ this.logger.verbose(`Adding background process: ${command} (${url})`);
16
+ this.backgroundProcessesDetails.push({
17
+ command,
18
+ url,
19
+ startedByScript,
20
+ process_tracking,
21
+ });
22
+ }
23
+
24
+ async runCommand(cmd, logFile, background = false, healthCheck = null) {
25
+ const LOGS_DIR = path.resolve(process.cwd(), 'scripts-orchestrator-logs');
26
+ const LOG_FILE = logFile || path.join(LOGS_DIR, `${cmd}.log`);
27
+
28
+ try {
29
+ if (!fs.existsSync(LOGS_DIR)) {
30
+ this.logger.verbose(`Creating logs directory at ${LOGS_DIR}`);
31
+ fs.mkdirSync(LOGS_DIR, { recursive: true });
32
+ }
33
+
34
+ this.logger.verbose(`Clearing log file at ${LOG_FILE}`);
35
+ fs.writeFileSync(LOG_FILE, ''); // Clear the log file
36
+ } catch (error) {
37
+ this.logger.error(`Failed to setup log file: ${error.message}`);
38
+ return Promise.resolve({ success: false, output: '' });
39
+ }
40
+
41
+ return new Promise((resolve) => {
42
+ this.logger.info(`Running: npm run ${cmd}`);
43
+ const options = {
44
+ shell: true,
45
+ detached: background,
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ cwd: process.cwd(),
48
+ env: {
49
+ ...process.env,
50
+ NODE_ENV: process.env.NODE_ENV || 'development',
51
+ },
52
+ windowsHide: true,
53
+ ...(background ? { processGroup: true } : {}),
54
+ };
55
+
56
+ //this.logger.verbose(`Process options: ${JSON.stringify(options, null, 2)}`);
57
+
58
+ try {
59
+ this.logger.verbose(`Spawning process with command: npm run ${cmd}`);
60
+ const processInstance = spawn('npm', ['run', cmd], options);
61
+
62
+ processInstance.on('error', (error) => {
63
+ this.logger.error(`Failed to start process: ${error.message}`);
64
+ //this.logger.verbose(`Process error details: ${JSON.stringify(error, null, 2)}`);
65
+ resolve({ success: false, output: '' });
66
+ });
67
+
68
+ if (background) {
69
+ const processGroupId = processInstance.pid;
70
+ this.logger.verbose(`Background process spawned with PID: ${processGroupId}`);
71
+
72
+ processInstance.stdout.on('data', (data) => {
73
+ try {
74
+ fs.appendFileSync(LOG_FILE, data.toString());
75
+ } catch (error) {
76
+ this.logger.error(`Failed to write to log file: ${error.message}`);
77
+ }
78
+ });
79
+
80
+ processInstance.stderr.on('data', (data) => {
81
+ try {
82
+ fs.appendFileSync(LOG_FILE, data.toString());
83
+ } catch (error) {
84
+ this.logger.error(`Failed to write to log file: ${error.message}`);
85
+ }
86
+ });
87
+
88
+ const verifyProcess = async () => {
89
+ const maxAttempts = 5;
90
+ const baseDelay = 1000;
91
+
92
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
93
+ try {
94
+ this.logger.verbose(`Verifying process ${processGroupId} (attempt ${attempt}/${maxAttempts})`);
95
+ process.kill(processGroupId, 0);
96
+ this.logger.verbose(`Process ${processGroupId} is running`);
97
+
98
+ this.backgroundProcesses.push(processGroupId);
99
+ this.backgroundProcessesDetails.push({
100
+ command: cmd,
101
+ pgid: processGroupId,
102
+ startTime: Date.now(),
103
+ url: healthCheck?.url,
104
+ startedByScript: true,
105
+ });
106
+
107
+ this.logger.verbose(`Unreferencing process ${processGroupId}`);
108
+ processInstance.unref();
109
+
110
+ this.logger.verbose(
111
+ `Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
112
+ );
113
+ return { success: true, output: '' };
114
+ } catch (error) {
115
+ if (attempt === maxAttempts) {
116
+ this.logger.error(`Failed to start background process: npm run ${cmd}`);
117
+ this.logger.verbose(`Final verification attempt failed: ${error.message}`);
118
+ return { success: false, output: '' };
119
+ }
120
+ this.logger.verbose(`Verification attempt ${attempt} failed: ${error.message}`);
121
+ this.logger.verbose(`Waiting ${baseDelay * Math.pow(2, attempt - 1)}ms before next attempt`);
122
+ await new Promise((resolve) =>
123
+ setTimeout(resolve, baseDelay * Math.pow(2, attempt - 1)),
124
+ );
125
+ }
126
+ }
127
+ return { success: false, output: '' };
128
+ };
129
+
130
+ verifyProcess().then(resolve);
131
+ } else {
132
+ processInstance.stdout.on('data', (data) => {
133
+ try {
134
+ fs.appendFileSync(LOG_FILE, data.toString());
135
+ } catch (error) {
136
+ this.logger.error(`Failed to write to log file: ${error.message}`);
137
+ }
138
+ });
139
+
140
+ processInstance.stderr.on('data', (data) => {
141
+ try {
142
+ fs.appendFileSync(LOG_FILE, data.toString());
143
+ } catch (error) {
144
+ this.logger.error(`Failed to write to log file: ${error.message}`);
145
+ }
146
+ });
147
+
148
+ processInstance.on('close', async (code) => {
149
+ let output = '';
150
+ try {
151
+ output = fs.readFileSync(LOG_FILE, 'utf8');
152
+ } catch (error) {
153
+ this.logger.error(`Failed to read log file: ${error.message}`);
154
+ }
155
+
156
+ if (code !== 0) {
157
+ this.logger.error(`Failed: npm run ${cmd} (exit code: ${code})`);
158
+ this.logger.verbose(`Process output: ${output}`);
159
+ resolve({ success: false, output });
160
+ } else {
161
+ this.logger.success(`Completed: npm run ${cmd}`);
162
+ resolve({ success: true, output });
163
+ }
164
+ });
165
+ }
166
+ } catch (error) {
167
+ this.logger.error(`Failed to spawn process: ${error.message}`);
168
+ //this.logger.verbose(`Spawn error details: ${JSON.stringify(error, null, 2)}`);
169
+ resolve({ success: false, output: '' });
170
+ }
171
+ });
172
+ }
173
+
174
+ async cleanup() {
175
+ this.logger.info('\nCleaning up background processes...');
176
+ const killPromises = this.backgroundProcessesDetails.map(
177
+ async ({ command, pgid, url, startedByScript }) => {
178
+ if (!startedByScript) {
179
+ this.logger.verbose(
180
+ `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
181
+ );
182
+ return;
183
+ }
184
+
185
+ try {
186
+ // First try to kill the process group
187
+ try {
188
+ process.kill(pgid, 0);
189
+ } catch (error) {
190
+ this.logger.verbose(
191
+ `- Process ${command} (PGID: ${pgid}) already terminated`,
192
+ );
193
+ return;
194
+ }
195
+
196
+ // Try SIGTERM first
197
+ process.kill(pgid, 'SIGTERM');
198
+
199
+ await new Promise((resolve, reject) => {
200
+ const timeout = setTimeout(() => {
201
+ clearInterval(checkInterval);
202
+ reject(new Error('Process termination timeout'));
203
+ }, 5000);
204
+
205
+ const checkInterval = setInterval(() => {
206
+ try {
207
+ process.kill(pgid, 0);
208
+ } catch (error) {
209
+ clearInterval(checkInterval);
210
+ clearTimeout(timeout);
211
+ resolve();
212
+ }
213
+ }, 100);
214
+ });
215
+ this.logger.verbose(
216
+ `- Terminated background process: ${command} (PGID: ${pgid})`,
217
+ );
218
+ } catch (error) {
219
+ this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
220
+ }
221
+
222
+ // Check if the URL is still responding after termination attempt
223
+ if (url) {
224
+ try {
225
+ const urlObj = new URL(url);
226
+ const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
227
+
228
+ const curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
229
+ const result = await new Promise((resolve) => {
230
+ let output = '';
231
+ curl.stdout.on('data', (data) => {
232
+ output += data.toString();
233
+ });
234
+ curl.on('close', (code) => {
235
+ resolve({ code, output });
236
+ });
237
+ });
238
+
239
+ if (result.code === 0 && result.output === '200') {
240
+ this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
241
+
242
+ // Find and kill process using the port
243
+ try {
244
+ const lsof = spawn('lsof', ['-i', `:${port}`, '-t']);
245
+ const result = await new Promise((resolve) => {
246
+ let output = '';
247
+ lsof.stdout.on('data', (data) => {
248
+ output += data.toString();
249
+ });
250
+ lsof.on('close', (code) => {
251
+ resolve({ code, output });
252
+ });
253
+ });
254
+
255
+ if (result.code === 0 && result.output.trim()) {
256
+ const pids = result.output.trim().split('\n');
257
+ for (const pid of pids) {
258
+ try {
259
+ process.kill(parseInt(pid), 'SIGKILL');
260
+ this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
261
+ } catch (killError) {
262
+ if (killError.code !== 'ESRCH') {
263
+ this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ } catch (lsofError) {
269
+ this.logger.error(`- Failed to find process using port ${port}: ${lsofError.message}`);
270
+ }
271
+ }
272
+ } catch (error) {
273
+ this.logger.verbose(`- URL check failed: ${error.message}`);
274
+ }
275
+ }
276
+
277
+ // Final attempt to kill the process group with SIGKILL
278
+ try {
279
+ process.kill(pgid, 'SIGKILL');
280
+ } catch (error) {
281
+ if (error.code !== 'ESRCH') {
282
+ this.logger.error(`- Failed to kill process group with SIGKILL: ${error.message}`);
283
+ }
284
+ }
285
+ },
286
+ );
287
+
288
+ await Promise.all(killPromises);
289
+ this.backgroundProcesses = [];
290
+ this.backgroundProcessesDetails = [];
291
+ }
292
+ }
293
+
294
+ // For backward compatibility
295
+ export const processManager = new ProcessManager();
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "scripts-orchestrator",
3
+ "version": "1.0.0",
4
+ "description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
5
+ "main": "lib/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "scripts-orchestrator": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
+ "start": "node index.js",
13
+ "lint": "eslint .",
14
+ "prepare": "npm run lint"
15
+ },
16
+ "keywords": [
17
+ "orchestrator",
18
+ "script",
19
+ "parallel",
20
+ "process",
21
+ "health-check",
22
+ "dependency",
23
+ "background",
24
+ "automation",
25
+ "ci",
26
+ "testing"
27
+ ],
28
+ "author": "vivekkodira@gmail.com",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/vivekkodira/scripts-orchestrator.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/vivekkodira/scripts-orchestrator/issues"
36
+ },
37
+ "homepage": "https://github.com/vivekkodira/scripts-orchestrator#readme",
38
+ "engines": {
39
+ "node": ">=14.0.0"
40
+ },
41
+ "dependencies": {
42
+ "chalk": "^4.1.2"
43
+ },
44
+ "devDependencies": {
45
+ "eslint": "^8.0.0"
46
+ },
47
+ "files": [
48
+ "lib/",
49
+ "index.js",
50
+ "scripts-orchestrator.config.js",
51
+ "README.md"
52
+ ]
53
+ }
@@ -0,0 +1,83 @@
1
+ export default [
2
+ {
3
+ command: 'build',
4
+ description: 'Build the project',
5
+ status: 'enabled',
6
+ attempts: 1,
7
+ },
8
+ {
9
+ command: 'test-ci',
10
+ description: 'Run unit tests',
11
+ status: 'enabled',
12
+ attempts: 2,
13
+ should_retry: (output) => {
14
+ // Check for actual test failures in the summary
15
+ const testSummaryMatch = output.match(/Test Suites:.*?(\d+) failed/);
16
+ const hasTestFailures =
17
+ testSummaryMatch && parseInt(testSummaryMatch[1]) > 0;
18
+
19
+ // Check for coverage failures
20
+ const coverageSummaryMatch = output.match(
21
+ /Jest: "global" coverage threshold/,
22
+ );
23
+ const hasCoverageFailures = coverageSummaryMatch !== null;
24
+
25
+ if (!hasTestFailures && hasCoverageFailures) {
26
+ console.log(
27
+ 'Tests have passed but coverage thresholds have not been met',
28
+ );
29
+ return false; // Don't retry if only coverage failed
30
+ }
31
+
32
+ return hasTestFailures; // Only retry if there are actual test failures
33
+ },
34
+ },
35
+ {
36
+ command: 'test-storybook',
37
+ description: 'Run Storybook tests',
38
+ status: 'enabled',
39
+ attempts: 2,
40
+ dependencies: [
41
+ {
42
+ command: 'storybook_silent',
43
+ background: true,
44
+ wait: 5000,
45
+ kill_script: 'kill_storybook',
46
+ dependencies: [],
47
+ // Add process tracking
48
+ process_tracking: true,
49
+ // Add health check
50
+ health_check: {
51
+ url: 'http://localhost:6006',
52
+ max_attempts: 20,
53
+ interval: 2000,
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ {
59
+ command: 'stylelint',
60
+ description: 'Run stylelint checks',
61
+ status: 'enabled',
62
+ },
63
+ { command: 'lint', description: 'Run lint checks', status: 'enabled' },
64
+ {
65
+ command: 'jscpd',
66
+ description: 'Run code duplication checks',
67
+ status: 'enabled',
68
+ },
69
+ {
70
+ command: 'playwright_ci',
71
+ description: 'Run Playwright tests',
72
+ status: 'enabled',
73
+ attempts: 1, //Playwright internally retries in CI mode
74
+ dependencies: [
75
+ {
76
+ command: 'dev',
77
+ background: true,
78
+ url: 'http://localhost:5173',
79
+ kill: 'application_end',
80
+ },
81
+ ],
82
+ },
83
+ ];