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 +131 -0
- package/index.js +58 -0
- package/lib/health-check.js +44 -0
- package/lib/index.js +7 -0
- package/lib/logger.js +45 -0
- package/lib/orchestrator.js +181 -0
- package/lib/process-manager.js +295 -0
- package/package.json +53 -0
- package/scripts-orchestrator.config.js +83 -0
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
|
+
];
|