recomposable 1.0.1 → 1.1.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 CHANGED
@@ -1,19 +1,13 @@
1
- ```
2
- __ __ _ _ _ _ ___
3
- \ \ / /| || | /_\ | | | __| .
4
- \ \/\/ / | __ |/ _ \| |__| _| ":"
5
- \_/\_/ |_||_/_/ \_|____|___| ___:____ |"\/"|
6
- ,' `. \ /
7
- docker compose manager | O \___/ |
8
- ~^~^~^~^~^~^~^~^~^~^~^~
9
- ```
10
-
11
1
  # recomposable
12
2
 
13
3
  A lightweight Docker Compose TUI manager with vim keybindings. Monitor service status, restart or rebuild containers, and tail logs — all from your terminal.
14
4
 
5
+ Eliminate switching between countless terminal tabs or windows to rebuild you docker compose containers.
6
+
15
7
  Zero dependencies. Pure Node.js.
16
8
 
9
+ ![recomposable list view](screenshots/list-view.png)
10
+
17
11
  ## Install
18
12
 
19
13
  ```bash
@@ -40,6 +34,23 @@ EOF
40
34
  recomposable
41
35
  ```
42
36
 
37
+ ## Features
38
+
39
+ - **Multi-file support** — manage services across multiple compose files, grouped by file
40
+ - **Live status** — polls container state, health, build and restart times
41
+ - **CPU/Memory monitoring** — live CPU% and memory usage per container, with configurable color thresholds
42
+ - **Port mappings** — shows published ports for each service
43
+ - **Log pattern scanning** — counts WRN/ERR (configurable) occurrences across all services
44
+ - **Inline log panel** — tail logs for the selected service without leaving the list view, with search (`/`)
45
+ - **Full log view** — scrollable full-screen log viewer with live auto-scroll and search (`/`, `n`/`N`)
46
+ - **Start / Stop / Restart / Rebuild** — full container lifecycle management per service
47
+ - **No cache mode** — toggle to force a full clean rebuild (`--no-cache` + `--force-recreate`), off by default
48
+ - **Vim keybindings** — navigate with `j`/`k`, `G`/`gg`, and more
49
+
50
+ ## Full Log View
51
+
52
+ ![recomposable full logs view](screenshots/logs-view.png)
53
+
43
54
  ## Adding Compose Files
44
55
 
45
56
  Create a `recomposable.json` file in your project root:
@@ -48,9 +59,7 @@ Create a `recomposable.json` file in your project root:
48
59
  {
49
60
  "composeFiles": [
50
61
  "docker-compose.yml"
51
- ],
52
- "pollInterval": 3000,
53
- "logTailLines": 100
62
+ ]
54
63
  }
55
64
  ```
56
65
 
@@ -81,22 +90,52 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
81
90
  |---|---|---|
82
91
  | `composeFiles` | `[]` | Array of docker-compose file paths (relative to `recomposable.json`) |
83
92
  | `pollInterval` | `3000` | Status polling interval in milliseconds |
84
- | `logTailLines` | `100` | Number of log lines to show when entering log view |
93
+ | `logTailLines` | `100` | Number of log lines to show when entering full log view |
94
+ | `logScanPatterns` | `["WRN]", "ERR]"]` | Patterns to count in container logs |
95
+ | `logScanLines` | `1000` | Number of log lines to scan for pattern counts |
96
+ | `logScanInterval` | `10000` | Pattern scanning interval in milliseconds |
97
+ | `bottomLogCount` | `10` | Number of log lines shown in the inline log panel |
98
+ | `statsInterval` | `5000` | CPU/memory polling interval in milliseconds |
99
+ | `statsBufferSize` | `6` | Number of samples for rolling average (e.g. 6 x 5s = 30s window) |
100
+ | `cpuWarnThreshold` | `50` | CPU % above which the column turns yellow |
101
+ | `cpuDangerThreshold` | `100` | CPU % above which the column turns red |
102
+ | `memWarnThreshold` | `512` | Memory in MB above which the column turns yellow |
103
+ | `memDangerThreshold` | `1024` | Memory in MB above which the column turns red |
85
104
 
86
105
  ## Keybindings
87
106
 
107
+ ### List view
108
+
88
109
  | Key | Action |
89
110
  |---|---|
90
111
  | `j` / `Down` | Move cursor down |
91
112
  | `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 |
113
+ | `s` | Start (if stopped) or restart (if running) |
114
+ | `p` | Stop selected service |
115
+ | `b` | Rebuild selected service (`up -d --build`) |
116
+ | `n` | Toggle no-cache mode (rebuild with `--no-cache` + `--force-recreate`) |
117
+ | `f` / `Enter` | Full-screen log view for selected service |
118
+ | `l` | Toggle inline log panel |
119
+ | `/` | Search in inline log panel |
96
120
  | `G` | Jump to bottom |
97
121
  | `gg` | Jump to top |
98
122
  | `q` | Quit |
99
- | `Ctrl+C` | Quit |
123
+
124
+ ### Full log view
125
+
126
+ | Key | Action |
127
+ |---|---|
128
+ | `j` / `Down` | Scroll down |
129
+ | `k` / `Up` | Scroll up |
130
+ | `Ctrl+D` | Page down |
131
+ | `Ctrl+U` | Page up |
132
+ | `G` | Jump to bottom (live mode) |
133
+ | `gg` | Jump to top |
134
+ | `/` | Search logs |
135
+ | `n` | Next search match |
136
+ | `N` | Previous search match |
137
+ | `Esc` / `f` | Exit log view |
138
+ | `q` | Quit |
100
139
 
101
140
  ## Status Icons
102
141
 
@@ -104,9 +143,13 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
104
143
  |---|---|
105
144
  | Green circle | Running (healthy) |
106
145
  | Red circle | Running (unhealthy) |
107
- | Yellow circle | Rebuilding / Restarting |
146
+ | Yellow circle | Rebuilding / Restarting / Starting / Stopping |
108
147
  | Gray circle | Stopped |
109
148
 
149
+ ## Todo
150
+
151
+ - Log search should search all container logs, not only the lines currently tailed by the tool
152
+
110
153
  ## Requirements
111
154
 
112
155
  - Node.js >= 16
package/index.js CHANGED
@@ -3,14 +3,14 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { listServices, getStatuses, rebuildService, restartService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs } = require('./lib/docker');
6
+ const { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine } = require('./lib/docker');
7
7
  const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
8
8
  const { clearScreen, showCursor, renderListView, renderLogView } = require('./lib/renderer');
9
9
 
10
10
  // --- Config ---
11
11
 
12
12
  function loadConfig() {
13
- const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000 };
13
+ const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000, statsInterval: 5000, statsBufferSize: 6, bottomLogCount: 10, cpuWarnThreshold: 50, cpuDangerThreshold: 100, memWarnThreshold: 512, memDangerThreshold: 1024 };
14
14
 
15
15
  // Load from recomposable.json in current working directory
16
16
  const configPath = path.join(process.cwd(), 'recomposable.json');
@@ -127,6 +127,80 @@ function pollLogCounts(state) {
127
127
  }
128
128
  }
129
129
 
130
+ // --- Stats Polling ---
131
+
132
+ let statsPollActive = false;
133
+
134
+ function pollContainerStats(state) {
135
+ if (statsPollActive) return;
136
+
137
+ const idToKey = new Map();
138
+ for (const group of state.groups) {
139
+ if (group.error) continue;
140
+ for (const service of group.services) {
141
+ const sk = statusKey(group.file, service);
142
+ const st = state.statuses.get(sk);
143
+ if (!st || st.state !== 'running' || !st.id) continue;
144
+ idToKey.set(st.id, sk);
145
+ }
146
+ }
147
+
148
+ const ids = [...idToKey.keys()];
149
+ if (ids.length === 0) return;
150
+
151
+ statsPollActive = true;
152
+ const child = fetchContainerStats(ids);
153
+ let output = '';
154
+ child.stdout.on('data', (d) => { output += d.toString(); });
155
+ child.stderr.on('data', () => {});
156
+ child.on('close', () => {
157
+ statsPollActive = false;
158
+ const bufferSize = state.config.statsBufferSize || 6;
159
+
160
+ for (const line of output.trim().split('\n')) {
161
+ if (!line.trim()) continue;
162
+ const parsed = parseStatsLine(line);
163
+ if (!parsed) continue;
164
+
165
+ // Find the statusKey for this container ID
166
+ let sk = null;
167
+ for (const [id, key] of idToKey) {
168
+ if (parsed.id.startsWith(id) || id.startsWith(parsed.id)) {
169
+ sk = key;
170
+ break;
171
+ }
172
+ }
173
+ if (!sk) continue;
174
+
175
+ // Update circular buffer
176
+ if (!state.containerStatsHistory.has(sk)) {
177
+ state.containerStatsHistory.set(sk, { cpu: new Array(bufferSize).fill(0), mem: new Array(bufferSize).fill(0), idx: 0, count: 0 });
178
+ }
179
+ const hist = state.containerStatsHistory.get(sk);
180
+ hist.cpu[hist.idx] = parsed.cpuPercent;
181
+ hist.mem[hist.idx] = parsed.memUsageBytes;
182
+ hist.idx = (hist.idx + 1) % bufferSize;
183
+ hist.count = Math.min(hist.count + 1, bufferSize);
184
+
185
+ // Compute rolling average
186
+ let cpuSum = 0, memSum = 0;
187
+ for (let i = 0; i < hist.count; i++) {
188
+ cpuSum += hist.cpu[i];
189
+ memSum += hist.mem[i];
190
+ }
191
+ state.containerStats.set(sk, {
192
+ cpuPercent: cpuSum / hist.count,
193
+ memUsageBytes: memSum / hist.count,
194
+ });
195
+ }
196
+
197
+ if (state.mode === MODE.LIST) throttledRender(state);
198
+ });
199
+ child.on('error', () => {
200
+ statsPollActive = false;
201
+ });
202
+ }
203
+
130
204
  // --- Rendering ---
131
205
 
132
206
  function render(state) {
@@ -173,6 +247,10 @@ function updateSelectedLogs(state) {
173
247
  // Same container already selected, nothing to do
174
248
  if (state.selectedLogKey === sk) return;
175
249
 
250
+ // Clear bottom search when switching services
251
+ state.bottomSearchQuery = '';
252
+ state.bottomSearchActive = false;
253
+
176
254
  // Cancel any pending debounced log fetch
177
255
  if (logFetchTimer) {
178
256
  clearTimeout(logFetchTimer);
@@ -221,7 +299,7 @@ function doRebuild(state) {
221
299
  state.bottomLogTails.delete(sk);
222
300
  }
223
301
 
224
- const child = rebuildService(entry.file, entry.service);
302
+ const child = rebuildService(entry.file, entry.service, { noCache: state.noCache });
225
303
  state.rebuilding.set(sk, child);
226
304
 
227
305
  state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
@@ -236,7 +314,8 @@ function doRebuild(state) {
236
314
  const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
237
315
  if (newLines.length === 0) return;
238
316
  info.lines.push(...newLines);
239
- if (info.lines.length > 10) info.lines = info.lines.slice(-10);
317
+ const maxLines = state.config.bottomLogCount || 10;
318
+ if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
240
319
  if (state.mode === MODE.LIST) throttledRender(state);
241
320
  };
242
321
 
@@ -246,6 +325,8 @@ function doRebuild(state) {
246
325
 
247
326
  child.on('close', () => {
248
327
  state.rebuilding.delete(sk);
328
+ state.containerStatsHistory.delete(sk);
329
+ state.containerStats.delete(sk);
249
330
  pollStatuses(state);
250
331
 
251
332
  // Show container application logs after rebuild+start
@@ -271,7 +352,8 @@ function startBottomLogTail(state, sk, file, service) {
271
352
  const containerId = getContainerId(file, service);
272
353
  if (!containerId) return;
273
354
 
274
- const logChild = tailContainerLogs(containerId, 10);
355
+ const maxLines = state.config.bottomLogCount || 10;
356
+ const logChild = tailContainerLogs(containerId, maxLines);
275
357
  state.bottomLogTails.set(sk, logChild);
276
358
 
277
359
  let buf = '';
@@ -284,7 +366,7 @@ function startBottomLogTail(state, sk, file, service) {
284
366
  const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
285
367
  if (newLines.length === 0) return;
286
368
  info.lines.push(...newLines);
287
- if (info.lines.length > 10) info.lines = info.lines.slice(-10);
369
+ if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
288
370
  if (state.mode === MODE.LIST) throttledRender(state);
289
371
  };
290
372
 
@@ -313,6 +395,8 @@ function doRestart(state) {
313
395
 
314
396
  child.on('close', () => {
315
397
  state.restarting.delete(sk);
398
+ state.containerStatsHistory.delete(sk);
399
+ state.containerStats.delete(sk);
316
400
  pollStatuses(state);
317
401
 
318
402
  // Show container application logs after restart
@@ -327,6 +411,67 @@ function doRestart(state) {
327
411
  });
328
412
  }
329
413
 
414
+ function doStop(state) {
415
+ const entry = selectedEntry(state);
416
+ if (!entry) return;
417
+
418
+ const sk = statusKey(entry.file, entry.service);
419
+ if (state.stopping.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk)) return;
420
+
421
+ // Only stop running containers
422
+ const st = state.statuses.get(sk);
423
+ if (!st || st.state !== 'running') return;
424
+
425
+ // Kill any existing log tail for this service
426
+ if (state.bottomLogTails.has(sk)) {
427
+ state.bottomLogTails.get(sk).kill('SIGTERM');
428
+ state.bottomLogTails.delete(sk);
429
+ }
430
+
431
+ const child = stopService(entry.file, entry.service);
432
+ state.stopping.set(sk, child);
433
+ state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
434
+ render(state);
435
+
436
+ child.on('close', () => {
437
+ state.stopping.delete(sk);
438
+ state.bottomLogLines.delete(sk);
439
+ pollStatuses(state);
440
+ if (state.mode === MODE.LIST) render(state);
441
+ });
442
+ }
443
+
444
+ function doStart(state) {
445
+ const entry = selectedEntry(state);
446
+ if (!entry) return;
447
+
448
+ const sk = statusKey(entry.file, entry.service);
449
+ if (state.starting.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk)) return;
450
+
451
+ // Only start stopped/exited containers
452
+ const st = state.statuses.get(sk);
453
+ if (st && st.state === 'running') return;
454
+
455
+ const child = startService(entry.file, entry.service);
456
+ state.starting.set(sk, child);
457
+ state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
458
+ render(state);
459
+
460
+ child.on('close', () => {
461
+ state.starting.delete(sk);
462
+ pollStatuses(state);
463
+
464
+ const info = state.bottomLogLines.get(sk);
465
+ if (info) {
466
+ info.action = 'started';
467
+ info.lines = [];
468
+ }
469
+
470
+ startBottomLogTail(state, sk, entry.file, entry.service);
471
+ if (state.mode === MODE.LIST) render(state);
472
+ });
473
+ }
474
+
330
475
  function enterLogs(state) {
331
476
  const entry = selectedEntry(state);
332
477
  if (!entry) return;
@@ -340,6 +485,10 @@ function enterLogs(state) {
340
485
  state.logLines = [];
341
486
  state.logScrollOffset = 0;
342
487
  state.logAutoScroll = true;
488
+ state.logSearchQuery = '';
489
+ state.logSearchActive = false;
490
+ state.logSearchMatches = [];
491
+ state.logSearchMatchIdx = -1;
343
492
 
344
493
  const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
345
494
  state.logChild = child;
@@ -388,6 +537,51 @@ function exitLogs(state) {
388
537
  render(state);
389
538
  }
390
539
 
540
+ // --- Log Search ---
541
+
542
+ function executeLogSearch(state) {
543
+ const query = state.logSearchQuery;
544
+ state.logSearchMatches = [];
545
+ state.logSearchMatchIdx = -1;
546
+ if (!query) return;
547
+
548
+ const lowerQuery = query.toLowerCase();
549
+ for (let i = 0; i < state.logLines.length; i++) {
550
+ if (state.logLines[i].toLowerCase().includes(lowerQuery)) {
551
+ state.logSearchMatches.push(i);
552
+ }
553
+ }
554
+
555
+ if (state.logSearchMatches.length > 0) {
556
+ state.logSearchMatchIdx = 0;
557
+ scrollToLogLine(state, state.logSearchMatches[0]);
558
+ }
559
+ }
560
+
561
+ function scrollToLogLine(state, lineIdx) {
562
+ const { rows = 24 } = process.stdout;
563
+ const headerHeight = 9; // logo + separator + legend + info line
564
+ const availableRows = Math.max(1, rows - headerHeight);
565
+ const totalLines = state.logLines.length;
566
+
567
+ // logScrollOffset is lines from bottom
568
+ state.logScrollOffset = Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2));
569
+ state.logAutoScroll = state.logScrollOffset === 0;
570
+ render(state);
571
+ }
572
+
573
+ function jumpToNextMatch(state) {
574
+ if (state.logSearchMatches.length === 0) return;
575
+ state.logSearchMatchIdx = (state.logSearchMatchIdx + 1) % state.logSearchMatches.length;
576
+ scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
577
+ }
578
+
579
+ function jumpToPrevMatch(state) {
580
+ if (state.logSearchMatches.length === 0) return;
581
+ state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
582
+ scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
583
+ }
584
+
391
585
  // --- Input Handling ---
392
586
 
393
587
  function handleKeypress(state, key) {
@@ -398,6 +592,29 @@ function handleKeypress(state, key) {
398
592
  }
399
593
 
400
594
  if (state.mode === MODE.LOGS) {
595
+ // Search input mode — capture keypresses into the search query
596
+ if (state.logSearchActive) {
597
+ if (key === '\x1b') {
598
+ // ESC cancels search
599
+ state.logSearchActive = false;
600
+ state.logSearchQuery = '';
601
+ render(state);
602
+ } else if (key === '\r') {
603
+ // Enter executes search
604
+ state.logSearchActive = false;
605
+ executeLogSearch(state);
606
+ render(state);
607
+ } else if (key === '\x7f' || key === '\b') {
608
+ // Backspace
609
+ state.logSearchQuery = state.logSearchQuery.slice(0, -1);
610
+ render(state);
611
+ } else if (key.length === 1 && key >= ' ') {
612
+ state.logSearchQuery += key;
613
+ render(state);
614
+ }
615
+ return;
616
+ }
617
+
401
618
  const { rows = 24 } = process.stdout;
402
619
  const pageSize = Math.max(1, Math.floor(rows / 2));
403
620
  const maxOffset = Math.max(0, state.logLines.length - 1);
@@ -440,6 +657,36 @@ function handleKeypress(state, key) {
440
657
  if (state.logScrollOffset === 0) state.logAutoScroll = true;
441
658
  render(state);
442
659
  break;
660
+ case '/':
661
+ state.logSearchActive = true;
662
+ state.logSearchQuery = '';
663
+ render(state);
664
+ break;
665
+ case 'n':
666
+ jumpToNextMatch(state);
667
+ break;
668
+ case 'N':
669
+ jumpToPrevMatch(state);
670
+ break;
671
+ }
672
+ return;
673
+ }
674
+
675
+ // LIST mode — bottom panel search input
676
+ if (state.bottomSearchActive) {
677
+ if (key === '\x1b') {
678
+ state.bottomSearchActive = false;
679
+ state.bottomSearchQuery = '';
680
+ render(state);
681
+ } else if (key === '\r') {
682
+ state.bottomSearchActive = false;
683
+ render(state);
684
+ } else if (key === '\x7f' || key === '\b') {
685
+ state.bottomSearchQuery = state.bottomSearchQuery.slice(0, -1);
686
+ render(state);
687
+ } else if (key.length === 1 && key >= ' ') {
688
+ state.bottomSearchQuery += key;
689
+ render(state);
443
690
  }
444
691
  return;
445
692
  }
@@ -458,11 +705,28 @@ function handleKeypress(state, key) {
458
705
  updateSelectedLogs(state);
459
706
  render(state);
460
707
  break;
461
- case 'r':
708
+ case 'b':
462
709
  doRebuild(state);
463
710
  break;
464
- case 's':
465
- doRestart(state);
711
+ case 's': {
712
+ const sEntry = selectedEntry(state);
713
+ if (sEntry) {
714
+ const sSk = statusKey(sEntry.file, sEntry.service);
715
+ const sSt = state.statuses.get(sSk);
716
+ if (sSt && sSt.state === 'running') {
717
+ doRestart(state);
718
+ } else {
719
+ doStart(state);
720
+ }
721
+ }
722
+ break;
723
+ }
724
+ case 'p':
725
+ doStop(state);
726
+ break;
727
+ case 'n':
728
+ state.noCache = !state.noCache;
729
+ render(state);
466
730
  break;
467
731
  case 'f':
468
732
  case '\r': // Enter
@@ -483,6 +747,13 @@ function handleKeypress(state, key) {
483
747
  break;
484
748
  case 'g': // gg handled via double-tap buffer below
485
749
  break;
750
+ case '/':
751
+ if (state.showBottomLogs) {
752
+ state.bottomSearchActive = true;
753
+ state.bottomSearchQuery = '';
754
+ render(state);
755
+ }
756
+ break;
486
757
  }
487
758
  }
488
759
 
@@ -531,6 +802,12 @@ function createInputHandler(state) {
531
802
  const ch = buf[0];
532
803
  buf = buf.slice(1);
533
804
 
805
+ // In search input mode, send all chars directly
806
+ if (state.logSearchActive || state.bottomSearchActive) {
807
+ handleKeypress(state, ch);
808
+ continue;
809
+ }
810
+
534
811
  // Handle gg (go to top)
535
812
  if (ch === 'g') {
536
813
  if (gPending) {
@@ -577,6 +854,14 @@ function cleanup(state) {
577
854
  child.kill('SIGTERM');
578
855
  }
579
856
  state.restarting.clear();
857
+ for (const [, child] of state.stopping) {
858
+ child.kill('SIGTERM');
859
+ }
860
+ state.stopping.clear();
861
+ for (const [, child] of state.starting) {
862
+ child.kill('SIGTERM');
863
+ }
864
+ state.starting.clear();
580
865
  for (const [, child] of state.bottomLogTails) {
581
866
  child.kill('SIGTERM');
582
867
  }
@@ -591,6 +876,9 @@ function cleanup(state) {
591
876
  if (state.pollTimer) {
592
877
  clearInterval(state.pollTimer);
593
878
  }
879
+ if (state.statsTimer) {
880
+ clearInterval(state.statsTimer);
881
+ }
594
882
  process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
595
883
  }
596
884
 
@@ -642,6 +930,14 @@ function main() {
642
930
  }
643
931
  }, config.logScanInterval || 10000);
644
932
 
933
+ // Stats polling loop
934
+ pollContainerStats(state);
935
+ state.statsTimer = setInterval(() => {
936
+ if (state.mode === MODE.LIST) {
937
+ pollContainerStats(state);
938
+ }
939
+ }, config.statsInterval || 5000);
940
+
645
941
  // Terminal resize
646
942
  process.stdout.on('resize', () => {
647
943
  render(state);
package/lib/docker.js CHANGED
@@ -27,10 +27,14 @@ function getStatuses(file) {
27
27
  let containers;
28
28
 
29
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));
30
+ try {
31
+ if (trimmed.startsWith('[')) {
32
+ containers = JSON.parse(trimmed);
33
+ } else {
34
+ containers = trimmed.split('\n').filter(Boolean).map(line => JSON.parse(line));
35
+ }
36
+ } catch {
37
+ return new Map();
34
38
  }
35
39
 
36
40
  const idToService = new Map();
@@ -41,7 +45,31 @@ function getStatuses(file) {
41
45
  const health = (c.Health || '').toLowerCase();
42
46
  const createdAt = c.CreatedAt || null;
43
47
  const id = c.ID || null;
44
- statuses.set(name, { state, health, createdAt, startedAt: null, id: id || null });
48
+
49
+ // Extract published ports
50
+ let ports = [];
51
+ if (Array.isArray(c.Publishers)) {
52
+ for (const p of c.Publishers) {
53
+ if (p.PublishedPort && p.PublishedPort > 0) {
54
+ ports.push({ published: p.PublishedPort, target: p.TargetPort });
55
+ }
56
+ }
57
+ } else if (c.Ports) {
58
+ // Fallback: parse from Ports string like "0.0.0.0:3000->3000/tcp"
59
+ const portMatches = c.Ports.matchAll(/(\d+)->(\d+)/g);
60
+ for (const m of portMatches) {
61
+ ports.push({ published: parseInt(m[1], 10), target: parseInt(m[2], 10) });
62
+ }
63
+ }
64
+ // Deduplicate by published port
65
+ const seen = new Set();
66
+ ports = ports.filter(p => {
67
+ if (seen.has(p.published)) return false;
68
+ seen.add(p.published);
69
+ return true;
70
+ });
71
+
72
+ statuses.set(name, { state, health, createdAt, startedAt: null, id: id || null, ports });
45
73
  if (id) idToService.set(id, name);
46
74
  }
47
75
 
@@ -72,13 +100,49 @@ function getStatuses(file) {
72
100
  return statuses;
73
101
  }
74
102
 
75
- function rebuildService(file, service) {
103
+ function rebuildService(file, service, opts = {}) {
76
104
  const cwd = path.dirname(path.resolve(file));
77
- const args = ['compose', '-f', path.resolve(file), 'up', '-d', '--build', service];
78
- const child = spawn('docker', args, {
105
+ const resolvedFile = path.resolve(file);
106
+ const spawnOpts = {
79
107
  cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false,
80
108
  env: { ...process.env, BUILDKIT_PROGRESS: 'plain' },
81
- });
109
+ };
110
+
111
+ if (opts.noCache) {
112
+ // Chain: build --no-cache then up --force-recreate (skip all caches)
113
+ // Use safe spawn (no shell) to avoid command injection
114
+ const { EventEmitter } = require('events');
115
+ const { PassThrough } = require('stream');
116
+ const emitter = new EventEmitter();
117
+ const stdout = new PassThrough();
118
+ const stderr = new PassThrough();
119
+ emitter.stdout = stdout;
120
+ emitter.stderr = stderr;
121
+
122
+ const buildChild = spawn('docker', ['compose', '-f', resolvedFile, 'build', '--no-cache', service], spawnOpts);
123
+ buildChild.stdout.pipe(stdout, { end: false });
124
+ buildChild.stderr.pipe(stderr, { end: false });
125
+
126
+ buildChild.on('close', (code) => {
127
+ if (code !== 0) {
128
+ stdout.end();
129
+ stderr.end();
130
+ emitter.emit('close', code);
131
+ return;
132
+ }
133
+ const upChild = spawn('docker', ['compose', '-f', resolvedFile, 'up', '-d', '--force-recreate', service], spawnOpts);
134
+ upChild.stdout.pipe(stdout);
135
+ upChild.stderr.pipe(stderr);
136
+ upChild.on('close', (upCode) => emitter.emit('close', upCode));
137
+ emitter.kill = (sig) => upChild.kill(sig);
138
+ });
139
+
140
+ emitter.kill = (sig) => buildChild.kill(sig);
141
+ return emitter;
142
+ }
143
+
144
+ const args = ['compose', '-f', resolvedFile, 'up', '-d', '--build', service];
145
+ const child = spawn('docker', args, spawnOpts);
82
146
  return child;
83
147
  }
84
148
 
@@ -120,4 +184,48 @@ function restartService(file, service) {
120
184
  return child;
121
185
  }
122
186
 
123
- module.exports = { listServices, getStatuses, rebuildService, restartService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs };
187
+ function stopService(file, service) {
188
+ const cwd = path.dirname(path.resolve(file));
189
+ const args = ['compose', '-f', path.resolve(file), 'stop', service];
190
+ const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
191
+ return child;
192
+ }
193
+
194
+ function startService(file, service) {
195
+ const cwd = path.dirname(path.resolve(file));
196
+ const args = ['compose', '-f', path.resolve(file), 'start', service];
197
+ const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
198
+ return child;
199
+ }
200
+
201
+ function fetchContainerStats(containerIds) {
202
+ const args = ['stats', '--no-stream', '--format', '{{json .}}', ...containerIds];
203
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
204
+ return child;
205
+ }
206
+
207
+ function parseMemString(str) {
208
+ if (!str) return 0;
209
+ const match = str.match(/^([\d.]+)\s*(B|KiB|MiB|GiB|TiB|kB|MB|GB|TB)$/i);
210
+ if (!match) return 0;
211
+ const val = parseFloat(match[1]);
212
+ const unit = match[2].toLowerCase();
213
+ const multipliers = { b: 1, kib: 1024, mib: 1024 * 1024, gib: 1024 * 1024 * 1024, tib: 1024 * 1024 * 1024 * 1024, kb: 1000, mb: 1e6, gb: 1e9, tb: 1e12 };
214
+ return val * (multipliers[unit] || 1);
215
+ }
216
+
217
+ function parseStatsLine(jsonStr) {
218
+ try {
219
+ const obj = JSON.parse(jsonStr);
220
+ const cpuPercent = parseFloat((obj.CPUPerc || '').replace('%', '')) || 0;
221
+ const memUsageStr = (obj.MemUsage || '').split('/')[0].trim();
222
+ const memUsageBytes = parseMemString(memUsageStr);
223
+ const id = obj.ID || '';
224
+ const name = obj.Name || '';
225
+ return { id, name, cpuPercent, memUsageBytes };
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ module.exports = { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine, parseMemString };
package/lib/renderer.js CHANGED
@@ -76,8 +76,8 @@ function showCursor() {
76
76
  return `${ESC}?25h`;
77
77
  }
78
78
 
79
- function statusIcon(status, isRebuilding, isRestarting) {
80
- if (isRebuilding || isRestarting) return `${FG_YELLOW}\u25CF${RESET}`;
79
+ function statusIcon(status, isRebuilding, isRestarting, isStopping, isStarting) {
80
+ if (isRebuilding || isRestarting || isStopping || isStarting) return `${FG_YELLOW}\u25CF${RESET}`;
81
81
  if (!status) return `${FG_GRAY}\u25CB${RESET}`;
82
82
 
83
83
  const { state, health } = status;
@@ -89,7 +89,9 @@ function statusIcon(status, isRebuilding, isRestarting) {
89
89
  return `${FG_GRAY}\u25CB${RESET}`;
90
90
  }
91
91
 
92
- function statusText(status, isRebuilding, isRestarting) {
92
+ function statusText(status, isRebuilding, isRestarting, isStopping, isStarting) {
93
+ if (isStopping) return `${FG_YELLOW}STOPPING...${RESET}`;
94
+ if (isStarting) return `${FG_YELLOW}STARTING...${RESET}`;
93
95
  if (isRestarting) return `${FG_YELLOW}RESTARTING...${RESET}`;
94
96
  if (isRebuilding) return `${FG_YELLOW}REBUILDING...${RESET}`;
95
97
  if (!status) return `${FG_GRAY}stopped${RESET}`;
@@ -109,8 +111,15 @@ function statusText(status, isRebuilding, isRestarting) {
109
111
  return `${DIM}${text}${RESET}`;
110
112
  }
111
113
 
114
+ function formatMem(bytes) {
115
+ if (bytes <= 0) return '-';
116
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
117
+ if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M`;
118
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
119
+ }
120
+
112
121
  function renderLegend(opts = {}) {
113
- const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false } = opts;
122
+ const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false, noCacheActive = false } = opts;
114
123
  const item = (text, active) => {
115
124
  if (active) return `${BG_HIGHLIGHT} ${text} ${RESET}`;
116
125
  return `${DIM}${text}${RESET}`;
@@ -121,12 +130,16 @@ function renderLegend(opts = {}) {
121
130
  item('[j/k] scroll', false),
122
131
  item('[G] bottom', false),
123
132
  item('[gg] top', false),
133
+ item('[/] search', false),
134
+ item('[n/N] next/prev', false),
124
135
  item('[Q]uit', false),
125
136
  ].join(' ');
126
137
  }
127
138
  return [
128
- item('[R]ebuild', false),
129
- item('[S]restart', false),
139
+ item('Re[B]uild', false),
140
+ item('[S]tart/restart', false),
141
+ item('Sto[P]', false),
142
+ item('[N]o cache', noCacheActive),
130
143
  item('[F]ull logs', fullLogsActive),
131
144
  item('[L]og panel', logPanelActive),
132
145
  item('[Q]uit', false),
@@ -142,7 +155,7 @@ function renderListView(state) {
142
155
  for (const line of LOGO) {
143
156
  buf.push(line);
144
157
  }
145
- const help = renderLegend({ logPanelActive: state.showBottomLogs });
158
+ const help = renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache });
146
159
  buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
147
160
  buf.push(` ${help}`);
148
161
 
@@ -157,10 +170,53 @@ function renderListView(state) {
157
170
  const info = state.bottomLogLines.get(sk);
158
171
  if (info) {
159
172
  bottomBuf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
160
- const actionColor = info.action === 'rebuilding' || info.action === 'restarting' ? FG_YELLOW : FG_GREEN;
161
- bottomBuf.push(` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`);
173
+ const actionColor = info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' ? FG_YELLOW : FG_GREEN;
174
+ let headerLine = ` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`;
175
+ // Show search info
176
+ const bq = state.bottomSearchQuery || '';
177
+ if (bq && !state.bottomSearchActive) {
178
+ const matchCount = info.lines.filter(l => l.toLowerCase().includes(bq.toLowerCase())).length;
179
+ headerLine += matchCount > 0
180
+ ? ` ${DIM}search: "${bq}" (${matchCount} match${matchCount !== 1 ? 'es' : ''})${RESET}`
181
+ : ` ${FG_RED}search: "${bq}" (no matches)${RESET}`;
182
+ }
183
+ bottomBuf.push(headerLine);
184
+
185
+ const searchQuery = bq && !state.bottomSearchActive ? bq : '';
186
+
162
187
  for (const line of info.lines) {
163
- bottomBuf.push(` ${FG_GRAY}${line.substring(0, columns - 4)}${RESET}`);
188
+ let coloredLine = line.substring(0, columns - 4);
189
+ // Highlight search query
190
+ if (searchQuery) {
191
+ const lowerLine = coloredLine.toLowerCase();
192
+ const lowerQ = searchQuery.toLowerCase();
193
+ if (lowerLine.includes(lowerQ)) {
194
+ let result = '';
195
+ let pos = 0;
196
+ while (pos < coloredLine.length) {
197
+ const idx = lowerLine.indexOf(lowerQ, pos);
198
+ if (idx === -1) { result += coloredLine.substring(pos); break; }
199
+ result += coloredLine.substring(pos, idx);
200
+ result += `${REVERSE}${FG_YELLOW}${coloredLine.substring(idx, idx + searchQuery.length)}${RESET}${FG_GRAY}`;
201
+ pos = idx + searchQuery.length;
202
+ }
203
+ coloredLine = result;
204
+ }
205
+ }
206
+ // Highlight log scan patterns
207
+ for (let pi = 0; pi < patterns.length; pi++) {
208
+ const p = patterns[pi];
209
+ if (coloredLine.includes(p)) {
210
+ const color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
211
+ coloredLine = coloredLine.split(p).join(`${color}${p}${RESET}${FG_GRAY}`);
212
+ }
213
+ }
214
+ bottomBuf.push(` ${FG_GRAY}${coloredLine}${RESET}`);
215
+ }
216
+
217
+ // Search prompt
218
+ if (state.bottomSearchActive) {
219
+ bottomBuf.push(`${BOLD}/${RESET}${state.bottomSearchQuery}${BOLD}_${RESET}`);
164
220
  }
165
221
  }
166
222
  }
@@ -187,6 +243,7 @@ function renderListView(state) {
187
243
  }
188
244
  let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
189
245
  for (const p of patterns) colHeader += patternLabel(p).padStart(5) + ' ';
246
+ colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
190
247
  lines.push({ type: 'colheader', text: colHeader + RESET });
191
248
  }
192
249
 
@@ -194,10 +251,42 @@ function renderListView(state) {
194
251
  const st = state.statuses.get(sk);
195
252
  const rebuilding = state.rebuilding.has(sk);
196
253
  const restarting = state.restarting.has(sk);
197
- const icon = statusIcon(st, rebuilding, restarting);
198
- const stext = statusText(st, rebuilding, restarting);
254
+ const stopping = state.stopping.has(sk);
255
+ const starting = state.starting.has(sk);
256
+ const icon = statusIcon(st, rebuilding, restarting, stopping, starting);
257
+ const stext = statusText(st, rebuilding, restarting, stopping, starting);
199
258
  const name = entry.service.padEnd(24);
200
259
  const statusPadded = padVisible(stext, 22);
260
+
261
+ // CPU/MEM column
262
+ let cpuMemStr;
263
+ const stats = state.containerStats ? state.containerStats.get(sk) : null;
264
+ if (stats && st && st.state === 'running') {
265
+ const cpu = stats.cpuPercent;
266
+ const mem = stats.memUsageBytes;
267
+ const cpuWarn = state.config.cpuWarnThreshold || 50;
268
+ const cpuDanger = state.config.cpuDangerThreshold || 100;
269
+ const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
270
+ const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
271
+ let color = DIM;
272
+ if (cpu > cpuDanger || mem > memDanger) color = FG_RED;
273
+ else if (cpu > cpuWarn || mem > memWarn) color = FG_YELLOW;
274
+ const cpuText = cpu.toFixed(1) + '%';
275
+ const memText = formatMem(mem);
276
+ cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
277
+ } else {
278
+ cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
279
+ }
280
+
281
+ // Ports column
282
+ let portsStr;
283
+ if (st && st.ports && st.ports.length > 0) {
284
+ const portsText = st.ports.map(p => p.published).join(' ');
285
+ portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
286
+ } else {
287
+ portsStr = padVisible(`${DIM}-${RESET}`, 14);
288
+ }
289
+
201
290
  const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
202
291
  const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
203
292
  const pointer = i === state.cursor ? `${REVERSE}` : '';
@@ -214,7 +303,7 @@ function renderListView(state) {
214
303
 
215
304
  lines.push({
216
305
  type: 'service',
217
- text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr}${endPointer}`,
306
+ text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`,
218
307
  flatIdx: i,
219
308
  });
220
309
  }
@@ -270,6 +359,25 @@ function truncateLine(str, maxWidth) {
270
359
  return str;
271
360
  }
272
361
 
362
+ function highlightSearchInLine(line, query) {
363
+ if (!query) return line;
364
+ const lowerLine = line.toLowerCase();
365
+ const lowerQuery = query.toLowerCase();
366
+ let result = '';
367
+ let pos = 0;
368
+ while (pos < line.length) {
369
+ const idx = lowerLine.indexOf(lowerQuery, pos);
370
+ if (idx === -1) {
371
+ result += line.substring(pos);
372
+ break;
373
+ }
374
+ result += line.substring(pos, idx);
375
+ result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}`;
376
+ pos = idx + query.length;
377
+ }
378
+ return result;
379
+ }
380
+
273
381
  function renderLogView(state) {
274
382
  const { columns = 80, rows = 24 } = process.stdout;
275
383
  const buf = [];
@@ -284,13 +392,24 @@ function renderLogView(state) {
284
392
  const serviceName = entry ? entry.service : '???';
285
393
  const totalLines = state.logLines.length;
286
394
 
395
+ let statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
287
396
  const scrollStatus = state.logAutoScroll
288
397
  ? `${FG_GREEN}live${RESET}`
289
398
  : `${FG_YELLOW}paused ${DIM}line ${Math.max(1, totalLines - state.logScrollOffset)} / ${totalLines}${RESET}`;
290
- buf.push(` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET} ${scrollStatus}`);
399
+ statusLine += ` ${scrollStatus}`;
291
400
 
401
+ // Show search match count
402
+ if (state.logSearchQuery && state.logSearchMatches.length > 0) {
403
+ statusLine += ` ${DIM}match ${state.logSearchMatchIdx + 1}/${state.logSearchMatches.length}${RESET}`;
404
+ } else if (state.logSearchQuery && state.logSearchMatches.length === 0) {
405
+ statusLine += ` ${FG_RED}no matches${RESET}`;
406
+ }
407
+ buf.push(statusLine);
408
+
409
+ // Reserve 1 row for search prompt if active
410
+ const bottomReserved = state.logSearchActive ? 1 : 0;
292
411
  const headerHeight = buf.length;
293
- const availableRows = Math.max(1, rows - headerHeight);
412
+ const availableRows = Math.max(1, rows - headerHeight - bottomReserved);
294
413
 
295
414
  let endLine;
296
415
  if (state.logAutoScroll || state.logScrollOffset === 0) {
@@ -300,15 +419,28 @@ function renderLogView(state) {
300
419
  }
301
420
  const startLine = Math.max(0, endLine - availableRows);
302
421
 
422
+ const searchQuery = state.logSearchQuery || '';
423
+ const matchSet = searchQuery ? new Set(state.logSearchMatches) : null;
424
+
303
425
  for (let i = startLine; i < endLine; i++) {
304
- buf.push(truncateLine(state.logLines[i], columns));
426
+ let line = state.logLines[i];
427
+ if (matchSet && matchSet.has(i)) {
428
+ line = highlightSearchInLine(line, searchQuery);
429
+ }
430
+ buf.push(truncateLine(line, columns));
305
431
  }
306
432
 
307
- // Pad to fill screen (prevents ghost content from previous render)
308
- for (let i = buf.length; i < rows; i++) {
433
+ // Pad to fill screen
434
+ const targetRows = rows - bottomReserved;
435
+ for (let i = buf.length; i < targetRows; i++) {
309
436
  buf.push('');
310
437
  }
311
438
 
439
+ // Search prompt at the bottom
440
+ if (state.logSearchActive) {
441
+ buf.push(`${BOLD}/${RESET}${state.logSearchQuery}${BOLD}_${RESET}`);
442
+ }
443
+
312
444
  return buf.join('\n');
313
445
  }
314
446
 
package/lib/state.js CHANGED
@@ -11,8 +11,13 @@ function createState(config) {
11
11
  statuses: new Map(), // "file::service" -> { state, health }
12
12
  rebuilding: new Map(), // "file::service" -> childProcess
13
13
  restarting: new Map(), // "file::service" -> childProcess
14
+ stopping: new Map(), // "file::service" -> childProcess
15
+ starting: new Map(), // "file::service" -> childProcess
16
+ containerStats: new Map(), // statusKey -> { cpuPercent, memUsageBytes }
17
+ containerStatsHistory: new Map(), // statusKey -> { cpu: number[], mem: number[], idx, count }
14
18
  logChild: null,
15
19
  scrollOffset: 0,
20
+ noCache: false,
16
21
  showBottomLogs: true,
17
22
  bottomLogLines: new Map(), // statusKey -> { action, service, lines: [] }
18
23
  bottomLogTails: new Map(), // statusKey -> childProcess (log tail after restart)
@@ -21,6 +26,12 @@ function createState(config) {
21
26
  logLines: [], // buffered log lines for full log view
22
27
  logScrollOffset: 0, // lines from bottom (0 = at bottom)
23
28
  logAutoScroll: true, // auto-scroll to bottom on new data
29
+ logSearchQuery: '',
30
+ logSearchActive: false, // true when typing search query
31
+ logSearchMatches: [], // array of line indices
32
+ logSearchMatchIdx: -1, // current match position
33
+ bottomSearchQuery: '',
34
+ bottomSearchActive: false, // true when typing search in bottom panel
24
35
  config,
25
36
  };
26
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recomposable",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Docker Compose TUI manager with vim keybindings — monitor, restart, rebuild, and tail logs for your services",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
- "lib/"
11
+ "lib/",
12
+ "screenshots/"
12
13
  ],
13
14
  "keywords": [
14
15
  "docker",
Binary file
Binary file