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 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 };
@@ -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
+ }