scripts-orchestrator 2.15.1 → 3.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 CHANGED
@@ -48,12 +48,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
48
48
 
49
49
  ```javascript
50
50
  {
51
- command: 'command_name', // The npm script to run
51
+ command: 'command_name', // The command to run (see "Command prefix" below)
52
52
  description: 'Description', // Optional description
53
53
  status: 'enabled', // 'enabled' or 'disabled'
54
54
  attempts: 1, // Number of retry attempts
55
55
  dependencies: [], // Array of dependent commands
56
56
  background: false, // Whether to run in background
57
+ shell: false, // true => run `command` verbatim as a shell command (no prefix)
58
+ prefix: 'npm run', // Optional per-command prefix override ('' to disable)
57
59
  env: { // Optional environment variables
58
60
  PORT: 3000,
59
61
  NODE_ENV: 'production'
@@ -70,6 +72,44 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
70
72
  }
71
73
  ```
72
74
 
75
+ ### Command prefix (`npm run` is optional)
76
+
77
+ By default every `command` is run as an npm script — the orchestrator prepends `npm run`,
78
+ so `command: 'build'` executes `npm run build`. This prefix is configurable:
79
+
80
+ - **Global default** — set `command_prefix` at the top level of the config. Use it to point at a
81
+ different runner (`'pnpm run'`, `'yarn'`) or to disable prefixing entirely so commands run as
82
+ regular shell commands:
83
+
84
+ ```javascript
85
+ export default {
86
+ command_prefix: '', // '' / false / null => run commands verbatim (plain shell)
87
+ phases: [
88
+ { name: 'checks', parallel: [
89
+ { command: 'eslint . --max-warnings 0' }, // runs as-is, supports args/pipes/&&
90
+ { command: './scripts/verify.sh' },
91
+ ]},
92
+ ],
93
+ };
94
+ ```
95
+
96
+ - **Per command** — `shell: true` forces a single command to run verbatim as a shell command
97
+ (ignoring any global prefix), and `prefix: '...'` overrides the prefix for just that command:
98
+
99
+ ```javascript
100
+ {
101
+ phases: [{ name: 'mixed', parallel: [
102
+ { command: 'build' }, // -> npm run build (global default)
103
+ { command: 'docker compose up -d', shell: true }, // raw shell command
104
+ { command: 'release', prefix: 'yarn' }, // -> yarn release
105
+ ]}],
106
+ }
107
+ ```
108
+
109
+ Precedence per command: `shell: true` (raw) → per-command `prefix` → global `command_prefix`
110
+ → the built-in `npm run` default. Existing configs are unaffected — omitting all of these keeps
111
+ the original `npm run` behaviour.
112
+
73
113
  ### Phase Configuration
74
114
 
75
115
  When using the phases format, each phase can have the following properties:
@@ -27,6 +27,12 @@ export class Orchestrator {
27
27
  this.sequential = sequential;
28
28
  this.force = force;
29
29
  this.metrics = Array.isArray(metrics) ? metrics : [];
30
+ // Global command prefix. Defaults to 'npm run' so existing configs keep working.
31
+ // Set `command_prefix` to '' / false / null in the config to run commands verbatim
32
+ // as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
33
+ this.commandPrefix = Object.prototype.hasOwnProperty.call(config ?? {}, 'command_prefix')
34
+ ? this._normalizePrefix(config.command_prefix)
35
+ : 'npm run';
30
36
  this.jsonResultsPath = jsonResultsPath ?? null;
31
37
  this.htmlResultsPath = htmlResultsPath ?? null;
32
38
  this.processManager = processManager;
@@ -263,6 +269,29 @@ export class Orchestrator {
263
269
  }
264
270
  }
265
271
 
272
+ // Normalize a prefix value into a clean string. false/null/'' all mean "no prefix"
273
+ // (run the command verbatim); any string is trimmed.
274
+ _normalizePrefix(value) {
275
+ if (value === false || value === null || value === undefined) return '';
276
+ return String(value).trim();
277
+ }
278
+
279
+ // Resolve the effective prefix for a single command.
280
+ // Precedence: per-command `shell: true` (raw) > per-command `prefix` > global commandPrefix.
281
+ _resolvePrefix(commandConfig = {}) {
282
+ if (commandConfig.shell === true) return '';
283
+ if (Object.prototype.hasOwnProperty.call(commandConfig, 'prefix')) {
284
+ return this._normalizePrefix(commandConfig.prefix);
285
+ }
286
+ return this.commandPrefix;
287
+ }
288
+
289
+ // Format a command for display, honoring its resolved prefix.
290
+ _displayCommand(command, commandConfig = {}) {
291
+ const prefix = this._resolvePrefix(commandConfig);
292
+ return prefix ? `${prefix} ${command}` : command;
293
+ }
294
+
266
295
  async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
267
296
  const {
268
297
  command,
@@ -281,6 +310,8 @@ export class Orchestrator {
281
310
  } = commandConfig;
282
311
 
283
312
  const startTime = Date.now();
313
+ // Effective invocation prefix for this command ('' => run verbatim as a shell command).
314
+ const prefix = this._resolvePrefix(commandConfig);
284
315
 
285
316
  // Record the destination log file for this command (honors per-command override).
286
317
  // Done early so even disabled/skipped commands report where output would land.
@@ -303,7 +334,7 @@ export class Orchestrator {
303
334
 
304
335
  // Skip execution if the command is disabled
305
336
  if (status === 'disabled') {
306
- this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
337
+ this.logger.warn(`Skipping: ${this._displayCommand(command, commandConfig)} (status: disabled)`);
307
338
  this.skippedCommands.push(command);
308
339
  this.skipReasons.set(command, 'disabled');
309
340
  setTiming(Date.now() - startTime);
@@ -336,6 +367,7 @@ export class Orchestrator {
336
367
  startedByScript: false,
337
368
  process_tracking,
338
369
  kill_command,
370
+ prefix,
339
371
  });
340
372
  setTiming(Date.now() - startTime);
341
373
  visited.delete(command);
@@ -411,6 +443,7 @@ export class Orchestrator {
411
443
  env,
412
444
  reportTime: this.metrics.includes('time'),
413
445
  reportMemory: this.metrics.includes('memory'),
446
+ prefix,
414
447
  });
415
448
  lastRunResult = runResult;
416
449
  const { success, output } = runResult;
@@ -0,0 +1,60 @@
1
+ import { Orchestrator } from './orchestrator.js';
2
+
3
+ const baseConfig = (extra = {}) => ({
4
+ phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
5
+ ...extra,
6
+ });
7
+
8
+ describe('Orchestrator command prefix resolution', () => {
9
+ test('defaults to "npm run" when no command_prefix is configured', () => {
10
+ const orch = new Orchestrator(baseConfig());
11
+ expect(orch.commandPrefix).toBe('npm run');
12
+ expect(orch._resolvePrefix({ command: 'build' })).toBe('npm run');
13
+ expect(orch._displayCommand('build', { command: 'build' })).toBe('npm run build');
14
+ });
15
+
16
+ test('global command_prefix can be disabled with empty string', () => {
17
+ const orch = new Orchestrator(baseConfig({ command_prefix: '' }));
18
+ expect(orch.commandPrefix).toBe('');
19
+ expect(orch._resolvePrefix({ command: 'ls -la' })).toBe('');
20
+ expect(orch._displayCommand('ls -la', { command: 'ls -la' })).toBe('ls -la');
21
+ });
22
+
23
+ test('global command_prefix can be disabled with false or null', () => {
24
+ expect(new Orchestrator(baseConfig({ command_prefix: false })).commandPrefix).toBe('');
25
+ expect(new Orchestrator(baseConfig({ command_prefix: null })).commandPrefix).toBe('');
26
+ });
27
+
28
+ test('global command_prefix can be set to a custom runner', () => {
29
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'pnpm run' }));
30
+ expect(orch._resolvePrefix({ command: 'build' })).toBe('pnpm run');
31
+ expect(orch._displayCommand('build', { command: 'build' })).toBe('pnpm run build');
32
+ });
33
+
34
+ test('per-command shell:true runs verbatim as a bash command (overrides global)', () => {
35
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
36
+ const cmd = { command: 'echo hello && ls', shell: true };
37
+ expect(orch._resolvePrefix(cmd)).toBe('');
38
+ expect(orch._displayCommand('echo hello && ls', cmd)).toBe('echo hello && ls');
39
+ });
40
+
41
+ test('per-command prefix string overrides the global prefix', () => {
42
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
43
+ const cmd = { command: 'build', prefix: 'yarn' };
44
+ expect(orch._resolvePrefix(cmd)).toBe('yarn');
45
+ expect(orch._displayCommand('build', cmd)).toBe('yarn build');
46
+ });
47
+
48
+ test('per-command empty prefix runs verbatim even when global prefix is set', () => {
49
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
50
+ const cmd = { command: 'make build', prefix: '' };
51
+ expect(orch._resolvePrefix(cmd)).toBe('');
52
+ expect(orch._displayCommand('make build', cmd)).toBe('make build');
53
+ });
54
+
55
+ test('shell:true takes precedence over an explicit prefix', () => {
56
+ const orch = new Orchestrator(baseConfig());
57
+ const cmd = { command: 'echo hi', shell: true, prefix: 'yarn' };
58
+ expect(orch._resolvePrefix(cmd)).toBe('');
59
+ });
60
+ });
@@ -46,6 +46,7 @@ export class ProcessManager {
46
46
  startedByScript,
47
47
  process_tracking,
48
48
  kill_command,
49
+ prefix = 'npm run',
49
50
  }) {
50
51
  this.logger.verbose(`Adding background process: ${command} (${url})`);
51
52
  this.backgroundProcessesDetails.push({
@@ -54,6 +55,7 @@ export class ProcessManager {
54
55
  startedByScript,
55
56
  process_tracking,
56
57
  kill_command,
58
+ prefix,
57
59
  });
58
60
  }
59
61
 
@@ -93,7 +95,13 @@ export class ProcessManager {
93
95
  env = null,
94
96
  reportTime = false,
95
97
  reportMemory = false,
98
+ prefix = 'npm run',
96
99
  }) {
100
+ // Resolve how the command is invoked. A non-empty prefix (e.g. 'npm run') is
101
+ // prepended to the command name; an empty/false prefix runs the command verbatim
102
+ // as a regular shell command. `displayCmd` is what we surface in logs.
103
+ const commandPrefix = prefix ? String(prefix).trim() : '';
104
+ const displayCmd = commandPrefix ? `${commandPrefix} ${cmd}` : cmd;
97
105
  const baseDir = this.logFolder
98
106
  ? path.resolve(this.logFolder)
99
107
  : process.cwd();
@@ -131,12 +139,12 @@ export class ProcessManager {
131
139
  const startTime = Date.now();
132
140
  let timeOutputPath = null;
133
141
  // Build command with environment variables if provided
134
- let fullCommand = `npm run ${cmd}`;
142
+ let fullCommand = displayCmd;
135
143
  if (env && Object.keys(env).length > 0) {
136
144
  const envStr = Object.entries(env)
137
145
  .map(([key, value]) => `${key}=${value}`)
138
146
  .join(' ');
139
- fullCommand = `${envStr} npm run ${cmd}`;
147
+ fullCommand = `${envStr} ${displayCmd}`;
140
148
  }
141
149
  const useTimeWrapper =
142
150
  reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
@@ -286,6 +294,7 @@ export class ProcessManager {
286
294
  url: healthCheck?.url,
287
295
  startedByScript: true,
288
296
  kill_command,
297
+ prefix: commandPrefix,
289
298
  });
290
299
 
291
300
  this.logger.verbose(`Unreferencing process ${processGroupId}`);
@@ -293,7 +302,7 @@ export class ProcessManager {
293
302
 
294
303
  this.logger.stopTask(cmd);
295
304
  this.logger.verbose(
296
- `Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
305
+ `Background process started: ${displayCmd} (PGID: ${processGroupId})`,
297
306
  );
298
307
  return {
299
308
  success: true,
@@ -304,7 +313,7 @@ export class ProcessManager {
304
313
  } catch (error) {
305
314
  if (attempt === maxAttempts) {
306
315
  this.logger.error(
307
- `Failed to start background process: npm run ${cmd}`,
316
+ `Failed to start background process: ${displayCmd}`,
308
317
  );
309
318
  this.logger.verbose(
310
319
  `Final verification attempt failed: ${error.message}`,
@@ -382,7 +391,7 @@ export class ProcessManager {
382
391
 
383
392
  if (code !== 0) {
384
393
  this.logger.error(
385
- `Failed: npm run ${cmd} ❌${durationStr} (exit code: ${code})`,
394
+ `Failed: ${displayCmd} ❌${durationStr} (exit code: ${code})`,
386
395
  );
387
396
  this.logger.verbose(`Process output: ${output}`);
388
397
  resolve({
@@ -392,7 +401,7 @@ export class ProcessManager {
392
401
  memoryKb,
393
402
  });
394
403
  } else {
395
- this.logger.success(`Completed: npm run ${cmd} ✅${durationStr}`);
404
+ this.logger.success(`Completed: ${displayCmd} ✅${durationStr}`);
396
405
  resolve({
397
406
  success: true,
398
407
  output,
@@ -507,13 +516,14 @@ export class ProcessManager {
507
516
  );
508
517
 
509
518
  const killPromises = commandProcesses.map(
510
- async ({ command, pgid, url, startedByScript, kill_command }) => {
519
+ async ({ command, pgid, url, startedByScript, kill_command, prefix }) => {
511
520
  await this.cleanupProcess({
512
521
  command,
513
522
  pgid,
514
523
  url,
515
524
  startedByScript,
516
525
  kill_command,
526
+ prefix,
517
527
  });
518
528
  },
519
529
  );
@@ -529,7 +539,7 @@ export class ProcessManager {
529
539
  );
530
540
  }
531
541
 
532
- async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
542
+ async cleanupProcess({ command, pgid, url, startedByScript, kill_command, prefix = 'npm run' }) {
533
543
  if (!startedByScript) {
534
544
  this.logger.verbose(
535
545
  `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
@@ -544,13 +554,15 @@ export class ProcessManager {
544
554
  // Try custom kill command first if specified
545
555
  if (kill_command) {
546
556
  try {
557
+ const killDisplay = prefix ? `${String(prefix).trim()} ${kill_command}` : kill_command;
547
558
  this.logger.verbose(
548
- `- Using custom kill command: npm run ${kill_command}`,
559
+ `- Using custom kill command: ${killDisplay}`,
549
560
  );
550
561
  const result = await this.runCommand({
551
562
  cmd: kill_command,
552
563
  logFile: null,
553
564
  background: false,
565
+ prefix,
554
566
  });
555
567
  if (result.success) {
556
568
  this.logger.verbose(
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
3
+ import os from 'os';
2
4
  import { ProcessManager } from './process-manager.js';
3
5
 
4
6
  describe('ProcessManager.getLogPath', () => {
@@ -20,3 +22,59 @@ describe('ProcessManager.getLogPath', () => {
20
22
  expect(result).toBe(path.resolve(override));
21
23
  });
22
24
  });
25
+
26
+ describe('ProcessManager.runCommand prefix handling', () => {
27
+ let tmpDir;
28
+ let prevCwd;
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'so-prefix-'));
32
+ prevCwd = process.cwd();
33
+ process.chdir(tmpDir);
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.chdir(prevCwd);
38
+ fs.rmSync(tmpDir, { recursive: true, force: true });
39
+ });
40
+
41
+ test('runs a regular bash command verbatim when prefix is disabled', async () => {
42
+ const pm = new ProcessManager();
43
+ pm.setLogFolder(tmpDir);
44
+ const marker = 'orchestrator-raw-bash-ok';
45
+ const result = await pm.runCommand({
46
+ cmd: `echo ${marker}`,
47
+ background: false,
48
+ prefix: '',
49
+ });
50
+ expect(result.success).toBe(true);
51
+ const logPath = pm.getLogPath(`echo ${marker}`);
52
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(marker);
53
+ });
54
+
55
+ test('honors a custom prefix by invoking it (failure surfaces a non-zero exit)', async () => {
56
+ const pm = new ProcessManager();
57
+ pm.setLogFolder(tmpDir);
58
+ // With prefix 'npm run' and no package.json script, the command must fail —
59
+ // proving the prefix is actually prepended rather than the command run raw.
60
+ const result = await pm.runCommand({
61
+ cmd: 'definitely-not-a-script',
62
+ background: false,
63
+ prefix: 'npm run',
64
+ });
65
+ expect(result.success).toBe(false);
66
+ });
67
+
68
+ test('supports multi-token shell commands (pipes, &&) when run raw', async () => {
69
+ const pm = new ProcessManager();
70
+ pm.setLogFolder(tmpDir);
71
+ const result = await pm.runCommand({
72
+ cmd: 'printf "a\\nb\\nc\\n" | grep b',
73
+ background: false,
74
+ prefix: '',
75
+ });
76
+ expect(result.success).toBe(true);
77
+ const logPath = pm.getLogPath('printf');
78
+ expect(fs.readFileSync(logPath, 'utf8').trim()).toBe('b');
79
+ });
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.15.1",
3
+ "version": "3.0.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",
@@ -1,4 +1,8 @@
1
1
  export default {
2
+ // Optional: prefix prepended to every command. Defaults to 'npm run'.
3
+ // Set to '' (or false/null) to run commands verbatim as regular shell commands,
4
+ // or to another runner like 'pnpm run'. Per-command `shell: true` / `prefix` override this.
5
+ // command_prefix: 'npm run',
2
6
  // Optional: metrics to report (time, memory). CLI --metrics overrides.
3
7
  // metrics: ['time'],
4
8
  // Optional: path for JSON results, or '-' for stdout. CLI --json-results overrides.