recomposable 1.0.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 +117 -0
- package/index.js +352 -0
- package/lib/docker.js +67 -0
- package/lib/renderer.js +171 -0
- package/lib/state.js +44 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
```
|
|
2
|
+
__ __ _ _ _ _ ___
|
|
3
|
+
\ \ / /| || | /_\ | | | __| .
|
|
4
|
+
\ \/\/ / | __ |/ _ \| |__| _| ":"
|
|
5
|
+
\_/\_/ |_||_/_/ \_|____|___| ___:____ |"\/"|
|
|
6
|
+
,' `. \ /
|
|
7
|
+
docker compose manager | O \___/ |
|
|
8
|
+
~^~^~^~^~^~^~^~^~^~^~^~
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
# recomposable
|
|
12
|
+
|
|
13
|
+
A lightweight Docker Compose TUI manager with vim keybindings. Monitor service status, restart or rebuild containers, and tail logs — all from your terminal.
|
|
14
|
+
|
|
15
|
+
Zero dependencies. Pure Node.js.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g recomposable
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This registers the `recomposable` command on your system.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
1. Navigate to your project directory (where your `docker-compose.yml` lives)
|
|
28
|
+
2. Create a `recomposable.json` config file
|
|
29
|
+
3. Run `recomposable`
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd ~/my-project
|
|
33
|
+
cat > recomposable.json << 'EOF'
|
|
34
|
+
{
|
|
35
|
+
"composeFiles": [
|
|
36
|
+
"docker-compose.yml"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
EOF
|
|
40
|
+
recomposable
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Adding Compose Files
|
|
44
|
+
|
|
45
|
+
Create a `recomposable.json` file in your project root:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"composeFiles": [
|
|
50
|
+
"docker-compose.yml"
|
|
51
|
+
],
|
|
52
|
+
"pollInterval": 3000,
|
|
53
|
+
"logTailLines": 100
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Multiple compose files
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"composeFiles": [
|
|
62
|
+
"docker-compose.yml",
|
|
63
|
+
"docker-compose.override.yml",
|
|
64
|
+
"infra/docker-compose.monitoring.yml"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### CLI override
|
|
70
|
+
|
|
71
|
+
You can skip `recomposable.json` entirely and pass compose files directly:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
recomposable -f docker-compose.yml
|
|
75
|
+
recomposable -f docker-compose.yml -f docker-compose.prod.yml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
| Option | Default | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `composeFiles` | `[]` | Array of docker-compose file paths (relative to `recomposable.json`) |
|
|
83
|
+
| `pollInterval` | `3000` | Status polling interval in milliseconds |
|
|
84
|
+
| `logTailLines` | `100` | Number of log lines to show when entering log view |
|
|
85
|
+
|
|
86
|
+
## Keybindings
|
|
87
|
+
|
|
88
|
+
| Key | Action |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `j` / `Down` | Move cursor down |
|
|
91
|
+
| `k` / `Up` | Move cursor up |
|
|
92
|
+
| `s` | Restart selected service |
|
|
93
|
+
| `r` | Rebuild selected service (`up -d --build`) |
|
|
94
|
+
| `l` / `Enter` | View logs for selected service |
|
|
95
|
+
| `Esc` / `l` | Exit log view |
|
|
96
|
+
| `G` | Jump to bottom |
|
|
97
|
+
| `gg` | Jump to top |
|
|
98
|
+
| `q` | Quit |
|
|
99
|
+
| `Ctrl+C` | Quit |
|
|
100
|
+
|
|
101
|
+
## Status Icons
|
|
102
|
+
|
|
103
|
+
| Icon | Meaning |
|
|
104
|
+
|---|---|
|
|
105
|
+
| Green circle | Running (healthy) |
|
|
106
|
+
| Red circle | Running (unhealthy) |
|
|
107
|
+
| Yellow circle | Rebuilding / Restarting |
|
|
108
|
+
| Gray circle | Stopped |
|
|
109
|
+
|
|
110
|
+
## Requirements
|
|
111
|
+
|
|
112
|
+
- Node.js >= 16
|
|
113
|
+
- Docker with `docker compose` (v2) CLI
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { listServices, getStatuses, rebuildService, restartService, tailLogs } = require('./lib/docker');
|
|
7
|
+
const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
|
|
8
|
+
const { clearScreen, showCursor, renderListView, renderLogHeader } = require('./lib/renderer');
|
|
9
|
+
|
|
10
|
+
// --- Config ---
|
|
11
|
+
|
|
12
|
+
function loadConfig() {
|
|
13
|
+
const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100 };
|
|
14
|
+
|
|
15
|
+
// Load from recomposable.json in current working directory
|
|
16
|
+
const configPath = path.join(process.cwd(), 'recomposable.json');
|
|
17
|
+
if (fs.existsSync(configPath)) {
|
|
18
|
+
Object.assign(defaults, JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// CLI overrides: -f <file> can be repeated
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const cliFiles = [];
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
if (args[i] === '-f' && args[i + 1]) {
|
|
26
|
+
cliFiles.push(args[++i]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (cliFiles.length > 0) {
|
|
30
|
+
defaults.composeFiles = cliFiles;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (defaults.composeFiles.length === 0) {
|
|
34
|
+
process.stderr.write('No compose files configured. Add them to recomposable.json or pass -f <file>.\n');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return defaults;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Service Discovery ---
|
|
42
|
+
|
|
43
|
+
function discoverServices(config) {
|
|
44
|
+
const groups = [];
|
|
45
|
+
for (const file of config.composeFiles) {
|
|
46
|
+
const resolved = path.resolve(file);
|
|
47
|
+
const label = path.basename(file, path.extname(file)).replace(/^docker-compose\.?/, '') || path.basename(file);
|
|
48
|
+
let services = [];
|
|
49
|
+
let error = null;
|
|
50
|
+
try {
|
|
51
|
+
services = listServices(resolved);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
error = e.message.split('\n')[0].substring(0, 60);
|
|
54
|
+
}
|
|
55
|
+
groups.push({ file: resolved, label, services, error });
|
|
56
|
+
}
|
|
57
|
+
return groups;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Status Polling ---
|
|
61
|
+
|
|
62
|
+
function pollStatuses(state) {
|
|
63
|
+
for (const group of state.groups) {
|
|
64
|
+
if (group.error) continue;
|
|
65
|
+
const statuses = getStatuses(group.file);
|
|
66
|
+
for (const [svc, st] of statuses) {
|
|
67
|
+
state.statuses.set(statusKey(group.file, svc), st);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Rendering ---
|
|
73
|
+
|
|
74
|
+
function render(state) {
|
|
75
|
+
let output = clearScreen();
|
|
76
|
+
if (state.mode === MODE.LIST) {
|
|
77
|
+
output += renderListView(state);
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(output);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Actions ---
|
|
83
|
+
|
|
84
|
+
function doRebuild(state) {
|
|
85
|
+
const entry = selectedEntry(state);
|
|
86
|
+
if (!entry) return;
|
|
87
|
+
|
|
88
|
+
const sk = statusKey(entry.file, entry.service);
|
|
89
|
+
if (state.rebuilding.has(sk)) return;
|
|
90
|
+
|
|
91
|
+
const child = rebuildService(entry.file, entry.service);
|
|
92
|
+
state.rebuilding.set(sk, child);
|
|
93
|
+
render(state);
|
|
94
|
+
|
|
95
|
+
child.on('close', () => {
|
|
96
|
+
state.rebuilding.delete(sk);
|
|
97
|
+
pollStatuses(state);
|
|
98
|
+
if (state.mode === MODE.LIST) render(state);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function doRestart(state) {
|
|
103
|
+
const entry = selectedEntry(state);
|
|
104
|
+
if (!entry) return;
|
|
105
|
+
|
|
106
|
+
const sk = statusKey(entry.file, entry.service);
|
|
107
|
+
if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
|
|
108
|
+
|
|
109
|
+
const child = restartService(entry.file, entry.service);
|
|
110
|
+
state.restarting.set(sk, child);
|
|
111
|
+
render(state);
|
|
112
|
+
|
|
113
|
+
child.on('close', () => {
|
|
114
|
+
state.restarting.delete(sk);
|
|
115
|
+
pollStatuses(state);
|
|
116
|
+
if (state.mode === MODE.LIST) render(state);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function enterLogs(state) {
|
|
121
|
+
const entry = selectedEntry(state);
|
|
122
|
+
if (!entry) return;
|
|
123
|
+
|
|
124
|
+
state.mode = MODE.LOGS;
|
|
125
|
+
|
|
126
|
+
// Clear screen and show log header
|
|
127
|
+
process.stdout.write(clearScreen() + renderLogHeader(entry.service) + '\n');
|
|
128
|
+
|
|
129
|
+
const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
|
|
130
|
+
state.logChild = child;
|
|
131
|
+
|
|
132
|
+
child.stdout.pipe(process.stdout);
|
|
133
|
+
child.stderr.pipe(process.stdout);
|
|
134
|
+
|
|
135
|
+
child.on('close', () => {
|
|
136
|
+
if (state.logChild === child) {
|
|
137
|
+
state.logChild = null;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function exitLogs(state) {
|
|
143
|
+
if (state.logChild) {
|
|
144
|
+
state.logChild.kill('SIGTERM');
|
|
145
|
+
state.logChild = null;
|
|
146
|
+
}
|
|
147
|
+
state.mode = MODE.LIST;
|
|
148
|
+
pollStatuses(state);
|
|
149
|
+
render(state);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Input Handling ---
|
|
153
|
+
|
|
154
|
+
function handleKeypress(state, key) {
|
|
155
|
+
// Ctrl+C always quits
|
|
156
|
+
if (key === '\x03') {
|
|
157
|
+
cleanup(state);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (state.mode === MODE.LOGS) {
|
|
162
|
+
if (key === 'l' || key === '\x1b' || key === 'q') {
|
|
163
|
+
if (key === 'q') {
|
|
164
|
+
cleanup(state);
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
exitLogs(state);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// LIST mode
|
|
173
|
+
switch (key) {
|
|
174
|
+
case 'j':
|
|
175
|
+
case '\x1b[B': // Arrow Down
|
|
176
|
+
moveCursor(state, 1);
|
|
177
|
+
render(state);
|
|
178
|
+
break;
|
|
179
|
+
case 'k':
|
|
180
|
+
case '\x1b[A': // Arrow Up
|
|
181
|
+
moveCursor(state, -1);
|
|
182
|
+
render(state);
|
|
183
|
+
break;
|
|
184
|
+
case 'r':
|
|
185
|
+
doRebuild(state);
|
|
186
|
+
break;
|
|
187
|
+
case 's':
|
|
188
|
+
doRestart(state);
|
|
189
|
+
break;
|
|
190
|
+
case 'l':
|
|
191
|
+
case '\r': // Enter
|
|
192
|
+
enterLogs(state);
|
|
193
|
+
break;
|
|
194
|
+
case 'q':
|
|
195
|
+
cleanup(state);
|
|
196
|
+
process.exit(0);
|
|
197
|
+
break;
|
|
198
|
+
case 'G': // vim: go to bottom
|
|
199
|
+
state.cursor = state.flatList.length - 1;
|
|
200
|
+
render(state);
|
|
201
|
+
break;
|
|
202
|
+
case 'g': // gg handled via double-tap buffer below
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Arrow key sequence buffering ---
|
|
208
|
+
|
|
209
|
+
function createInputHandler(state) {
|
|
210
|
+
let buf = '';
|
|
211
|
+
let gPending = false;
|
|
212
|
+
|
|
213
|
+
return function onData(data) {
|
|
214
|
+
const str = data.toString();
|
|
215
|
+
|
|
216
|
+
// Handle escape sequences (arrow keys)
|
|
217
|
+
buf += str;
|
|
218
|
+
|
|
219
|
+
while (buf.length > 0) {
|
|
220
|
+
// Check for escape sequences
|
|
221
|
+
if (buf === '\x1b') {
|
|
222
|
+
// Could be start of escape sequence — wait for more
|
|
223
|
+
setTimeout(() => {
|
|
224
|
+
if (buf === '\x1b') {
|
|
225
|
+
handleKeypress(state, '\x1b');
|
|
226
|
+
buf = '';
|
|
227
|
+
}
|
|
228
|
+
}, 50);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (buf.startsWith('\x1b[A')) {
|
|
233
|
+
handleKeypress(state, '\x1b[A');
|
|
234
|
+
buf = buf.slice(3);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (buf.startsWith('\x1b[B')) {
|
|
238
|
+
handleKeypress(state, '\x1b[B');
|
|
239
|
+
buf = buf.slice(3);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (buf.startsWith('\x1b[')) {
|
|
243
|
+
// Unknown escape sequence — skip it
|
|
244
|
+
buf = buf.slice(buf.length);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Single character
|
|
249
|
+
const ch = buf[0];
|
|
250
|
+
buf = buf.slice(1);
|
|
251
|
+
|
|
252
|
+
// Handle gg (go to top)
|
|
253
|
+
if (ch === 'g') {
|
|
254
|
+
if (gPending) {
|
|
255
|
+
gPending = false;
|
|
256
|
+
state.cursor = 0;
|
|
257
|
+
state.scrollOffset = 0;
|
|
258
|
+
render(state);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
gPending = true;
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
if (gPending) {
|
|
264
|
+
gPending = false;
|
|
265
|
+
// Single g — ignore
|
|
266
|
+
}
|
|
267
|
+
}, 300);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
gPending = false;
|
|
272
|
+
handleKeypress(state, ch);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Cleanup ---
|
|
278
|
+
|
|
279
|
+
function cleanup(state) {
|
|
280
|
+
if (state.logChild) {
|
|
281
|
+
state.logChild.kill('SIGTERM');
|
|
282
|
+
state.logChild = null;
|
|
283
|
+
}
|
|
284
|
+
for (const [, child] of state.rebuilding) {
|
|
285
|
+
child.kill('SIGTERM');
|
|
286
|
+
}
|
|
287
|
+
state.rebuilding.clear();
|
|
288
|
+
for (const [, child] of state.restarting) {
|
|
289
|
+
child.kill('SIGTERM');
|
|
290
|
+
}
|
|
291
|
+
state.restarting.clear();
|
|
292
|
+
if (state.pollTimer) {
|
|
293
|
+
clearInterval(state.pollTimer);
|
|
294
|
+
}
|
|
295
|
+
process.stdout.write(showCursor() + '\x1b[0m');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Main ---
|
|
299
|
+
|
|
300
|
+
function main() {
|
|
301
|
+
const config = loadConfig();
|
|
302
|
+
const state = createState(config);
|
|
303
|
+
|
|
304
|
+
// Discover services
|
|
305
|
+
state.groups = discoverServices(config);
|
|
306
|
+
state.flatList = buildFlatList(state.groups);
|
|
307
|
+
|
|
308
|
+
if (state.flatList.length === 0) {
|
|
309
|
+
process.stderr.write('No services found in any compose file.\n');
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Initial status poll
|
|
314
|
+
pollStatuses(state);
|
|
315
|
+
|
|
316
|
+
// Setup terminal
|
|
317
|
+
if (process.stdin.isTTY) {
|
|
318
|
+
process.stdin.setRawMode(true);
|
|
319
|
+
}
|
|
320
|
+
process.stdin.resume();
|
|
321
|
+
process.stdin.setEncoding('utf8');
|
|
322
|
+
process.stdin.on('data', createInputHandler(state));
|
|
323
|
+
|
|
324
|
+
// Render
|
|
325
|
+
render(state);
|
|
326
|
+
|
|
327
|
+
// Poll loop
|
|
328
|
+
state.pollTimer = setInterval(() => {
|
|
329
|
+
if (state.mode === MODE.LIST) {
|
|
330
|
+
pollStatuses(state);
|
|
331
|
+
render(state);
|
|
332
|
+
}
|
|
333
|
+
}, config.pollInterval);
|
|
334
|
+
|
|
335
|
+
// Terminal resize
|
|
336
|
+
process.stdout.on('resize', () => {
|
|
337
|
+
if (state.mode === MODE.LIST) render(state);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Cleanup on exit
|
|
341
|
+
process.on('exit', () => cleanup(state));
|
|
342
|
+
process.on('SIGINT', () => {
|
|
343
|
+
cleanup(state);
|
|
344
|
+
process.exit(0);
|
|
345
|
+
});
|
|
346
|
+
process.on('SIGTERM', () => {
|
|
347
|
+
cleanup(state);
|
|
348
|
+
process.exit(0);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
main();
|
package/lib/docker.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync, spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function listServices(file) {
|
|
7
|
+
const cwd = path.dirname(path.resolve(file));
|
|
8
|
+
const args = ['compose', '-f', path.resolve(file), 'config', '--services'];
|
|
9
|
+
const out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 10000 });
|
|
10
|
+
return out.trim().split('\n').filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getStatuses(file) {
|
|
14
|
+
const cwd = path.dirname(path.resolve(file));
|
|
15
|
+
const args = ['compose', '-f', path.resolve(file), 'ps', '--format', 'json'];
|
|
16
|
+
let out;
|
|
17
|
+
try {
|
|
18
|
+
out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
19
|
+
} catch {
|
|
20
|
+
return new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trimmed = out.trim();
|
|
24
|
+
if (!trimmed) return new Map();
|
|
25
|
+
|
|
26
|
+
const statuses = new Map();
|
|
27
|
+
let containers;
|
|
28
|
+
|
|
29
|
+
// docker compose ps outputs NDJSON (one object per line) or a JSON array
|
|
30
|
+
if (trimmed.startsWith('[')) {
|
|
31
|
+
containers = JSON.parse(trimmed);
|
|
32
|
+
} else {
|
|
33
|
+
containers = trimmed.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const c of containers) {
|
|
37
|
+
const name = c.Service || c.Name;
|
|
38
|
+
const state = (c.State || '').toLowerCase();
|
|
39
|
+
const health = (c.Health || '').toLowerCase();
|
|
40
|
+
statuses.set(name, { state, health });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return statuses;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rebuildService(file, service) {
|
|
47
|
+
const cwd = path.dirname(path.resolve(file));
|
|
48
|
+
const args = ['compose', '-f', path.resolve(file), 'up', '-d', '--build', service];
|
|
49
|
+
const child = spawn('docker', args, { cwd, stdio: 'ignore', detached: false });
|
|
50
|
+
return child;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tailLogs(file, service, tailLines) {
|
|
54
|
+
const cwd = path.dirname(path.resolve(file));
|
|
55
|
+
const args = ['compose', '-f', path.resolve(file), 'logs', '-f', '--tail', String(tailLines), service];
|
|
56
|
+
const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
57
|
+
return child;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function restartService(file, service) {
|
|
61
|
+
const cwd = path.dirname(path.resolve(file));
|
|
62
|
+
const args = ['compose', '-f', path.resolve(file), 'restart', service];
|
|
63
|
+
const child = spawn('docker', args, { cwd, stdio: 'ignore', detached: false });
|
|
64
|
+
return child;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { listServices, getStatuses, rebuildService, restartService, tailLogs };
|
package/lib/renderer.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { statusKey, MODE } = require('./state');
|
|
4
|
+
|
|
5
|
+
const ESC = '\x1b[';
|
|
6
|
+
const RESET = `${ESC}0m`;
|
|
7
|
+
const BOLD = `${ESC}1m`;
|
|
8
|
+
const DIM = `${ESC}2m`;
|
|
9
|
+
const REVERSE = `${ESC}7m`;
|
|
10
|
+
const FG_GREEN = `${ESC}32m`;
|
|
11
|
+
const FG_YELLOW = `${ESC}33m`;
|
|
12
|
+
const FG_RED = `${ESC}31m`;
|
|
13
|
+
const FG_GRAY = `${ESC}90m`;
|
|
14
|
+
const FG_CYAN = `${ESC}36m`;
|
|
15
|
+
const FG_WHITE = `${ESC}37m`;
|
|
16
|
+
|
|
17
|
+
const LOGO_L = [
|
|
18
|
+
` ${BOLD}${FG_CYAN}__ __ _ _ _ _ ___${RESET}`,
|
|
19
|
+
` ${BOLD}${FG_CYAN}\\ \\ / /| || | /_\\ | | | __|${RESET}`,
|
|
20
|
+
` ${BOLD}${FG_CYAN} \\ \\/\\/ / | __ |/ _ \\| |__| _|${RESET}`,
|
|
21
|
+
` ${BOLD}${FG_CYAN} \\_/\\_/ |_||_/_/ \\_|____|___|${RESET}`,
|
|
22
|
+
``,
|
|
23
|
+
` ${DIM}docker compose manager${RESET}`,
|
|
24
|
+
];
|
|
25
|
+
const LOGO_R = [
|
|
26
|
+
`${FG_CYAN} .${RESET}`,
|
|
27
|
+
`${FG_CYAN} ":"${RESET}`,
|
|
28
|
+
`${FG_CYAN} ___:____ |"\\/"|${RESET}`,
|
|
29
|
+
`${FG_CYAN} ,' \`. \\ /${RESET}`,
|
|
30
|
+
`${FG_CYAN} | O \\___/ |${RESET}`,
|
|
31
|
+
`${FG_CYAN}~^~^~^~^~^~^~^~^~^~^~^~${RESET}`,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function visLen(str) {
|
|
35
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function padVisible(str, width) {
|
|
39
|
+
const pad = Math.max(0, width - visLen(str));
|
|
40
|
+
return str + ' '.repeat(pad);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clearScreen() {
|
|
44
|
+
return `${ESC}2J${ESC}H${ESC}?25l`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function showCursor() {
|
|
48
|
+
return `${ESC}?25h`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function statusIcon(status, isRebuilding, isRestarting) {
|
|
52
|
+
if (isRebuilding || isRestarting) return `${FG_YELLOW}\u25CF${RESET}`;
|
|
53
|
+
if (!status) return `${FG_GRAY}\u25CB${RESET}`;
|
|
54
|
+
|
|
55
|
+
const { state, health } = status;
|
|
56
|
+
if (state === 'running') {
|
|
57
|
+
if (health === 'unhealthy') return `${FG_RED}\u25CF${RESET}`;
|
|
58
|
+
return `${FG_GREEN}\u25CF${RESET}`;
|
|
59
|
+
}
|
|
60
|
+
if (state === 'restarting') return `${FG_YELLOW}\u25CF${RESET}`;
|
|
61
|
+
return `${FG_GRAY}\u25CB${RESET}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function statusText(status, isRebuilding, isRestarting) {
|
|
65
|
+
if (isRestarting) return `${FG_YELLOW}RESTARTING...${RESET}`;
|
|
66
|
+
if (isRebuilding) return `${FG_YELLOW}REBUILDING...${RESET}`;
|
|
67
|
+
if (!status) return `${FG_GRAY}stopped${RESET}`;
|
|
68
|
+
|
|
69
|
+
const { state, health } = status;
|
|
70
|
+
let text = state;
|
|
71
|
+
if (health && health !== 'none' && health !== '') {
|
|
72
|
+
text += ` (${health})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (state === 'running') {
|
|
76
|
+
if (health === 'unhealthy') return `${FG_RED}${text}${RESET}`;
|
|
77
|
+
return `${FG_GREEN}${text}${RESET}`;
|
|
78
|
+
}
|
|
79
|
+
if (state === 'exited') return `${FG_GRAY}${text}${RESET}`;
|
|
80
|
+
if (state === 'restarting') return `${FG_YELLOW}${text}${RESET}`;
|
|
81
|
+
return `${DIM}${text}${RESET}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderListView(state) {
|
|
85
|
+
const { columns = 80, rows = 24 } = process.stdout;
|
|
86
|
+
const buf = [];
|
|
87
|
+
|
|
88
|
+
// Logo: text left, whale art right
|
|
89
|
+
const logoGap = 6;
|
|
90
|
+
const leftWidth = 32;
|
|
91
|
+
for (let i = 0; i < LOGO_L.length; i++) {
|
|
92
|
+
const left = padVisible(LOGO_L[i] || '', leftWidth);
|
|
93
|
+
const right = LOGO_R[i] || '';
|
|
94
|
+
buf.push(` ${left}${' '.repeat(logoGap)}${right}`);
|
|
95
|
+
}
|
|
96
|
+
const help = `${DIM}[S]restart [R]ebuild [L]ogs [Q]uit${RESET}`;
|
|
97
|
+
buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET} ${help}`);
|
|
98
|
+
buf.push('');
|
|
99
|
+
|
|
100
|
+
// Build all display lines
|
|
101
|
+
const lines = [];
|
|
102
|
+
let currentGroup = -1;
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < state.flatList.length; i++) {
|
|
105
|
+
const entry = state.flatList[i];
|
|
106
|
+
|
|
107
|
+
// Group header
|
|
108
|
+
if (entry.groupIdx !== currentGroup) {
|
|
109
|
+
currentGroup = entry.groupIdx;
|
|
110
|
+
const group = state.groups[entry.groupIdx];
|
|
111
|
+
if (lines.length > 0) lines.push({ type: 'blank' });
|
|
112
|
+
const label = ` ${BOLD}${group.label}${RESET}`;
|
|
113
|
+
if (group.error) {
|
|
114
|
+
lines.push({ type: 'header', text: `${label} ${FG_RED}(${group.error})${RESET}` });
|
|
115
|
+
} else {
|
|
116
|
+
lines.push({ type: 'header', text: label });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sk = statusKey(entry.file, entry.service);
|
|
121
|
+
const st = state.statuses.get(sk);
|
|
122
|
+
const rebuilding = state.rebuilding.has(sk);
|
|
123
|
+
const restarting = state.restarting.has(sk);
|
|
124
|
+
const icon = statusIcon(st, rebuilding, restarting);
|
|
125
|
+
const stext = statusText(st, rebuilding, restarting);
|
|
126
|
+
const name = entry.service.padEnd(30);
|
|
127
|
+
const pointer = i === state.cursor ? `${REVERSE}` : '';
|
|
128
|
+
const endPointer = i === state.cursor ? `${RESET}` : '';
|
|
129
|
+
|
|
130
|
+
lines.push({
|
|
131
|
+
type: 'service',
|
|
132
|
+
text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${stext}${endPointer}`,
|
|
133
|
+
flatIdx: i,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Scrolling
|
|
138
|
+
const availableRows = rows - (LOGO_L.length + 3); // logo + ruler + blank + bottom margin
|
|
139
|
+
const serviceLines = lines.filter(l => l.type === 'service');
|
|
140
|
+
|
|
141
|
+
// Find line index of cursor
|
|
142
|
+
const cursorLineIdx = lines.findIndex(l => l.type === 'service' && l.flatIdx === state.cursor);
|
|
143
|
+
|
|
144
|
+
// Adjust scroll offset
|
|
145
|
+
if (cursorLineIdx < state.scrollOffset) {
|
|
146
|
+
state.scrollOffset = cursorLineIdx;
|
|
147
|
+
} else if (cursorLineIdx >= state.scrollOffset + availableRows) {
|
|
148
|
+
state.scrollOffset = cursorLineIdx - availableRows + 1;
|
|
149
|
+
}
|
|
150
|
+
state.scrollOffset = Math.max(0, Math.min(lines.length - availableRows, state.scrollOffset));
|
|
151
|
+
|
|
152
|
+
const visible = lines.slice(state.scrollOffset, state.scrollOffset + availableRows);
|
|
153
|
+
for (const line of visible) {
|
|
154
|
+
buf.push(line.text || '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return buf.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderLogHeader(serviceName) {
|
|
161
|
+
const { columns = 80 } = process.stdout;
|
|
162
|
+
const title = `${BOLD}${FG_CYAN} whale${RESET} ${FG_GRAY}>${RESET} ${BOLD}${serviceName}${RESET} ${DIM}logs${RESET}`;
|
|
163
|
+
const help = `${DIM}[L] or [Esc] back${RESET}`;
|
|
164
|
+
const pad = Math.max(0, columns - serviceName.length - 21 - 17);
|
|
165
|
+
const buf = [];
|
|
166
|
+
buf.push(title + ' '.repeat(pad) + help);
|
|
167
|
+
buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
|
|
168
|
+
return buf.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { clearScreen, showCursor, renderListView, renderLogHeader };
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MODE = { LIST: 'LIST', LOGS: 'LOGS' };
|
|
4
|
+
|
|
5
|
+
function createState(config) {
|
|
6
|
+
return {
|
|
7
|
+
mode: MODE.LIST,
|
|
8
|
+
groups: [], // [{ file, label, services: string[], error: string|null }]
|
|
9
|
+
flatList: [], // [{ groupIdx, serviceIdx, service, file }]
|
|
10
|
+
cursor: 0,
|
|
11
|
+
statuses: new Map(), // "file::service" -> { state, health }
|
|
12
|
+
rebuilding: new Map(), // "file::service" -> childProcess
|
|
13
|
+
restarting: new Map(), // "file::service" -> childProcess
|
|
14
|
+
logChild: null,
|
|
15
|
+
scrollOffset: 0,
|
|
16
|
+
config,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function statusKey(file, service) {
|
|
21
|
+
return `${file}::${service}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildFlatList(groups) {
|
|
25
|
+
const list = [];
|
|
26
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
27
|
+
const g = groups[gi];
|
|
28
|
+
for (let si = 0; si < g.services.length; si++) {
|
|
29
|
+
list.push({ groupIdx: gi, serviceIdx: si, service: g.services[si], file: g.file });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return list;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function moveCursor(state, delta) {
|
|
36
|
+
if (state.flatList.length === 0) return;
|
|
37
|
+
state.cursor = Math.max(0, Math.min(state.flatList.length - 1, state.cursor + delta));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function selectedEntry(state) {
|
|
41
|
+
return state.flatList[state.cursor] || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "recomposable",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Docker Compose TUI manager with vim keybindings — monitor, restart, rebuild, and tail logs for your services",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"recomposable": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"lib/"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"docker",
|
|
15
|
+
"docker-compose",
|
|
16
|
+
"compose",
|
|
17
|
+
"tui",
|
|
18
|
+
"cli",
|
|
19
|
+
"terminal",
|
|
20
|
+
"devops",
|
|
21
|
+
"containers",
|
|
22
|
+
"vim"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/janvandorth/recomposable.git"
|
|
27
|
+
},
|
|
28
|
+
"author": "Jan van Dorth",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=16.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|