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/README.md +19 -2
- 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 +1 -1
- package/src/dependency-resolver.js +72 -52
- package/src/index.js +3 -7
- package/src/interactive.js +342 -0
- package/src/last-selection.js +75 -0
- package/src/orchestrator.js +21 -5
- package/src/run.js +62 -0
- package/src/save-preset.js +103 -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
|
@@ -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,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 };
|