go-dev 0.6.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/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
@@ -141,4 +141,4 @@ function loadConfig(configPath) {
141
141
  return value;
142
142
  }
143
143
 
144
- module.exports = { loadConfig };
144
+ module.exports = { loadConfig, findConfigFile };
@@ -1,55 +1,72 @@
1
- const log = require('./logger');
2
-
3
- function resolveServiceExecutionGraph(config, presetName) {
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 preset.services) {
41
+ for (const serviceName of selection.services) {
13
42
  addService(
14
43
  serviceName,
15
- preset.modes[serviceName],
44
+ modes[serviceName],
16
45
  null,
17
46
  );
18
47
  }
19
48
 
20
- return { dependencies, services };
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, mode, dependentService) {
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
- if (dependentService != null) {
29
- const existingService = services.find(({ name }) => {
30
- return name === serviceName;
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
- if (dependentService == null) {
64
- services.push({
65
- name: serviceName,
66
- mode,
67
- config: serviceConfig,
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.unshift({
71
- name: serviceName,
72
- mode,
73
- config: serviceConfig,
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 Orchestrator = require('./orchestrator');
2
- const { parseCliArgs } = require('./cli-args');
1
+ const { run } = require('./run');
3
2
 
4
- const { presetName, configPath, logLevel, remaining } = parseCliArgs(process.argv.slice(2));
3
+ run(process.argv.slice(2));
5
4
 
6
- process.argv = [process.argv[0], process.argv[1], presetName, ...remaining];
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 };
@@ -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
- async start(presetName) {
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
- presetName,
41
+ selection,
37
42
  );
38
43
 
39
- log.info(bold(`Preset: ${presetName}`));
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 ${presetName} ${serviceArgsKeyword} <service> <args>`);
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/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 };