go-dev 0.6.0 → 0.8.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,342 @@
1
+ const termkit = require('terminal-kit');
2
+ const { savePreset } = require('./save-preset');
3
+ const { resolveServiceExecutionGraph } = require('./dependency-resolver');
4
+ const { loadLastSelection } = require('./last-selection');
5
+ const log = require('./logger');
6
+
7
+ /**
8
+ * Full-screen interactive selection TUI (terminal-kit).
9
+ *
10
+ * Two tabs — "Presets" and "Services & Modes" — let the user either launch an
11
+ * existing preset or compose a custom selection (toggle services, pick a mode
12
+ * for hybrid services), optionally saving it back as a new preset.
13
+ *
14
+ * Runs to completion *before* the orchestrator takes over stdout, then fully
15
+ * restores the terminal. Resolves to a selection `{ name?, services, modes }`,
16
+ * or `null` if the user cancels (q / Esc / Ctrl+C).
17
+ *
18
+ * @param {object} config - the loaded, validated go-dev config.
19
+ * @param {{ configPath: string, presetName?: string }} options
20
+ * @returns {Promise<{ name?: string, services: string[], modes: Record<string,string> } | null>}
21
+ */
22
+ function runInteractive(config, { configPath, presetName } = {}) {
23
+ const term = termkit.terminal;
24
+ const serviceNames = Object.keys(config.services ?? {});
25
+ const presetNames = Object.keys(config.presets ?? {});
26
+
27
+ const isHybrid = (name) => config.services[name].type === 'hybrid';
28
+ const modesFor = (name) => (isHybrid(name) ? Object.keys(config.services[name].modes) : ['dev']);
29
+ const defaultModeFor = (name) =>
30
+ isHybrid(name)
31
+ ? config.services[name].defaultMode ?? modesFor(name)[0] ?? 'dev'
32
+ : 'dev';
33
+
34
+ // --- state ---------------------------------------------------------------
35
+ const selected = new Set();
36
+ const chosenMode = new Map();
37
+ for (const name of serviceNames) chosenMode.set(name, defaultModeFor(name));
38
+
39
+ // Pre-populate the custom selection: from a preset when forced interactive
40
+ // with one, otherwise restore the last launched selection (dropping anything
41
+ // no longer valid in the current config).
42
+ if (presetName && config.presets?.[presetName]) {
43
+ const preset = config.presets[presetName];
44
+ for (const s of preset.services) selected.add(s);
45
+ for (const [s, m] of Object.entries(preset.modes ?? {})) chosenMode.set(s, m);
46
+ } else {
47
+ const last = loadLastSelection(configPath);
48
+ if (last) {
49
+ for (const s of last.services) {
50
+ if (serviceNames.includes(s)) selected.add(s);
51
+ }
52
+ for (const [s, m] of Object.entries(last.modes ?? {})) {
53
+ if (serviceNames.includes(s) && modesFor(s).includes(m)) chosenMode.set(s, m);
54
+ }
55
+ }
56
+ }
57
+
58
+ const TABS = ['Services & Modes', 'Presets'];
59
+ let activeTab = 0; // land on Services & Modes; Tab switches to Presets
60
+ let cursor = 0;
61
+ let message = '';
62
+
63
+ // --- rendering -----------------------------------------------------------
64
+ function drawHeader() {
65
+ term.moveTo(1, 1).styleReset().bold('go-dev')(' — selezione servizi');
66
+ term.moveTo(1, 2);
67
+ TABS.forEach((label, i) => {
68
+ if (i === activeTab) term.bgBrightWhite.black(` ${label} `);
69
+ else term.bgGray.white(` ${label} `);
70
+ term(' ');
71
+ });
72
+ }
73
+
74
+ function drawPresets() {
75
+ term.moveTo(1, 4)('Preset disponibili — ↑/↓ scegli, invio avvia:');
76
+ if (presetNames.length === 0) {
77
+ term.moveTo(3, 6).gray('(nessun preset definito — usa la tab Services & Modes)');
78
+ return;
79
+ }
80
+ presetNames.forEach((name, i) => {
81
+ const marker = i === cursor ? '❯' : ' ';
82
+ const services = (config.presets[name].services ?? []).join(', ');
83
+ term.moveTo(2, 6 + i);
84
+ const text = `${marker} ${name}`;
85
+ if (i === cursor) term.brightCyan(text);
86
+ else term(text);
87
+ term.gray(` services: ${services}`);
88
+ });
89
+ }
90
+
91
+ function drawServices() {
92
+ term.moveTo(1, 4)('Servizi — spazio: on/off, m: modalità, invio: avvia:');
93
+ if (serviceNames.length === 0) {
94
+ term.moveTo(3, 6).gray('(nessun servizio definito)');
95
+ return;
96
+ }
97
+ serviceNames.forEach((name, i) => {
98
+ const marker = i === cursor ? '❯' : ' ';
99
+ const box = selected.has(name) ? '[x]' : '[ ]';
100
+ term.moveTo(2, 6 + i);
101
+ const text = `${marker} ${box} ${name}`;
102
+ if (i === cursor) term.brightCyan(text);
103
+ else term(text);
104
+ const mode = isHybrid(name) ? chosenMode.get(name) : 'dev';
105
+ term.gray(` mode: ${mode}${isHybrid(name) ? ' (m per cambiare)' : ''}`);
106
+ });
107
+ }
108
+
109
+ // Selection the bottom panel previews. On the Services tab it reflects the
110
+ // *checked* services (not the cursor); on the Presets tab, the highlighted preset.
111
+ function panelSelection() {
112
+ if (activeTab === 0) {
113
+ const services = serviceNames.filter((s) => selected.has(s));
114
+ const modes = {};
115
+ for (const s of services) {
116
+ if (isHybrid(s)) modes[s] = chosenMode.get(s);
117
+ }
118
+ return { services, modes };
119
+ }
120
+ const preset = presetNames[cursor];
121
+ if (!preset) return null;
122
+ return { services: config.presets[preset].services, modes: config.presets[preset].modes ?? {} };
123
+ }
124
+
125
+ // Bottom panel: the resolved selection split into labelled sections.
126
+ function drawPanel(top) {
127
+ term.moveTo(1, top).styleReset().gray('─'.repeat(Math.min(term.width, 64)));
128
+
129
+ const selection = panelSelection();
130
+ if (!selection) return;
131
+
132
+ let graph;
133
+ const previousLevel = log.getLogLevel();
134
+ try {
135
+ log.setLogLevel('error'); // silence resolver dedup warnings while previewing
136
+ graph = resolveServiceExecutionGraph(config, selection);
137
+ } catch (error) {
138
+ term.moveTo(3, top + 1).styleReset().red(`⚠ ${error.message}`);
139
+ return;
140
+ } finally {
141
+ log.setLogLevel(previousLevel);
142
+ }
143
+
144
+ let row = top + 1;
145
+ const header = (label) => {
146
+ if (row >= term.height - 1) return;
147
+ term.moveTo(1, row++).styleReset().bold(label);
148
+ };
149
+ const item = (label, style) => {
150
+ if (row >= term.height - 1) return;
151
+ term.moveTo(3, row++).styleReset();
152
+ style(label);
153
+ };
154
+ const blank = () => { row++; };
155
+
156
+ header('servizi principali');
157
+ if (graph.services.length) {
158
+ graph.services.forEach((s) => item(`${s.name}:${s.mode}`, (t) => term.brightWhite(t)));
159
+ } else {
160
+ item(activeTab === 0 ? '(nessun servizio selezionato)' : '(nessuno)', (t) => term.gray(t));
161
+ }
162
+
163
+ blank();
164
+ header('dipendenze');
165
+ if (graph.dependencies.length) {
166
+ graph.dependencies.forEach((d) => item(`${d.name}:${d.mode}`, (t) => term.white(t)));
167
+ } else {
168
+ item('(nessuna)', (t) => term.gray(t));
169
+ }
170
+
171
+ if (graph.conflicts.length) {
172
+ blank();
173
+ if (row < term.height - 1) term.moveTo(1, row++).styleReset().red('⚠ conflitti di modalità');
174
+ for (const { service, requests } of graph.conflicts) {
175
+ const where = requests
176
+ .map((r) => `${r.mode}${r.by ? ` (dip. di ${r.by})` : ' (primario)'}`)
177
+ .join(' vs ');
178
+ item(`${service}: ${where}`, (t) => term.yellow(t));
179
+ }
180
+ item('un solo avvio per servizio — alcune dipendenze restano non soddisfatte', (t) => term.gray(t));
181
+ }
182
+ }
183
+
184
+ function render() {
185
+ term.clear();
186
+ drawHeader();
187
+ if (activeTab === 0) drawServices();
188
+ else drawPresets();
189
+
190
+ const panelTop = 6 + Math.max(serviceNames.length, presetNames.length, 1) + 1;
191
+ drawPanel(panelTop);
192
+
193
+ if (message) {
194
+ term.moveTo(1, term.height - 1).styleReset().yellow(message);
195
+ }
196
+ term.moveTo(1, term.height).styleReset().gray(
197
+ ' ←/→ tab ↑/↓ muovi spazio on/off m modalità invio avvia q esci'
198
+ );
199
+ }
200
+
201
+ // --- helpers -------------------------------------------------------------
202
+ function currentList() {
203
+ return activeTab === 0 ? serviceNames : presetNames;
204
+ }
205
+
206
+ function cycleMode(name) {
207
+ if (!isHybrid(name)) return;
208
+ const modes = modesFor(name);
209
+ const next = (modes.indexOf(chosenMode.get(name)) + 1) % modes.length;
210
+ chosenMode.set(name, modes[next]);
211
+ }
212
+
213
+ function buildCustomSelection() {
214
+ const services = serviceNames.filter((s) => selected.has(s));
215
+ const modes = {};
216
+ for (const s of services) {
217
+ if (isHybrid(s)) modes[s] = chosenMode.get(s);
218
+ }
219
+ return { name: undefined, services, modes };
220
+ }
221
+
222
+ // --- lifecycle -----------------------------------------------------------
223
+ return new Promise((resolve) => {
224
+ let finished = false;
225
+ let inPrompt = false; // true while terminal-kit's save prompts own the screen
226
+
227
+ function cleanup() {
228
+ term.removeListener('key', onKey);
229
+ term.removeListener('resize', onResize);
230
+ term.grabInput(false);
231
+ term.hideCursor(false);
232
+ term.fullscreen(false);
233
+ }
234
+
235
+ // terminal-kit updates term.width/term.height before emitting 'resize';
236
+ // render() recomputes its layout from those, so a full redraw is enough.
237
+ function onResize() {
238
+ if (finished || inPrompt) return;
239
+ render();
240
+ }
241
+
242
+ function finish(result) {
243
+ if (finished) return;
244
+ finished = true;
245
+ cleanup();
246
+ resolve(result);
247
+ }
248
+
249
+ async function confirmCustom() {
250
+ const selection = buildCustomSelection();
251
+ if (selection.services.length === 0) {
252
+ message = 'Seleziona almeno un servizio (spazio).';
253
+ return render();
254
+ }
255
+
256
+ // Hand input over to terminal-kit's prompt helpers for the save flow.
257
+ inPrompt = true;
258
+ term.removeListener('key', onKey);
259
+ term.hideCursor(false);
260
+
261
+ term.moveTo(1, term.height - 2).styleReset().eraseLine();
262
+ term('Salvare questa selezione come preset? [s/N] ');
263
+ const wantsSave = await term.yesOrNo({ yes: ['s', 'y'], no: ['n', 'ENTER', 'ESCAPE'] }).promise;
264
+
265
+ if (wantsSave) {
266
+ term.moveTo(1, term.height - 1).styleReset().eraseLine();
267
+ term('Nome del preset: ');
268
+ const name = ((await term.inputField().promise) || '').trim();
269
+ if (name) {
270
+ try {
271
+ savePreset(configPath, name, selection);
272
+ selection.name = name;
273
+ } catch (error) {
274
+ term.moveTo(1, term.height).styleReset().eraseLine().red(`Errore: ${error.message}`);
275
+ await term.yesOrNo({ yes: ['ENTER', 'y', 's'], no: ['n'] }).promise; // pausa per leggere
276
+ }
277
+ }
278
+ }
279
+
280
+ return finish(selection);
281
+ }
282
+
283
+ function onKey(name) {
284
+ message = '';
285
+
286
+ if (name === 'CTRL_C' || name === 'ESCAPE' || name === 'q') return finish(null);
287
+
288
+ if (name === 'TAB' || name === 'LEFT' || name === 'RIGHT') {
289
+ activeTab = (activeTab + 1) % TABS.length;
290
+ cursor = 0;
291
+ return render();
292
+ }
293
+
294
+ const list = currentList();
295
+ if (name === 'UP') {
296
+ cursor = list.length ? (cursor - 1 + list.length) % list.length : 0;
297
+ return render();
298
+ }
299
+ if (name === 'DOWN') {
300
+ cursor = list.length ? (cursor + 1) % list.length : 0;
301
+ return render();
302
+ }
303
+
304
+ if (activeTab === 1) {
305
+ // Presets tab
306
+ if (name === 'ENTER') {
307
+ const preset = presetNames[cursor];
308
+ if (!preset) return;
309
+ return finish({
310
+ name: preset,
311
+ services: config.presets[preset].services,
312
+ modes: config.presets[preset].modes ?? {},
313
+ });
314
+ }
315
+ return;
316
+ }
317
+
318
+ // Services & Modes tab
319
+ const svc = serviceNames[cursor];
320
+ if (!svc) return;
321
+ if (name === ' ' || name === 'SPACE') {
322
+ if (selected.has(svc)) selected.delete(svc);
323
+ else selected.add(svc);
324
+ return render();
325
+ }
326
+ if (name === 'm') {
327
+ cycleMode(svc);
328
+ return render();
329
+ }
330
+ if (name === 'ENTER') return confirmCustom();
331
+ }
332
+
333
+ term.fullscreen(true);
334
+ term.grabInput(true);
335
+ term.hideCursor(true);
336
+ term.on('key', onKey);
337
+ term.on('resize', onResize);
338
+ render();
339
+ });
340
+ }
341
+
342
+ module.exports = { runInteractive };
@@ -0,0 +1,75 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Where we remember the last launched selection — a single per-user file,
6
+ // keyed by config path, kept OUTSIDE the consumer's repo so it never shows up
7
+ // in their working tree. Follows XDG state on Linux/macOS, LOCALAPPDATA on
8
+ // Windows.
9
+ function stateDir() {
10
+ if (process.platform === 'win32') {
11
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
12
+ return path.join(base, 'go-dev');
13
+ }
14
+ const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
15
+ return path.join(base, 'go-dev');
16
+ }
17
+
18
+ function stateFile() {
19
+ return path.join(stateDir(), 'last-selections.json');
20
+ }
21
+
22
+ function readAll() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(stateFile(), 'utf8')) || {};
25
+ } catch {
26
+ return {}; // missing or corrupt — start fresh
27
+ }
28
+ }
29
+
30
+ // Key by the canonical absolute path of the config file, so the same file
31
+ // reached via a relative path, a symlink, or `..` always maps to one entry.
32
+ function keyFor(configPath) {
33
+ const absolute = path.resolve(configPath);
34
+ try {
35
+ return fs.realpathSync(absolute);
36
+ } catch {
37
+ return absolute; // file not resolvable (shouldn't happen for a loaded config)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Returns the last selection launched against this config, or null.
43
+ * @param {string} configPath
44
+ * @returns {{ name?: string, services: string[], modes: Record<string,string> } | null}
45
+ */
46
+ function loadLastSelection(configPath) {
47
+ if (!configPath) return null;
48
+ const entry = readAll()[keyFor(configPath)];
49
+ if (!entry || !Array.isArray(entry.services)) return null;
50
+ return { name: entry.name, services: entry.services, modes: entry.modes ?? {} };
51
+ }
52
+
53
+ /**
54
+ * Persists the last launched selection for this config. Best-effort: never lets
55
+ * a persistence failure (e.g. read-only home) break a launch.
56
+ * @param {string} configPath
57
+ * @param {{ name?: string, services: string[], modes?: Record<string,string> }} selection
58
+ */
59
+ function saveLastSelection(configPath, selection) {
60
+ if (!configPath || !selection) return;
61
+ try {
62
+ const all = readAll();
63
+ all[keyFor(configPath)] = {
64
+ name: selection.name,
65
+ services: selection.services,
66
+ modes: selection.modes ?? {},
67
+ };
68
+ fs.mkdirSync(stateDir(), { recursive: true });
69
+ fs.writeFileSync(stateFile(), JSON.stringify(all, null, 2), 'utf8');
70
+ } catch {
71
+ // ignore — remembering the selection is a convenience, not a requirement
72
+ }
73
+ }
74
+
75
+ module.exports = { loadLastSelection, saveLastSelection, stateFile };