go-dev 0.2.1 → 0.3.1

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
@@ -14,13 +14,15 @@ In complex monorepos, starting your development environment can be a chore. You
14
14
 
15
15
  * **Unified Configuration:** Define all your services, their modes (e.g., `dev`, `docker`, `serve`), and dependencies in a single `go-dev.yml` file.
16
16
  * **Service Types:**
17
- * **`cmd` services:** Run any command-line process (e.g., `npm run dev`, `rollup -w`, `python app.py`). Supports `preCommands` for setup tasks like builds.
17
+ * **`cmd` services:** Run any command-line process (e.g., `npm run dev`, `rollup -w`, `python app.py`). Supports `preCommands` for setup tasks like builds. Commands can be defined in multiple flexible ways to run single or multiple processes in parallel for a service.
18
18
  * **`docker` services:** Manage Docker containers via `docker compose`. Automatically checks container status and performs health checks.
19
19
  * **Mode-Aware Dependencies:** Services can depend on other services running in specific modes (e.g., your `api` dev mode might depend on `frontend` in `serve` mode).
20
20
  * **Preset-Driven Startup:** Define different "presets" (e.g., `api`, `frontend`, `all`) to easily spin up specific combinations of services tailored to your current development focus.
21
21
  * **Automatic Dependency Resolution:** `go-dev` builds an intelligent execution graph, starting services in the correct topological order.
22
22
  * **Centralized Logging:** Prefixes logs from each service, making it easy to follow activity from multiple concurrent processes.
23
- * **Robust Cleanup:** Handles graceful shutdown of all started processes and Docker services on exit (e.g., via `Ctrl+C`).
23
+ * **Automatic Process Exit:** The `go-dev` process will automatically exit when all primary services (those directly listed in the chosen preset) exit cleanly (with a success code of `0`).
24
+ * **Precise Docker Cleanup:** `go-dev` intelligently tracks which Docker Compose services it **actively started** (i.e., those that were not already running). During cleanup, it will only stop these specific services, leaving any pre-existing containers untouched.
25
+ * **Robust Cleanup:** Handles graceful shutdown of all started processes and Docker services on exit (e.g., via `Ctrl+C` or automatic exit).
24
26
 
25
27
  ## 🤔 Why `go-dev`?
26
28
 
@@ -52,15 +54,35 @@ npx go-dev <preset_name> [config_path]
52
54
  * `<preset_name>`: The name of the preset defined in your `go-dev.yml` (e.g., `api`, `frontend`, `all`).
53
55
  * `[config_path]`: (Optional) Path to your `go-dev.yml` file. Defaults to looking for `go-dev.yml`, `.go-dev.yml`, `go-dev.yaml`, or `.go-dev.yaml` in the current directory.
54
56
 
55
- **Example:**
57
+ **Passing Arguments to Service Commands:**
58
+
59
+ To pass additional arguments from the command line to a specific service command, use a keyword flag followed by the target and its arguments.
60
+
61
+ By default, the keyword flag is `--args-for`. This can be customized in your `go-dev.yml` using the `serviceArgsKeyword` option (e.g., `serviceArgsKeyword: pass-to`).
62
+
63
+ The target for arguments is specified as `<service_name>[:<command_index>]`:
64
+
65
+ Specify the target for arguments as `<service_name>:<command_index>` (e.g., `api:0`, `frontend:1`). The `command_index` is 0-based and refers to the position of the command within a service's `commands` array. If the `:<command_index>` part is omitted (e.g., just `<service_name>`), arguments are passed to the **first command (index `0`)** defined for that service.
66
+
67
+ You can combine multiple keyword flag blocks for different services or specific commands.
56
68
 
57
69
  ```bash
58
- npx go-dev api # Start the environment for API development
59
- npx go-dev frontend # Start the environment for Frontend development
60
- npx go-dev all # Start the full development environment
70
+ npx go-dev <preset_name> [--<serviceArgsKeyword> <service_name>[:<command_index>] [args...] ] [...]
61
71
  ```
62
72
 
63
- Press `Ctrl+C` at any time to gracefully shut down all running services.
73
+ Example:
74
+
75
+ Consider an `api` service with two parallel commands: `api:0` (main server) and `api:1` (TypeScript compiler watch).
76
+
77
+ ```bash
78
+ npx go-dev all \
79
+ --args-for api:0 --host 0.0.0.0 --port 8081 \
80
+ --args-for api:1 --pretty --diagnostics \
81
+ --args-for frontend --log-level verbose
82
+ ```
83
+ *In the example above, `--args-for frontend` is equivalent to `--args-for frontend:0`.*
84
+
85
+ Press `Ctrl+C` at any time to gracefully shut down all running services. `go-dev` will also automatically exit once all primary services (those directly listed in your chosen preset) have completed their execution cleanly.
64
86
 
65
87
  ## ⚙️ Configuration (`go-dev.yml`)
66
88
 
@@ -73,6 +95,10 @@ Common examples include: `go-dev.yml`, `.go-dev.yml`, and `go-dev.config.yaml`.
73
95
  ```yaml
74
96
  # go-dev.yml
75
97
 
98
+ # Customize the keyword used to pass arguments to service commands from the CLI.
99
+ # Change this if 'args-for' conflicts with a command your services use.
100
+ serviceArgsKeyword: args-for # Default value
101
+
76
102
  # Define your individual services here
77
103
  services:
78
104
  # Example: A Docker-based PostgreSQL database
@@ -86,15 +112,27 @@ services:
86
112
  api:
87
113
  type: hybrid # This service has multiple modes
88
114
  defaultMode: dev # Default mode if not specified by a preset or dependency
89
- # Note: The name of the modes is totally arbirtary
115
+ # Note: The name of the modes is totally arbitrary
90
116
  modes:
91
117
  # API in active development mode (runs directly on host)
92
118
  dev:
93
119
  type: cmd # This mode runs a command-line process
120
+ # The 'commands' property can be specified in three ways:
121
+ # 1. As a simple array of strings (for a single command without extra options)
122
+ # commands: [npx, rollup, -c, -w]
123
+ # 2. As a single command object (for one command with options like 'directory' or 'restartOnError')
124
+ # commands:
125
+ # command: [npx, rollup, -c, -w]
126
+ # directory: ./api
127
+ # restartOnError: true # Default is true for 'cmd' services
128
+ # 3. As an array of command objects (for multiple parallel commands)
94
129
  commands:
95
- start:
96
- command: [npx, rollup, -c, -w] # The primary command for development
130
+ - command: [npx, rollup, -c, -w] # The primary command for development (index 0)
97
131
  directory: ./api # Directory to run the command from
132
+ # restartOnError: true # (Optional, defaults to true)
133
+ # Example of a second parallel command for API (index 1)
134
+ # - command: [npx, tsc, --watch]
135
+ # directory: ./api
98
136
  dependencies: # What this mode depends on
99
137
  - postgres # API dev needs PostgreSQL (will use postgres's default docker mode)
100
138
  - { service: frontend, mode: serve } # API dev needs frontend running in its 'serve' mode
@@ -117,9 +155,8 @@ services:
117
155
  dev:
118
156
  type: cmd
119
157
  commands:
120
- start:
121
- command: [npx, rollup, -c, -w]
122
- directory: ./frontend
158
+ command: [npx, rollup, -c, -w]
159
+ directory: ./frontend
123
160
  dependencies:
124
161
  # Frontend dev needs API (will use api's default docker mode for this preset)
125
162
  # Note: No direct circular dependency between dev modes.
@@ -129,12 +166,11 @@ services:
129
166
  # Frontend serving its built assets (e.g., when API depends on it)
130
167
  serve:
131
168
  type: cmd
132
- preCommands: # Commands to run and await completion BEFORE the main 'start' command
169
+ preCommands: # Commands to run and await completion BEFORE the main command
133
170
  - [npm, --prefix, frontend, run, build] # Build frontend assets first
134
171
  commands:
135
- start:
136
- command: [node, ./localserver.mjs] # Then start the local server
137
- directory: ./frontend
172
+ command: [node, ./localserver.mjs] # Then start the local server
173
+ directory: ./frontend
138
174
  dependencies:
139
175
  - api # Frontend serve needs API (will use api's default dev mode for this preset)
140
176
 
package/bin/go-dev CHANGED
@@ -4,13 +4,12 @@ const Orchestrator = require('../src/orchestrator');
4
4
  const path = require('path');
5
5
 
6
6
  const presetName = process.argv[2];
7
- const configPath = process.argv[3];
8
7
 
9
8
  if (!presetName) {
10
- console.error('Error: Please specify a preset to run. Usage: dev-orchestrator <preset_name> [config_path]');
9
+ console.error('Error: Please specify a preset to run. Usage: dev-orchestrator <preset_name>');
11
10
  process.exit(1);
12
11
  }
13
12
 
14
- const orchestrator = new Orchestrator(configPath);
13
+ const orchestrator = new Orchestrator();
15
14
 
16
15
  orchestrator.start(presetName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-dev",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "main": "src/index.js",
5
5
  "bin": {
6
6
  "go-dev": "bin/go-dev"
@@ -9,6 +9,10 @@
9
9
  "test": "echo \"Error: no test specified\" && exit 1"
10
10
  },
11
11
  "author": "Giuliano Collacchioni",
12
+ "repository": {
13
+ "type": "github",
14
+ "url": "https://github.com/Kal-Aster/go-dev"
15
+ },
12
16
  "license": "MIT",
13
17
  "description": "",
14
18
  "dependencies": {
package/src/config.js CHANGED
@@ -15,6 +15,7 @@ const commandSchema = Joi.array().items(Joi.string().min(1)).min(1);
15
15
  const commandObjectSchema = Joi.object({
16
16
  command: commandSchema,
17
17
  directory: Joi.string().min(1).optional(),
18
+ restartOnError: Joi.boolean().optional(),
18
19
  })
19
20
  const commandConfigSchema = Joi.alternatives().try(
20
21
  commandSchema,
@@ -24,13 +25,10 @@ const commandConfigSchema = Joi.alternatives().try(
24
25
  const cmdServiceConfigSchema = Joi.object({
25
26
  type: Joi.string().valid('cmd').required(),
26
27
  preCommands: Joi.array().items(commandConfigSchema).default([]),
27
- commands: Joi.object().pattern(
28
- Joi.string(),
29
- Joi.alternatives().try(
30
- commandConfigSchema,
31
- Joi.array().items(commandObjectSchema).min(1)
32
- )
33
- ).min(1).required(),
28
+ commands: Joi.alternatives().try(
29
+ commandConfigSchema,
30
+ Joi.array().items(commandObjectSchema).min(1)
31
+ ),
34
32
  defaultCommand: Joi.string().default('start'),
35
33
  directory: Joi.string(),
36
34
  dependencies: Joi.array().items(dependencyEntrySchema).default([]),
@@ -62,6 +60,7 @@ const serviceSchema = Joi.alternatives().try(
62
60
  );
63
61
 
64
62
  const configSchema = Joi.object({
63
+ serviceArgsKeyword: Joi.string().min(1).optional(),
65
64
  services: Joi.object().pattern(Joi.string(), serviceSchema).required(),
66
65
  presets: Joi.object().pattern(
67
66
  Joi.string(),
@@ -112,6 +111,8 @@ function loadConfig(configPath) {
112
111
 
113
112
  const configContent = fs.readFileSync(configPath, 'utf8');
114
113
  const config = yaml.load(configContent);
114
+
115
+ console.log(config.services.api.modes);
115
116
 
116
117
  const { error, value } = configSchema.validate(config);
117
118
 
@@ -33,6 +33,63 @@ class Orchestrator {
33
33
  console.log('--- Resolved Primary Services to Run ---');
34
34
  primaryServices.forEach(s => console.log(` - ${s.name} (mode: ${s.mode})`));
35
35
 
36
+ const extraArgs = new Map();
37
+ {
38
+ const argsToParse = process.argv.slice(3);
39
+ if (argsToParse.length > 0) {
40
+ console.log(`--- Gathering args to pass to services ---`);
41
+ const serviceArgsKeyword = `--${this.config.serviceArgsKeyword ?? 'args-for'}`;
42
+ let isGettingService = false;
43
+ let currentService = null;
44
+ let currentIndex = 0;
45
+ let argsToPass = null;
46
+ for (const arg of argsToParse) {
47
+ if (arg === serviceArgsKeyword) {
48
+ isGettingService = true;
49
+ currentService = null;
50
+ currentIndex = 0;
51
+ continue;
52
+ }
53
+ if (currentService == null) {
54
+ if (isGettingService === false) {
55
+ throw new Error(`Invalid arguments, use format: npx go-dev ${presetName} ${serviceArgsKeyword} <service> <args>`);
56
+ }
57
+
58
+ const splitArg = arg.split(':');
59
+ if (splitArg.length > 2) {
60
+ throw new Error(`Invalid service name + index '${arg}': should be <service> or <service>:<command_index>`);
61
+ }
62
+
63
+ const index = splitArg.length > 1 ? parseInt(splitArg[1]) : 0;;
64
+ if (splitArg.length > 1 && `${index}` !== splitArg[1]) {
65
+ throw new Error(`Invalid service name + index '${arg}': should be <service> or <service>:<command_index>`);
66
+ }
67
+
68
+ currentService = splitArg[0];
69
+ currentIndex = index;
70
+ argsToPass = extraArgs.get(currentService) ?? [];
71
+ extraArgs.set(currentService, argsToPass);
72
+ isGettingService = false;
73
+ continue;
74
+ }
75
+
76
+ let args = argsToPass[currentIndex];
77
+ if (args == null) {
78
+ args = [];
79
+ argsToPass[currentIndex] = args;
80
+ }
81
+
82
+ args.push(arg);
83
+ }
84
+
85
+ for (const [service, indexedArgs] of extraArgs.entries()) {
86
+ indexedArgs.forEach((args, index) => {
87
+ console.log(`Extra args for service '${service}:${index}': [${args.join(', ')}]`);
88
+ });
89
+ }
90
+ }
91
+ }
92
+
36
93
  console.log('\n--- Starting Dependencies ---');
37
94
  for (const { name, mode, config } of dependencies) {
38
95
  if (this.activeServiceInstances.has(name)) {
@@ -43,12 +100,13 @@ class Orchestrator {
43
100
  if (!ServiceClass) {
44
101
  throw new Error(`Unknown service type '${config.type}' for service '${name}'.`);
45
102
  }
46
- const serviceInstance = new ServiceClass(name, mode, config);
103
+ const serviceInstance = new ServiceClass(name, mode, config, () => {});
47
104
  this.activeServiceInstances.set(name, serviceInstance);
48
105
  await serviceInstance.start();
49
106
  }
50
107
 
51
108
  console.log('\n--- Starting Primary Services ---');
109
+ const activePrimaryServices = new Map();
52
110
  const primaryServicePromises = [];
53
111
  for (const { name, mode, config } of primaryServices) {
54
112
  if (this.activeServiceInstances.has(name)) {
@@ -59,8 +117,16 @@ class Orchestrator {
59
117
  if (!ServiceClass) {
60
118
  throw new Error(`Unknown service type '${config.type}' for service '${name}'.`);
61
119
  }
62
- const serviceInstance = new ServiceClass(name, mode, config);
120
+ const serviceInstance = new ServiceClass(name, mode, config, () => {
121
+ activePrimaryServices.delete(name);
122
+ if (activePrimaryServices.size > 0) {
123
+ return;
124
+ }
125
+
126
+ this.cleanup();
127
+ }, extraArgs.get(name));
63
128
  this.activeServiceInstances.set(name, serviceInstance);
129
+ activePrimaryServices.set(name, serviceInstance);
64
130
  primaryServicePromises.push(serviceInstance.start());
65
131
  }
66
132
  await Promise.all(primaryServicePromises);
@@ -93,10 +93,12 @@ class ProcessManager {
93
93
  * @param {string[]} args - Arguments for the command.
94
94
  * @param {object} options - Options for spawn (e.g., cwd).
95
95
  * @param {string} prefix - Prefix for stdout/stderr lines (e.g., 'frontend:').
96
- * @param {boolean} restartOnExit - Whether to restart the process if it exits with non-zero code.
96
+ * @param {boolean} restartOnError - Whether to restart the process if it exits with non-zero code.
97
+ * @param {Function} onExit
98
+ * @param {ChildProcess[] | undefined} processReference - An array where to save the process to gain a reference to it.
97
99
  * @returns {ChildProcess} The spawned child process instance.
98
100
  */
99
- startManagedProcess(command, args, options, prefix, restartOnExit) {
101
+ startManagedProcess(command, args, options, prefix, restartOnError, onExit, processReference) {
100
102
  if (this.cleanupInProgress) {
101
103
  console.warn(`[ProcessManager] Skipping managed process '${command}' during cleanup.`);
102
104
  return null;
@@ -107,13 +109,18 @@ class ProcessManager {
107
109
  )} (prefix: ${prefix})`,
108
110
  );
109
111
 
110
- const proc = spawn(command, args, {
112
+ if (processReference == null) {
113
+ processReference = {}
114
+ }
115
+
116
+ const startedProcess = spawn(command, args, {
111
117
  shell: true,
112
118
  stdio: 'pipe',
113
119
  ...options,
114
120
  });
121
+ processReference.process = startedProcess;
115
122
 
116
- proc.stdout.on('data', (data) => {
123
+ startedProcess.stdout.on('data', (data) => {
117
124
  let prefixedData = data.toString().split('\n').map(line => `${prefix} ${line}`).join('\n');
118
125
  if (prefixedData.endsWith(`${prefix} `)) {
119
126
  prefixedData = prefixedData.slice(0, -(`${prefix} `).length);
@@ -124,7 +131,7 @@ class ProcessManager {
124
131
  prefixedData = prefixedData.replace(/\x1b\[(?:2J|3J|H)/gi, '');
125
132
  process.stdout.write(prefixedData);
126
133
  });
127
- proc.stderr.on('data', (data) => {
134
+ startedProcess.stderr.on('data', (data) => {
128
135
  let prefixedData = data.toString().split('\n').map(line => `${prefix} ${line}`).join('\n');
129
136
  if (prefixedData.endsWith(`${prefix} `)) {
130
137
  prefixedData = prefixedData.slice(0, -(`${prefix} `).length);
@@ -136,49 +143,56 @@ class ProcessManager {
136
143
  process.stderr.write(prefixedData);
137
144
  });
138
145
 
139
- this.managedProcesses.add(proc);
146
+ this.managedProcesses.add(startedProcess);
140
147
 
141
- proc.on('error', (err) => {
148
+ startedProcess.on('error', (err) => {
142
149
  console.error(
143
150
  `[ProcessManager] Error starting managed process '${command}': ${err.message}`,
144
151
  );
145
- this.managedProcesses.delete(proc);
152
+ this.managedProcesses.delete(startedProcess);
146
153
  });
147
154
 
148
- proc.on('exit', (code, signal) => {
149
- if (!this.managedProcesses.has(proc)) {
155
+ startedProcess.on('exit', (code) => {
156
+ if (!this.managedProcesses.has(startedProcess)) {
150
157
  return;
151
158
  }
152
159
 
153
- this.managedProcesses.delete(proc);
160
+ this.managedProcesses.delete(startedProcess);
154
161
  if (this.cleanupInProgress) {
155
162
  console.log(
156
- `[ProcessManager] Managed process '${command}' (PID: ${proc.pid}) exited due to cleanup.`,
163
+ `[ProcessManager] Managed process '${command}' (PID: ${startedProcess.pid}) exited due to cleanup.`,
157
164
  );
158
165
  return;
159
166
  }
160
167
 
161
168
  if (code !== 0) {
162
169
  console.error(
163
- `[ProcessManager] Managed process '${command}' (PID: ${proc.pid}) exited with code ${code}.`,
170
+ `[ProcessManager] Managed process '${command}' (PID: ${startedProcess.pid}) exited with code ${code}.`,
164
171
  );
165
- if (restartOnExit) {
172
+ if (restartOnError) {
166
173
  console.warn(
167
174
  `[ProcessManager] Restarting managed process '${command}'...`,
168
175
  );
169
- this.startManagedProcess(command, args, options, prefix, restartOnExit);
176
+ this.startManagedProcess(command, args, options, prefix, restartOnError, onExit, processReference);
177
+ } else {
178
+ onExit?.();
170
179
  }
171
180
  } else {
172
181
  console.log(
173
- `[ProcessManager] Managed process '${command}' (PID: ${proc.pid}) exited cleanly.`,
182
+ `[ProcessManager] Managed process '${command}' (PID: ${startedProcess.pid}) exited cleanly.`,
174
183
  );
184
+ onExit?.();
175
185
  }
176
186
  });
177
187
 
178
- return proc;
188
+ return processReference;
179
189
  }
180
190
 
181
191
  killProcess(process) {
192
+ if (process.killed || process.exitCode != null) {
193
+ return;
194
+ }
195
+
182
196
  this.managedProcesses.delete(process);
183
197
 
184
198
  return new Promise((resolve, reject) => {
@@ -229,7 +243,7 @@ class ProcessManager {
229
243
  }
230
244
  for (let index = this.managedProcesses.length - 1; index >= 0; index--) {
231
245
  const process = this.managedProcesses[index];
232
- if (process.killed) {
246
+ if (process.killed || process.exitCode != null) {
233
247
  this.managedProcesses.splice(index, 1);
234
248
  }
235
249
  }
@@ -239,7 +253,7 @@ class ProcessManager {
239
253
  console.log('\n[ProcessManager] Initiating cleanup of managed processes...');
240
254
 
241
255
  for (const proc of [...this.managedProcesses]) {
242
- if (proc.killed) {
256
+ if (proc.killed || proc.exitCode != null) {
243
257
  continue;
244
258
  }
245
259
 
@@ -253,7 +267,7 @@ class ProcessManager {
253
267
  await new Promise(resolve => setTimeout(resolve, 500));
254
268
 
255
269
  for (const proc of [...this.managedProcesses]) {
256
- if (proc.killed) {
270
+ if (proc.killed || proc.exitCode != null) {
257
271
  continue;
258
272
  }
259
273
 
@@ -28,10 +28,12 @@ class BaseService {
28
28
  * @param {string} mode - The resolved mode for this service (e.g., 'dev', 'docker', 'serve').
29
29
  * @param {object} config - The concrete configuration object for this service and mode.
30
30
  */
31
- constructor(name, mode, config) {
31
+ constructor(name, mode, config, onExit, extraArgs) {
32
32
  this.name = name;
33
33
  this.mode = mode;
34
34
  this.config = config;
35
+ this.onExit = onExit;
36
+ this.extraArgs = extraArgs;
35
37
  }
36
38
 
37
39
  async start() {
@@ -1,8 +1,8 @@
1
1
  const { BaseService } = require('./base');
2
2
 
3
3
  class CmdService extends BaseService {
4
- constructor(name, mode, config) {
5
- super(name, mode, config);
4
+ constructor(name, mode, config, onExit, extraArgs) {
5
+ super(name, mode, config, onExit, extraArgs);
6
6
  this.prefix = `${name}:${mode}:`;
7
7
  this.processes = [];
8
8
  }
@@ -10,7 +10,12 @@ class CmdService extends BaseService {
10
10
  async start() {
11
11
  console.log(`[${this.name}:${this.mode}] Starting cmd service...`);
12
12
 
13
- const { preCommands, commands, defaultCommand } = this.config;
13
+ const { preCommands, commands } = this.config;
14
+ if (!commands) {
15
+ throw new Error(
16
+ `[${this.name}:${this.mode}] Commands not found for service.`,
17
+ );
18
+ }
14
19
 
15
20
  if (preCommands && preCommands.length > 0) {
16
21
  console.log(`[${this.name}:${this.mode}] Running pre-commands...`);
@@ -36,38 +41,46 @@ class CmdService extends BaseService {
36
41
  console.log(`[${this.name}:${this.mode}] Pre-commands completed.`);
37
42
  }
38
43
 
39
- const mainCommand = commands[defaultCommand];
40
- if (!mainCommand) {
41
- throw new Error(
42
- `[${this.name}:${this.mode}] Default command '${defaultCommand}' not found for service.`,
43
- );
44
- }
45
-
46
- const { cmdArgs, directory } = (Array.isArray(mainCommand) && typeof mainCommand[0] === 'string' ?
47
- { cmdArgs: [mainCommand], directory: [undefined] } :
48
- (Array.isArray(mainCommand) ?
44
+ const { cmdArgs, directory, restartOnError } = (Array.isArray(commands) && typeof commands[0] === 'string' ?
45
+ { cmdArgs: [commands], directory: [undefined], restartOnError: [undefined] } :
46
+ (Array.isArray(commands) ?
49
47
  {
50
- cmdArgs: mainCommand.map(({ command }) => command),
51
- directory: mainCommand.map(({ directory }) => directory),
48
+ cmdArgs: commands.map(({ command }) => command),
49
+ directory: commands.map(({ directory }) => directory),
50
+ restartOnError: commands.map(({ restartOnError }) => restartOnError),
52
51
  } :
53
- { cmdArgs: [mainCommand.command], directory: [mainCommand.directory] }
52
+ {
53
+ cmdArgs: [commands.command],
54
+ directory: [commands.directory],
55
+ restartOnError: [commands.restartOnError],
56
+ }
54
57
  )
55
58
  );
56
59
 
57
60
  const useProcessIndex = cmdArgs.length > 1;
61
+ const exitedProcess = Array.from({ length: cmdArgs.length });
58
62
  for (let index = 0; index < cmdArgs.length; index++) {
59
63
  const command = cmdArgs[index];
60
- const cwd = directory[index];
64
+
65
+ const extraArgs = this.extraArgs?.[index] ?? [];
61
66
 
62
67
  const process = CmdService._processManager.startManagedProcess(
63
68
  command[0],
64
- command.slice(1),
65
- { cwd },
69
+ command.slice(1).concat(extraArgs),
70
+ { cwd: directory[index] },
66
71
  (useProcessIndex ?
67
72
  `${this.prefix}${index}:` :
68
73
  this.prefix
69
74
  ),
70
- true,
75
+ restartOnError[index],
76
+ () => {
77
+ exitedProcess[index] = true;
78
+ if (exitedProcess.some(exited => !exited)) {
79
+ return;
80
+ }
81
+
82
+ this.onExit?.();
83
+ }
71
84
  );
72
85
 
73
86
  if (!process) {
@@ -78,13 +91,13 @@ class CmdService extends BaseService {
78
91
 
79
92
  this.processes.push(process);
80
93
  console.log(
81
- `[${this.name}:${this.mode}] Process started (PID: ${process.pid}).`,
94
+ `[${this.name}:${this.mode}] Process started (PID: ${process.process.pid}).`,
82
95
  );
83
96
  }
84
97
  }
85
98
 
86
99
  async stop() {
87
- const promises = this.processes.map(process => {
100
+ const promises = this.processes.map(({ process }) => {
88
101
  console.log(`[${this.name}:${this.mode}] Stopping process (PID: ${process.pid}).`);
89
102
  return CmdService._processManager.killProcess(process);
90
103
  });
@@ -1,8 +1,8 @@
1
1
  const { BaseService } = require('./base');
2
2
 
3
3
  class DockerService extends BaseService {
4
- /** @type {Set<string>} */
5
- static _composeFilesToStop = new Set();
4
+ /** @type {Map<string, string[]>} */
5
+ static _servicesToStop = new Map();
6
6
 
7
7
  static async cleanup() {
8
8
  if (!DockerService._processManager) {
@@ -10,23 +10,23 @@ class DockerService extends BaseService {
10
10
  return;
11
11
  }
12
12
 
13
- if (DockerService._composeFilesToStop.size > 0) {
13
+ if (DockerService._servicesToStop.size > 0) {
14
14
  console.log('[DockerService] Stopping all docker services from used compose files...');
15
- for (const composeFile of DockerService._composeFilesToStop) {
15
+ for (const [composeFile, services] of this._servicesToStop.entries()) {
16
16
  try {
17
- console.log(`[DockerService] Running 'docker compose -f ${composeFile} stop'`);
17
+ console.log(`[DockerService] Stopping services ${services.map(service => `'${service}'`).join(', ')} of '${composeFile}'`);
18
18
  DockerService._processManager.runSync(
19
19
  'docker',
20
- ['compose', '-f', composeFile, 'stop'],
20
+ ['compose', '-f', composeFile, 'stop', ...services],
21
21
  { stdio: 'inherit' },
22
22
  );
23
23
  } catch (error) {
24
24
  console.error(
25
- `[DockerService] Failed to execute 'docker compose -f ${composeFile} stop': ${error.message}`,
25
+ `[DockerService] Failed to stop services of '${composeFile}': ${error.message}`,
26
26
  );
27
27
  }
28
28
  }
29
- DockerService._composeFilesToStop.clear();
29
+ DockerService._servicesToStop.clear();
30
30
  } else {
31
31
  console.log('[DockerService] No docker services were started by orchestrator, skipping global stop.');
32
32
  }
@@ -47,16 +47,33 @@ class DockerService extends BaseService {
47
47
  console.log(
48
48
  `[${this.name}:${this.mode}] Docker container for '${this.dockerServiceName}' is already running.`,
49
49
  );
50
- DockerService._composeFilesToStop.add(this.dockerComposeFile);
51
50
  } else {
52
51
  console.log(`[${this.name}:${this.mode}] Bringing up docker service '${this.dockerServiceName}'...`);
53
52
  try {
53
+ const servicesBeforeStart = this._getCurrentlyRunningServices();
54
54
  await DockerService._processManager.runInherited(
55
55
  'docker',
56
56
  ['compose', '-f', this.dockerComposeFile, 'up', this.dockerServiceName, '-d'],
57
57
  );
58
- DockerService._composeFilesToStop.add(this.dockerComposeFile);
58
+ const servicesAfterStart = this._getCurrentlyRunningServices();
59
+ let servicesOfComposeFile = DockerService._servicesToStop.get(this.dockerComposeFile);
60
+ if (servicesOfComposeFile == null) {
61
+ servicesOfComposeFile = [];
62
+ }
63
+ const newServices = servicesAfterStart.filter(service => {
64
+ return !(
65
+ servicesBeforeStart.includes(service) ||
66
+ servicesOfComposeFile.includes(service)
67
+ );
68
+ });
69
+ DockerService._servicesToStop.set(
70
+ this.dockerComposeFile,
71
+ servicesOfComposeFile.concat(newServices)
72
+ );
59
73
  console.log(`[${this.name}:${this.mode}] Docker service '${this.dockerServiceName}' brought up.`);
74
+ if (newServices.length > 1) {
75
+ console.log(`[${this.name}:${this.mode}] Dependency service${newServices.length > 2 ? 's' : ''} for '${this.dockerServiceName}': ${newServices.filter(service => service.name !== this.dockerServiceName).join(', ')}`);
76
+ }
60
77
  } catch (error) {
61
78
  throw new Error(
62
79
  `[${this.name}:${this.mode}] Failed to bring up docker service '${this.dockerServiceName}': ${error.message}`,
@@ -88,6 +105,23 @@ class DockerService extends BaseService {
88
105
  return await this._checkServiceHealthiness();
89
106
  }
90
107
 
108
+ _getCurrentlyRunningServices() {
109
+ try {
110
+ const services = DockerService._processManager.runSync(
111
+ 'docker',
112
+ ['compose', '-f', this.dockerComposeFile, 'ps', '--services'],
113
+ );
114
+ return (services
115
+ .split('\n')
116
+ .map(serviceName => serviceName.trim())
117
+ .filter(serviceName => serviceName !== '')
118
+ );
119
+ } catch (error) {
120
+ // console.warn(...);
121
+ }
122
+ return null;
123
+ }
124
+
91
125
  _getContainerName() {
92
126
  if (this.containerName) {
93
127
  return this.containerName;
@@ -143,7 +177,7 @@ class DockerService extends BaseService {
143
177
  );
144
178
 
145
179
  if (healthStatus === 'healthy' || healthStatus === 'none') {
146
- console.log(`[${this.name}:${this.mode}] Container '${containerName}' healty!\n`);
180
+ console.log(`[${this.name}:${this.mode}] Container '${containerName}' healthy!`);
147
181
  return true;
148
182
  }
149
183
 
package/test.js DELETED
@@ -1,136 +0,0 @@
1
- const { loadConfig } = require('./src/config');
2
- const { resolveServiceExecutionGraph } = require('./src/dependency-resolver');
3
- const fs = require('fs');
4
-
5
- const testConfigContent = `
6
- composeFile: infrastructure/docker-compose.yml
7
-
8
- services:
9
- postgres:
10
- type: docker
11
- service: postgres
12
- healthCheck: true
13
-
14
- api:
15
- type: hybrid
16
- defaultMode: dev
17
- modes:
18
- dev:
19
- type: cmd
20
- commands:
21
- start: [npx, rollup, -c, -w]
22
- directory: ./api
23
- dependencies:
24
- - postgres
25
- - { service: frontend, mode: serve }
26
-
27
- docker:
28
- type: docker
29
- service: api
30
- healthCheck: true
31
- dependencies:
32
- - postgres
33
-
34
- frontend:
35
- type: hybrid
36
- defaultMode: dev
37
- modes:
38
- dev:
39
- type: cmd
40
- commands:
41
- start: [npx, rollup, -c, -w]
42
- directory: ./frontend
43
- dependencies:
44
- - { service: api, mode: docker }
45
-
46
- serve:
47
- type: cmd
48
- preCommands:
49
- - [npm, --prefix, frontend, run, build]
50
- commands:
51
- start: [node, ./localserver.mjs]
52
- directory: ./frontend
53
- dependencies:
54
- - api
55
-
56
- presets:
57
- api:
58
- services: [api]
59
-
60
- frontend:
61
- services: [frontend]
62
-
63
- all:
64
- services: [api, frontend]
65
-
66
- # Example of a preset that would cause a cycle if dependencies were set up naively
67
- # (and without the \`dev\` modes being peers)
68
- # cycle-test:
69
- # services: [api, frontend]
70
- # modes:
71
- # api: dev
72
- # frontend: cycle-dev # Imagine 'cycle-dev' depends on 'api:dev'
73
- # # To trigger a cycle, you'd need api:dev -> frontend:dev and frontend:dev -> api:dev
74
-
75
- `;
76
-
77
- const testConfigPath = 'temp-dev-orchestrator.yml';
78
- fs.writeFileSync(testConfigPath, testConfigContent);
79
-
80
- console.log('--- Testing Dependency Resolver ---');
81
-
82
- try {
83
- const config = loadConfig(testConfigPath);
84
- console.log('✅ Config loaded successfully');
85
-
86
- console.log('\n--- Resolving "api" preset ---');
87
- const apiExecutionGraph = resolveServiceExecutionGraph(config, 'api');
88
- console.log('✅ "api" preset resolved successfully.');
89
- console.log('Dependencies:');
90
- apiExecutionGraph.dependencies.forEach((node) => {
91
- console.log(` - ${node.name} (mode: ${node.mode})`);
92
- });
93
- console.log('Services:');
94
- apiExecutionGraph.services.forEach((node) => {
95
- console.log(` - ${node.name} (mode: ${node.mode})`);
96
- });
97
-
98
- console.log('\n--- Resolving "frontend" preset ---');
99
- const frontendExecutionGraph = resolveServiceExecutionGraph(config, 'frontend');
100
- console.log('✅ "frontend" preset resolved successfully.');
101
- console.log('Dependencies:');
102
- frontendExecutionGraph.dependencies.forEach((node) => {
103
- console.log(` - ${node.name} (mode: ${node.mode})`);
104
- });
105
- console.log('Services:');
106
- frontendExecutionGraph.services.forEach((node) => {
107
- console.log(` - ${node.name} (mode: ${node.mode})`);
108
- });
109
-
110
- console.log('\n--- Resolving "all" preset ---');
111
- const allExecutionGraph = resolveServiceExecutionGraph(config, 'all');
112
- console.log('✅ "all" preset resolved successfully.');
113
- console.log('Dependencies:');
114
- allExecutionGraph.dependencies.forEach((node) => {
115
- console.log(` - ${node.name} (mode: ${node.mode})`);
116
- });
117
- console.log('Services:');
118
- allExecutionGraph.services.forEach((node) => {
119
- console.log(` - ${node.name} (mode: ${node.mode})`);
120
- });
121
-
122
- console.log('\n--- Testing non-existent preset ---');
123
- try {
124
- resolveServiceExecutionGraph(config, 'non-existent');
125
- } catch (error) {
126
- console.log(`✅ Caught expected error: ${error.message}`);
127
- }
128
- } catch (error) {
129
- console.error('❌ An unexpected error occurred during testing:', error);
130
- } finally {
131
- // Clean up the temporary config file
132
- if (fs.existsSync(testConfigPath)) {
133
- fs.unlinkSync(testConfigPath);
134
- console.log(`\nCleaned up temporary config file: ${testConfigPath}`);
135
- }
136
- }