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 +53 -17
- package/bin/go-dev +2 -3
- package/package.json +5 -1
- package/src/config.js +8 -7
- package/src/orchestrator.js +68 -2
- package/src/process-manager.js +34 -20
- package/src/services/base.js +3 -1
- package/src/services/cmd.js +35 -22
- package/src/services/docker.js +45 -11
- package/test.js +0 -136
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
|
-
* **
|
|
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
|
-
**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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>
|
|
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(
|
|
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.
|
|
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.
|
|
28
|
-
|
|
29
|
-
Joi.
|
|
30
|
-
|
|
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
|
|
package/src/orchestrator.js
CHANGED
|
@@ -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);
|
package/src/process-manager.js
CHANGED
|
@@ -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}
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
146
|
+
this.managedProcesses.add(startedProcess);
|
|
140
147
|
|
|
141
|
-
|
|
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(
|
|
152
|
+
this.managedProcesses.delete(startedProcess);
|
|
146
153
|
});
|
|
147
154
|
|
|
148
|
-
|
|
149
|
-
if (!this.managedProcesses.has(
|
|
155
|
+
startedProcess.on('exit', (code) => {
|
|
156
|
+
if (!this.managedProcesses.has(startedProcess)) {
|
|
150
157
|
return;
|
|
151
158
|
}
|
|
152
159
|
|
|
153
|
-
this.managedProcesses.delete(
|
|
160
|
+
this.managedProcesses.delete(startedProcess);
|
|
154
161
|
if (this.cleanupInProgress) {
|
|
155
162
|
console.log(
|
|
156
|
-
`[ProcessManager] Managed process '${command}' (PID: ${
|
|
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: ${
|
|
170
|
+
`[ProcessManager] Managed process '${command}' (PID: ${startedProcess.pid}) exited with code ${code}.`,
|
|
164
171
|
);
|
|
165
|
-
if (
|
|
172
|
+
if (restartOnError) {
|
|
166
173
|
console.warn(
|
|
167
174
|
`[ProcessManager] Restarting managed process '${command}'...`,
|
|
168
175
|
);
|
|
169
|
-
this.startManagedProcess(command, args, options, prefix,
|
|
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: ${
|
|
182
|
+
`[ProcessManager] Managed process '${command}' (PID: ${startedProcess.pid}) exited cleanly.`,
|
|
174
183
|
);
|
|
184
|
+
onExit?.();
|
|
175
185
|
}
|
|
176
186
|
});
|
|
177
187
|
|
|
178
|
-
return
|
|
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
|
|
package/src/services/base.js
CHANGED
|
@@ -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() {
|
package/src/services/cmd.js
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
51
|
-
directory:
|
|
48
|
+
cmdArgs: commands.map(({ command }) => command),
|
|
49
|
+
directory: commands.map(({ directory }) => directory),
|
|
50
|
+
restartOnError: commands.map(({ restartOnError }) => restartOnError),
|
|
52
51
|
} :
|
|
53
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/services/docker.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { BaseService } = require('./base');
|
|
2
2
|
|
|
3
3
|
class DockerService extends BaseService {
|
|
4
|
-
/** @type {
|
|
5
|
-
static
|
|
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.
|
|
13
|
+
if (DockerService._servicesToStop.size > 0) {
|
|
14
14
|
console.log('[DockerService] Stopping all docker services from used compose files...');
|
|
15
|
-
for (const composeFile of
|
|
15
|
+
for (const [composeFile, services] of this._servicesToStop.entries()) {
|
|
16
16
|
try {
|
|
17
|
-
console.log(`[DockerService]
|
|
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
|
|
25
|
+
`[DockerService] Failed to stop services of '${composeFile}': ${error.message}`,
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
DockerService.
|
|
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
|
-
|
|
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}'
|
|
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
|
-
}
|