go-dev 0.5.0 → 0.6.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
@@ -14,7 +14,7 @@ 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. Commands can be defined in multiple flexible ways to run single or multiple processes in parallel for a service.
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, and `readyWhen` to hold back dependents until the service is actually usable (log match, file, or open port). 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.
@@ -174,6 +174,17 @@ services:
174
174
  commands:
175
175
  command: [npx, rollup, -c, -w]
176
176
  directory: ./frontend
177
+ # 'readyWhen' holds back dependents until this (long-running) service is
178
+ # actually usable, instead of resolving as soon as the process spawns.
179
+ # This is the watch-mode counterpart of docker's 'healthCheck': prefer it
180
+ # over building shared artifacts again as a preCommand of every consumer.
181
+ # Provide at least one condition (multiple are combined with AND):
182
+ # logMatch: "<regex>" — ready when a line on stdout/stderr matches
183
+ # file: ./dist/index.js — ready when the path exists on disk
184
+ # port: 5173 — ready when a TCP connection succeeds
185
+ # Optional: host (default 127.0.0.1), timeoutMs (60000), pollIntervalMs (500).
186
+ readyWhen:
187
+ logMatch: "created .* in" # rollup's "created dist/... in 1.2s"
177
188
  dependencies:
178
189
  # Frontend dev needs API (will use api's default docker mode for this preset)
179
190
  # Note: No direct circular dependency between dev modes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-dev",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "main": "src/index.js",
5
5
  "bin": {
6
6
  "go-dev": "bin/go-dev"
package/src/config.js CHANGED
@@ -31,6 +31,15 @@ const preCommandConfigSchema = Joi.alternatives().try(
31
31
  serviceRefSchema,
32
32
  );
33
33
 
34
+ const readyWhenSchema = Joi.object({
35
+ logMatch: Joi.string().min(1),
36
+ file: Joi.string().min(1),
37
+ port: Joi.number().integer().min(1).max(65535),
38
+ host: Joi.string().min(1).default('127.0.0.1'),
39
+ timeoutMs: Joi.number().integer().min(0).default(60000),
40
+ pollIntervalMs: Joi.number().integer().min(50).default(500),
41
+ }).or('logMatch', 'file', 'port');
42
+
34
43
  const cmdServiceConfigSchema = Joi.object({
35
44
  type: Joi.string().valid('cmd').required(),
36
45
  preCommands: Joi.array().items(preCommandConfigSchema).default([]),
@@ -41,6 +50,7 @@ const cmdServiceConfigSchema = Joi.object({
41
50
  defaultCommand: Joi.string().default('start'),
42
51
  directory: Joi.string(),
43
52
  dependencies: Joi.array().items(dependencyEntrySchema).default([]),
53
+ readyWhen: readyWhenSchema.optional(),
44
54
  healthCheck: Joi.boolean().default(false)
45
55
  });
46
56
 
@@ -88,6 +88,56 @@ class ProcessManager {
88
88
  });
89
89
  }
90
90
 
91
+ /**
92
+ * Runs a command asynchronously, prefixing its output like a managed process.
93
+ * Used for pre-commands (literal or service-as-preCommand) so their output is
94
+ * attributed to the owning service instead of leaking raw to the terminal.
95
+ * @param {string} command - The command to execute.
96
+ * @param {string[]} args - Arguments for the command.
97
+ * @param {object} [options={}] - Options for spawn (e.g., cwd).
98
+ * @param {string} prefix - Prefix for stdout/stderr lines.
99
+ * @returns {Promise<void>} Resolves when the process exits successfully.
100
+ */
101
+ runInheritedPrefixed(command, args = [], options = {}, prefix) {
102
+ return new Promise((resolve, reject) => {
103
+ if (this.cleanupInProgress) {
104
+ log.warn(`[ProcessManager] Skipping inherited command '${command}' during cleanup.`);
105
+ return resolve();
106
+ }
107
+ log.debug(`[ProcessManager] Running inherited (prefixed): ${command} ${args.join(' ')}`);
108
+ const proc = spawn(command, args, {
109
+ shell: true,
110
+ stdio: 'pipe',
111
+ ...options,
112
+ env: { FORCE_COLOR: '1', ...process.env, ...(options.env ?? {}) },
113
+ });
114
+
115
+ let lastFormatting = '';
116
+ const writePrefixed = (data, target) => {
117
+ const result = prefixLines(data.toString(), prefix, lastFormatting);
118
+ lastFormatting = result.lastFormatting;
119
+ target.write(result.prefixedText);
120
+ };
121
+ proc.stdout.on('data', (data) => writePrefixed(data, process.stdout));
122
+ proc.stderr.on('data', (data) => writePrefixed(data, process.stderr));
123
+
124
+ proc.on('error', (err) => {
125
+ log.error(`[ProcessManager] Failed to start inherited command '${command}':`, err);
126
+ reject(err);
127
+ });
128
+
129
+ proc.on('exit', (code) => {
130
+ if (code !== 0) {
131
+ reject(
132
+ new Error(`Inherited command '${command}' exited with code ${code}`),
133
+ );
134
+ } else {
135
+ resolve();
136
+ }
137
+ });
138
+ });
139
+ }
140
+
91
141
  /**
92
142
  * Starts a long-running, managed process (like 'npx rollup -w').
93
143
  * Its output is prefixed, and it can be configured to restart on exit.
@@ -1,6 +1,7 @@
1
1
  const log = require('../logger');
2
2
  const { BaseService } = require('./base');
3
3
  const { buildColoredPrefix, buildColoredTag } = require('../service-colors');
4
+ const { waitForReady } = require('./ready-check');
4
5
 
5
6
  class CmdService extends BaseService {
6
7
  /**
@@ -70,9 +71,11 @@ class CmdService extends BaseService {
70
71
  }
71
72
 
72
73
  const normalized = CmdService._normalizeCommands(config.commands);
73
- await Promise.all(normalized.map(({ command, directory }) => {
74
+ const useIndex = normalized.length > 1;
75
+ await Promise.all(normalized.map(({ command, directory }, index) => {
74
76
  const [cmd, ...args] = command;
75
- return CmdService._processManager.runInherited(cmd, args, { cwd: directory });
77
+ const prefix = buildColoredPrefix(serviceName, mode, useIndex ? index : null);
78
+ return CmdService._processManager.runInheritedPrefixed(cmd, args, { cwd: directory }, prefix);
76
79
  }));
77
80
 
78
81
  log.info(`[${ctx}] preCommand service completed.`);
@@ -98,10 +101,12 @@ class CmdService extends BaseService {
98
101
  ? { cmdArgs: pre }
99
102
  : { cmdArgs: pre.command, directory: pre.directory });
100
103
  try {
101
- CmdService._processManager.runSync(cmdArgs[0], cmdArgs.slice(1), {
102
- cwd: directory,
103
- stdio: 'inherit',
104
- });
104
+ await CmdService._processManager.runInheritedPrefixed(
105
+ cmdArgs[0],
106
+ cmdArgs.slice(1),
107
+ { cwd: directory },
108
+ fromContext,
109
+ );
105
110
  } catch (error) {
106
111
  log.debug({ cmdArgs });
107
112
  throw new Error(
@@ -222,6 +227,14 @@ class CmdService extends BaseService {
222
227
  `[${this.coloredId}] Process started (PID: ${process.process.pid}).`,
223
228
  );
224
229
  }
230
+
231
+ if (this.config.readyWhen) {
232
+ await waitForReady(
233
+ this.processes.map(({ process }) => process),
234
+ this.config.readyWhen,
235
+ this.coloredId,
236
+ );
237
+ }
225
238
  }
226
239
 
227
240
  async stop() {
@@ -0,0 +1,134 @@
1
+ const net = require('net');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const log = require('../logger');
5
+
6
+ const MAX_BUFFER = 16 * 1024;
7
+
8
+ /**
9
+ * Resolves when one of the managed processes prints a line matching `pattern`.
10
+ * Attaches an extra listener on top of the existing prefixing one, so it does
11
+ * not interfere with normal output.
12
+ */
13
+ function logMatchCheck(processes, pattern, timeoutMs) {
14
+ const regex = new RegExp(pattern);
15
+ let cleanup = () => {};
16
+ const promise = new Promise((resolve, reject) => {
17
+ let buffer = '';
18
+ const listeners = [];
19
+ const onData = (data) => {
20
+ buffer = (buffer + data.toString()).slice(-MAX_BUFFER);
21
+ if (regex.test(buffer)) {
22
+ resolve();
23
+ }
24
+ };
25
+ for (const proc of processes) {
26
+ for (const stream of [proc.stdout, proc.stderr]) {
27
+ if (!stream) {
28
+ continue;
29
+ }
30
+ stream.on('data', onData);
31
+ listeners.push(stream);
32
+ }
33
+ }
34
+ const timer = setTimeout(() => {
35
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for log /${pattern}/`));
36
+ }, timeoutMs);
37
+ cleanup = () => {
38
+ clearTimeout(timer);
39
+ for (const stream of listeners) {
40
+ stream.off('data', onData);
41
+ }
42
+ };
43
+ });
44
+ return { promise: promise.finally(() => cleanup()), cancel: () => cleanup() };
45
+ }
46
+
47
+ /** Resolves once `filePath` exists on disk, polling until the timeout. */
48
+ function fileCheck(filePath, timeoutMs, pollIntervalMs) {
49
+ const resolved = path.resolve(filePath);
50
+ let cancelled = false;
51
+ const promise = (async () => {
52
+ const deadline = Date.now() + timeoutMs;
53
+ while (!cancelled) {
54
+ if (fs.existsSync(resolved)) {
55
+ return;
56
+ }
57
+ if (Date.now() >= deadline) {
58
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for file '${resolved}'`);
59
+ }
60
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
61
+ }
62
+ })();
63
+ return { promise, cancel: () => { cancelled = true; } };
64
+ }
65
+
66
+ function tryConnect(host, port) {
67
+ return new Promise((resolve) => {
68
+ const socket = net.connect({ host, port });
69
+ const done = (ok) => {
70
+ socket.destroy();
71
+ resolve(ok);
72
+ };
73
+ socket.once('connect', () => done(true));
74
+ socket.once('error', () => done(false));
75
+ socket.setTimeout(1000, () => done(false));
76
+ });
77
+ }
78
+
79
+ /** Resolves once a TCP connection to `host:port` succeeds, polling until the timeout. */
80
+ function portCheck(host, port, timeoutMs, pollIntervalMs) {
81
+ let cancelled = false;
82
+ const promise = (async () => {
83
+ const deadline = Date.now() + timeoutMs;
84
+ while (!cancelled) {
85
+ if (await tryConnect(host, port)) {
86
+ return;
87
+ }
88
+ if (Date.now() >= deadline) {
89
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${host}:${port}`);
90
+ }
91
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
92
+ }
93
+ })();
94
+ return { promise, cancel: () => { cancelled = true; } };
95
+ }
96
+
97
+ /**
98
+ * Blocks until the given `readyWhen` conditions are all satisfied for the
99
+ * provided managed processes. Multiple conditions are combined with AND.
100
+ * @param {import('child_process').ChildProcess[]} processes
101
+ * @param {object} readyWhen - Validated `readyWhen` config (with defaults applied).
102
+ * @param {string} coloredId - The service's colored tag, for logging.
103
+ */
104
+ async function waitForReady(processes, readyWhen, coloredId) {
105
+ const { logMatch, file, port, host, timeoutMs, pollIntervalMs } = readyWhen;
106
+
107
+ const checks = [];
108
+ const labels = [];
109
+ if (logMatch != null) {
110
+ checks.push(logMatchCheck(processes, logMatch, timeoutMs));
111
+ labels.push(`log /${logMatch}/`);
112
+ }
113
+ if (file != null) {
114
+ checks.push(fileCheck(file, timeoutMs, pollIntervalMs));
115
+ labels.push(`file '${file}'`);
116
+ }
117
+ if (port != null) {
118
+ checks.push(portCheck(host, port, timeoutMs, pollIntervalMs));
119
+ labels.push(`${host}:${port}`);
120
+ }
121
+
122
+ log.info(`[${coloredId}] Waiting until ready (${labels.join(' AND ')})...`);
123
+ try {
124
+ await Promise.all(checks.map(check => check.promise));
125
+ log.info(`[${coloredId}] Service is ready.`);
126
+ } finally {
127
+ // Stop any still-pending checks so they don't reject after we're done.
128
+ for (const check of checks) {
129
+ check.cancel();
130
+ }
131
+ }
132
+ }
133
+
134
+ module.exports = { waitForReady };