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/README.md +29 -3
- package/bin/go-dev +2 -16
- package/package.json +3 -2
- package/spike/README.md +77 -0
- package/spike/opentui/index.js +181 -0
- package/spike/opentui/package.json +14 -0
- package/spike/shared/fake-log-source.js +89 -0
- package/spike/terminal-kit/index.js +206 -0
- package/spike/terminal-kit/package.json +13 -0
- package/src/cli-args.js +9 -2
- package/src/config.js +11 -1
- package/src/dependency-resolver.js +72 -52
- package/src/index.js +3 -7
- package/src/interactive.js +318 -0
- package/src/orchestrator.js +21 -5
- package/src/process-manager.js +50 -0
- package/src/run.js +57 -0
- package/src/save-preset.js +103 -0
- package/src/services/cmd.js +19 -6
- package/src/services/ready-check.js +134 -0
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 };
|
package/src/services/cmd.js
CHANGED
|
@@ -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
|
-
|
|
74
|
+
const useIndex = normalized.length > 1;
|
|
75
|
+
await Promise.all(normalized.map(({ command, directory }, index) => {
|
|
74
76
|
const [cmd, ...args] = command;
|
|
75
|
-
|
|
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.
|
|
102
|
-
|
|
103
|
-
|
|
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 };
|