recomposable 1.0.2 → 1.1.1

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/index.js DELETED
@@ -1,662 +0,0 @@
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, getContainerId, tailContainerLogs, fetchContainerLogs } = require('./lib/docker');
7
- const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
8
- const { clearScreen, showCursor, renderListView, renderLogView } = require('./lib/renderer');
9
-
10
- // --- Config ---
11
-
12
- function loadConfig() {
13
- const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000 };
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
- // --- Log Pattern Scanning ---
73
-
74
- let logScanActive = false;
75
-
76
- function pollLogCounts(state) {
77
- if (logScanActive) return;
78
- const scanPatterns = state.config.logScanPatterns || [];
79
- if (scanPatterns.length === 0) return;
80
- const tailLines = state.config.logScanLines || 1000;
81
-
82
- const toScan = [];
83
- for (const group of state.groups) {
84
- if (group.error) continue;
85
- for (const service of group.services) {
86
- const sk = statusKey(group.file, service);
87
- const st = state.statuses.get(sk);
88
- if (!st || st.state !== 'running' || !st.id) continue;
89
- toScan.push({ sk, containerId: st.id });
90
- }
91
- }
92
-
93
- if (toScan.length === 0) return;
94
- logScanActive = true;
95
- let remaining = toScan.length;
96
-
97
- for (const { sk, containerId } of toScan) {
98
- const child = fetchContainerLogs(containerId, tailLines);
99
- let output = '';
100
- child.stdout.on('data', (d) => { output += d.toString(); });
101
- child.stderr.on('data', (d) => { output += d.toString(); });
102
- child.on('close', () => {
103
- const counts = new Map();
104
- for (const pattern of scanPatterns) {
105
- let count = 0;
106
- let idx = 0;
107
- while ((idx = output.indexOf(pattern, idx)) !== -1) {
108
- count++;
109
- idx += pattern.length;
110
- }
111
- counts.set(pattern, count);
112
- }
113
- state.logCounts.set(sk, counts);
114
- remaining--;
115
- if (remaining === 0) {
116
- logScanActive = false;
117
- if (state.mode === MODE.LIST) throttledRender(state);
118
- }
119
- });
120
- child.on('error', () => {
121
- remaining--;
122
- if (remaining === 0) {
123
- logScanActive = false;
124
- if (state.mode === MODE.LIST) throttledRender(state);
125
- }
126
- });
127
- }
128
- }
129
-
130
- // --- Rendering ---
131
-
132
- function render(state) {
133
- let output = clearScreen();
134
- if (state.mode === MODE.LIST) {
135
- output += renderListView(state);
136
- } else if (state.mode === MODE.LOGS) {
137
- output += renderLogView(state);
138
- }
139
- process.stdout.write(output);
140
- }
141
-
142
- function stripAnsi(str) {
143
- return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
144
- }
145
-
146
- let lastRenderTime = 0;
147
- let pendingRender = null;
148
- let logFetchTimer = null;
149
-
150
- function throttledRender(state) {
151
- const now = Date.now();
152
- const elapsed = now - lastRenderTime;
153
- if (elapsed >= 150) {
154
- lastRenderTime = now;
155
- render(state);
156
- } else if (!pendingRender) {
157
- pendingRender = setTimeout(() => {
158
- pendingRender = null;
159
- lastRenderTime = Date.now();
160
- render(state);
161
- }, 150 - elapsed);
162
- }
163
- }
164
-
165
- // --- Actions ---
166
-
167
- function updateSelectedLogs(state) {
168
- const entry = selectedEntry(state);
169
- if (!entry) return;
170
-
171
- const sk = statusKey(entry.file, entry.service);
172
-
173
- // Same container already selected, nothing to do
174
- if (state.selectedLogKey === sk) return;
175
-
176
- // Cancel any pending debounced log fetch
177
- if (logFetchTimer) {
178
- clearTimeout(logFetchTimer);
179
- logFetchTimer = null;
180
- }
181
-
182
- // Clean up previous selected container's passive log tail
183
- if (state.selectedLogKey) {
184
- const oldInfo = state.bottomLogLines.get(state.selectedLogKey);
185
- if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
186
- if (!state.rebuilding.has(state.selectedLogKey) && !state.restarting.has(state.selectedLogKey)) {
187
- state.bottomLogLines.delete(state.selectedLogKey);
188
- if (state.bottomLogTails.has(state.selectedLogKey)) {
189
- state.bottomLogTails.get(state.selectedLogKey).kill('SIGTERM');
190
- state.bottomLogTails.delete(state.selectedLogKey);
191
- }
192
- }
193
- }
194
- }
195
-
196
- state.selectedLogKey = sk;
197
-
198
- // If this container already has active action logs (rebuild/restart/started), keep those
199
- if (state.bottomLogLines.has(sk)) return;
200
-
201
- // Set up empty log entry immediately so the UI shows the container name
202
- state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
203
-
204
- // Debounce the expensive log fetch (getContainerId is a blocking execFileSync)
205
- logFetchTimer = setTimeout(() => {
206
- logFetchTimer = null;
207
- startBottomLogTail(state, sk, entry.file, entry.service);
208
- }, 500);
209
- }
210
-
211
- function doRebuild(state) {
212
- const entry = selectedEntry(state);
213
- if (!entry) return;
214
-
215
- const sk = statusKey(entry.file, entry.service);
216
- if (state.rebuilding.has(sk)) return;
217
-
218
- // Kill any existing startup log tail for this service
219
- if (state.bottomLogTails.has(sk)) {
220
- state.bottomLogTails.get(sk).kill('SIGTERM');
221
- state.bottomLogTails.delete(sk);
222
- }
223
-
224
- const child = rebuildService(entry.file, entry.service);
225
- state.rebuilding.set(sk, child);
226
-
227
- state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
228
-
229
- let lineBuf = '';
230
- const onData = (data) => {
231
- const info = state.bottomLogLines.get(sk);
232
- if (!info) return;
233
- lineBuf += data.toString();
234
- const parts = lineBuf.split(/\r?\n|\r/);
235
- lineBuf = parts.pop();
236
- const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
237
- if (newLines.length === 0) return;
238
- info.lines.push(...newLines);
239
- if (info.lines.length > 10) info.lines = info.lines.slice(-10);
240
- if (state.mode === MODE.LIST) throttledRender(state);
241
- };
242
-
243
- child.stdout.on('data', onData);
244
- child.stderr.on('data', onData);
245
- render(state);
246
-
247
- child.on('close', () => {
248
- state.rebuilding.delete(sk);
249
- pollStatuses(state);
250
-
251
- // Show container application logs after rebuild+start
252
- const info = state.bottomLogLines.get(sk);
253
- if (info) {
254
- info.action = 'started';
255
- info.lines = [];
256
- }
257
-
258
- startBottomLogTail(state, sk, entry.file, entry.service);
259
- if (state.mode === MODE.LIST) render(state);
260
- });
261
- }
262
-
263
- function startBottomLogTail(state, sk, file, service) {
264
- // Kill any existing tail for this service
265
- if (state.bottomLogTails.has(sk)) {
266
- state.bottomLogTails.get(sk).kill('SIGTERM');
267
- state.bottomLogTails.delete(sk);
268
- }
269
-
270
- // Get container ID and use docker logs directly (avoids compose buffering)
271
- const containerId = getContainerId(file, service);
272
- if (!containerId) return;
273
-
274
- const logChild = tailContainerLogs(containerId, 10);
275
- state.bottomLogTails.set(sk, logChild);
276
-
277
- let buf = '';
278
- const onData = (data) => {
279
- const info = state.bottomLogLines.get(sk);
280
- if (!info) return;
281
- buf += data.toString();
282
- const parts = buf.split(/\r?\n|\r/);
283
- buf = parts.pop();
284
- const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
285
- if (newLines.length === 0) return;
286
- info.lines.push(...newLines);
287
- if (info.lines.length > 10) info.lines = info.lines.slice(-10);
288
- if (state.mode === MODE.LIST) throttledRender(state);
289
- };
290
-
291
- logChild.stdout.on('data', onData);
292
- logChild.stderr.on('data', onData);
293
- }
294
-
295
- function doRestart(state) {
296
- const entry = selectedEntry(state);
297
- if (!entry) return;
298
-
299
- const sk = statusKey(entry.file, entry.service);
300
- if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
301
-
302
- // Kill any existing startup log tail for this service
303
- if (state.bottomLogTails.has(sk)) {
304
- state.bottomLogTails.get(sk).kill('SIGTERM');
305
- state.bottomLogTails.delete(sk);
306
- }
307
-
308
- const child = restartService(entry.file, entry.service);
309
- state.restarting.set(sk, child);
310
-
311
- state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
312
- render(state);
313
-
314
- child.on('close', () => {
315
- state.restarting.delete(sk);
316
- pollStatuses(state);
317
-
318
- // Show container application logs after restart
319
- const info = state.bottomLogLines.get(sk);
320
- if (info) {
321
- info.action = 'started';
322
- info.lines = [];
323
- }
324
-
325
- startBottomLogTail(state, sk, entry.file, entry.service);
326
- if (state.mode === MODE.LIST) render(state);
327
- });
328
- }
329
-
330
- function enterLogs(state) {
331
- const entry = selectedEntry(state);
332
- if (!entry) return;
333
-
334
- if (logFetchTimer) {
335
- clearTimeout(logFetchTimer);
336
- logFetchTimer = null;
337
- }
338
-
339
- state.mode = MODE.LOGS;
340
- state.logLines = [];
341
- state.logScrollOffset = 0;
342
- state.logAutoScroll = true;
343
-
344
- const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
345
- state.logChild = child;
346
-
347
- let lineBuf = '';
348
- const onData = (data) => {
349
- lineBuf += data.toString();
350
- const parts = lineBuf.split(/\r?\n|\r/);
351
- lineBuf = parts.pop();
352
- if (parts.length === 0) return;
353
- for (const line of parts) {
354
- state.logLines.push(stripAnsi(line));
355
- }
356
- // Cap buffer at 10000 lines
357
- if (state.logLines.length > 10000) {
358
- const excess = state.logLines.length - 10000;
359
- state.logLines.splice(0, excess);
360
- if (!state.logAutoScroll) {
361
- state.logScrollOffset = Math.max(0, state.logScrollOffset - excess);
362
- }
363
- }
364
- if (state.logAutoScroll) {
365
- throttledRender(state);
366
- }
367
- };
368
-
369
- child.stdout.on('data', onData);
370
- child.stderr.on('data', onData);
371
- child.on('close', () => {
372
- if (state.logChild === child) {
373
- state.logChild = null;
374
- }
375
- });
376
-
377
- render(state);
378
- }
379
-
380
- function exitLogs(state) {
381
- if (state.logChild) {
382
- state.logChild.kill('SIGTERM');
383
- state.logChild = null;
384
- }
385
- state.logLines = [];
386
- state.mode = MODE.LIST;
387
- pollStatuses(state);
388
- render(state);
389
- }
390
-
391
- // --- Input Handling ---
392
-
393
- function handleKeypress(state, key) {
394
- // Ctrl+C always quits
395
- if (key === '\x03') {
396
- cleanup(state);
397
- process.exit(0);
398
- }
399
-
400
- if (state.mode === MODE.LOGS) {
401
- const { rows = 24 } = process.stdout;
402
- const pageSize = Math.max(1, Math.floor(rows / 2));
403
- const maxOffset = Math.max(0, state.logLines.length - 1);
404
-
405
- switch (key) {
406
- case 'f':
407
- case '\x1b':
408
- exitLogs(state);
409
- break;
410
- case 'q':
411
- cleanup(state);
412
- process.exit(0);
413
- break;
414
- case 'k':
415
- case '\x1b[A':
416
- state.logAutoScroll = false;
417
- state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
418
- render(state);
419
- break;
420
- case 'j':
421
- case '\x1b[B':
422
- if (state.logScrollOffset > 0) {
423
- state.logScrollOffset--;
424
- if (state.logScrollOffset === 0) state.logAutoScroll = true;
425
- }
426
- render(state);
427
- break;
428
- case 'G':
429
- state.logScrollOffset = 0;
430
- state.logAutoScroll = true;
431
- render(state);
432
- break;
433
- case '\x15': // Ctrl+U - page up
434
- state.logAutoScroll = false;
435
- state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
436
- render(state);
437
- break;
438
- case '\x04': // Ctrl+D - page down
439
- state.logScrollOffset = Math.max(0, state.logScrollOffset - pageSize);
440
- if (state.logScrollOffset === 0) state.logAutoScroll = true;
441
- render(state);
442
- break;
443
- }
444
- return;
445
- }
446
-
447
- // LIST mode
448
- switch (key) {
449
- case 'j':
450
- case '\x1b[B': // Arrow Down
451
- moveCursor(state, 1);
452
- updateSelectedLogs(state);
453
- render(state);
454
- break;
455
- case 'k':
456
- case '\x1b[A': // Arrow Up
457
- moveCursor(state, -1);
458
- updateSelectedLogs(state);
459
- render(state);
460
- break;
461
- case 'r':
462
- doRebuild(state);
463
- break;
464
- case 's':
465
- doRestart(state);
466
- break;
467
- case 'f':
468
- case '\r': // Enter
469
- enterLogs(state);
470
- break;
471
- case 'l':
472
- state.showBottomLogs = !state.showBottomLogs;
473
- render(state);
474
- break;
475
- case 'q':
476
- cleanup(state);
477
- process.exit(0);
478
- break;
479
- case 'G': // vim: go to bottom
480
- state.cursor = state.flatList.length - 1;
481
- updateSelectedLogs(state);
482
- render(state);
483
- break;
484
- case 'g': // gg handled via double-tap buffer below
485
- break;
486
- }
487
- }
488
-
489
- // --- Arrow key sequence buffering ---
490
-
491
- function createInputHandler(state) {
492
- let buf = '';
493
- let gPending = false;
494
-
495
- return function onData(data) {
496
- const str = data.toString();
497
-
498
- // Handle escape sequences (arrow keys)
499
- buf += str;
500
-
501
- while (buf.length > 0) {
502
- // Check for escape sequences
503
- if (buf === '\x1b') {
504
- // Could be start of escape sequence — wait for more
505
- setTimeout(() => {
506
- if (buf === '\x1b') {
507
- handleKeypress(state, '\x1b');
508
- buf = '';
509
- }
510
- }, 50);
511
- return;
512
- }
513
-
514
- if (buf.startsWith('\x1b[A')) {
515
- handleKeypress(state, '\x1b[A');
516
- buf = buf.slice(3);
517
- continue;
518
- }
519
- if (buf.startsWith('\x1b[B')) {
520
- handleKeypress(state, '\x1b[B');
521
- buf = buf.slice(3);
522
- continue;
523
- }
524
- if (buf.startsWith('\x1b[')) {
525
- // Unknown escape sequence — skip it
526
- buf = buf.slice(buf.length);
527
- continue;
528
- }
529
-
530
- // Single character
531
- const ch = buf[0];
532
- buf = buf.slice(1);
533
-
534
- // Handle gg (go to top)
535
- if (ch === 'g') {
536
- if (gPending) {
537
- gPending = false;
538
- if (state.mode === MODE.LIST) {
539
- state.cursor = 0;
540
- state.scrollOffset = 0;
541
- updateSelectedLogs(state);
542
- } else if (state.mode === MODE.LOGS) {
543
- state.logAutoScroll = false;
544
- state.logScrollOffset = Math.max(0, state.logLines.length - 1);
545
- }
546
- render(state);
547
- continue;
548
- }
549
- gPending = true;
550
- setTimeout(() => {
551
- if (gPending) {
552
- gPending = false;
553
- // Single g — ignore
554
- }
555
- }, 300);
556
- continue;
557
- }
558
-
559
- gPending = false;
560
- handleKeypress(state, ch);
561
- }
562
- };
563
- }
564
-
565
- // --- Cleanup ---
566
-
567
- function cleanup(state) {
568
- if (state.logChild) {
569
- state.logChild.kill('SIGTERM');
570
- state.logChild = null;
571
- }
572
- for (const [, child] of state.rebuilding) {
573
- child.kill('SIGTERM');
574
- }
575
- state.rebuilding.clear();
576
- for (const [, child] of state.restarting) {
577
- child.kill('SIGTERM');
578
- }
579
- state.restarting.clear();
580
- for (const [, child] of state.bottomLogTails) {
581
- child.kill('SIGTERM');
582
- }
583
- state.bottomLogTails.clear();
584
- if (logFetchTimer) {
585
- clearTimeout(logFetchTimer);
586
- logFetchTimer = null;
587
- }
588
- if (state.logScanTimer) {
589
- clearInterval(state.logScanTimer);
590
- }
591
- if (state.pollTimer) {
592
- clearInterval(state.pollTimer);
593
- }
594
- process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
595
- }
596
-
597
- // --- Main ---
598
-
599
- function main() {
600
- const config = loadConfig();
601
- const state = createState(config);
602
-
603
- // Discover services
604
- state.groups = discoverServices(config);
605
- state.flatList = buildFlatList(state.groups);
606
-
607
- if (state.flatList.length === 0) {
608
- process.stderr.write('No services found in any compose file.\n');
609
- process.exit(1);
610
- }
611
-
612
- // Initial status poll
613
- pollStatuses(state);
614
-
615
- // Setup terminal
616
- if (process.stdin.isTTY) {
617
- process.stdin.setRawMode(true);
618
- }
619
- process.stdin.resume();
620
- process.stdin.setEncoding('utf8');
621
- process.stdin.on('data', createInputHandler(state));
622
-
623
- // Initial log pattern scan
624
- pollLogCounts(state);
625
-
626
- // Start log tail for initially selected container and render
627
- updateSelectedLogs(state);
628
- render(state);
629
-
630
- // Poll loop
631
- state.pollTimer = setInterval(() => {
632
- if (state.mode === MODE.LIST) {
633
- pollStatuses(state);
634
- render(state);
635
- }
636
- }, config.pollInterval);
637
-
638
- // Log pattern scan loop
639
- state.logScanTimer = setInterval(() => {
640
- if (state.mode === MODE.LIST) {
641
- pollLogCounts(state);
642
- }
643
- }, config.logScanInterval || 10000);
644
-
645
- // Terminal resize
646
- process.stdout.on('resize', () => {
647
- render(state);
648
- });
649
-
650
- // Cleanup on exit
651
- process.on('exit', () => cleanup(state));
652
- process.on('SIGINT', () => {
653
- cleanup(state);
654
- process.exit(0);
655
- });
656
- process.on('SIGTERM', () => {
657
- cleanup(state);
658
- process.exit(0);
659
- });
660
- }
661
-
662
- main();