scripts-orchestrator 2.10.0 → 2.12.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/lib/health-check.js +27 -11
- package/lib/logger.js +142 -12
- package/lib/orchestrator.js +135 -61
- package/lib/process-manager.js +229 -88
- package/package.json +1 -1
package/lib/health-check.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { log } from './logger.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
2
3
|
|
|
3
4
|
export class HealthCheck {
|
|
4
5
|
constructor() {
|
|
@@ -17,17 +18,17 @@ export class HealthCheck {
|
|
|
17
18
|
const urlObj = new URL(url);
|
|
18
19
|
const isHttps = urlObj.protocol === 'https:';
|
|
19
20
|
const httpModule = isHttps ? await import('https') : await import('http');
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
return await new Promise((resolve) => {
|
|
22
23
|
const req = httpModule.default.get(url, (res) => {
|
|
23
24
|
resolve({ statusCode: res.statusCode, success: true });
|
|
24
25
|
res.destroy(); // Close the response stream
|
|
25
26
|
});
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
req.on('error', (error) => {
|
|
28
29
|
resolve({ statusCode: null, success: false, error: error.message });
|
|
29
30
|
});
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
req.setTimeout(timeout, () => {
|
|
32
33
|
req.destroy();
|
|
33
34
|
resolve({ statusCode: null, success: false, error: 'Timeout' });
|
|
@@ -38,30 +39,45 @@ export class HealthCheck {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
async waitForUrl({url, maxAttempts = 20, interval = 2000, silent=false}) {
|
|
42
|
-
!silent
|
|
42
|
+
async waitForUrl({ url, maxAttempts = 20, interval = 2000, silent = false }) {
|
|
43
|
+
if (!silent) {
|
|
44
|
+
this.logger.startEphemeral(
|
|
45
|
+
`wait_${url}`,
|
|
46
|
+
chalk.yellow(`[INFO] ⏳ Waiting for ${url} to be available...`),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
43
49
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
44
50
|
try {
|
|
45
51
|
const result = await HealthCheck.makeHttpRequest(url, 5000);
|
|
46
52
|
|
|
47
53
|
if (result.success && result.statusCode === 200) {
|
|
48
|
-
!silent
|
|
54
|
+
if (!silent) {
|
|
55
|
+
this.logger.stopEphemeral(`wait_${url}`, `✅ ${url} is available`);
|
|
56
|
+
}
|
|
49
57
|
return true;
|
|
50
58
|
}
|
|
51
59
|
} catch (error) {
|
|
52
|
-
!silent
|
|
60
|
+
if (!silent) {
|
|
61
|
+
// You could potentially log attempts via verbose if needed, but not necessary.
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
if (attempt < maxAttempts) {
|
|
56
66
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
57
67
|
}
|
|
58
68
|
}
|
|
59
|
-
|
|
60
|
-
!silent
|
|
61
|
-
|
|
69
|
+
|
|
70
|
+
if (!silent) {
|
|
71
|
+
this.logger.stopEphemeral(
|
|
72
|
+
`wait_${url}`,
|
|
73
|
+
`❌ Failed to connect to ${url} after ${maxAttempts} attempts`,
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
return false;
|
|
63
79
|
}
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
// For backward compatibility
|
|
67
|
-
export const healthCheck = new HealthCheck();
|
|
83
|
+
export const healthCheck = new HealthCheck();
|
package/lib/logger.js
CHANGED
|
@@ -30,9 +30,131 @@ class Logger {
|
|
|
30
30
|
this.logFolder = argv.logFolder || 'scripts-orchestrator-logs';
|
|
31
31
|
this.logFile = null;
|
|
32
32
|
this.logStream = null;
|
|
33
|
+
|
|
34
|
+
// For TTY dynamic output
|
|
35
|
+
this.isTTY = process.stdout.isTTY;
|
|
36
|
+
this.activeTasks = new Map();
|
|
37
|
+
this.linesRendered = 0;
|
|
38
|
+
|
|
33
39
|
this.initializeLogFile();
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// --- Dynamic Output Handling ---
|
|
43
|
+
|
|
44
|
+
clearActiveTasks() {
|
|
45
|
+
if (!this.isTTY || this.linesRendered === 0) return;
|
|
46
|
+
// Move cursor up and clear lines
|
|
47
|
+
for (let i = 0; i < this.linesRendered; i++) {
|
|
48
|
+
process.stdout.write('\x1b[1A\x1b[2K'); // Up one line, clear entire line
|
|
49
|
+
}
|
|
50
|
+
this.linesRendered = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
renderActiveTasks() {
|
|
54
|
+
if (!this.isTTY) return;
|
|
55
|
+
if (this.activeTasks.size === 0) return;
|
|
56
|
+
|
|
57
|
+
// Render active tasks with a spinner or prefix
|
|
58
|
+
for (const [, text] of this.activeTasks) {
|
|
59
|
+
process.stdout.write(`${text}\n`);
|
|
60
|
+
this.linesRendered++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
startTask(id, text) {
|
|
65
|
+
if (this.isTTY) {
|
|
66
|
+
this.clearActiveTasks();
|
|
67
|
+
this.activeTasks.set(id, chalk.cyan(`[INFO] ⏳ Running: ${text}`));
|
|
68
|
+
this.renderActiveTasks();
|
|
69
|
+
} else {
|
|
70
|
+
this.info(`Running: ${text}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
updateTask(id, text) {
|
|
75
|
+
if (this.isTTY && this.activeTasks.has(id)) {
|
|
76
|
+
this.clearActiveTasks();
|
|
77
|
+
this.activeTasks.set(id, chalk.cyan(`[INFO] ⏳ Running: ${text}`));
|
|
78
|
+
this.renderActiveTasks();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
stopTask(id) {
|
|
83
|
+
if (this.isTTY && this.activeTasks.has(id)) {
|
|
84
|
+
this.clearActiveTasks();
|
|
85
|
+
this.activeTasks.delete(id);
|
|
86
|
+
this.renderActiveTasks();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Wrapper for output methods ---
|
|
91
|
+
|
|
92
|
+
printMessage(logFn) {
|
|
93
|
+
if (this.isTTY) {
|
|
94
|
+
this.clearActiveTasks();
|
|
95
|
+
logFn();
|
|
96
|
+
this.renderActiveTasks();
|
|
97
|
+
} else {
|
|
98
|
+
logFn();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
startEphemeral(id, message) {
|
|
103
|
+
if (this.isTTY) {
|
|
104
|
+
this.clearActiveTasks();
|
|
105
|
+
this.activeTasks.set(id, message);
|
|
106
|
+
this.renderActiveTasks();
|
|
107
|
+
} else {
|
|
108
|
+
this.printMessage(() => console.log(message));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stopEphemeral(id, finalMessage = '', isError = false) {
|
|
113
|
+
if (this.isTTY && this.activeTasks.has(id)) {
|
|
114
|
+
this.clearActiveTasks();
|
|
115
|
+
this.activeTasks.delete(id);
|
|
116
|
+
this.renderActiveTasks();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (finalMessage) {
|
|
120
|
+
if (isError) {
|
|
121
|
+
this.printMessage(() =>
|
|
122
|
+
console.error(chalk.red(`[ERROR] ${finalMessage}`)),
|
|
123
|
+
);
|
|
124
|
+
} else {
|
|
125
|
+
this.printMessage(() =>
|
|
126
|
+
console.log(chalk.green(`[SUCCESS] ${finalMessage}`)),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stopPhase(phaseName, success, durationStr) {
|
|
133
|
+
if (success) {
|
|
134
|
+
this.printMessage(() =>
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.green(
|
|
137
|
+
`[SUCCESS] ✅ Phase "${phaseName}" completed successfully ${durationStr}`,
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
// Add a blank line after phase logs for readability
|
|
142
|
+
this.printMessage(() => console.log(''));
|
|
143
|
+
this.writeToFile('');
|
|
144
|
+
} else {
|
|
145
|
+
this.printMessage(() =>
|
|
146
|
+
console.error(
|
|
147
|
+
chalk.red(
|
|
148
|
+
`[ERROR] ❌ Phase "${phaseName}" completed with failures ${durationStr}`,
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
// Add a blank line after phase logs for readability
|
|
153
|
+
this.printMessage(() => console.log(''));
|
|
154
|
+
this.writeToFile('');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
36
158
|
initializeLogFile() {
|
|
37
159
|
try {
|
|
38
160
|
// Create log directory if it doesn't exist
|
|
@@ -41,12 +163,18 @@ class Logger {
|
|
|
41
163
|
}
|
|
42
164
|
|
|
43
165
|
// Create main log file with timestamp
|
|
44
|
-
const timestamp = new Date()
|
|
45
|
-
|
|
46
|
-
|
|
166
|
+
const timestamp = new Date()
|
|
167
|
+
.toISOString()
|
|
168
|
+
.replace(/:/g, '-')
|
|
169
|
+
.replace(/\..+/, '');
|
|
170
|
+
this.logFile = path.join(
|
|
171
|
+
this.logFolder,
|
|
172
|
+
`orchestrator-main-${timestamp}.log`,
|
|
173
|
+
);
|
|
174
|
+
|
|
47
175
|
// Create write stream
|
|
48
176
|
this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' });
|
|
49
|
-
|
|
177
|
+
|
|
50
178
|
// Handle stream errors
|
|
51
179
|
this.logStream.on('error', (err) => {
|
|
52
180
|
console.error(`Error writing to log file: ${err.message}`);
|
|
@@ -60,7 +188,9 @@ class Logger {
|
|
|
60
188
|
});
|
|
61
189
|
|
|
62
190
|
// Write initial log entry
|
|
63
|
-
this.writeToFile(
|
|
191
|
+
this.writeToFile(
|
|
192
|
+
`[START] Orchestrator started at ${new Date().toISOString()}\n`,
|
|
193
|
+
);
|
|
64
194
|
} catch (error) {
|
|
65
195
|
console.error(`Failed to initialize log file: ${error.message}`);
|
|
66
196
|
}
|
|
@@ -74,7 +204,7 @@ class Logger {
|
|
|
74
204
|
|
|
75
205
|
// Update log folder
|
|
76
206
|
this.logFolder = newLogFolder;
|
|
77
|
-
|
|
207
|
+
|
|
78
208
|
// Reinitialize with new folder
|
|
79
209
|
this.initializeLogFile();
|
|
80
210
|
}
|
|
@@ -89,28 +219,28 @@ class Logger {
|
|
|
89
219
|
}
|
|
90
220
|
|
|
91
221
|
info(message) {
|
|
92
|
-
console.log(chalk.blue(`[INFO] ${message}`));
|
|
222
|
+
this.printMessage(() => console.log(chalk.blue(`[INFO] ${message}`)));
|
|
93
223
|
this.writeToFile(`[INFO] ${message}`);
|
|
94
224
|
}
|
|
95
225
|
|
|
96
226
|
success(message) {
|
|
97
|
-
console.log(chalk.green(`[SUCCESS] ${message}`));
|
|
227
|
+
this.printMessage(() => console.log(chalk.green(`[SUCCESS] ${message}`)));
|
|
98
228
|
this.writeToFile(`[SUCCESS] ${message}`);
|
|
99
229
|
}
|
|
100
230
|
|
|
101
231
|
error(message) {
|
|
102
|
-
console.error(chalk.red(`[ERROR] ${message}`));
|
|
232
|
+
this.printMessage(() => console.error(chalk.red(`[ERROR] ${message}`)));
|
|
103
233
|
this.writeToFile(`[ERROR] ${message}`);
|
|
104
234
|
}
|
|
105
235
|
|
|
106
236
|
warn(message) {
|
|
107
|
-
console.warn(chalk.yellow(`[WARN] ${message}`));
|
|
237
|
+
this.printMessage(() => console.warn(chalk.yellow(`[WARN] ${message}`)));
|
|
108
238
|
this.writeToFile(`[WARN] ${message}`);
|
|
109
239
|
}
|
|
110
240
|
|
|
111
241
|
verbose(message) {
|
|
112
242
|
if (this.isVerbose) {
|
|
113
|
-
console.log(chalk.gray(`[VERBOSE] ${message}`));
|
|
243
|
+
this.printMessage(() => console.log(chalk.gray(`[VERBOSE] ${message}`)));
|
|
114
244
|
this.writeToFile(`[VERBOSE] ${message}`);
|
|
115
245
|
}
|
|
116
246
|
}
|
|
@@ -120,4 +250,4 @@ class Logger {
|
|
|
120
250
|
const logger = new Logger();
|
|
121
251
|
|
|
122
252
|
// Export both the class and the instance
|
|
123
|
-
export { Logger, logger as log };
|
|
253
|
+
export { Logger, logger as log };
|
package/lib/orchestrator.js
CHANGED
|
@@ -2,9 +2,17 @@ import { processManager } from './process-manager.js';
|
|
|
2
2
|
import { healthCheck } from './health-check.js';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
import { GitCache } from './git-cache.js';
|
|
5
|
+
import chalk from 'chalk';
|
|
5
6
|
|
|
6
7
|
export class Orchestrator {
|
|
7
|
-
constructor(
|
|
8
|
+
constructor(
|
|
9
|
+
config,
|
|
10
|
+
startPhase = null,
|
|
11
|
+
logFolder = null,
|
|
12
|
+
phases = null,
|
|
13
|
+
sequential = false,
|
|
14
|
+
force = false,
|
|
15
|
+
) {
|
|
8
16
|
this.config = config;
|
|
9
17
|
this.startPhase = startPhase;
|
|
10
18
|
this.logFolder = logFolder;
|
|
@@ -16,14 +24,15 @@ export class Orchestrator {
|
|
|
16
24
|
this.logger = log;
|
|
17
25
|
this.failedCommands = [];
|
|
18
26
|
this.skippedCommands = [];
|
|
27
|
+
this.skipReasons = new Map(); // Track why commands were skipped
|
|
19
28
|
this.commandTimings = new Map();
|
|
20
29
|
this.gitCache = new GitCache(logFolder);
|
|
21
|
-
|
|
30
|
+
|
|
22
31
|
// Set the log folder in process manager
|
|
23
32
|
if (logFolder) {
|
|
24
33
|
this.processManager.setLogFolder(logFolder);
|
|
25
34
|
}
|
|
26
|
-
|
|
35
|
+
|
|
27
36
|
// Flatten commands for easier tracking
|
|
28
37
|
this.allCommands = this.flattenCommands(config);
|
|
29
38
|
}
|
|
@@ -33,11 +42,11 @@ export class Orchestrator {
|
|
|
33
42
|
if (Array.isArray(config)) {
|
|
34
43
|
return config;
|
|
35
44
|
}
|
|
36
|
-
|
|
45
|
+
|
|
37
46
|
if (config.phases) {
|
|
38
|
-
return config.phases.flatMap(phase => phase.parallel || []);
|
|
47
|
+
return config.phases.flatMap((phase) => phase.parallel || []);
|
|
39
48
|
}
|
|
40
|
-
|
|
49
|
+
|
|
41
50
|
return [];
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -86,6 +95,7 @@ export class Orchestrator {
|
|
|
86
95
|
if (status === 'disabled') {
|
|
87
96
|
this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
|
|
88
97
|
this.skippedCommands.push(command);
|
|
98
|
+
this.skipReasons.set(command, 'disabled');
|
|
89
99
|
this.commandTimings.set(command, Date.now() - startTime);
|
|
90
100
|
visited.delete(command);
|
|
91
101
|
return true;
|
|
@@ -93,10 +103,23 @@ export class Orchestrator {
|
|
|
93
103
|
|
|
94
104
|
const checkUrl = health_check?.url;
|
|
95
105
|
if (checkUrl) {
|
|
96
|
-
this.logger.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
this.logger.startEphemeral(
|
|
107
|
+
`check_${checkUrl}`,
|
|
108
|
+
chalk.blue(`[INFO] ⏳ Checking if ${checkUrl} is already available...`),
|
|
109
|
+
);
|
|
110
|
+
const urlAvailable = await this.healthCheck.waitForUrl({
|
|
111
|
+
url: checkUrl,
|
|
112
|
+
maxAttempts: 1,
|
|
113
|
+
silent: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!urlAvailable) {
|
|
117
|
+
this.logger.stopEphemeral(`check_${checkUrl}`);
|
|
118
|
+
} else {
|
|
119
|
+
this.logger.stopEphemeral(
|
|
120
|
+
`check_${checkUrl}`,
|
|
121
|
+
`✅ ${checkUrl} is already available. Skipping ${command} start.`,
|
|
122
|
+
);
|
|
100
123
|
this.processManager.addBackgroundProcess({
|
|
101
124
|
command,
|
|
102
125
|
url: checkUrl,
|
|
@@ -116,23 +139,21 @@ export class Orchestrator {
|
|
|
116
139
|
if (!dependencySuccess) {
|
|
117
140
|
this.logger.error(`Skipping ${command} due to failed dependency`);
|
|
118
141
|
this.skippedCommands.push(command);
|
|
142
|
+
this.skipReasons.set(command, 'failed_dependency');
|
|
119
143
|
this.commandTimings.set(command, Date.now() - startTime);
|
|
120
144
|
visited.delete(command);
|
|
121
145
|
return false;
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
if (dependency.health_check?.url) {
|
|
125
|
-
this.logger.info(`Waiting for ${dependency.health_check.url} to be available...`);
|
|
126
149
|
const urlAvailable = await this.healthCheck.waitForUrl({
|
|
127
150
|
url: dependency.health_check.url,
|
|
128
151
|
maxAttempts: dependency.health_check?.max_attempts || 20,
|
|
129
152
|
interval: dependency.health_check?.interval || 2000,
|
|
130
153
|
});
|
|
131
154
|
if (!urlAvailable) {
|
|
132
|
-
this.logger.error(
|
|
133
|
-
`URL ${dependency.health_check.url} is not available after maximum attempts`,
|
|
134
|
-
);
|
|
135
155
|
this.skippedCommands.push(command);
|
|
156
|
+
this.skipReasons.set(command, 'failed_dependency');
|
|
136
157
|
this.commandTimings.set(command, Date.now() - startTime);
|
|
137
158
|
visited.delete(command);
|
|
138
159
|
return false;
|
|
@@ -141,7 +162,9 @@ export class Orchestrator {
|
|
|
141
162
|
this.logger.verbose(`Waiting ${dependency.wait}ms`);
|
|
142
163
|
await new Promise((resolve) => {
|
|
143
164
|
setTimeout(() => {
|
|
144
|
-
this.logger.verbose(
|
|
165
|
+
this.logger.verbose(
|
|
166
|
+
`Resolving after a wait of ${dependency.wait}ms`,
|
|
167
|
+
);
|
|
145
168
|
resolve(true);
|
|
146
169
|
}, dependency.wait);
|
|
147
170
|
});
|
|
@@ -153,10 +176,12 @@ export class Orchestrator {
|
|
|
153
176
|
let result = false;
|
|
154
177
|
let commandOutput = '';
|
|
155
178
|
let commandFailed = false;
|
|
156
|
-
|
|
179
|
+
|
|
157
180
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
158
181
|
if (attempt > 1) {
|
|
159
|
-
this.logger.warn(
|
|
182
|
+
this.logger.warn(
|
|
183
|
+
`Retrying ${command} (attempt ${attempt}/${attempts})`,
|
|
184
|
+
);
|
|
160
185
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
161
186
|
}
|
|
162
187
|
|
|
@@ -174,7 +199,9 @@ export class Orchestrator {
|
|
|
174
199
|
|
|
175
200
|
if (result) {
|
|
176
201
|
// Remove from failed commands if it was there
|
|
177
|
-
this.failedCommands = this.failedCommands.filter(
|
|
202
|
+
this.failedCommands = this.failedCommands.filter(
|
|
203
|
+
(cmd) => cmd !== command,
|
|
204
|
+
);
|
|
178
205
|
commandFailed = false;
|
|
179
206
|
break;
|
|
180
207
|
} else if (attempt < attempts) {
|
|
@@ -185,7 +212,9 @@ export class Orchestrator {
|
|
|
185
212
|
commandFailed = true;
|
|
186
213
|
break;
|
|
187
214
|
}
|
|
188
|
-
this.logger.error(
|
|
215
|
+
this.logger.error(
|
|
216
|
+
`Attempt ${attempt}/${attempts} failed for ${command}`,
|
|
217
|
+
);
|
|
189
218
|
commandFailed = true;
|
|
190
219
|
} else {
|
|
191
220
|
commandFailed = true;
|
|
@@ -194,14 +223,18 @@ export class Orchestrator {
|
|
|
194
223
|
|
|
195
224
|
if (commandFailed) {
|
|
196
225
|
this.failedCommands.push(command);
|
|
197
|
-
|
|
226
|
+
|
|
198
227
|
// Cleanup any background processes for this failed command
|
|
199
228
|
if (background) {
|
|
200
|
-
this.logger.warn(
|
|
229
|
+
this.logger.warn(
|
|
230
|
+
`Command ${command} failed after all attempts. Cleaning up background processes.`,
|
|
231
|
+
);
|
|
201
232
|
try {
|
|
202
233
|
await this.processManager.cleanupCommand(command);
|
|
203
234
|
} catch (cleanupError) {
|
|
204
|
-
this.logger.error(
|
|
235
|
+
this.logger.error(
|
|
236
|
+
`Failed to cleanup processes for ${command}: ${cleanupError.message}`,
|
|
237
|
+
);
|
|
205
238
|
}
|
|
206
239
|
}
|
|
207
240
|
}
|
|
@@ -212,34 +245,32 @@ export class Orchestrator {
|
|
|
212
245
|
}
|
|
213
246
|
|
|
214
247
|
summarizeResults() {
|
|
215
|
-
this.logger.info('\nCommand Summary:');
|
|
216
248
|
let hasFailures = false;
|
|
217
|
-
|
|
249
|
+
|
|
250
|
+
// Check if any command failed or was skipped due to failure
|
|
218
251
|
this.allCommands.forEach(({ command }) => {
|
|
219
|
-
const duration = this.commandTimings.get(command);
|
|
220
|
-
const durationStr = duration ? ` (${this.formatDuration(duration)})` : '';
|
|
221
|
-
|
|
222
252
|
if (this.failedCommands.includes(command)) {
|
|
223
253
|
hasFailures = true;
|
|
224
|
-
// Get the actual log path from process manager
|
|
225
|
-
const logPath = this.processManager.getLogPath(command);
|
|
226
|
-
this.logger.error(`- ${command}: ❌${durationStr} (See ${logPath})`);
|
|
227
254
|
} else if (this.skippedCommands.includes(command)) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
255
|
+
const skipReason = this.skipReasons.get(command);
|
|
256
|
+
if (
|
|
257
|
+
skipReason === 'failed_dependency' ||
|
|
258
|
+
skipReason === 'after_phase_failure'
|
|
259
|
+
) {
|
|
260
|
+
hasFailures = true;
|
|
261
|
+
}
|
|
232
262
|
}
|
|
233
263
|
});
|
|
234
264
|
|
|
235
265
|
if (hasFailures) {
|
|
236
|
-
this.logger.error('\n❌ Some commands failed or were skipped.
|
|
266
|
+
this.logger.error('\n❌ Some commands failed or were skipped.');
|
|
237
267
|
} else {
|
|
238
268
|
this.logger.success('\n🎉 All commands executed successfully!');
|
|
239
269
|
}
|
|
240
270
|
}
|
|
241
271
|
|
|
242
272
|
async run() {
|
|
273
|
+
this.startTime = Date.now();
|
|
243
274
|
try {
|
|
244
275
|
// Check if we should skip execution based on git state (unless forced)
|
|
245
276
|
if (!this.force) {
|
|
@@ -250,7 +281,9 @@ export class Orchestrator {
|
|
|
250
281
|
process.exit(0);
|
|
251
282
|
}
|
|
252
283
|
} else {
|
|
253
|
-
this.logger.info(
|
|
284
|
+
this.logger.info(
|
|
285
|
+
'⚡ Force execution enabled, skipping git cache check',
|
|
286
|
+
);
|
|
254
287
|
}
|
|
255
288
|
|
|
256
289
|
let hasFailures = false;
|
|
@@ -276,14 +309,14 @@ export class Orchestrator {
|
|
|
276
309
|
this.executeCommand(commandConfig),
|
|
277
310
|
);
|
|
278
311
|
const results = await Promise.all(tasks);
|
|
279
|
-
hasFailures = results.some(result => !result);
|
|
312
|
+
hasFailures = results.some((result) => !result);
|
|
280
313
|
}
|
|
281
314
|
} else if (this.config.phases) {
|
|
282
315
|
// New: Run phases sequentially, commands within phases in parallel or sequential based on flag
|
|
283
316
|
if (this.sequential) {
|
|
284
317
|
this.logger.info('🔄 Running in sequential mode');
|
|
285
318
|
}
|
|
286
|
-
|
|
319
|
+
|
|
287
320
|
for (const phase of this.config.phases) {
|
|
288
321
|
// Check if we should start from this phase
|
|
289
322
|
if (this.startPhase && !startPhaseFound) {
|
|
@@ -294,6 +327,7 @@ export class Orchestrator {
|
|
|
294
327
|
// Mark all commands in previous phases as skipped
|
|
295
328
|
phase.parallel.forEach(({ command }) => {
|
|
296
329
|
this.skippedCommands.push(command);
|
|
330
|
+
this.skipReasons.set(command, 'before_start_phase');
|
|
297
331
|
this.commandTimings.set(command, 0);
|
|
298
332
|
});
|
|
299
333
|
continue;
|
|
@@ -301,11 +335,18 @@ export class Orchestrator {
|
|
|
301
335
|
}
|
|
302
336
|
|
|
303
337
|
// Check if this is an optional phase that should be skipped
|
|
304
|
-
if (
|
|
305
|
-
|
|
338
|
+
if (
|
|
339
|
+
phase.optional === true &&
|
|
340
|
+
this.phases &&
|
|
341
|
+
!this.phases.includes(phase.name)
|
|
342
|
+
) {
|
|
343
|
+
this.logger.info(
|
|
344
|
+
`\n⏭️ Skipping optional phase: ${phase.name} (not explicitly requested)`,
|
|
345
|
+
);
|
|
306
346
|
// Mark all commands in this phase as skipped
|
|
307
347
|
phase.parallel.forEach(({ command }) => {
|
|
308
348
|
this.skippedCommands.push(command);
|
|
349
|
+
this.skipReasons.set(command, 'optional_phase_not_requested');
|
|
309
350
|
this.commandTimings.set(command, 0);
|
|
310
351
|
});
|
|
311
352
|
continue;
|
|
@@ -315,13 +356,14 @@ export class Orchestrator {
|
|
|
315
356
|
// Mark all commands in remaining phases as skipped
|
|
316
357
|
phase.parallel.forEach(({ command }) => {
|
|
317
358
|
this.skippedCommands.push(command);
|
|
359
|
+
this.skipReasons.set(command, 'after_phase_failure');
|
|
318
360
|
this.commandTimings.set(command, 0);
|
|
319
361
|
});
|
|
320
362
|
continue;
|
|
321
363
|
}
|
|
322
364
|
|
|
323
|
-
|
|
324
|
-
|
|
365
|
+
const phaseStartTime = Date.now();
|
|
366
|
+
|
|
325
367
|
let results;
|
|
326
368
|
if (this.sequential) {
|
|
327
369
|
// Run commands sequentially
|
|
@@ -341,58 +383,90 @@ export class Orchestrator {
|
|
|
341
383
|
);
|
|
342
384
|
results = await Promise.all(tasks);
|
|
343
385
|
}
|
|
344
|
-
|
|
345
|
-
const phaseHasFailures = results.some(result => !result);
|
|
346
|
-
|
|
386
|
+
|
|
387
|
+
const phaseHasFailures = results.some((result) => !result);
|
|
388
|
+
const phaseDurationStr = `(${this.formatDuration(
|
|
389
|
+
Date.now() - phaseStartTime,
|
|
390
|
+
)})`;
|
|
391
|
+
|
|
347
392
|
if (phaseHasFailures) {
|
|
348
393
|
hasFailures = true;
|
|
349
394
|
phaseFailed = true;
|
|
350
|
-
this.logger.
|
|
395
|
+
this.logger.stopPhase(phase.name, false, phaseDurationStr);
|
|
351
396
|
} else {
|
|
352
|
-
this.logger.
|
|
397
|
+
this.logger.stopPhase(phase.name, true, phaseDurationStr);
|
|
353
398
|
}
|
|
354
399
|
}
|
|
355
400
|
}
|
|
356
401
|
|
|
357
402
|
// Validate start phase if specified
|
|
358
403
|
if (this.startPhase && !startPhaseFound) {
|
|
359
|
-
const availablePhases = this.config.phases
|
|
360
|
-
|
|
404
|
+
const availablePhases = this.config.phases
|
|
405
|
+
.map((p) => p.name)
|
|
406
|
+
.join(', ');
|
|
407
|
+
this.logger.error(
|
|
408
|
+
`❌ Start phase "${this.startPhase}" not found. Available phases: ${availablePhases}`,
|
|
409
|
+
);
|
|
361
410
|
process.exit(1);
|
|
362
411
|
}
|
|
363
412
|
|
|
364
413
|
// Validate phases if specified
|
|
365
414
|
if (this.phases) {
|
|
366
|
-
const availablePhases = this.config.phases.map(p => p.name);
|
|
367
|
-
const invalidPhases = this.phases.filter(
|
|
415
|
+
const availablePhases = this.config.phases.map((p) => p.name);
|
|
416
|
+
const invalidPhases = this.phases.filter(
|
|
417
|
+
(phase) => !availablePhases.includes(phase),
|
|
418
|
+
);
|
|
368
419
|
if (invalidPhases.length > 0) {
|
|
369
|
-
this.logger.error(
|
|
420
|
+
this.logger.error(
|
|
421
|
+
`❌ Invalid phases specified: ${invalidPhases.join(', ')}. Available phases: ${availablePhases.join(', ')}`,
|
|
422
|
+
);
|
|
370
423
|
process.exit(1);
|
|
371
424
|
}
|
|
372
425
|
}
|
|
373
426
|
|
|
374
427
|
// Check final status
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
428
|
+
// Only count skipped commands as failures if they're due to dependency issues or phase failures
|
|
429
|
+
const failureSkippedCommands = Array.from(this.skipReasons.entries())
|
|
430
|
+
.filter(
|
|
431
|
+
([, reason]) =>
|
|
432
|
+
reason === 'failed_dependency' || reason === 'after_phase_failure',
|
|
433
|
+
)
|
|
434
|
+
.map(([command]) => command);
|
|
435
|
+
|
|
436
|
+
hasFailures =
|
|
437
|
+
hasFailures ||
|
|
438
|
+
this.failedCommands.length > 0 ||
|
|
439
|
+
failureSkippedCommands.length > 0;
|
|
378
440
|
|
|
379
441
|
// Add a small delay to ensure all processes have finished
|
|
380
442
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
381
443
|
|
|
382
444
|
this.summarizeResults();
|
|
383
|
-
|
|
445
|
+
|
|
384
446
|
// Cleanup before exit since finally blocks don't run after process.exit()
|
|
385
447
|
try {
|
|
386
448
|
await this.processManager.cleanup();
|
|
387
449
|
} catch (error) {
|
|
388
450
|
this.logger.error(`Cleanup failed: ${error.message}`);
|
|
389
451
|
}
|
|
390
|
-
|
|
452
|
+
|
|
453
|
+
// Log overall time after cleanup has finished
|
|
454
|
+
if (this.startTime) {
|
|
455
|
+
const overallDuration = Date.now() - this.startTime;
|
|
456
|
+
this.logger.printMessage(() =>
|
|
457
|
+
console.log(
|
|
458
|
+
chalk.cyan(
|
|
459
|
+
`[INFO] ⏱️ Overall time taken: ${this.formatDuration(overallDuration)}`,
|
|
460
|
+
),
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
391
465
|
// Update git cache on successful execution
|
|
392
466
|
if (!hasFailures) {
|
|
393
467
|
await this.gitCache.updateCache();
|
|
394
468
|
}
|
|
395
|
-
|
|
469
|
+
|
|
396
470
|
// Force exit with appropriate status
|
|
397
471
|
if (hasFailures) {
|
|
398
472
|
this.logger.info('Exiting with failure status...');
|
|
@@ -403,15 +477,15 @@ export class Orchestrator {
|
|
|
403
477
|
}
|
|
404
478
|
} catch (error) {
|
|
405
479
|
this.logger.error(`Orchestrator failed: ${error.message}`);
|
|
406
|
-
|
|
480
|
+
|
|
407
481
|
// Cleanup on error
|
|
408
482
|
try {
|
|
409
483
|
await this.processManager.cleanup();
|
|
410
484
|
} catch (cleanupError) {
|
|
411
485
|
this.logger.error(`Cleanup failed: ${cleanupError.message}`);
|
|
412
486
|
}
|
|
413
|
-
|
|
487
|
+
|
|
414
488
|
process.exit(1);
|
|
415
489
|
}
|
|
416
490
|
}
|
|
417
|
-
}
|
|
491
|
+
}
|
package/lib/process-manager.js
CHANGED
|
@@ -4,7 +4,6 @@ import path from 'path';
|
|
|
4
4
|
import { log } from './logger.js';
|
|
5
5
|
import { HealthCheck } from './health-check.js';
|
|
6
6
|
|
|
7
|
-
|
|
8
7
|
export class ProcessManager {
|
|
9
8
|
constructor() {
|
|
10
9
|
this.logger = log;
|
|
@@ -13,20 +12,39 @@ export class ProcessManager {
|
|
|
13
12
|
this.logFolder = 'scripts-orchestrator-logs'; // Default log folder
|
|
14
13
|
}
|
|
15
14
|
|
|
15
|
+
formatDuration(ms) {
|
|
16
|
+
if (ms < 1000) return `${ms}ms`;
|
|
17
|
+
const seconds = Math.floor(ms / 1000);
|
|
18
|
+
const minutes = Math.floor(seconds / 60);
|
|
19
|
+
const remainingSeconds = seconds % 60;
|
|
20
|
+
if (minutes > 0) {
|
|
21
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
22
|
+
}
|
|
23
|
+
return `${seconds}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
setLogFolder(logFolder) {
|
|
17
27
|
this.logFolder = logFolder;
|
|
18
28
|
this.logger.verbose(`Log folder set to: ${logFolder}`);
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
getLogPath(command) {
|
|
22
|
-
const baseDir = this.logFolder
|
|
32
|
+
const baseDir = this.logFolder
|
|
33
|
+
? path.resolve(this.logFolder)
|
|
34
|
+
: process.cwd();
|
|
23
35
|
const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
|
|
24
36
|
// Use only the first word of the command for the log filename
|
|
25
37
|
const logName = command.split(/\s+/)[0];
|
|
26
38
|
return path.join(LOGS_DIR, `${logName}.log`);
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
addBackgroundProcess({
|
|
41
|
+
addBackgroundProcess({
|
|
42
|
+
command,
|
|
43
|
+
url,
|
|
44
|
+
startedByScript,
|
|
45
|
+
process_tracking,
|
|
46
|
+
kill_command,
|
|
47
|
+
}) {
|
|
30
48
|
this.logger.verbose(`Adding background process: ${command} (${url})`);
|
|
31
49
|
this.backgroundProcessesDetails.push({
|
|
32
50
|
command,
|
|
@@ -37,8 +55,18 @@ export class ProcessManager {
|
|
|
37
55
|
});
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
async runCommand({
|
|
41
|
-
|
|
58
|
+
async runCommand({
|
|
59
|
+
cmd,
|
|
60
|
+
logFile,
|
|
61
|
+
background = false,
|
|
62
|
+
healthCheck = null,
|
|
63
|
+
kill_command = null,
|
|
64
|
+
isRetry = false,
|
|
65
|
+
env = null,
|
|
66
|
+
}) {
|
|
67
|
+
const baseDir = this.logFolder
|
|
68
|
+
? path.resolve(this.logFolder)
|
|
69
|
+
: process.cwd();
|
|
42
70
|
const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
|
|
43
71
|
// Use only the first word of the command for the log filename
|
|
44
72
|
const logName = cmd.split(/\s+/)[0];
|
|
@@ -54,7 +82,9 @@ export class ProcessManager {
|
|
|
54
82
|
this.logger.verbose(`Clearing log file at ${LOG_FILE}`);
|
|
55
83
|
fs.writeFileSync(LOG_FILE, ''); // Clear the log file
|
|
56
84
|
} else {
|
|
57
|
-
this.logger.verbose(
|
|
85
|
+
this.logger.verbose(
|
|
86
|
+
`Appending to existing log file at ${LOG_FILE} (retry attempt)`,
|
|
87
|
+
);
|
|
58
88
|
}
|
|
59
89
|
} catch (error) {
|
|
60
90
|
this.logger.error(`Failed to setup log file: ${error.message}`);
|
|
@@ -62,18 +92,21 @@ export class ProcessManager {
|
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
return new Promise((resolve) => {
|
|
95
|
+
const startTime = Date.now();
|
|
65
96
|
// Build command with environment variables if provided
|
|
66
97
|
let fullCommand = `npm run ${cmd}`;
|
|
67
98
|
if (env && Object.keys(env).length > 0) {
|
|
68
|
-
const envStr = Object.entries(env)
|
|
99
|
+
const envStr = Object.entries(env)
|
|
100
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
101
|
+
.join(' ');
|
|
69
102
|
fullCommand = `${envStr} npm run ${cmd}`;
|
|
70
103
|
}
|
|
71
|
-
|
|
72
|
-
this.logger.
|
|
73
|
-
|
|
104
|
+
|
|
105
|
+
this.logger.startTask(cmd, fullCommand);
|
|
106
|
+
|
|
74
107
|
// Create isolated environment for each process
|
|
75
108
|
const isolatedEnv = this.createIsolatedEnvironment({ command: cmd, env });
|
|
76
|
-
|
|
109
|
+
|
|
77
110
|
const options = {
|
|
78
111
|
shell: true,
|
|
79
112
|
detached: background,
|
|
@@ -91,6 +124,7 @@ export class ProcessManager {
|
|
|
91
124
|
const processInstance = spawn(fullCommand, [], options);
|
|
92
125
|
|
|
93
126
|
processInstance.on('error', (error) => {
|
|
127
|
+
this.logger.stopTask(cmd);
|
|
94
128
|
this.logger.error(`Failed to start process: ${error.message}`);
|
|
95
129
|
//this.logger.verbose(`Process error details: ${JSON.stringify(error, null, 2)}`);
|
|
96
130
|
resolve({ success: false, output: '' });
|
|
@@ -98,23 +132,29 @@ export class ProcessManager {
|
|
|
98
132
|
|
|
99
133
|
if (background) {
|
|
100
134
|
const processGroupId = processInstance.pid;
|
|
101
|
-
this.logger.verbose(
|
|
135
|
+
this.logger.verbose(
|
|
136
|
+
`Background process spawned with PID: ${processGroupId}`,
|
|
137
|
+
);
|
|
102
138
|
|
|
103
139
|
// Track process exit for background processes
|
|
104
140
|
let processExited = false;
|
|
105
141
|
let processExitCode = null;
|
|
106
|
-
|
|
142
|
+
|
|
107
143
|
processInstance.on('exit', (code, signal) => {
|
|
108
144
|
processExited = true;
|
|
109
145
|
processExitCode = code;
|
|
110
|
-
this.logger.verbose(
|
|
146
|
+
this.logger.verbose(
|
|
147
|
+
`Background process ${cmd} (PID: ${processGroupId}) exited with code: ${code}, signal: ${signal}`,
|
|
148
|
+
);
|
|
111
149
|
});
|
|
112
150
|
|
|
113
151
|
processInstance.stdout.on('data', (data) => {
|
|
114
152
|
try {
|
|
115
153
|
fs.appendFileSync(LOG_FILE, data.toString());
|
|
116
154
|
} catch (error) {
|
|
117
|
-
this.logger.error(
|
|
155
|
+
this.logger.error(
|
|
156
|
+
`Failed to write to log file: ${error.message}`,
|
|
157
|
+
);
|
|
118
158
|
}
|
|
119
159
|
});
|
|
120
160
|
|
|
@@ -122,7 +162,9 @@ export class ProcessManager {
|
|
|
122
162
|
try {
|
|
123
163
|
fs.appendFileSync(LOG_FILE, data.toString());
|
|
124
164
|
} catch (error) {
|
|
125
|
-
this.logger.error(
|
|
165
|
+
this.logger.error(
|
|
166
|
+
`Failed to write to log file: ${error.message}`,
|
|
167
|
+
);
|
|
126
168
|
}
|
|
127
169
|
});
|
|
128
170
|
|
|
@@ -134,37 +176,49 @@ export class ProcessManager {
|
|
|
134
176
|
try {
|
|
135
177
|
// First check if the process has already exited with an error
|
|
136
178
|
if (processExited && processExitCode !== 0) {
|
|
137
|
-
this.logger.
|
|
179
|
+
this.logger.stopTask(cmd);
|
|
180
|
+
this.logger.error(
|
|
181
|
+
`Background process ${cmd} exited with code ${processExitCode}`,
|
|
182
|
+
);
|
|
138
183
|
let output = '';
|
|
139
184
|
try {
|
|
140
185
|
output = fs.readFileSync(LOG_FILE, 'utf8');
|
|
141
186
|
this.logger.verbose(`Process output: ${output}`);
|
|
142
187
|
} catch (error) {
|
|
143
|
-
this.logger.error(
|
|
188
|
+
this.logger.error(
|
|
189
|
+
`Failed to read log file: ${error.message}`,
|
|
190
|
+
);
|
|
144
191
|
}
|
|
145
192
|
return { success: false, output };
|
|
146
193
|
}
|
|
147
|
-
|
|
148
|
-
this.logger.verbose(
|
|
194
|
+
|
|
195
|
+
this.logger.verbose(
|
|
196
|
+
`Verifying process ${processGroupId} (attempt ${attempt}/${maxAttempts})`,
|
|
197
|
+
);
|
|
149
198
|
process.kill(processGroupId, 0);
|
|
150
199
|
this.logger.verbose(`Process ${processGroupId} is running`);
|
|
151
|
-
|
|
200
|
+
|
|
152
201
|
// Wait a bit more to ensure the process doesn't exit immediately after verification
|
|
153
202
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
154
|
-
|
|
203
|
+
|
|
155
204
|
// Check again if the process exited during our wait
|
|
156
205
|
if (processExited && processExitCode !== 0) {
|
|
157
|
-
this.logger.
|
|
206
|
+
this.logger.stopTask(cmd);
|
|
207
|
+
this.logger.error(
|
|
208
|
+
`Background process ${cmd} exited with code ${processExitCode} shortly after starting`,
|
|
209
|
+
);
|
|
158
210
|
let output = '';
|
|
159
211
|
try {
|
|
160
212
|
output = fs.readFileSync(LOG_FILE, 'utf8');
|
|
161
213
|
this.logger.verbose(`Process output: ${output}`);
|
|
162
214
|
} catch (error) {
|
|
163
|
-
this.logger.error(
|
|
215
|
+
this.logger.error(
|
|
216
|
+
`Failed to read log file: ${error.message}`,
|
|
217
|
+
);
|
|
164
218
|
}
|
|
165
219
|
return { success: false, output };
|
|
166
220
|
}
|
|
167
|
-
|
|
221
|
+
|
|
168
222
|
this.backgroundProcesses.push(processGroupId);
|
|
169
223
|
this.backgroundProcessesDetails.push({
|
|
170
224
|
command: cmd,
|
|
@@ -174,22 +228,31 @@ export class ProcessManager {
|
|
|
174
228
|
startedByScript: true,
|
|
175
229
|
kill_command,
|
|
176
230
|
});
|
|
177
|
-
|
|
231
|
+
|
|
178
232
|
this.logger.verbose(`Unreferencing process ${processGroupId}`);
|
|
179
233
|
processInstance.unref();
|
|
180
|
-
|
|
234
|
+
|
|
235
|
+
this.logger.stopTask(cmd);
|
|
181
236
|
this.logger.verbose(
|
|
182
237
|
`Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
|
|
183
238
|
);
|
|
184
239
|
return { success: true, output: '' };
|
|
185
240
|
} catch (error) {
|
|
186
241
|
if (attempt === maxAttempts) {
|
|
187
|
-
this.logger.error(
|
|
188
|
-
|
|
242
|
+
this.logger.error(
|
|
243
|
+
`Failed to start background process: npm run ${cmd}`,
|
|
244
|
+
);
|
|
245
|
+
this.logger.verbose(
|
|
246
|
+
`Final verification attempt failed: ${error.message}`,
|
|
247
|
+
);
|
|
189
248
|
return { success: false, output: '' };
|
|
190
249
|
}
|
|
191
|
-
this.logger.verbose(
|
|
192
|
-
|
|
250
|
+
this.logger.verbose(
|
|
251
|
+
`Verification attempt ${attempt} failed: ${error.message}`,
|
|
252
|
+
);
|
|
253
|
+
this.logger.verbose(
|
|
254
|
+
`Waiting ${baseDelay * Math.pow(2, attempt - 1)}ms before next attempt`,
|
|
255
|
+
);
|
|
193
256
|
await new Promise((resolve) =>
|
|
194
257
|
setTimeout(resolve, baseDelay * Math.pow(2, attempt - 1)),
|
|
195
258
|
);
|
|
@@ -204,7 +267,9 @@ export class ProcessManager {
|
|
|
204
267
|
try {
|
|
205
268
|
fs.appendFileSync(LOG_FILE, data.toString());
|
|
206
269
|
} catch (error) {
|
|
207
|
-
this.logger.error(
|
|
270
|
+
this.logger.error(
|
|
271
|
+
`Failed to write to log file: ${error.message}`,
|
|
272
|
+
);
|
|
208
273
|
}
|
|
209
274
|
});
|
|
210
275
|
|
|
@@ -212,7 +277,9 @@ export class ProcessManager {
|
|
|
212
277
|
try {
|
|
213
278
|
fs.appendFileSync(LOG_FILE, data.toString());
|
|
214
279
|
} catch (error) {
|
|
215
|
-
this.logger.error(
|
|
280
|
+
this.logger.error(
|
|
281
|
+
`Failed to write to log file: ${error.message}`,
|
|
282
|
+
);
|
|
216
283
|
}
|
|
217
284
|
});
|
|
218
285
|
|
|
@@ -224,17 +291,25 @@ export class ProcessManager {
|
|
|
224
291
|
this.logger.error(`Failed to read log file: ${error.message}`);
|
|
225
292
|
}
|
|
226
293
|
|
|
294
|
+
this.logger.stopTask(cmd);
|
|
295
|
+
|
|
296
|
+
const duration = Date.now() - startTime;
|
|
297
|
+
const durationStr = ` (${this.formatDuration(duration)})`;
|
|
298
|
+
|
|
227
299
|
if (code !== 0) {
|
|
228
|
-
this.logger.error(
|
|
300
|
+
this.logger.error(
|
|
301
|
+
`Failed: npm run ${cmd} ❌${durationStr} (exit code: ${code})`,
|
|
302
|
+
);
|
|
229
303
|
this.logger.verbose(`Process output: ${output}`);
|
|
230
304
|
resolve({ success: false, output });
|
|
231
305
|
} else {
|
|
232
|
-
this.logger.success(`Completed: npm run ${cmd}`);
|
|
306
|
+
this.logger.success(`Completed: npm run ${cmd} ✅${durationStr}`);
|
|
233
307
|
resolve({ success: true, output });
|
|
234
308
|
}
|
|
235
309
|
});
|
|
236
310
|
}
|
|
237
311
|
} catch (error) {
|
|
312
|
+
this.logger.stopTask(cmd);
|
|
238
313
|
this.logger.error(`Failed to spawn process: ${error.message}`);
|
|
239
314
|
//this.logger.verbose(`Spawn error details: ${JSON.stringify(error, null, 2)}`);
|
|
240
315
|
resolve({ success: false, output: '' });
|
|
@@ -245,7 +320,7 @@ export class ProcessManager {
|
|
|
245
320
|
createIsolatedEnvironment({ command, env = null }) {
|
|
246
321
|
// Create a deep copy to avoid any reference sharing
|
|
247
322
|
const baseEnv = JSON.parse(JSON.stringify(process.env));
|
|
248
|
-
|
|
323
|
+
|
|
249
324
|
// Set standard environment variables
|
|
250
325
|
const isolatedEnv = {
|
|
251
326
|
...baseEnv,
|
|
@@ -272,25 +347,37 @@ export class ProcessManager {
|
|
|
272
347
|
// Remove any potentially problematic environment variables
|
|
273
348
|
delete isolatedEnv.npm_lifecycle_event;
|
|
274
349
|
delete isolatedEnv.npm_lifecycle_script;
|
|
275
|
-
|
|
350
|
+
|
|
276
351
|
return isolatedEnv;
|
|
277
352
|
}
|
|
278
353
|
|
|
279
354
|
async cleanup() {
|
|
280
355
|
try {
|
|
281
356
|
this.logger.info('\nCleaning up background processes...');
|
|
282
|
-
|
|
357
|
+
|
|
283
358
|
// Debug: Log the number of processes we're tracking
|
|
284
|
-
this.logger.info(
|
|
285
|
-
|
|
359
|
+
this.logger.info(
|
|
360
|
+
`- Found ${this.backgroundProcessesDetails.length} background processes to clean up`,
|
|
361
|
+
);
|
|
362
|
+
|
|
286
363
|
// Debug: Log each process details
|
|
287
|
-
this.backgroundProcessesDetails.forEach(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
364
|
+
this.backgroundProcessesDetails.forEach(
|
|
365
|
+
({ command, pgid, url, startedByScript, kill_command }, index) => {
|
|
366
|
+
this.logger.verbose(
|
|
367
|
+
`- Process ${index + 1}: command=${command}, pgid=${pgid}, url=${url}, startedByScript=${startedByScript}, kill_command=${kill_command}`,
|
|
368
|
+
);
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
|
|
291
372
|
const killPromises = this.backgroundProcessesDetails.map(
|
|
292
373
|
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
293
|
-
await this.cleanupProcess({
|
|
374
|
+
await this.cleanupProcess({
|
|
375
|
+
command,
|
|
376
|
+
pgid,
|
|
377
|
+
url,
|
|
378
|
+
startedByScript,
|
|
379
|
+
kill_command,
|
|
380
|
+
});
|
|
294
381
|
},
|
|
295
382
|
);
|
|
296
383
|
|
|
@@ -304,33 +391,43 @@ export class ProcessManager {
|
|
|
304
391
|
|
|
305
392
|
async cleanupCommand(commandName) {
|
|
306
393
|
this.logger.info(`\nCleaning up processes for command: ${commandName}`);
|
|
307
|
-
|
|
394
|
+
|
|
308
395
|
// Find processes for this specific command
|
|
309
396
|
const commandProcesses = this.backgroundProcessesDetails.filter(
|
|
310
|
-
({ command }) => command === commandName
|
|
397
|
+
({ command }) => command === commandName,
|
|
311
398
|
);
|
|
312
|
-
|
|
399
|
+
|
|
313
400
|
if (commandProcesses.length === 0) {
|
|
314
|
-
this.logger.verbose(
|
|
401
|
+
this.logger.verbose(
|
|
402
|
+
`- No background processes found for command: ${commandName}`,
|
|
403
|
+
);
|
|
315
404
|
return;
|
|
316
405
|
}
|
|
317
|
-
|
|
318
|
-
this.logger.verbose(
|
|
319
|
-
|
|
406
|
+
|
|
407
|
+
this.logger.verbose(
|
|
408
|
+
`- Found ${commandProcesses.length} background processes for command: ${commandName}`,
|
|
409
|
+
);
|
|
410
|
+
|
|
320
411
|
const killPromises = commandProcesses.map(
|
|
321
412
|
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
322
|
-
await this.cleanupProcess({
|
|
323
|
-
|
|
413
|
+
await this.cleanupProcess({
|
|
414
|
+
command,
|
|
415
|
+
pgid,
|
|
416
|
+
url,
|
|
417
|
+
startedByScript,
|
|
418
|
+
kill_command,
|
|
419
|
+
});
|
|
420
|
+
},
|
|
324
421
|
);
|
|
325
422
|
|
|
326
423
|
await Promise.allSettled(killPromises);
|
|
327
|
-
|
|
424
|
+
|
|
328
425
|
// Remove the cleaned up processes from our tracking arrays
|
|
329
|
-
this.backgroundProcesses = this.backgroundProcesses.filter(
|
|
330
|
-
!commandProcesses.some(proc => proc.pgid === pgid)
|
|
426
|
+
this.backgroundProcesses = this.backgroundProcesses.filter(
|
|
427
|
+
(pgid) => !commandProcesses.some((proc) => proc.pgid === pgid),
|
|
331
428
|
);
|
|
332
429
|
this.backgroundProcessesDetails = this.backgroundProcessesDetails.filter(
|
|
333
|
-
({ command }) => command !== commandName
|
|
430
|
+
({ command }) => command !== commandName,
|
|
334
431
|
);
|
|
335
432
|
}
|
|
336
433
|
|
|
@@ -342,24 +439,40 @@ export class ProcessManager {
|
|
|
342
439
|
return;
|
|
343
440
|
}
|
|
344
441
|
|
|
345
|
-
this.logger.verbose(
|
|
442
|
+
this.logger.verbose(
|
|
443
|
+
`- Processing cleanup for ${command} (kill_command: ${kill_command})`,
|
|
444
|
+
);
|
|
346
445
|
|
|
347
446
|
// Try custom kill command first if specified
|
|
348
447
|
if (kill_command) {
|
|
349
448
|
try {
|
|
350
|
-
this.logger.verbose(
|
|
351
|
-
|
|
449
|
+
this.logger.verbose(
|
|
450
|
+
`- Using custom kill command: npm run ${kill_command}`,
|
|
451
|
+
);
|
|
452
|
+
const result = await this.runCommand({
|
|
453
|
+
cmd: kill_command,
|
|
454
|
+
logFile: null,
|
|
455
|
+
background: false,
|
|
456
|
+
});
|
|
352
457
|
if (result.success) {
|
|
353
|
-
this.logger.verbose(
|
|
458
|
+
this.logger.verbose(
|
|
459
|
+
`- Successfully killed ${command} using custom command`,
|
|
460
|
+
);
|
|
354
461
|
return;
|
|
355
462
|
} else {
|
|
356
|
-
this.logger.verbose(
|
|
463
|
+
this.logger.verbose(
|
|
464
|
+
'- Custom kill command failed, falling back to process signals',
|
|
465
|
+
);
|
|
357
466
|
}
|
|
358
467
|
} catch (error) {
|
|
359
|
-
this.logger.verbose(
|
|
468
|
+
this.logger.verbose(
|
|
469
|
+
`- Custom kill command error: ${error.message}, falling back`,
|
|
470
|
+
);
|
|
360
471
|
}
|
|
361
472
|
} else {
|
|
362
|
-
this.logger.verbose(
|
|
473
|
+
this.logger.verbose(
|
|
474
|
+
`- No kill_command specified for ${command}, using process signals`,
|
|
475
|
+
);
|
|
363
476
|
}
|
|
364
477
|
|
|
365
478
|
try {
|
|
@@ -375,27 +488,36 @@ export class ProcessManager {
|
|
|
375
488
|
|
|
376
489
|
// Cross-platform process termination
|
|
377
490
|
const isWindows = process.platform === 'win32';
|
|
378
|
-
|
|
491
|
+
|
|
379
492
|
if (isWindows) {
|
|
380
493
|
// Windows: use taskkill to terminate process tree
|
|
381
494
|
try {
|
|
382
|
-
const killProcess = spawn('taskkill', [
|
|
495
|
+
const killProcess = spawn('taskkill', [
|
|
496
|
+
'/F',
|
|
497
|
+
'/T',
|
|
498
|
+
'/PID',
|
|
499
|
+
pgid.toString(),
|
|
500
|
+
]);
|
|
383
501
|
await new Promise((resolve) => {
|
|
384
502
|
killProcess.on('close', resolve);
|
|
385
503
|
});
|
|
386
|
-
this.logger.verbose(
|
|
504
|
+
this.logger.verbose(
|
|
505
|
+
`- Terminated background process: ${command} (PID: ${pgid})`,
|
|
506
|
+
);
|
|
387
507
|
return;
|
|
388
508
|
} catch (killError) {
|
|
389
|
-
this.logger.verbose(
|
|
509
|
+
this.logger.verbose(
|
|
510
|
+
`- Failed to use taskkill, falling back to process.kill: ${killError.message}`,
|
|
511
|
+
);
|
|
390
512
|
}
|
|
391
513
|
}
|
|
392
|
-
|
|
514
|
+
|
|
393
515
|
// Unix/Linux/macOS or Windows fallback: Try SIGTERM first
|
|
394
516
|
process.kill(pgid, 'SIGTERM');
|
|
395
517
|
|
|
396
518
|
await new Promise((resolve, reject) => {
|
|
397
519
|
let timeout, checkInterval;
|
|
398
|
-
|
|
520
|
+
|
|
399
521
|
timeout = setTimeout(() => {
|
|
400
522
|
if (checkInterval) clearInterval(checkInterval);
|
|
401
523
|
reject(new Error('Process termination timeout'));
|
|
@@ -415,26 +537,31 @@ export class ProcessManager {
|
|
|
415
537
|
`- Terminated background process: ${command} (PGID: ${pgid})`,
|
|
416
538
|
);
|
|
417
539
|
} catch (error) {
|
|
418
|
-
this.logger.verbose(
|
|
540
|
+
this.logger.verbose(
|
|
541
|
+
`- Failed to terminate process group: ${error.message}`,
|
|
542
|
+
);
|
|
419
543
|
}
|
|
420
544
|
|
|
421
545
|
// Check if the URL is still responding after termination attempt
|
|
422
546
|
if (url) {
|
|
423
547
|
try {
|
|
424
548
|
const urlObj = new URL(url);
|
|
425
|
-
const port =
|
|
426
|
-
|
|
549
|
+
const port =
|
|
550
|
+
urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
|
|
551
|
+
|
|
427
552
|
// Use shared HTTP utility for cross-platform compatibility
|
|
428
553
|
const urlResult = await HealthCheck.makeHttpRequest(url, 2000);
|
|
429
|
-
|
|
554
|
+
|
|
430
555
|
if (urlResult.success && urlResult.statusCode === 200) {
|
|
431
|
-
this.logger.verbose(
|
|
432
|
-
|
|
556
|
+
this.logger.verbose(
|
|
557
|
+
`- URL ${url} is still responding after termination, finding process on port ${port}`,
|
|
558
|
+
);
|
|
559
|
+
|
|
433
560
|
// Find and kill process using the port - cross-platform approach
|
|
434
561
|
try {
|
|
435
562
|
const isWindows = process.platform === 'win32';
|
|
436
563
|
let findPortCmd, findPortArgs;
|
|
437
|
-
|
|
564
|
+
|
|
438
565
|
if (isWindows) {
|
|
439
566
|
// Windows: use netstat
|
|
440
567
|
findPortCmd = 'netstat';
|
|
@@ -444,7 +571,7 @@ export class ProcessManager {
|
|
|
444
571
|
findPortCmd = 'lsof';
|
|
445
572
|
findPortArgs = ['-i', `:${port}`, '-t'];
|
|
446
573
|
}
|
|
447
|
-
|
|
574
|
+
|
|
448
575
|
const findProcess = spawn(findPortCmd, findPortArgs);
|
|
449
576
|
const result = await new Promise((resolve) => {
|
|
450
577
|
let output = '';
|
|
@@ -458,12 +585,15 @@ export class ProcessManager {
|
|
|
458
585
|
|
|
459
586
|
if (result.code === 0 && result.output.trim()) {
|
|
460
587
|
let pids = [];
|
|
461
|
-
|
|
588
|
+
|
|
462
589
|
if (isWindows) {
|
|
463
590
|
// Parse netstat output to find PIDs for the specific port
|
|
464
591
|
const lines = result.output.split('\n');
|
|
465
592
|
for (const line of lines) {
|
|
466
|
-
if (
|
|
593
|
+
if (
|
|
594
|
+
line.includes(`:${port} `) &&
|
|
595
|
+
line.includes('LISTENING')
|
|
596
|
+
) {
|
|
467
597
|
const parts = line.trim().split(/\s+/);
|
|
468
598
|
const pid = parts[parts.length - 1];
|
|
469
599
|
if (pid && !isNaN(pid)) {
|
|
@@ -475,7 +605,7 @@ export class ProcessManager {
|
|
|
475
605
|
// lsof output is already just PIDs
|
|
476
606
|
pids = result.output.trim().split('\n');
|
|
477
607
|
}
|
|
478
|
-
|
|
608
|
+
|
|
479
609
|
for (const pid of pids) {
|
|
480
610
|
try {
|
|
481
611
|
if (isWindows) {
|
|
@@ -488,16 +618,22 @@ export class ProcessManager {
|
|
|
488
618
|
// Unix/Linux/macOS: use process.kill
|
|
489
619
|
process.kill(parseInt(pid), 'SIGKILL');
|
|
490
620
|
}
|
|
491
|
-
this.logger.verbose(
|
|
621
|
+
this.logger.verbose(
|
|
622
|
+
`- Killed process (PID: ${pid}) using port ${port}`,
|
|
623
|
+
);
|
|
492
624
|
} catch (killError) {
|
|
493
625
|
if (killError.code !== 'ESRCH') {
|
|
494
|
-
this.logger.error(
|
|
626
|
+
this.logger.error(
|
|
627
|
+
`- Failed to kill process (PID: ${pid}): ${killError.message}`,
|
|
628
|
+
);
|
|
495
629
|
}
|
|
496
630
|
}
|
|
497
631
|
}
|
|
498
632
|
}
|
|
499
633
|
} catch (portError) {
|
|
500
|
-
this.logger.error(
|
|
634
|
+
this.logger.error(
|
|
635
|
+
`- Failed to find process using port ${port}: ${portError.message}`,
|
|
636
|
+
);
|
|
501
637
|
}
|
|
502
638
|
}
|
|
503
639
|
} catch (error) {
|
|
@@ -508,10 +644,15 @@ export class ProcessManager {
|
|
|
508
644
|
// Final attempt to kill the process group
|
|
509
645
|
try {
|
|
510
646
|
const isWindows = process.platform === 'win32';
|
|
511
|
-
|
|
647
|
+
|
|
512
648
|
if (isWindows) {
|
|
513
649
|
// Windows: force kill with taskkill
|
|
514
|
-
const killProcess = spawn('taskkill', [
|
|
650
|
+
const killProcess = spawn('taskkill', [
|
|
651
|
+
'/F',
|
|
652
|
+
'/T',
|
|
653
|
+
'/PID',
|
|
654
|
+
pgid.toString(),
|
|
655
|
+
]);
|
|
515
656
|
await new Promise((resolve) => {
|
|
516
657
|
killProcess.on('close', resolve);
|
|
517
658
|
});
|
|
@@ -528,4 +669,4 @@ export class ProcessManager {
|
|
|
528
669
|
}
|
|
529
670
|
|
|
530
671
|
// For backward compatibility
|
|
531
|
-
export const processManager = new ProcessManager();
|
|
672
|
+
export const processManager = new ProcessManager();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scripts-orchestrator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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",
|