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/cli-args.js
CHANGED
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
* <preset> — positional preset name (first non-flag arg)
|
|
6
6
|
* -c <path>, --config <path>, -c=<path>, --config=<path>
|
|
7
7
|
* -l <level>, --log-level <level>, -l=<lvl>, --log-level=<lvl>
|
|
8
|
+
* -i, --interactive — force the interactive TUI even when a preset is given
|
|
8
9
|
*
|
|
9
10
|
* Flags are only interpreted before the first `--args-for` token. Everything
|
|
10
11
|
* from `--args-for` onward is preserved verbatim in `remaining` so the
|
|
11
12
|
* orchestrator's per-service args parser can consume it untouched.
|
|
12
13
|
*
|
|
13
14
|
* @param {string[]} argv - argv tail (already stripped of node + script path).
|
|
14
|
-
* @returns {{ presetName?: string, configPath?: string, logLevel?: string, remaining: string[] }}
|
|
15
|
+
* @returns {{ presetName?: string, configPath?: string, logLevel?: string, interactive: boolean, remaining: string[] }}
|
|
15
16
|
*/
|
|
16
17
|
function parseCliArgs(argv) {
|
|
17
18
|
let presetName;
|
|
18
19
|
let configPath;
|
|
19
20
|
let logLevel;
|
|
21
|
+
let interactive = false;
|
|
20
22
|
const remaining = [];
|
|
21
23
|
|
|
22
24
|
let i = 0;
|
|
@@ -65,6 +67,11 @@ function parseCliArgs(argv) {
|
|
|
65
67
|
continue;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
if (arg === '-i' || arg === '--interactive') {
|
|
71
|
+
interactive = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
if (presetName == null && !arg.startsWith('-')) {
|
|
69
76
|
presetName = arg;
|
|
70
77
|
continue;
|
|
@@ -77,7 +84,7 @@ function parseCliArgs(argv) {
|
|
|
77
84
|
remaining.push(argv[i]);
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
return { presetName, configPath, logLevel, remaining };
|
|
87
|
+
return { presetName, configPath, logLevel, interactive, remaining };
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
module.exports = { parseCliArgs };
|
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
|
|
|
@@ -131,4 +141,4 @@ function loadConfig(configPath) {
|
|
|
131
141
|
return value;
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
module.exports = { loadConfig };
|
|
144
|
+
module.exports = { loadConfig, findConfigFile };
|
|
@@ -1,55 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a named preset to its `{ services, modes }` selection object,
|
|
3
|
+
* throwing if it does not exist. This is the single place that maps a preset
|
|
4
|
+
* name to data — everything downstream operates on the selection object, so a
|
|
5
|
+
* preset is just one way (alongside the interactive TUI) to produce one.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} config
|
|
8
|
+
* @param {string} presetName
|
|
9
|
+
* @returns {{ services: string[], modes: Record<string, string> }}
|
|
10
|
+
*/
|
|
11
|
+
function resolvePreset(config, presetName) {
|
|
4
12
|
const preset = config.presets[presetName];
|
|
5
13
|
if (!preset) {
|
|
6
14
|
throw new Error(`Preset '${presetName}' not found in configuration.`);
|
|
7
15
|
}
|
|
16
|
+
return preset;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} config
|
|
21
|
+
* @param {{ services: string[], modes?: Record<string, string> }} selection
|
|
22
|
+
* A service selection — same shape as a preset. May come from a preset
|
|
23
|
+
* (via {@link resolvePreset}) or be built interactively.
|
|
24
|
+
* @returns {{
|
|
25
|
+
* services: { name: string, mode: string, config: object }[],
|
|
26
|
+
* dependencies: { name: string, mode: string, config: object, requiredBy: string }[],
|
|
27
|
+
* conflicts: { service: string, requests: { mode: string, by: string | null }[] }[]
|
|
28
|
+
* }} `conflicts` lists services pulled in under more than one mode (e.g. one as
|
|
29
|
+
* a primary and another as a dependency). go-dev runs one instance per
|
|
30
|
+
* service name, so a conflicting selection leaves some dependency unmet.
|
|
31
|
+
*/
|
|
32
|
+
function resolveServiceExecutionGraph(config, selection) {
|
|
33
|
+
const modes = selection.modes ?? {};
|
|
8
34
|
|
|
9
35
|
const services = [];
|
|
10
36
|
const dependencies = [];
|
|
37
|
+
// serviceName -> Map<mode, requestedBy|null> — every mode a service is asked
|
|
38
|
+
// to run in, regardless of which instance actually wins the dedup below.
|
|
39
|
+
const requests = new Map();
|
|
11
40
|
|
|
12
|
-
for (const serviceName of
|
|
41
|
+
for (const serviceName of selection.services) {
|
|
13
42
|
addService(
|
|
14
43
|
serviceName,
|
|
15
|
-
|
|
44
|
+
modes[serviceName],
|
|
16
45
|
null,
|
|
17
46
|
);
|
|
18
47
|
}
|
|
19
48
|
|
|
20
|
-
|
|
49
|
+
const conflicts = [];
|
|
50
|
+
for (const [service, byMode] of requests) {
|
|
51
|
+
if (byMode.size > 1) {
|
|
52
|
+
conflicts.push({
|
|
53
|
+
service,
|
|
54
|
+
requests: [...byMode].map(([mode, by]) => ({ mode, by })),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { dependencies, services, conflicts };
|
|
21
60
|
|
|
22
|
-
function addService(serviceName,
|
|
61
|
+
function addService(serviceName, requestedMode, dependentService) {
|
|
23
62
|
const service = config.services[serviceName];
|
|
24
63
|
if (service == null) {
|
|
25
64
|
throw new Error(`Service named '${serviceName}' not found in configuration.`);
|
|
26
65
|
}
|
|
27
66
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
if (existingService != null) {
|
|
33
|
-
log.warn(
|
|
34
|
-
`Ignoring dependency '${serviceName}' for '${dependentService}' because it is flagged to be run as service in mode '${existingService.mode}'.`
|
|
35
|
-
);
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
} else {
|
|
39
|
-
const existingDependencyIndex = dependencies.findIndex(({ name }) => {
|
|
40
|
-
return name === serviceName;
|
|
41
|
-
});
|
|
42
|
-
if (existingDependencyIndex >= 0) {
|
|
43
|
-
log.warn(
|
|
44
|
-
`Removing service '${serviceName}' from dependencies because it is flagged to be run as service in mode '${dependencies[existingDependencyIndex].mode}'.`
|
|
45
|
-
);
|
|
46
|
-
dependencies.splice(existingDependencyIndex, 1);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
mode = (service.type === 'hybrid' ?
|
|
51
|
-
mode ?? service.defaultMode ?? 'dev' :
|
|
52
|
-
mode ?? 'dev'
|
|
67
|
+
const mode = (service.type === 'hybrid' ?
|
|
68
|
+
requestedMode ?? service.defaultMode ?? 'dev' :
|
|
69
|
+
requestedMode ?? 'dev'
|
|
53
70
|
);
|
|
54
71
|
const serviceConfig = (service.type === 'hybrid' ?
|
|
55
72
|
service.modes[mode] :
|
|
@@ -60,18 +77,29 @@ function resolveServiceExecutionGraph(config, presetName) {
|
|
|
60
77
|
throw new Error(`Mode named '${mode}' not found in service '${serviceName}'.`);
|
|
61
78
|
}
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
// Record the requested mode so conflicting selections can be flagged later,
|
|
81
|
+
// even for the instance that loses the dedup below.
|
|
82
|
+
let requested = requests.get(serviceName);
|
|
83
|
+
if (!requested) {
|
|
84
|
+
requests.set(serviceName, requested = new Map());
|
|
85
|
+
}
|
|
86
|
+
if (!requested.has(mode)) {
|
|
87
|
+
requested.set(mode, dependentService ?? null);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (dependentService != null) {
|
|
91
|
+
// One instance per service name: if it's already scheduled (as a primary
|
|
92
|
+
// or another dependency), keep the first. Mode mismatches surface via
|
|
93
|
+
// `conflicts`, not by silently swapping the running mode.
|
|
94
|
+
if (services.some(({ name }) => name === serviceName)) return;
|
|
95
|
+
if (dependencies.some(({ name }) => name === serviceName)) return;
|
|
96
|
+
dependencies.unshift({ name: serviceName, mode, config: serviceConfig, requiredBy: dependentService });
|
|
69
97
|
} else {
|
|
70
|
-
dependencies.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
98
|
+
const existingDependencyIndex = dependencies.findIndex(({ name }) => name === serviceName);
|
|
99
|
+
if (existingDependencyIndex >= 0) {
|
|
100
|
+
dependencies.splice(existingDependencyIndex, 1); // promote dependency -> primary
|
|
101
|
+
}
|
|
102
|
+
services.push({ name: serviceName, mode, config: serviceConfig });
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
for (let index = serviceConfig.dependencies.length - 1; index >= 0; index--) {
|
|
@@ -84,14 +112,6 @@ function resolveServiceExecutionGraph(config, presetName) {
|
|
|
84
112
|
dependency
|
|
85
113
|
);
|
|
86
114
|
|
|
87
|
-
const existingDependencyIndex = dependencies.find(({ name }) => {
|
|
88
|
-
return name === dependencyName;
|
|
89
|
-
});
|
|
90
|
-
if (existingDependencyIndex != null) {
|
|
91
|
-
log.warn(`Skipping dependency '${dependencyName}' for '${serviceName}' because it's already present in dependencies list.`);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
115
|
addService(
|
|
96
116
|
dependencyName,
|
|
97
117
|
dependencyMode,
|
|
@@ -101,4 +121,4 @@ function resolveServiceExecutionGraph(config, presetName) {
|
|
|
101
121
|
}
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
module.exports = { resolveServiceExecutionGraph };
|
|
124
|
+
module.exports = { resolveServiceExecutionGraph, resolvePreset };
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
const
|
|
2
|
-
const { parseCliArgs } = require('./cli-args');
|
|
1
|
+
const { run } = require('./run');
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
run(process.argv.slice(2));
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const orchestrator = new Orchestrator(configPath, { logLevel });
|
|
9
|
-
orchestrator.start(presetName);
|
|
5
|
+
module.exports = { run };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
const termkit = require('terminal-kit');
|
|
2
|
+
const { savePreset } = require('./save-preset');
|
|
3
|
+
const { resolveServiceExecutionGraph } = require('./dependency-resolver');
|
|
4
|
+
const log = require('./logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Full-screen interactive selection TUI (terminal-kit).
|
|
8
|
+
*
|
|
9
|
+
* Two tabs — "Presets" and "Services & Modes" — let the user either launch an
|
|
10
|
+
* existing preset or compose a custom selection (toggle services, pick a mode
|
|
11
|
+
* for hybrid services), optionally saving it back as a new preset.
|
|
12
|
+
*
|
|
13
|
+
* Runs to completion *before* the orchestrator takes over stdout, then fully
|
|
14
|
+
* restores the terminal. Resolves to a selection `{ name?, services, modes }`,
|
|
15
|
+
* or `null` if the user cancels (q / Esc / Ctrl+C).
|
|
16
|
+
*
|
|
17
|
+
* @param {object} config - the loaded, validated go-dev config.
|
|
18
|
+
* @param {{ configPath: string, presetName?: string }} options
|
|
19
|
+
* @returns {Promise<{ name?: string, services: string[], modes: Record<string,string> } | null>}
|
|
20
|
+
*/
|
|
21
|
+
function runInteractive(config, { configPath, presetName } = {}) {
|
|
22
|
+
const term = termkit.terminal;
|
|
23
|
+
const serviceNames = Object.keys(config.services ?? {});
|
|
24
|
+
const presetNames = Object.keys(config.presets ?? {});
|
|
25
|
+
|
|
26
|
+
const isHybrid = (name) => config.services[name].type === 'hybrid';
|
|
27
|
+
const modesFor = (name) => (isHybrid(name) ? Object.keys(config.services[name].modes) : ['dev']);
|
|
28
|
+
const defaultModeFor = (name) =>
|
|
29
|
+
isHybrid(name)
|
|
30
|
+
? config.services[name].defaultMode ?? modesFor(name)[0] ?? 'dev'
|
|
31
|
+
: 'dev';
|
|
32
|
+
|
|
33
|
+
// --- state ---------------------------------------------------------------
|
|
34
|
+
const selected = new Set();
|
|
35
|
+
const chosenMode = new Map();
|
|
36
|
+
for (const name of serviceNames) chosenMode.set(name, defaultModeFor(name));
|
|
37
|
+
|
|
38
|
+
// Pre-populate from a preset when forced interactive with a preset given.
|
|
39
|
+
if (presetName && config.presets?.[presetName]) {
|
|
40
|
+
const preset = config.presets[presetName];
|
|
41
|
+
for (const s of preset.services) selected.add(s);
|
|
42
|
+
for (const [s, m] of Object.entries(preset.modes ?? {})) chosenMode.set(s, m);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TABS = ['Services & Modes', 'Presets'];
|
|
46
|
+
let activeTab = 0; // land on Services & Modes; Tab switches to Presets
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
let message = '';
|
|
49
|
+
|
|
50
|
+
// --- rendering -----------------------------------------------------------
|
|
51
|
+
function drawHeader() {
|
|
52
|
+
term.moveTo(1, 1).styleReset().bold('go-dev')(' — selezione servizi');
|
|
53
|
+
term.moveTo(1, 2);
|
|
54
|
+
TABS.forEach((label, i) => {
|
|
55
|
+
if (i === activeTab) term.bgBrightWhite.black(` ${label} `);
|
|
56
|
+
else term.bgGray.white(` ${label} `);
|
|
57
|
+
term(' ');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function drawPresets() {
|
|
62
|
+
term.moveTo(1, 4)('Preset disponibili — ↑/↓ scegli, invio avvia:');
|
|
63
|
+
if (presetNames.length === 0) {
|
|
64
|
+
term.moveTo(3, 6).gray('(nessun preset definito — usa la tab Services & Modes)');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
presetNames.forEach((name, i) => {
|
|
68
|
+
const marker = i === cursor ? '❯' : ' ';
|
|
69
|
+
const services = (config.presets[name].services ?? []).join(', ');
|
|
70
|
+
term.moveTo(2, 6 + i);
|
|
71
|
+
const text = `${marker} ${name}`;
|
|
72
|
+
if (i === cursor) term.brightCyan(text);
|
|
73
|
+
else term(text);
|
|
74
|
+
term.gray(` services: ${services}`);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drawServices() {
|
|
79
|
+
term.moveTo(1, 4)('Servizi — spazio: on/off, m: modalità, invio: avvia:');
|
|
80
|
+
if (serviceNames.length === 0) {
|
|
81
|
+
term.moveTo(3, 6).gray('(nessun servizio definito)');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
serviceNames.forEach((name, i) => {
|
|
85
|
+
const marker = i === cursor ? '❯' : ' ';
|
|
86
|
+
const box = selected.has(name) ? '[x]' : '[ ]';
|
|
87
|
+
term.moveTo(2, 6 + i);
|
|
88
|
+
const text = `${marker} ${box} ${name}`;
|
|
89
|
+
if (i === cursor) term.brightCyan(text);
|
|
90
|
+
else term(text);
|
|
91
|
+
const mode = isHybrid(name) ? chosenMode.get(name) : 'dev';
|
|
92
|
+
term.gray(` mode: ${mode}${isHybrid(name) ? ' (m per cambiare)' : ''}`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Selection the bottom panel previews. On the Services tab it reflects the
|
|
97
|
+
// *checked* services (not the cursor); on the Presets tab, the highlighted preset.
|
|
98
|
+
function panelSelection() {
|
|
99
|
+
if (activeTab === 0) {
|
|
100
|
+
const services = serviceNames.filter((s) => selected.has(s));
|
|
101
|
+
const modes = {};
|
|
102
|
+
for (const s of services) {
|
|
103
|
+
if (isHybrid(s)) modes[s] = chosenMode.get(s);
|
|
104
|
+
}
|
|
105
|
+
return { services, modes };
|
|
106
|
+
}
|
|
107
|
+
const preset = presetNames[cursor];
|
|
108
|
+
if (!preset) return null;
|
|
109
|
+
return { services: config.presets[preset].services, modes: config.presets[preset].modes ?? {} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Bottom panel: the resolved selection split into labelled sections.
|
|
113
|
+
function drawPanel(top) {
|
|
114
|
+
term.moveTo(1, top).styleReset().gray('─'.repeat(Math.min(term.width, 64)));
|
|
115
|
+
|
|
116
|
+
const selection = panelSelection();
|
|
117
|
+
if (!selection) return;
|
|
118
|
+
|
|
119
|
+
let graph;
|
|
120
|
+
const previousLevel = log.getLogLevel();
|
|
121
|
+
try {
|
|
122
|
+
log.setLogLevel('error'); // silence resolver dedup warnings while previewing
|
|
123
|
+
graph = resolveServiceExecutionGraph(config, selection);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
term.moveTo(3, top + 1).styleReset().red(`⚠ ${error.message}`);
|
|
126
|
+
return;
|
|
127
|
+
} finally {
|
|
128
|
+
log.setLogLevel(previousLevel);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let row = top + 1;
|
|
132
|
+
const header = (label) => {
|
|
133
|
+
if (row >= term.height - 1) return;
|
|
134
|
+
term.moveTo(1, row++).styleReset().bold(label);
|
|
135
|
+
};
|
|
136
|
+
const item = (label, style) => {
|
|
137
|
+
if (row >= term.height - 1) return;
|
|
138
|
+
term.moveTo(3, row++).styleReset();
|
|
139
|
+
style(label);
|
|
140
|
+
};
|
|
141
|
+
const blank = () => { row++; };
|
|
142
|
+
|
|
143
|
+
header('servizi principali');
|
|
144
|
+
if (graph.services.length) {
|
|
145
|
+
graph.services.forEach((s) => item(`${s.name}:${s.mode}`, (t) => term.brightWhite(t)));
|
|
146
|
+
} else {
|
|
147
|
+
item(activeTab === 0 ? '(nessun servizio selezionato)' : '(nessuno)', (t) => term.gray(t));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
blank();
|
|
151
|
+
header('dipendenze');
|
|
152
|
+
if (graph.dependencies.length) {
|
|
153
|
+
graph.dependencies.forEach((d) => item(`${d.name}:${d.mode}`, (t) => term.white(t)));
|
|
154
|
+
} else {
|
|
155
|
+
item('(nessuna)', (t) => term.gray(t));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (graph.conflicts.length) {
|
|
159
|
+
blank();
|
|
160
|
+
if (row < term.height - 1) term.moveTo(1, row++).styleReset().red('⚠ conflitti di modalità');
|
|
161
|
+
for (const { service, requests } of graph.conflicts) {
|
|
162
|
+
const where = requests
|
|
163
|
+
.map((r) => `${r.mode}${r.by ? ` (dip. di ${r.by})` : ' (primario)'}`)
|
|
164
|
+
.join(' vs ');
|
|
165
|
+
item(`${service}: ${where}`, (t) => term.yellow(t));
|
|
166
|
+
}
|
|
167
|
+
item('un solo avvio per servizio — alcune dipendenze restano non soddisfatte', (t) => term.gray(t));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function render() {
|
|
172
|
+
term.clear();
|
|
173
|
+
drawHeader();
|
|
174
|
+
if (activeTab === 0) drawServices();
|
|
175
|
+
else drawPresets();
|
|
176
|
+
|
|
177
|
+
const panelTop = 6 + Math.max(serviceNames.length, presetNames.length, 1) + 1;
|
|
178
|
+
drawPanel(panelTop);
|
|
179
|
+
|
|
180
|
+
if (message) {
|
|
181
|
+
term.moveTo(1, term.height - 1).styleReset().yellow(message);
|
|
182
|
+
}
|
|
183
|
+
term.moveTo(1, term.height).styleReset().gray(
|
|
184
|
+
' ←/→ tab ↑/↓ muovi spazio on/off m modalità invio avvia q esci'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- helpers -------------------------------------------------------------
|
|
189
|
+
function currentList() {
|
|
190
|
+
return activeTab === 0 ? serviceNames : presetNames;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function cycleMode(name) {
|
|
194
|
+
if (!isHybrid(name)) return;
|
|
195
|
+
const modes = modesFor(name);
|
|
196
|
+
const next = (modes.indexOf(chosenMode.get(name)) + 1) % modes.length;
|
|
197
|
+
chosenMode.set(name, modes[next]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildCustomSelection() {
|
|
201
|
+
const services = serviceNames.filter((s) => selected.has(s));
|
|
202
|
+
const modes = {};
|
|
203
|
+
for (const s of services) {
|
|
204
|
+
if (isHybrid(s)) modes[s] = chosenMode.get(s);
|
|
205
|
+
}
|
|
206
|
+
return { name: undefined, services, modes };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- lifecycle -----------------------------------------------------------
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
let finished = false;
|
|
212
|
+
|
|
213
|
+
function cleanup() {
|
|
214
|
+
term.removeListener('key', onKey);
|
|
215
|
+
term.grabInput(false);
|
|
216
|
+
term.hideCursor(false);
|
|
217
|
+
term.fullscreen(false);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function finish(result) {
|
|
221
|
+
if (finished) return;
|
|
222
|
+
finished = true;
|
|
223
|
+
cleanup();
|
|
224
|
+
resolve(result);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function confirmCustom() {
|
|
228
|
+
const selection = buildCustomSelection();
|
|
229
|
+
if (selection.services.length === 0) {
|
|
230
|
+
message = 'Seleziona almeno un servizio (spazio).';
|
|
231
|
+
return render();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Hand input over to terminal-kit's prompt helpers for the save flow.
|
|
235
|
+
term.removeListener('key', onKey);
|
|
236
|
+
term.hideCursor(false);
|
|
237
|
+
|
|
238
|
+
term.moveTo(1, term.height - 2).styleReset().eraseLine();
|
|
239
|
+
term('Salvare questa selezione come preset? [s/N] ');
|
|
240
|
+
const wantsSave = await term.yesOrNo({ yes: ['s', 'y'], no: ['n', 'ENTER', 'ESCAPE'] }).promise;
|
|
241
|
+
|
|
242
|
+
if (wantsSave) {
|
|
243
|
+
term.moveTo(1, term.height - 1).styleReset().eraseLine();
|
|
244
|
+
term('Nome del preset: ');
|
|
245
|
+
const name = ((await term.inputField().promise) || '').trim();
|
|
246
|
+
if (name) {
|
|
247
|
+
try {
|
|
248
|
+
savePreset(configPath, name, selection);
|
|
249
|
+
selection.name = name;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
term.moveTo(1, term.height).styleReset().eraseLine().red(`Errore: ${error.message}`);
|
|
252
|
+
await term.yesOrNo({ yes: ['ENTER', 'y', 's'], no: ['n'] }).promise; // pausa per leggere
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return finish(selection);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function onKey(name) {
|
|
261
|
+
message = '';
|
|
262
|
+
|
|
263
|
+
if (name === 'CTRL_C' || name === 'ESCAPE' || name === 'q') return finish(null);
|
|
264
|
+
|
|
265
|
+
if (name === 'TAB' || name === 'LEFT' || name === 'RIGHT') {
|
|
266
|
+
activeTab = (activeTab + 1) % TABS.length;
|
|
267
|
+
cursor = 0;
|
|
268
|
+
return render();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const list = currentList();
|
|
272
|
+
if (name === 'UP') {
|
|
273
|
+
cursor = list.length ? (cursor - 1 + list.length) % list.length : 0;
|
|
274
|
+
return render();
|
|
275
|
+
}
|
|
276
|
+
if (name === 'DOWN') {
|
|
277
|
+
cursor = list.length ? (cursor + 1) % list.length : 0;
|
|
278
|
+
return render();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (activeTab === 1) {
|
|
282
|
+
// Presets tab
|
|
283
|
+
if (name === 'ENTER') {
|
|
284
|
+
const preset = presetNames[cursor];
|
|
285
|
+
if (!preset) return;
|
|
286
|
+
return finish({
|
|
287
|
+
name: preset,
|
|
288
|
+
services: config.presets[preset].services,
|
|
289
|
+
modes: config.presets[preset].modes ?? {},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Services & Modes tab
|
|
296
|
+
const svc = serviceNames[cursor];
|
|
297
|
+
if (!svc) return;
|
|
298
|
+
if (name === ' ' || name === 'SPACE') {
|
|
299
|
+
if (selected.has(svc)) selected.delete(svc);
|
|
300
|
+
else selected.add(svc);
|
|
301
|
+
return render();
|
|
302
|
+
}
|
|
303
|
+
if (name === 'm') {
|
|
304
|
+
cycleMode(svc);
|
|
305
|
+
return render();
|
|
306
|
+
}
|
|
307
|
+
if (name === 'ENTER') return confirmCustom();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
term.fullscreen(true);
|
|
311
|
+
term.grabInput(true);
|
|
312
|
+
term.hideCursor(true);
|
|
313
|
+
term.on('key', onKey);
|
|
314
|
+
render();
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { runInteractive };
|
package/src/orchestrator.js
CHANGED
|
@@ -16,6 +16,7 @@ const bold = (text) => `\x1b[1m${text}\x1b[0m`;
|
|
|
16
16
|
|
|
17
17
|
class Orchestrator {
|
|
18
18
|
constructor(configPath, options = {}) {
|
|
19
|
+
this.configPath = configPath;
|
|
19
20
|
this.config = loadConfig(configPath);
|
|
20
21
|
|
|
21
22
|
const level = options.logLevel ?? this.config.logLevel;
|
|
@@ -29,19 +30,34 @@ class Orchestrator {
|
|
|
29
30
|
BaseService.initialize(this.processManager, this.config.services);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ name?: string, services: string[], modes?: Record<string, string> }} selection
|
|
35
|
+
* The service selection to run — from a preset or built interactively.
|
|
36
|
+
*/
|
|
37
|
+
async start(selection) {
|
|
33
38
|
try {
|
|
34
|
-
const { dependencies, services: primaryServices } = resolveServiceExecutionGraph(
|
|
39
|
+
const { dependencies, services: primaryServices, conflicts } = resolveServiceExecutionGraph(
|
|
35
40
|
this.config,
|
|
36
|
-
|
|
41
|
+
selection,
|
|
37
42
|
);
|
|
38
43
|
|
|
39
|
-
log.info(bold(`Preset: ${
|
|
44
|
+
log.info(bold(`Preset: ${selection.name ?? 'selezione personalizzata'}`));
|
|
40
45
|
log.info(`\n${bold('Resolved dependencies')}`);
|
|
41
46
|
dependencies.forEach(s => log.info(` - ${colorService(s.name, s.mode)} (mode: ${colorMode(s.name, s.mode)})`));
|
|
42
47
|
log.info(`\n${bold('Resolved primary services')}`);
|
|
43
48
|
primaryServices.forEach(s => log.info(` - ${colorService(s.name, s.mode)} (mode: ${colorMode(s.name, s.mode)})`));
|
|
44
49
|
|
|
50
|
+
if (conflicts.length > 0) {
|
|
51
|
+
log.warn(`\n${bold('⚠ Mode conflicts')}`);
|
|
52
|
+
for (const { service, requests } of conflicts) {
|
|
53
|
+
const where = requests
|
|
54
|
+
.map(r => `${colorMode(service, r.mode)}${r.by ? ` (dep of ${r.by})` : ' (primary)'}`)
|
|
55
|
+
.join(' vs ');
|
|
56
|
+
log.warn(` - '${service}' requested in conflicting modes: ${where}.`);
|
|
57
|
+
}
|
|
58
|
+
log.warn(` go-dev runs one instance per service, so some dependencies above may be unmet.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
const extraArgs = new Map();
|
|
46
62
|
{
|
|
47
63
|
const argsToParse = process.argv.slice(3);
|
|
@@ -61,7 +77,7 @@ class Orchestrator {
|
|
|
61
77
|
}
|
|
62
78
|
if (currentService == null) {
|
|
63
79
|
if (isGettingService === false) {
|
|
64
|
-
throw new Error(`Invalid arguments, use format: npx go-dev ${
|
|
80
|
+
throw new Error(`Invalid arguments, use format: npx go-dev ${selection.name ?? '<preset>'} ${serviceArgsKeyword} <service> <args>`);
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
const splitArg = arg.split(':');
|
package/src/process-manager.js
CHANGED
|
@@ -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.
|