scripts-orchestrator 2.7.0 → 2.8.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
@@ -31,6 +31,9 @@ npm install --save-dev scripts-orchestrator
31
31
  - **Retry Mechanism**: Configurable retry attempts for failed commands
32
32
  - **Process Management**: Proper cleanup of background processes
33
33
  - **Health Checks**: Verifies service availability before proceeding
34
+ - **Environment Variables**: Pass custom environment variables to commands
35
+ - **Optional Phases**: Mark phases as optional and run them selectively
36
+ - **Git-Based Caching**: Automatically skips execution when git state is unchanged
34
37
  - **Comprehensive Logging**: Detailed logging of command execution and results
35
38
 
36
39
  ## Configuration
@@ -45,6 +48,10 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
45
48
  attempts: 1, // Number of retry attempts
46
49
  dependencies: [], // Array of dependent commands
47
50
  background: false, // Whether to run in background
51
+ env: { // Optional environment variables
52
+ PORT: 3000,
53
+ NODE_ENV: 'production'
54
+ },
48
55
  kill_command: 'kill_storybook', // Optional kill command to kill the process
49
56
  health_check: { // Health check configuration
50
57
  url: 'http://localhost:port',
@@ -155,6 +162,52 @@ export default {
155
162
  };
156
163
  ```
157
164
 
165
+ ### Using Environment Variables
166
+
167
+ You can pass custom environment variables to commands using the `env` property. This is useful for configuring ports, API endpoints, or any environment-specific settings:
168
+
169
+ ```javascript
170
+ export default {
171
+ phases: [
172
+ {
173
+ name: 'playwright',
174
+ parallel: [
175
+ {
176
+ command: 'playwright_ci',
177
+ description: 'Run Playwright tests',
178
+ env: {
179
+ PLAYWRIGHT_PORT: 5173,
180
+ API_URL: 'http://localhost:3000',
181
+ TEST_ENV: 'ci'
182
+ },
183
+ status: 'enabled',
184
+ attempts: 1,
185
+ dependencies: [
186
+ {
187
+ command: 'dev',
188
+ background: true,
189
+ env: {
190
+ PORT: 5173
191
+ },
192
+ health_check: {
193
+ url: 'http://localhost:5173',
194
+ max_attempts: 20,
195
+ interval: 2000
196
+ }
197
+ }
198
+ ]
199
+ }
200
+ ]
201
+ }
202
+ ]
203
+ };
204
+ ```
205
+
206
+ The command will run with the environment variables set, equivalent to:
207
+ ```bash
208
+ PLAYWRIGHT_PORT=5173 API_URL=http://localhost:3000 TEST_ENV=ci npm run playwright_ci
209
+ ```
210
+
158
211
  See more examples [here](./docs/samples.md)
159
212
 
160
213
  ## Command Types
@@ -295,9 +348,25 @@ npm run scripts-orchestrator -- --phases "build,test,optional-e2e,optional-perfo
295
348
  ## Logging
296
349
 
297
350
  - Each command's output is logged to `scripts-orchestrator-logs/<command>.log` in the current working directory
351
+ - Git commit hash is cached in `scripts-orchestrator-logs/.git-hash-cache` for skip detection
298
352
  - Provides real-time status updates during execution
299
353
  - Summarizes results at the end of execution
300
354
 
355
+ ## Git-Based Caching
356
+
357
+ The orchestrator automatically tracks the git commit hash and repository state to optimize execution:
358
+
359
+ - **On first run**: Records the current git commit hash in `scripts-orchestrator-logs/.git-hash-cache`
360
+ - **On subsequent runs**: Checks if:
361
+ - The git commit hash matches the cached hash
362
+ - There are no staged or unstaged changes in the repository
363
+ - **When conditions are met**: Skips execution entirely with message `✓ Git state unchanged`
364
+ - **When conditions fail**: Runs normally and updates the cache on successful completion
365
+
366
+ This feature is particularly useful in CI/CD pipelines where the same commit might be processed multiple times, saving time and resources by avoiding redundant executions.
367
+
368
+ **Note**: The cache is only updated on successful execution. Failed runs will not update the cache, ensuring subsequent runs will retry.
369
+
301
370
  ## Exit Codes
302
371
 
303
372
  - `0`: All commands executed successfully
@@ -0,0 +1,187 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { log } from './logger.js';
5
+
6
+ export class GitCache {
7
+ constructor(logFolder = 'scripts-orchestrator-logs') {
8
+ this.logFolder = logFolder;
9
+ this.cacheFileName = '.git-hash-cache';
10
+ }
11
+
12
+ /**
13
+ * Execute a git command and return the output
14
+ * @param {string[]} args - Git command arguments
15
+ * @returns {Promise<{success: boolean, output: string}>}
16
+ */
17
+ async executeGitCommand(args) {
18
+ return new Promise((resolve) => {
19
+ const gitProcess = spawn('git', args, {
20
+ cwd: process.cwd(),
21
+ shell: false,
22
+ });
23
+
24
+ let output = '';
25
+ let errorOutput = '';
26
+
27
+ gitProcess.stdout.on('data', (data) => {
28
+ output += data.toString();
29
+ });
30
+
31
+ gitProcess.stderr.on('data', (data) => {
32
+ errorOutput += data.toString();
33
+ });
34
+
35
+ gitProcess.on('close', (code) => {
36
+ if (code === 0) {
37
+ resolve({ success: true, output: output.trim() });
38
+ } else {
39
+ resolve({ success: false, output: errorOutput.trim() });
40
+ }
41
+ });
42
+
43
+ gitProcess.on('error', (error) => {
44
+ resolve({ success: false, output: error.message });
45
+ });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Get the current git commit hash
51
+ * @returns {Promise<string|null>}
52
+ */
53
+ async getCurrentCommitHash() {
54
+ const result = await this.executeGitCommand(['rev-parse', 'HEAD']);
55
+ if (result.success) {
56
+ return result.output;
57
+ }
58
+ log.verbose(`Failed to get git commit hash: ${result.output}`);
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Check if there are any staged or unstaged changes
64
+ * @returns {Promise<boolean>}
65
+ */
66
+ async hasGitChanges() {
67
+ // Check for staged and unstaged changes
68
+ const statusResult = await this.executeGitCommand(['status', '--porcelain']);
69
+
70
+ if (!statusResult.success) {
71
+ log.verbose(`Failed to check git status: ${statusResult.output}`);
72
+ // If we can't check git status, assume there are changes to be safe
73
+ return true;
74
+ }
75
+
76
+ // If there's any output, there are changes
77
+ return statusResult.output.length > 0;
78
+ }
79
+
80
+ /**
81
+ * Get the path to the cache file
82
+ * @returns {string}
83
+ */
84
+ getCacheFilePath() {
85
+ const baseDir = this.logFolder ? path.resolve(this.logFolder) : process.cwd();
86
+ const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
87
+ return path.join(LOGS_DIR, this.cacheFileName);
88
+ }
89
+
90
+ /**
91
+ * Read the cached git hash
92
+ * @returns {string|null}
93
+ */
94
+ readCachedHash() {
95
+ const cacheFilePath = this.getCacheFilePath();
96
+
97
+ try {
98
+ if (fs.existsSync(cacheFilePath)) {
99
+ const cachedHash = fs.readFileSync(cacheFilePath, 'utf8').trim();
100
+ log.verbose(`Read cached git hash: ${cachedHash}`);
101
+ return cachedHash;
102
+ }
103
+ } catch (error) {
104
+ log.verbose(`Failed to read cached git hash: ${error.message}`);
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Write the current git hash to cache
112
+ * @param {string} hash
113
+ * @returns {boolean}
114
+ */
115
+ writeCachedHash(hash) {
116
+ const cacheFilePath = this.getCacheFilePath();
117
+
118
+ try {
119
+ // Ensure the directory exists
120
+ const cacheDir = path.dirname(cacheFilePath);
121
+ if (!fs.existsSync(cacheDir)) {
122
+ fs.mkdirSync(cacheDir, { recursive: true });
123
+ }
124
+
125
+ fs.writeFileSync(cacheFilePath, hash, 'utf8');
126
+ log.verbose(`Wrote git hash to cache: ${hash}`);
127
+ return true;
128
+ } catch (error) {
129
+ log.error(`Failed to write git hash to cache: ${error.message}`);
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if the orchestrator should skip running based on git state
136
+ * @returns {Promise<boolean>} - true if should skip, false if should run
137
+ */
138
+ async shouldSkipExecution() {
139
+ log.verbose('Checking git state for caching...');
140
+
141
+ // Get current commit hash
142
+ const currentHash = await this.getCurrentCommitHash();
143
+ if (!currentHash) {
144
+ log.verbose('Could not get current git hash, will not skip execution');
145
+ return false;
146
+ }
147
+
148
+ // Get cached hash
149
+ const cachedHash = this.readCachedHash();
150
+ if (!cachedHash) {
151
+ log.verbose('No cached git hash found, will not skip execution');
152
+ return false;
153
+ }
154
+
155
+ // Check if hashes match
156
+ if (currentHash !== cachedHash) {
157
+ log.verbose(`Git hash changed (${cachedHash.substring(0, 7)} -> ${currentHash.substring(0, 7)}), will not skip execution`);
158
+ return false;
159
+ }
160
+
161
+ // Check for uncommitted changes
162
+ const hasChanges = await this.hasGitChanges();
163
+ if (hasChanges) {
164
+ log.verbose('Git repository has uncommitted changes, will not skip execution');
165
+ return false;
166
+ }
167
+
168
+ // All conditions met - can skip
169
+ log.info(`✓ Git state unchanged (${currentHash.substring(0, 7)}), skipping execution`);
170
+ return true;
171
+ }
172
+
173
+ /**
174
+ * Update the cached git hash with the current commit
175
+ * @returns {Promise<void>}
176
+ */
177
+ async updateCache() {
178
+ const currentHash = await this.getCurrentCommitHash();
179
+ if (currentHash) {
180
+ this.writeCachedHash(currentHash);
181
+ }
182
+ }
183
+ }
184
+
185
+ // Export a singleton instance
186
+ export const gitCache = new GitCache();
187
+
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { Orchestrator } from './orchestrator.js';
2
2
  import { ProcessManager } from './process-manager.js';
3
3
  import { HealthCheck } from './health-check.js';
4
4
  import { Logger } from './logger.js';
5
+ import { GitCache } from './git-cache.js';
5
6
 
6
- export { Orchestrator, ProcessManager, HealthCheck, Logger };
7
+ export { Orchestrator, ProcessManager, HealthCheck, Logger, GitCache };
7
8
  export default Orchestrator;
package/lib/logger.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import yargs from 'yargs';
3
3
  import { hideBin } from 'yargs/helpers';
4
+ import fs from 'fs';
5
+ import path from 'path';
4
6
 
5
7
  const argv = yargs(hideBin(process.argv))
6
8
  .option('verbose', {
@@ -25,27 +27,78 @@ const argv = yargs(hideBin(process.argv))
25
27
  class Logger {
26
28
  constructor() {
27
29
  this.isVerbose = argv.verbose;
30
+ this.logFolder = argv.logFolder || 'scripts-orchestrator-logs';
31
+ this.logFile = null;
32
+ this.logStream = null;
33
+ this.initializeLogFile();
34
+ }
35
+
36
+ initializeLogFile() {
37
+ try {
38
+ // Create log directory if it doesn't exist
39
+ if (!fs.existsSync(this.logFolder)) {
40
+ fs.mkdirSync(this.logFolder, { recursive: true });
41
+ }
42
+
43
+ // Create main log file with timestamp
44
+ const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
45
+ this.logFile = path.join(this.logFolder, `orchestrator-main-${timestamp}.log`);
46
+
47
+ // Create write stream
48
+ this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' });
49
+
50
+ // Handle stream errors
51
+ this.logStream.on('error', (err) => {
52
+ console.error(`Error writing to log file: ${err.message}`);
53
+ });
54
+
55
+ // Ensure the stream is closed on process exit
56
+ process.on('exit', () => {
57
+ if (this.logStream) {
58
+ this.logStream.end();
59
+ }
60
+ });
61
+
62
+ // Write initial log entry
63
+ this.writeToFile(`[START] Orchestrator started at ${new Date().toISOString()}\n`);
64
+ } catch (error) {
65
+ console.error(`Failed to initialize log file: ${error.message}`);
66
+ }
67
+ }
68
+
69
+ writeToFile(message) {
70
+ if (this.logStream) {
71
+ // Strip ANSI color codes for file output
72
+ // eslint-disable-next-line no-control-regex
73
+ const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
74
+ this.logStream.write(`${cleanMessage}\n`);
75
+ }
28
76
  }
29
77
 
30
78
  info(message) {
31
79
  console.log(chalk.blue(`[INFO] ${message}`));
80
+ this.writeToFile(`[INFO] ${message}`);
32
81
  }
33
82
 
34
83
  success(message) {
35
84
  console.log(chalk.green(`[SUCCESS] ${message}`));
85
+ this.writeToFile(`[SUCCESS] ${message}`);
36
86
  }
37
87
 
38
88
  error(message) {
39
89
  console.error(chalk.red(`[ERROR] ${message}`));
90
+ this.writeToFile(`[ERROR] ${message}`);
40
91
  }
41
92
 
42
93
  warn(message) {
43
94
  console.warn(chalk.yellow(`[WARN] ${message}`));
95
+ this.writeToFile(`[WARN] ${message}`);
44
96
  }
45
97
 
46
98
  verbose(message) {
47
99
  if (this.isVerbose) {
48
100
  console.log(chalk.gray(`[VERBOSE] ${message}`));
101
+ this.writeToFile(`[VERBOSE] ${message}`);
49
102
  }
50
103
  }
51
104
  }
@@ -1,6 +1,7 @@
1
1
  import { processManager } from './process-manager.js';
2
2
  import { healthCheck } from './health-check.js';
3
3
  import { log } from './logger.js';
4
+ import { GitCache } from './git-cache.js';
4
5
 
5
6
  export class Orchestrator {
6
7
  constructor(config, startPhase = null, logFolder = null, phases = null) {
@@ -14,6 +15,7 @@ export class Orchestrator {
14
15
  this.failedCommands = [];
15
16
  this.skippedCommands = [];
16
17
  this.commandTimings = new Map();
18
+ this.gitCache = new GitCache(logFolder);
17
19
 
18
20
  // Set the log folder in process manager
19
21
  if (logFolder) {
@@ -62,6 +64,7 @@ export class Orchestrator {
62
64
  process_tracking = false,
63
65
  health_check,
64
66
  kill_command,
67
+ env,
65
68
  } = commandConfig;
66
69
 
67
70
  const startTime = Date.now();
@@ -162,6 +165,7 @@ export class Orchestrator {
162
165
  healthCheck: health_check,
163
166
  kill_command,
164
167
  isRetry: attempt > 1,
168
+ env,
165
169
  });
166
170
  commandOutput = output;
167
171
  result = success;
@@ -235,6 +239,13 @@ export class Orchestrator {
235
239
 
236
240
  async run() {
237
241
  try {
242
+ // Check if we should skip execution based on git state
243
+ const shouldSkip = await this.gitCache.shouldSkipExecution();
244
+ if (shouldSkip) {
245
+ this.logger.success('🎉 No changes detected, skipping execution!');
246
+ process.exit(0);
247
+ }
248
+
238
249
  let hasFailures = false;
239
250
  let phaseFailed = false;
240
251
  let startPhaseFound = false;
@@ -338,6 +349,11 @@ export class Orchestrator {
338
349
  this.logger.error(`Cleanup failed: ${error.message}`);
339
350
  }
340
351
 
352
+ // Update git cache on successful execution
353
+ if (!hasFailures) {
354
+ await this.gitCache.updateCache();
355
+ }
356
+
341
357
  // Force exit with appropriate status
342
358
  if (hasFailures) {
343
359
  this.logger.info('Exiting with failure status...');
@@ -37,7 +37,7 @@ export class ProcessManager {
37
37
  });
38
38
  }
39
39
 
40
- async runCommand({ cmd, logFile, background = false, healthCheck = null, kill_command = null, isRetry = false }) {
40
+ async runCommand({ cmd, logFile, background = false, healthCheck = null, kill_command = null, isRetry = false, env = null }) {
41
41
  const baseDir = this.logFolder ? path.resolve(this.logFolder) : process.cwd();
42
42
  const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
43
43
  // Use only the first word of the command for the log filename
@@ -62,10 +62,17 @@ export class ProcessManager {
62
62
  }
63
63
 
64
64
  return new Promise((resolve) => {
65
- this.logger.info(`Running: npm run ${cmd}`);
65
+ // Build command with environment variables if provided
66
+ let fullCommand = `npm run ${cmd}`;
67
+ if (env && Object.keys(env).length > 0) {
68
+ const envStr = Object.entries(env).map(([key, value]) => `${key}=${value}`).join(' ');
69
+ fullCommand = `${envStr} npm run ${cmd}`;
70
+ }
71
+
72
+ this.logger.info(`Running: ${fullCommand}`);
66
73
 
67
74
  // Create isolated environment for each process
68
- const isolatedEnv = this.createIsolatedEnvironment({ command: cmd });
75
+ const isolatedEnv = this.createIsolatedEnvironment({ command: cmd, env });
69
76
 
70
77
  const options = {
71
78
  shell: true,
@@ -80,8 +87,8 @@ export class ProcessManager {
80
87
  //this.logger.verbose(`Process options: ${JSON.stringify(options, null, 2)}`);
81
88
 
82
89
  try {
83
- this.logger.verbose(`Spawning process with command: npm run ${cmd}`);
84
- const processInstance = spawn('npm', ['run', cmd], options);
90
+ this.logger.verbose(`Spawning process with command: ${fullCommand}`);
91
+ const processInstance = spawn(fullCommand, [], options);
85
92
 
86
93
  processInstance.on('error', (error) => {
87
94
  this.logger.error(`Failed to start process: ${error.message}`);
@@ -235,7 +242,7 @@ export class ProcessManager {
235
242
  });
236
243
  }
237
244
 
238
- createIsolatedEnvironment({ command }) {
245
+ createIsolatedEnvironment({ command, env = null }) {
239
246
  // Create a deep copy to avoid any reference sharing
240
247
  const baseEnv = JSON.parse(JSON.stringify(process.env));
241
248
 
@@ -255,6 +262,13 @@ export class ProcessManager {
255
262
  npm_config_loglevel: 'error',
256
263
  };
257
264
 
265
+ // Merge custom environment variables if provided
266
+ if (env && typeof env === 'object') {
267
+ Object.entries(env).forEach(([key, value]) => {
268
+ isolatedEnv[key] = String(value);
269
+ });
270
+ }
271
+
258
272
  // Remove any potentially problematic environment variables
259
273
  delete isolatedEnv.npm_lifecycle_event;
260
274
  delete isolatedEnv.npm_lifecycle_script;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",
@@ -99,6 +99,9 @@ export default {
99
99
  {
100
100
  command: 'playwright_ci',
101
101
  description: 'Run Playwright tests',
102
+ env: {
103
+ PLAYWRIGHT_PORT: 5173,
104
+ },
102
105
  status: 'enabled',
103
106
  attempts: 1, //Playwright internally retries in CI mode
104
107
  dependencies: [