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/README.md
CHANGED
|
@@ -14,10 +14,11 @@ In complex monorepos, starting your development environment can be a chore. You
|
|
|
14
14
|
|
|
15
15
|
* **Unified Configuration:** Define all your services, their modes (e.g., `dev`, `docker`, `serve`), and dependencies in a single `go-dev.yml` file.
|
|
16
16
|
* **Service Types:**
|
|
17
|
-
* **`cmd` services:** Run any command-line process (e.g., `npm run dev`, `rollup -w`, `python app.py`). Supports `preCommands` for setup tasks like builds. Commands can be defined in multiple flexible ways to run single or multiple processes in parallel for a service.
|
|
17
|
+
* **`cmd` services:** Run any command-line process (e.g., `npm run dev`, `rollup -w`, `python app.py`). Supports `preCommands` for setup tasks like builds, and `readyWhen` to hold back dependents until the service is actually usable (log match, file, or open port). Commands can be defined in multiple flexible ways to run single or multiple processes in parallel for a service.
|
|
18
18
|
* **`docker` services:** Manage Docker containers via `docker compose`. Automatically checks container status and performs health checks.
|
|
19
19
|
* **Mode-Aware Dependencies:** Services can depend on other services running in specific modes (e.g., your `api` dev mode might depend on `frontend` in `serve` mode).
|
|
20
20
|
* **Preset-Driven Startup:** Define different "presets" (e.g., `api`, `frontend`, `all`) to easily spin up specific combinations of services tailored to your current development focus.
|
|
21
|
+
* **Interactive Selection (no preset required):** Run `go-dev` with no preset to open a full-screen TUI where you can pick a preset *or* compose a custom selection — toggle services and choose a mode per service — and optionally save that selection as a new preset. Presets become a convenience, not a requirement.
|
|
21
22
|
* **Automatic Dependency Resolution:** `go-dev` builds an intelligent execution graph, starting services in the correct topological order.
|
|
22
23
|
* **Centralized Logging:** Prefixes logs from each service, making it easy to follow activity from multiple concurrent processes.
|
|
23
24
|
* **Automatic Process Exit:** The `go-dev` process will automatically exit when all primary services (those directly listed in the chosen preset) exit cleanly (with a success code of `0`).
|
|
@@ -48,11 +49,25 @@ yarn add --dev go-dev
|
|
|
48
49
|
Once installed, simply run `go-dev` with the name of the preset you want to start:
|
|
49
50
|
|
|
50
51
|
```bash
|
|
51
|
-
npx go-dev
|
|
52
|
+
npx go-dev [preset_name] [-c|--config <path>] [-i|--interactive]
|
|
52
53
|
```
|
|
53
54
|
|
|
54
|
-
*
|
|
55
|
+
* `[preset_name]`: (Optional) The name of the preset defined in your `go-dev.yml` (e.g., `api`, `frontend`, `all`). When omitted, `go-dev` opens the interactive selector (see below).
|
|
55
56
|
* `-c <path>` / `--config <path>` (also `-c=<path>` / `--config=<path>`): (Optional) Path to your `go-dev.yml` file. When omitted, `go-dev` auto-discovers a config file in the current directory (see the **Configuration** section below for the lookup order). The flag must appear before any `--args-for` block.
|
|
57
|
+
* `-i` / `--interactive`: (Optional) Force the interactive selector even when a preset is given, pre-populating it from that preset so you can tweak the selection before starting.
|
|
58
|
+
|
|
59
|
+
### Interactive selection
|
|
60
|
+
|
|
61
|
+
Running `go-dev` **without a preset** (in an interactive terminal) opens a full-screen TUI with two tabs:
|
|
62
|
+
|
|
63
|
+
* **Services & Modes** — toggle services with <kbd>Space</kbd>, cycle the mode of a hybrid service with <kbd>m</kbd>, then press <kbd>Enter</kbd>. You'll be offered to save the selection as a new preset (written back to your config, preserving comments).
|
|
64
|
+
* **Presets** — pick an existing preset and press <kbd>Enter</kbd> to start it.
|
|
65
|
+
|
|
66
|
+
A panel at the bottom shows the **resolved selection** split into sections — *primary services* and *dependencies* (each with its mode) — so you can see exactly what will start. On the **Services & Modes** tab it reflects the services you've checked; on the **Presets** tab, the highlighted preset.
|
|
67
|
+
|
|
68
|
+
If the same service is pulled in under **two different modes** (e.g. `keplero:build` as a primary while another service depends on `keplero:dev`), the panel flags a *mode conflict*: go-dev runs one instance per service, so the losing mode is dropped and that dependency goes unmet. The same warning is printed at startup, so it's visible even when launching a preset by name without the TUI.
|
|
69
|
+
|
|
70
|
+
Navigate tabs with <kbd>←</kbd>/<kbd>→</kbd>, move with <kbd>↑</kbd>/<kbd>↓</kbd>, and quit with <kbd>q</kbd>. When stdin is not a TTY (e.g. CI) and no preset is given, `go-dev` exits with an error instead of opening the TUI.
|
|
56
71
|
|
|
57
72
|
**Passing Arguments to Service Commands:**
|
|
58
73
|
|
|
@@ -174,6 +189,17 @@ services:
|
|
|
174
189
|
commands:
|
|
175
190
|
command: [npx, rollup, -c, -w]
|
|
176
191
|
directory: ./frontend
|
|
192
|
+
# 'readyWhen' holds back dependents until this (long-running) service is
|
|
193
|
+
# actually usable, instead of resolving as soon as the process spawns.
|
|
194
|
+
# This is the watch-mode counterpart of docker's 'healthCheck': prefer it
|
|
195
|
+
# over building shared artifacts again as a preCommand of every consumer.
|
|
196
|
+
# Provide at least one condition (multiple are combined with AND):
|
|
197
|
+
# logMatch: "<regex>" — ready when a line on stdout/stderr matches
|
|
198
|
+
# file: ./dist/index.js — ready when the path exists on disk
|
|
199
|
+
# port: 5173 — ready when a TCP connection succeeds
|
|
200
|
+
# Optional: host (default 127.0.0.1), timeoutMs (60000), pollIntervalMs (500).
|
|
201
|
+
readyWhen:
|
|
202
|
+
logMatch: "created .* in" # rollup's "created dist/... in 1.2s"
|
|
177
203
|
dependencies:
|
|
178
204
|
# Frontend dev needs API (will use api's default docker mode for this preset)
|
|
179
205
|
# Note: No direct circular dependency between dev modes.
|
package/bin/go-dev
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const path = require('path');
|
|
3
|
+
const { run } = require('../src/run');
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const { presetName, configPath, logLevel, remaining } = parseCliArgs(process.argv.slice(2));
|
|
9
|
-
|
|
10
|
-
if (!presetName) {
|
|
11
|
-
console.error('Error: Please specify a preset to run. Usage: go-dev <preset_name> [-c|--config <path>] [-l|--log-level <level>] [--args-for ...]');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
process.argv = [process.argv[0], process.argv[1], presetName, ...remaining];
|
|
16
|
-
|
|
17
|
-
const orchestrator = new Orchestrator(configPath, { logLevel });
|
|
18
|
-
|
|
19
|
-
orchestrator.start(presetName);
|
|
5
|
+
run(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "go-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"main": "src/index.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"go-dev": "bin/go-dev"
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"description": "",
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"joi": "^17.13.3",
|
|
20
|
-
"js-yaml": "^4.1.0"
|
|
20
|
+
"js-yaml": "^4.1.0",
|
|
21
|
+
"terminal-kit": "^3.1.2"
|
|
21
22
|
}
|
|
22
23
|
}
|
package/spike/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Spike TUI — terminal-kit vs OpenTUI
|
|
2
|
+
|
|
3
|
+
Prototipo comparativo per la TUI full-screen unificata di go-dev (schermata di
|
|
4
|
+
selezione a tab → viewer log streaming). Obiettivo: scegliere la libreria di
|
|
5
|
+
base sui **fatti**, non sulle opinioni.
|
|
6
|
+
|
|
7
|
+
Entrambi gli spike implementano le **stesse due schermate**, alimentate dalla
|
|
8
|
+
**stessa** sorgente di log fasulla ad alto volume (`shared/fake-log-source.js`,
|
|
9
|
+
~350 righe/s, mix servizi + libreria/`core`):
|
|
10
|
+
|
|
11
|
+
1. **SELECT** — due tab (`Presets` / `Services & Modes`).
|
|
12
|
+
2. **LOGS** — pannello scrollabile con filtro libreria-vs-servizi (`f`, `1-4`, `a`).
|
|
13
|
+
|
|
14
|
+
## Come eseguire
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# interattivo (serve un vero terminale)
|
|
18
|
+
cd terminal-kit && npm install && npm start
|
|
19
|
+
cd opentui && npm install && npm start # ⚠️ richiede Bun, vedi sotto
|
|
20
|
+
|
|
21
|
+
# smoke test non interattivo (sotto pseudo-TTY, auto-exit dopo ~1.5s)
|
|
22
|
+
script -qec "stty rows 40 cols 120; node index.js --smoke" /dev/null # terminal-kit
|
|
23
|
+
script -qec "stty rows 40 cols 120; bun index.js --smoke" /dev/null # opentui
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Risultati misurati (Node 22.22, Linux x64)
|
|
27
|
+
|
|
28
|
+
| Criterio | terminal-kit | OpenTUI (`@opentui/core` 0.4) |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Gira su Node.js** | ✅ `received=480`, ok | ❌ **fallisce all'init** |
|
|
31
|
+
| **Runtime richiesto** | Node (JS puro) | **Bun** (`received=498`, ok solo con `bun`) |
|
|
32
|
+
| Tab nativi | ❌ fatti a mano (~15 righe) | ✅ `TabSelectRenderable` (con descrizioni/underline) |
|
|
33
|
+
| Log scrollabili nativi | ✅ `TextBox` + `appendLog` | ✅ `ScrollBoxRenderable` (stickyScroll bottom) |
|
|
34
|
+
| LOC dello shell | 161 | 137 (tab gratis dal widget) |
|
|
35
|
+
| Footprint installato | **4.2 MB**, pure JS, 0 binari | **18 MB**, include `libopentui.so` per-piattaforma |
|
|
36
|
+
| Cross-platform | ovunque giri Node | binario nativo per OS/arch (linux/darwin/win32 x64+arm64 presenti su npm) |
|
|
37
|
+
|
|
38
|
+
## Il blocco decisivo: OpenTUI richiede Bun
|
|
39
|
+
|
|
40
|
+
OpenTUI renderizza tramite **FFI nativo**. Il backend FFI nel bundle fa così:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
if (isBun) return createBunBackend(require("bun:ffi")); // ok
|
|
44
|
+
try { return createNodeBackend(require("node:ffi")); } // node:ffi NON ESISTE
|
|
45
|
+
catch { return createUnsupportedBackend(error); } // -> throw all'uso
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`node:ffi` **non è un modulo di Node** (`ERR_UNKNOWN_BUILTIN_MODULE`), quindi
|
|
49
|
+
sotto Node il render lib non si inizializza e `createCliRenderer()` lancia:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Error: Failed to initialize OpenTUI render library:
|
|
53
|
+
OpenTUI native FFI is not available for this runtime yet
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Sotto **Bun** lo stesso identico codice funziona e renderizza benissimo (tab con
|
|
57
|
+
descrizioni, underline, viewer log pinnato in fondo). La pagina "getting started"
|
|
58
|
+
dice che puoi *importare* `@opentui/core` da Node — ed è vero — ma *renderizzare*
|
|
59
|
+
no: serve Bun.
|
|
60
|
+
|
|
61
|
+
## Conclusione / raccomandazione
|
|
62
|
+
|
|
63
|
+
Per go-dev **così com'è (CLI Node.js)** la scelta è **terminal-kit**:
|
|
64
|
+
|
|
65
|
+
- è l'unico dei due che **gira sul runtime del progetto** senza migrazioni;
|
|
66
|
+
- JS puro, zero binari nativi → portabilità Windows inclusa, in linea con
|
|
67
|
+
l'attenzione cross-platform già presente in `src/process-manager.js`;
|
|
68
|
+
- copre il pezzo difficile (log streaming scrollabili) con `TextBox.appendLog`;
|
|
69
|
+
- costo extra reale e contenuto: i **tab non sono nativi** e vanno disegnati a
|
|
70
|
+
mano (qui ~15 righe). Tutto il resto è coperto.
|
|
71
|
+
|
|
72
|
+
OpenTUI resta tecnicamente superiore per ergonomia widget (tab nativi, layout
|
|
73
|
+
flex, frame sub-ms) **ma solo se go-dev adottasse Bun come runtime** — decisione
|
|
74
|
+
ben più grande e fuori dallo scope di "migliorare la fruibilità con una TUI".
|
|
75
|
+
|
|
76
|
+
> Da rivalutare se/quando OpenTUI abiliterà il backend FFI per Node
|
|
77
|
+
> ("...not available for this runtime **yet**").
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// go-dev TUI spike — OpenTUI core imperative API (non-React, Zig-backed).
|
|
2
|
+
//
|
|
3
|
+
// Same two screens as the terminal-kit spike, so the two are comparable:
|
|
4
|
+
// 1. SELECT — a native TabSelectRenderable ("Presets" / "Services & Modes")
|
|
5
|
+
// + a body Text. This is OpenTUI's selling point: tabs are a
|
|
6
|
+
// first-class widget, not hand-rolled.
|
|
7
|
+
// 2. LOGS — a native ScrollBoxRenderable with stickyScroll pinned to the
|
|
8
|
+
// bottom, fed by the same high-volume fake source, with the same
|
|
9
|
+
// library-vs-service filter.
|
|
10
|
+
//
|
|
11
|
+
// Run interactively: npm start
|
|
12
|
+
// Smoke test (PTY): node index.js --smoke
|
|
13
|
+
//
|
|
14
|
+
// ESM + top-level await: @opentui/core is ESM-only (note "type":"module").
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import {
|
|
18
|
+
createCliRenderer,
|
|
19
|
+
BoxRenderable,
|
|
20
|
+
TextRenderable,
|
|
21
|
+
ScrollBoxRenderable,
|
|
22
|
+
TabSelectRenderable,
|
|
23
|
+
} from '@opentui/core';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { FakeLogSource, SERVICES } = require('../shared/fake-log-source');
|
|
27
|
+
|
|
28
|
+
const SMOKE = process.argv.includes('--smoke');
|
|
29
|
+
|
|
30
|
+
// ---- shared log model (the "log bus") --------------------------------------
|
|
31
|
+
const RING_CAP = 2000;
|
|
32
|
+
const ring = [];
|
|
33
|
+
let showCore = true;
|
|
34
|
+
let serviceFilter = null;
|
|
35
|
+
let received = 0;
|
|
36
|
+
|
|
37
|
+
function visibleLines() {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const e of ring) {
|
|
40
|
+
if (e.kind === 'core' && !showCore) continue;
|
|
41
|
+
if (serviceFilter && e.kind === 'service' && e.source !== serviceFilter) continue;
|
|
42
|
+
const tag = e.kind === 'core' ? '[core]' : `[${e.source}]`;
|
|
43
|
+
out.push(`${tag} ${e.level} ${e.line}`);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- renderer + UI ----------------------------------------------------------
|
|
49
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 });
|
|
50
|
+
const root = renderer.root;
|
|
51
|
+
|
|
52
|
+
let screen = 'select';
|
|
53
|
+
const PRESETS = ['api', 'dedup', 'basic'];
|
|
54
|
+
|
|
55
|
+
const header = new TextRenderable(renderer, { content: '', height: 1 });
|
|
56
|
+
root.add(header);
|
|
57
|
+
|
|
58
|
+
// SELECT view ---------------------------------------------------------------
|
|
59
|
+
const selectView = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'column', padding: 1 });
|
|
60
|
+
const tabs = new TabSelectRenderable(renderer, {
|
|
61
|
+
height: 3,
|
|
62
|
+
options: [
|
|
63
|
+
{ name: 'Presets', description: 'Avvia da un preset esistente' },
|
|
64
|
+
{ name: 'Services & Modes', description: 'Componi una selezione personalizzata' },
|
|
65
|
+
],
|
|
66
|
+
showDescription: true,
|
|
67
|
+
});
|
|
68
|
+
const selectBody = new TextRenderable(renderer, { content: '', flexGrow: 1 });
|
|
69
|
+
selectView.add(tabs);
|
|
70
|
+
selectView.add(selectBody);
|
|
71
|
+
root.add(selectView);
|
|
72
|
+
|
|
73
|
+
// LOGS view -----------------------------------------------------------------
|
|
74
|
+
const logsView = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'column', visible: false });
|
|
75
|
+
const scroll = new ScrollBoxRenderable(renderer, {
|
|
76
|
+
flexGrow: 1,
|
|
77
|
+
stickyScroll: true,
|
|
78
|
+
stickyStart: 'bottom',
|
|
79
|
+
border: true,
|
|
80
|
+
});
|
|
81
|
+
const logText = new TextRenderable(renderer, { content: '' });
|
|
82
|
+
scroll.content.add(logText);
|
|
83
|
+
logsView.add(scroll);
|
|
84
|
+
root.add(logsView);
|
|
85
|
+
|
|
86
|
+
const footer = new TextRenderable(renderer, { content: '', height: 1 });
|
|
87
|
+
root.add(footer);
|
|
88
|
+
|
|
89
|
+
// ---- rendering --------------------------------------------------------------
|
|
90
|
+
function renderSelect() {
|
|
91
|
+
const tab = tabs.getSelectedIndex();
|
|
92
|
+
header.content = 'go-dev — selezione servizi';
|
|
93
|
+
if (tab === 0) {
|
|
94
|
+
selectBody.content =
|
|
95
|
+
'Preset disponibili (invio per avviare):\n\n' +
|
|
96
|
+
PRESETS.map((p) => ` • ${p}`).join('\n');
|
|
97
|
+
} else {
|
|
98
|
+
selectBody.content =
|
|
99
|
+
'Servizi disponibili (spazio = on/off, m = modalità):\n\n' +
|
|
100
|
+
SERVICES.map((s) => ` [x] ${s} mode: dev`).join('\n');
|
|
101
|
+
}
|
|
102
|
+
footer.content = ' ←/→ cambia tab invio: vai ai log q: esci';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderLogs() {
|
|
106
|
+
header.content =
|
|
107
|
+
`go-dev · logs core:${showCore ? 'ON' : 'OFF'}` +
|
|
108
|
+
` filtro:${serviceFilter ?? 'tutti'} (${received} righe)`;
|
|
109
|
+
logText.content = visibleLines().join('\n');
|
|
110
|
+
footer.content =
|
|
111
|
+
' f: libreria on/off 1-4: filtra servizio a: tutti ↑↓/pgup: scorri b: indietro q: esci';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function showSelect() {
|
|
115
|
+
screen = 'select';
|
|
116
|
+
logsView.visible = false;
|
|
117
|
+
selectView.visible = true;
|
|
118
|
+
renderSelect();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showLogs() {
|
|
122
|
+
screen = 'logs';
|
|
123
|
+
selectView.visible = false;
|
|
124
|
+
logsView.visible = true;
|
|
125
|
+
renderLogs();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- input ------------------------------------------------------------------
|
|
129
|
+
renderer.keyInput.on('keypress', (key) => {
|
|
130
|
+
const n = key.name;
|
|
131
|
+
if (n === 'q' || (key.ctrl && n === 'c')) return shutdown(0);
|
|
132
|
+
|
|
133
|
+
if (screen === 'select') {
|
|
134
|
+
if (n === 'left') { tabs.moveLeft(); renderSelect(); }
|
|
135
|
+
else if (n === 'right') { tabs.moveRight(); renderSelect(); }
|
|
136
|
+
else if (n === 'return' || n === 'enter') showLogs();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (n) {
|
|
141
|
+
case 'f': showCore = !showCore; renderLogs(); break;
|
|
142
|
+
case 'a': case '0': serviceFilter = null; renderLogs(); break;
|
|
143
|
+
case '1': case '2': case '3': case '4':
|
|
144
|
+
serviceFilter = SERVICES[Number(n) - 1] || null; renderLogs(); break;
|
|
145
|
+
case 'up': scroll.scrollBy(-1); break;
|
|
146
|
+
case 'down': scroll.scrollBy(1); break;
|
|
147
|
+
case 'pageup': scroll.scrollBy(-scroll.height); break;
|
|
148
|
+
case 'pagedown': scroll.scrollBy(scroll.height); break;
|
|
149
|
+
case 'b': showSelect(); break;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---- lifecycle --------------------------------------------------------------
|
|
154
|
+
const source = new FakeLogSource({ intervalMs: 8, burst: 3 });
|
|
155
|
+
source.on('log', (e) => {
|
|
156
|
+
received++;
|
|
157
|
+
ring.push(e);
|
|
158
|
+
if (ring.length > RING_CAP) ring.shift();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let renderTimer = setInterval(() => { if (screen === 'logs') renderLogs(); }, 33);
|
|
162
|
+
|
|
163
|
+
function shutdown(code) {
|
|
164
|
+
source.stop();
|
|
165
|
+
clearInterval(renderTimer);
|
|
166
|
+
try { renderer.stop(); } catch {}
|
|
167
|
+
try { renderer.destroy?.(); } catch {}
|
|
168
|
+
if (SMOKE) console.log(`[smoke] opentui ok — received=${received} ring=${ring.length}`);
|
|
169
|
+
setTimeout(() => process.exit(code), 50);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
showSelect();
|
|
173
|
+
renderer.start();
|
|
174
|
+
source.start();
|
|
175
|
+
|
|
176
|
+
if (SMOKE) {
|
|
177
|
+
setTimeout(showLogs, 300);
|
|
178
|
+
setTimeout(() => { showCore = false; }, 700);
|
|
179
|
+
setTimeout(() => { serviceFilter = SERVICES[0]; }, 1000);
|
|
180
|
+
setTimeout(() => shutdown(0), 1500);
|
|
181
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spike-opentui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"description": "go-dev TUI spike — OpenTUI core (non-React, Zig-backed)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@opentui/core": "latest"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared, dependency-free high-volume log generator used by both spikes.
|
|
4
|
+
// Mirrors what go-dev's future "log bus" would emit: tagged events
|
|
5
|
+
// { source, kind, level, line } where kind is 'service' or 'core' (library).
|
|
6
|
+
//
|
|
7
|
+
// The point of the spike is to stress the TUI's log pane: many lines per
|
|
8
|
+
// second, interleaved sources, so we can judge streaming smoothness.
|
|
9
|
+
|
|
10
|
+
const { EventEmitter } = require('node:events');
|
|
11
|
+
|
|
12
|
+
const SERVICES = ['api', 'worker', 'main', 'db'];
|
|
13
|
+
const LEVELS = ['info', 'info', 'info', 'warn', 'debug', 'error'];
|
|
14
|
+
|
|
15
|
+
const SERVICE_LINES = [
|
|
16
|
+
'GET /health 200 1ms',
|
|
17
|
+
'processing job %d',
|
|
18
|
+
'cache miss for key user:%d',
|
|
19
|
+
'flush batch size=%d',
|
|
20
|
+
'connection pool: %d active',
|
|
21
|
+
'emitted event order.created id=%d',
|
|
22
|
+
'slow query took %dms',
|
|
23
|
+
'retrying upstream (attempt %d)',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const CORE_LINES = [
|
|
27
|
+
'resolved dependency graph (%d nodes)',
|
|
28
|
+
'service ready gate satisfied',
|
|
29
|
+
'spawned managed process pid=%d',
|
|
30
|
+
'readyWhen: port check ok',
|
|
31
|
+
'preCommand finished in %dms',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Deterministic-ish pseudo counter so we don't need Math.random (and so output
|
|
35
|
+
// is reproducible run-to-run, which makes comparing the two spikes fairer).
|
|
36
|
+
let seq = 0;
|
|
37
|
+
function nextInt(mod) {
|
|
38
|
+
seq = (seq * 1103515245 + 12345) & 0x7fffffff;
|
|
39
|
+
return seq % mod;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fmt(template) {
|
|
43
|
+
return template.replace('%d', String(nextInt(9000) + 100));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class FakeLogSource extends EventEmitter {
|
|
47
|
+
constructor({ intervalMs = 8, burst = 3 } = {}) {
|
|
48
|
+
super();
|
|
49
|
+
this.intervalMs = intervalMs; // how often we emit a burst
|
|
50
|
+
this.burst = burst; // lines per burst -> ~burst/intervalMs * 1000 lines/s
|
|
51
|
+
this._timer = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start() {
|
|
55
|
+
if (this._timer) return;
|
|
56
|
+
this._timer = setInterval(() => {
|
|
57
|
+
for (let i = 0; i < this.burst; i++) this._emitOne();
|
|
58
|
+
}, this.intervalMs);
|
|
59
|
+
// ~375 lines/s at defaults — enough to expose flicker/lag.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stop() {
|
|
63
|
+
if (this._timer) clearInterval(this._timer);
|
|
64
|
+
this._timer = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_emitOne() {
|
|
68
|
+
const isCore = nextInt(6) === 0; // ~1 in 6 lines are library/core logs
|
|
69
|
+
const level = LEVELS[nextInt(LEVELS.length)];
|
|
70
|
+
if (isCore) {
|
|
71
|
+
this.emit('log', {
|
|
72
|
+
source: 'core',
|
|
73
|
+
kind: 'core',
|
|
74
|
+
level,
|
|
75
|
+
line: fmt(CORE_LINES[nextInt(CORE_LINES.length)]),
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
const service = SERVICES[nextInt(SERVICES.length)];
|
|
79
|
+
this.emit('log', {
|
|
80
|
+
source: service,
|
|
81
|
+
kind: 'service',
|
|
82
|
+
level,
|
|
83
|
+
line: fmt(SERVICE_LINES[nextInt(SERVICE_LINES.length)]),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { FakeLogSource, SERVICES };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// go-dev TUI spike — terminal-kit (pure JS, non-React).
|
|
4
|
+
//
|
|
5
|
+
// Two screens, mirroring the real design:
|
|
6
|
+
// 1. SELECT — two hand-rolled tabs ("Presets" / "Services"). terminal-kit has
|
|
7
|
+
// no native tab widget, so we draw the tab bar ourselves. This is
|
|
8
|
+
// an honest data point on the per-feature cost.
|
|
9
|
+
// 2. LOGS — a scrollable TextBox fed by a high-volume fake source, with a
|
|
10
|
+
// library-vs-service filter. TextBox + appendLog/setContent does
|
|
11
|
+
// the heavy lifting here, which is terminal-kit's strong suit.
|
|
12
|
+
//
|
|
13
|
+
// Run interactively: npm start
|
|
14
|
+
// Smoke test (CI/PTY): node index.js --smoke (build UI, pump logs, auto-exit)
|
|
15
|
+
|
|
16
|
+
const termkit = require('terminal-kit');
|
|
17
|
+
const term = termkit.terminal;
|
|
18
|
+
const { FakeLogSource, SERVICES } = require('../shared/fake-log-source');
|
|
19
|
+
|
|
20
|
+
const SMOKE = process.argv.includes('--smoke');
|
|
21
|
+
|
|
22
|
+
// ---- shared log model (this is the "log bus" go-dev would grow) -------------
|
|
23
|
+
const RING_CAP = 2000;
|
|
24
|
+
const ring = []; // { source, kind, level, line }
|
|
25
|
+
let showCore = true; // 'f' toggles library/core logs
|
|
26
|
+
let serviceFilter = null; // 1-4 -> a single service; null = all services
|
|
27
|
+
let pinnedToBottom = true; // false once the user scrolls up
|
|
28
|
+
let received = 0;
|
|
29
|
+
|
|
30
|
+
function levelColor(level) {
|
|
31
|
+
switch (level) {
|
|
32
|
+
case 'error': return '^r';
|
|
33
|
+
case 'warn': return '^y';
|
|
34
|
+
case 'debug': return '^K';
|
|
35
|
+
default: return '^g';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function visibleLines() {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const e of ring) {
|
|
42
|
+
if (e.kind === 'core' && !showCore) continue;
|
|
43
|
+
if (serviceFilter && e.kind === 'service' && e.source !== serviceFilter) continue;
|
|
44
|
+
const tag = e.kind === 'core' ? '^c[core]^:' : `^b[${e.source}]^:`;
|
|
45
|
+
out.push(`${tag} ${levelColor(e.level)}${e.level}^: ${e.line}`);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- UI ---------------------------------------------------------------------
|
|
51
|
+
let screen = 'select'; // 'select' | 'logs'
|
|
52
|
+
let activeTab = 0; // 0 = Presets, 1 = Services
|
|
53
|
+
const TABS = ['Presets', 'Services & Modes'];
|
|
54
|
+
const PRESETS = ['api', 'dedup', 'basic'];
|
|
55
|
+
|
|
56
|
+
let document, logBox, headerText, bodyText, footerText;
|
|
57
|
+
|
|
58
|
+
function buildUI() {
|
|
59
|
+
term.fullscreen(true);
|
|
60
|
+
document = term.createDocument();
|
|
61
|
+
|
|
62
|
+
headerText = new termkit.Text({ parent: document, x: 0, y: 0, content: '', contentHasMarkup: true });
|
|
63
|
+
bodyText = new termkit.Text({ parent: document, x: 0, y: 2, content: '', contentHasMarkup: true });
|
|
64
|
+
|
|
65
|
+
logBox = new termkit.TextBox({
|
|
66
|
+
parent: document,
|
|
67
|
+
x: 0, y: 2,
|
|
68
|
+
width: term.width,
|
|
69
|
+
height: term.height - 4,
|
|
70
|
+
scrollable: true,
|
|
71
|
+
vScrollBar: true,
|
|
72
|
+
contentHasMarkup: true,
|
|
73
|
+
hidden: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
footerText = new termkit.Text({
|
|
77
|
+
parent: document,
|
|
78
|
+
x: 0, y: term.height - 1,
|
|
79
|
+
content: '', contentHasMarkup: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
renderSelect();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderSelect() {
|
|
86
|
+
const tabBar = TABS.map((t, i) =>
|
|
87
|
+
i === activeTab ? `^#^k ${t} ^:` : `^K ${t} ^:`
|
|
88
|
+
).join(' ');
|
|
89
|
+
headerText.setContent(`^+go-dev^: ${tabBar}`, true);
|
|
90
|
+
|
|
91
|
+
let body;
|
|
92
|
+
if (activeTab === 0) {
|
|
93
|
+
body = PRESETS.length
|
|
94
|
+
? ' Preset disponibili (↑↓ + invio per avviare):\n\n' +
|
|
95
|
+
PRESETS.map(p => ` ^b•^: ${p}`).join('\n')
|
|
96
|
+
: ' Nessun preset definito — usa la tab Services.';
|
|
97
|
+
} else {
|
|
98
|
+
body = ' Servizi disponibili (spazio per (de)selezionare, m per modalità):\n\n' +
|
|
99
|
+
SERVICES.map(s => ` ^g[x]^: ${s} ^Kmode: dev^:`).join('\n');
|
|
100
|
+
}
|
|
101
|
+
bodyText.setContent(body, true);
|
|
102
|
+
footerText.setContent('^K tab^: cambia tab ^K invio^: vai ai log ^K q^: esci', true);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderLogs() {
|
|
106
|
+
headerText.setContent(
|
|
107
|
+
`^+go-dev · logs^: core:${showCore ? '^gON^:' : '^rOFF^:'}` +
|
|
108
|
+
` filtro:${serviceFilter ? '^b' + serviceFilter + '^:' : 'tutti'}` +
|
|
109
|
+
` ^K(${received} righe)^:`,
|
|
110
|
+
true
|
|
111
|
+
);
|
|
112
|
+
const lines = visibleLines();
|
|
113
|
+
logBox.setContent(lines.join('\n'), true, true);
|
|
114
|
+
if (pinnedToBottom) logBox.scrollToBottom(true);
|
|
115
|
+
logBox.draw();
|
|
116
|
+
footerText.setContent(
|
|
117
|
+
'^K f^: libreria on/off ^K 1-4^: filtra servizio ^K a^: tutti ' +
|
|
118
|
+
'^K ↑↓/pgup^: scorri ^K g/G^: cima/fondo ^K b^: indietro ^K q^: esci',
|
|
119
|
+
true
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function goToLogs() {
|
|
124
|
+
screen = 'logs';
|
|
125
|
+
bodyText.hide();
|
|
126
|
+
logBox.show();
|
|
127
|
+
logBox.y = 2;
|
|
128
|
+
logBox.outerHeight = term.height - 4;
|
|
129
|
+
renderLogs();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function goToSelect() {
|
|
133
|
+
screen = 'select';
|
|
134
|
+
logBox.hide();
|
|
135
|
+
bodyText.show();
|
|
136
|
+
renderSelect();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- input ------------------------------------------------------------------
|
|
140
|
+
function onKey(name) {
|
|
141
|
+
if (name === 'CTRL_C' || name === 'q') return shutdown(0);
|
|
142
|
+
|
|
143
|
+
if (screen === 'select') {
|
|
144
|
+
if (name === 'TAB' || name === 'RIGHT') { activeTab = (activeTab + 1) % TABS.length; renderSelect(); }
|
|
145
|
+
else if (name === 'LEFT') { activeTab = (activeTab + TABS.length - 1) % TABS.length; renderSelect(); }
|
|
146
|
+
else if (name === 'ENTER') goToLogs();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// logs screen
|
|
151
|
+
switch (name) {
|
|
152
|
+
case 'f': showCore = !showCore; renderLogs(); break;
|
|
153
|
+
case 'a': case '0': serviceFilter = null; renderLogs(); break;
|
|
154
|
+
case '1': case '2': case '3': case '4':
|
|
155
|
+
serviceFilter = SERVICES[Number(name) - 1] || null; renderLogs(); break;
|
|
156
|
+
case 'UP': pinnedToBottom = false; logBox.scroll(0, 1); break;
|
|
157
|
+
case 'DOWN': logBox.scroll(0, -1); break;
|
|
158
|
+
case 'PAGE_UP': pinnedToBottom = false; logBox.scroll(0, logBox.outerHeight); break;
|
|
159
|
+
case 'PAGE_DOWN': logBox.scroll(0, -logBox.outerHeight); break;
|
|
160
|
+
case 'g': pinnedToBottom = false; logBox.scrollTo(0, 0); break;
|
|
161
|
+
case 'G': pinnedToBottom = true; logBox.scrollToBottom(); break;
|
|
162
|
+
case 'b': goToSelect(); break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- lifecycle --------------------------------------------------------------
|
|
167
|
+
const source = new FakeLogSource({ intervalMs: 8, burst: 3 });
|
|
168
|
+
source.on('log', (e) => {
|
|
169
|
+
received++;
|
|
170
|
+
ring.push(e);
|
|
171
|
+
if (ring.length > RING_CAP) ring.shift();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
let renderTimer = null;
|
|
175
|
+
function startRenderLoop() {
|
|
176
|
+
renderTimer = setInterval(() => { if (screen === 'logs') renderLogs(); }, 33); // ~30fps
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function shutdown(code) {
|
|
180
|
+
source.stop();
|
|
181
|
+
if (renderTimer) clearInterval(renderTimer);
|
|
182
|
+
term.grabInput(false);
|
|
183
|
+
term.fullscreen(false);
|
|
184
|
+
term.hideCursor(false);
|
|
185
|
+
if (SMOKE) console.log(`[smoke] terminal-kit ok — received=${received} ring=${ring.length}`);
|
|
186
|
+
setTimeout(() => process.exit(code), 50);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function main() {
|
|
190
|
+
buildUI();
|
|
191
|
+
term.grabInput(true);
|
|
192
|
+
term.hideCursor(true);
|
|
193
|
+
term.on('key', onKey);
|
|
194
|
+
source.start();
|
|
195
|
+
startRenderLoop();
|
|
196
|
+
|
|
197
|
+
if (SMOKE) {
|
|
198
|
+
// Drive the real code path without a human: jump to logs, toggle a filter, exit.
|
|
199
|
+
setTimeout(goToLogs, 300);
|
|
200
|
+
setTimeout(() => { showCore = false; }, 700);
|
|
201
|
+
setTimeout(() => { serviceFilter = SERVICES[0]; }, 1000);
|
|
202
|
+
setTimeout(() => shutdown(0), 1500);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
main();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spike-terminal-kit",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"description": "go-dev TUI spike — terminal-kit (pure JS, non-React)",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"terminal-kit": "^3.1.2"
|
|
12
|
+
}
|
|
13
|
+
}
|