go-dev 0.5.0 → 0.7.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/src/run.js ADDED
@@ -0,0 +1,57 @@
1
+ const Orchestrator = require('./orchestrator');
2
+ const { parseCliArgs } = require('./cli-args');
3
+ const { findConfigFile } = require('./config');
4
+ const { resolvePreset } = require('./dependency-resolver');
5
+ const { runInteractive } = require('./interactive');
6
+ const log = require('./logger');
7
+
8
+ /**
9
+ * Shared CLI entry flow, used by both `bin/go-dev` and `src/index.js`.
10
+ *
11
+ * Resolves a service selection — from a preset name, or interactively when no
12
+ * preset is given (or `--interactive` is set) — and hands it to the orchestrator.
13
+ *
14
+ * @param {string[]} argv - argv tail (already stripped of node + script path).
15
+ */
16
+ async function run(argv) {
17
+ try {
18
+ const { presetName, configPath, logLevel, interactive, remaining } = parseCliArgs(argv);
19
+
20
+ const resolvedConfigPath = configPath ?? findConfigFile();
21
+ const orchestrator = new Orchestrator(resolvedConfigPath, { logLevel });
22
+
23
+ let selection;
24
+ if (interactive || !presetName) {
25
+ if (!presetName && !process.stdin.isTTY) {
26
+ console.error(
27
+ 'Error: no preset given and no interactive terminal. ' +
28
+ 'Specify a preset (go-dev <preset>) or run in a TTY to use the interactive selector.'
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ selection = await runInteractive(orchestrator.config, {
34
+ configPath: resolvedConfigPath,
35
+ presetName,
36
+ });
37
+ if (!selection) {
38
+ process.exit(0); // user cancelled
39
+ }
40
+ } else {
41
+ selection = { name: presetName, ...resolvePreset(orchestrator.config, presetName) };
42
+ }
43
+
44
+ // Keep `remaining` (the `--args-for ...` tail) at argv index >= 3, where the
45
+ // orchestrator's per-service args parser reads it. Index 2 is unused there.
46
+ process.argv = [process.argv[0], process.argv[1], selection.name ?? '', ...remaining];
47
+
48
+ await orchestrator.start(selection);
49
+ } catch (error) {
50
+ // Setup-phase failures (bad config, unknown preset, TUI errors). The
51
+ // orchestrator handles its own runtime errors and cleanup internally.
52
+ log.error(`\n❌ ${error.message}`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ module.exports = { run };
@@ -0,0 +1,103 @@
1
+ const fs = require('fs');
2
+ const yaml = require('js-yaml');
3
+ const { loadConfig } = require('./config');
4
+
5
+ /**
6
+ * Renders a value as a YAML scalar, quoting it only when it isn't a plain,
7
+ * safe identifier. Service/mode names come from config keys (already safe);
8
+ * user-typed preset names may need quoting.
9
+ */
10
+ function scalar(value) {
11
+ return /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(value) ? value : JSON.stringify(value);
12
+ }
13
+
14
+ /**
15
+ * Builds the YAML block for a single preset entry, indented under `presets:`.
16
+ *
17
+ * @param {string} name
18
+ * @param {{ services: string[], modes?: Record<string, string> }} selection
19
+ * @param {string} indent - leading indentation for the entry key (e.g. ' ').
20
+ */
21
+ function buildPresetBlock(name, selection, indent) {
22
+ const step = ' ';
23
+ const lines = [
24
+ `${indent}${scalar(name)}:`,
25
+ `${indent}${step}services: [${selection.services.map(scalar).join(', ')}]`,
26
+ ];
27
+
28
+ const modes = selection.modes ?? {};
29
+ const modeEntries = Object.entries(modes).filter(([, mode]) => mode != null);
30
+ if (modeEntries.length > 0) {
31
+ lines.push(`${indent}${step}modes:`);
32
+ for (const [service, mode] of modeEntries) {
33
+ lines.push(`${indent}${step}${step}${scalar(service)}: ${scalar(mode)}`);
34
+ }
35
+ }
36
+
37
+ return lines.join('\n');
38
+ }
39
+
40
+ /**
41
+ * Inserts a preset into a raw YAML config, preserving the rest of the file
42
+ * (comments, key order, formatting). Falls back to a full re-dump only when the
43
+ * existing `presets:` key isn't a plain block we can append to.
44
+ *
45
+ * @param {string} raw
46
+ * @param {string} name
47
+ * @param {object} selection
48
+ * @returns {string} the new file content
49
+ */
50
+ function insertPreset(raw, name, selection) {
51
+ const lines = raw.split('\n');
52
+ const presetsIndex = lines.findIndex((line) => /^(\s*)presets:\s*$/.test(line));
53
+
54
+ // Case A: a plain `presets:` block key exists — insert as its first entry.
55
+ if (presetsIndex >= 0) {
56
+ const baseIndent = lines[presetsIndex].match(/^(\s*)/)[1] + ' ';
57
+ const block = buildPresetBlock(name, selection, baseIndent);
58
+ lines.splice(presetsIndex + 1, 0, block);
59
+ return lines.join('\n');
60
+ }
61
+
62
+ // Case B: no `presets:` key at all — append a fresh block at the end.
63
+ const hasPresetsKey = lines.some((line) => /^\s*presets:/.test(line));
64
+ if (!hasPresetsKey) {
65
+ const trimmed = raw.replace(/\s*$/, '');
66
+ const block = buildPresetBlock(name, selection, ' ');
67
+ return `${trimmed}\n\npresets:\n${block}\n`;
68
+ }
69
+
70
+ // Case C: `presets:` exists but inline (e.g. `presets: {}`) — re-dump to stay
71
+ // structurally correct. Comments are lost; acceptable for this edge case.
72
+ const parsed = yaml.load(raw) ?? {};
73
+ parsed.presets = parsed.presets ?? {};
74
+ parsed.presets[name] = {
75
+ services: selection.services,
76
+ ...(Object.keys(selection.modes ?? {}).length > 0 ? { modes: selection.modes } : {}),
77
+ };
78
+ return yaml.dump(parsed, { lineWidth: 120 });
79
+ }
80
+
81
+ /**
82
+ * Persists a selection as a named preset in the given config file, then
83
+ * re-validates the file by loading it. On validation failure the original
84
+ * content is restored and the error is re-thrown.
85
+ *
86
+ * @param {string} configPath
87
+ * @param {string} name
88
+ * @param {{ services: string[], modes?: Record<string, string> }} selection
89
+ */
90
+ function savePreset(configPath, name, selection) {
91
+ const original = fs.readFileSync(configPath, 'utf8');
92
+ const updated = insertPreset(original, name, selection);
93
+
94
+ fs.writeFileSync(configPath, updated, 'utf8');
95
+ try {
96
+ loadConfig(configPath);
97
+ } catch (error) {
98
+ fs.writeFileSync(configPath, original, 'utf8');
99
+ throw new Error(`Failed to save preset '${name}': ${error.message}`);
100
+ }
101
+ }
102
+
103
+ module.exports = { savePreset, insertPreset };
@@ -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 };