startall 0.0.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.
@@ -0,0 +1,23 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: '20'
19
+ registry-url: 'https://registry.npmjs.org'
20
+
21
+ - run: npm ci
22
+
23
+ - run: npm publish --access public --provenance
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # 🚀 Start
2
+
3
+ > An interactive terminal UI for managing multiple npm scripts in parallel
4
+
5
+ ## The Problem
6
+
7
+ Running multiple npm scripts during development is tedious:
8
+
9
+ - **Repetitive**: Manually typing `npm run frontend`, `npm run backend`, etc. every time
10
+ - **Cluttered terminal**: Multiple terminal tabs/windows get messy fast
11
+ - **No visibility**: Hard to tell if one process crashed while others are running
12
+ - **No control**: Can't easily restart a single service without restarting everything
13
+ - **No filtering**: Output from 4+ processes becomes unreadable noise
14
+
15
+ Traditional solutions fall short:
16
+ - `npm-run-all`/`concurrently`: No interactivity, just dumps output
17
+ - PM2: Overkill for dev workflows, designed for production
18
+ - Overmind: Amazing UX but requires tmux (not Windows-friendly)
19
+ - Manual shell scripts: No real-time status or control
20
+
21
+ ## The Solution
22
+
23
+ **Start** is a lightweight, interactive TUI that gives you complete control over your development processes:
24
+
25
+ ```
26
+ ┌─ Starting in 7s... [Enter to start now] ─────────────┐
27
+ │ [x] frontend (npm run start:frontend) │
28
+ │ [x] backend (npm run start:backend) │
29
+ │ [ ] worker (npm run start:worker) │
30
+ │ [x] db (npm run start:db) │
31
+ │ │
32
+ │ ↑/↓ Navigate | Space: Toggle | Enter: Start │
33
+ └───────────────────────────────────────────────────────┘
34
+
35
+ After starting:
36
+ ┌─ Processes ──────────────┬─ Output (filter: error) ───┐
37
+ │ [f] frontend ● Running │ [backend] Error: ECONNREF │
38
+ │ [b] backend ✖ Crashed │ [backend] Retrying... │
39
+ │ [w] worker ⏸ Stopped │ [frontend] Started on 3000 │
40
+ │ [d] db ● Running │ │
41
+ │ │ │
42
+ │ Space: Start/Stop │ │
43
+ │ r: Restart │ │
44
+ │ /: Filter output │ │
45
+ └──────────────────────────┴────────────────────────────┘
46
+ ```
47
+
48
+ ## Features
49
+
50
+ ### ✅ Current
51
+ - **Auto-discovery**: Reads all scripts from `package.json` automatically
52
+ - **Smart defaults**: Remembers your last selection
53
+ - **10-second countdown**: Time to review/change selections before starting
54
+ - **Parallel execution**: Run multiple npm scripts simultaneously
55
+ - **Colored output**: Each process gets its own color prefix
56
+
57
+ ### 🚧 Planned
58
+ - **Live status monitoring**: See which processes are running/crashed/stopped at a glance
59
+ - **Interactive controls**: Start, stop, and restart individual processes with keyboard shortcuts
60
+ - **Output filtering**: Search/filter logs across all processes in real-time
61
+ - **Cross-platform**: Works identically on Windows, Linux, and macOS
62
+ - **Tab view**: Switch between different process outputs
63
+ - **Resource monitoring**: CPU/memory usage per process
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ npm install -g startall
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ In any project with a `package.json`:
74
+
75
+ ```bash
76
+ startall # uses startall.json if present
77
+ startall myconfig.json # uses custom config file
78
+ ```
79
+
80
+ That's it! The TUI will:
81
+
82
+ 1. Show all available npm scripts
83
+ 2. Pre-select your last choices
84
+ 3. Give you 10 seconds to adjust
85
+ 4. Start all selected scripts in parallel
86
+
87
+ ### Keyboard Shortcuts
88
+
89
+ **Selection Screen:**
90
+ - `↑`/`↓` - Navigate scripts
91
+ - `Space` - Toggle selection
92
+ - `Enter` - Start immediately (skip countdown)
93
+ - `Ctrl+C` - Exit
94
+
95
+ **Running Screen (planned):**
96
+ - `Space` - Start/stop selected process
97
+ - `r` - Restart selected process
98
+ - `/` - Filter output
99
+ - `Tab` - Switch between processes
100
+ - `Ctrl+C` - Stop all and exit
101
+
102
+ ## Why Build This?
103
+
104
+ Existing tools either:
105
+ - Lack interactivity (concurrently, npm-run-all)
106
+ - Are production-focused (PM2, forever)
107
+ - Don't support Windows (Overmind, tmux-based tools)
108
+ - Are too heavyweight for simple dev workflows
109
+
110
+ **Start** is purpose-built for the development workflow: lightweight, cross-platform, and interactive.
111
+
112
+ ## Technical Details
113
+
114
+ - Built with [OpenTUI](https://github.com/openmux/opentui) for a modern terminal UI
115
+ - Uses standard Node.js `child_process` (no PTY required = Windows support)
116
+ - Parses `package.json` scripts automatically
117
+ - Saves configuration in `startall.json` with structure: `{ defaultSelection: [], ignore: [] }`
118
+
119
+ ## Roadmap
120
+
121
+ - [ ] Interactive process control (start/stop/restart)
122
+ - [ ] Real-time status indicators
123
+ - [ ] Output filtering and search
124
+ - [ ] Tab view for individual process logs
125
+ - [ ] Resource monitoring
126
+ - [ ] Custom color schemes
127
+ - [ ] Configuration file support
128
+ - [ ] Watch mode (restart on file changes)
129
+
130
+ ## Contributing
131
+
132
+ PRs welcome! This is a tool built by developers, for developers.
133
+
134
+ ## License
135
+
136
+ MIT
package/index.js ADDED
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, t, fg } from '@opentui/core';
4
+ import { spawn } from 'child_process';
5
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import kill from 'tree-kill';
8
+
9
+ // Configuration
10
+ const CONFIG_FILE = process.argv[2] || 'startall.json';
11
+ const COUNTDOWN_SECONDS = 10;
12
+
13
+ // Match string against pattern with wildcard support
14
+ function matchesPattern(str, pattern) {
15
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
16
+ return regex.test(str);
17
+ }
18
+
19
+ function isIgnored(name, ignorePatterns) {
20
+ return ignorePatterns.some(pattern => matchesPattern(name, pattern));
21
+ }
22
+
23
+ // Parse npm scripts from package.json
24
+ function parseNpmScripts(packageJsonPath) {
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
27
+ const scripts = pkg.scripts || {};
28
+
29
+ return Object.entries(scripts)
30
+ .filter(([name]) => !name.startsWith('pre') && !name.startsWith('post') && name !== 'start')
31
+ .map(([name, command]) => ({
32
+ name,
33
+ command: `npm run ${name}`,
34
+ displayName: name,
35
+ }));
36
+ } catch (error) {
37
+ console.error('Error reading package.json:', error.message);
38
+ process.exit(1);
39
+ }
40
+ }
41
+
42
+ // Load config
43
+ function loadConfig() {
44
+ if (existsSync(CONFIG_FILE)) {
45
+ try {
46
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
47
+ } catch {
48
+ return { defaultSelection: [], ignore: [] };
49
+ }
50
+ }
51
+ return { defaultSelection: [], ignore: [] };
52
+ }
53
+
54
+ // Save config
55
+ function saveConfig(config) {
56
+ try {
57
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
58
+ } catch (error) {
59
+ console.error('Error saving config:', error.message);
60
+ }
61
+ }
62
+
63
+ // Process Manager
64
+ class ProcessManager {
65
+ constructor(renderer, scripts) {
66
+ this.renderer = renderer;
67
+ this.config = loadConfig();
68
+ this.scripts = scripts.filter(s => !isIgnored(s.name, this.config.ignore));
69
+ this.phase = 'selection'; // 'selection' | 'running'
70
+ this.selectedScripts = new Set(this.config.defaultSelection);
71
+ this.countdown = COUNTDOWN_SECONDS;
72
+ this.selectedIndex = 0;
73
+ this.processes = new Map();
74
+ this.processRefs = new Map();
75
+ this.outputLines = [];
76
+ this.filter = '';
77
+ this.maxOutputLines = 1000;
78
+ this.maxVisibleLines = 30; // Number of recent lines to show when not paused
79
+ this.isPaused = false; // Whether output scrolling is paused
80
+ this.wasPaused = false; // Track previous pause state to detect changes
81
+ this.isFilterMode = false; // Whether in filter input mode
82
+ this.outputBox = null; // Reference to the output container
83
+ this.lastRenderedLineCount = 0; // Track how many lines we've rendered
84
+ this.headerRenderable = null; // Reference to header text in running UI
85
+ this.processListRenderable = null; // Reference to process list text in running UI
86
+
87
+ // Assign colors to each script
88
+ this.processColors = new Map();
89
+ const colors = ['#7aa2f7', '#bb9af7', '#9ece6a', '#f7768e', '#e0af68', '#73daca'];
90
+ scripts.forEach((script, index) => {
91
+ this.processColors.set(script.name, colors[index % colors.length]);
92
+ });
93
+
94
+ // UI references
95
+ this.headerText = null;
96
+ this.scriptLines = [];
97
+ this.scriptLinePositions = []; // Track Y positions of script lines for mouse clicks
98
+ this.selectionContainer = null;
99
+ this.runningContainer = null;
100
+
101
+ this.setupKeyboardHandlers();
102
+ this.setupMouseHandlers();
103
+ this.buildSelectionUI();
104
+ this.startCountdown();
105
+ }
106
+
107
+ setupKeyboardHandlers() {
108
+ this.renderer.keyInput.on('keypress', (key) => {
109
+ // Handle Ctrl+C (if exitOnCtrlC is false)
110
+ if (key.ctrl && key.name === 'c') {
111
+ this.cleanup();
112
+ this.renderer.destroy();
113
+ return;
114
+ }
115
+
116
+ this.handleInput(key.name, key);
117
+ this.render();
118
+ });
119
+ }
120
+
121
+ setupMouseHandlers() {
122
+ // Mouse events are handled via BoxRenderable properties, not a global handler
123
+ // We'll add onMouseDown to individual script lines in buildSelectionUI
124
+ }
125
+
126
+ handleInput(keyName, keyEvent) {
127
+ if (this.phase === 'selection') {
128
+ if (keyName === 'enter' || keyName === 'return') {
129
+ clearInterval(this.countdownInterval);
130
+ this.startProcesses();
131
+ } else if (keyName === 'up') {
132
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
133
+ } else if (keyName === 'down') {
134
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
135
+ } else if (keyName === 'space') {
136
+ const scriptName = this.scripts[this.selectedIndex]?.name;
137
+ if (scriptName) {
138
+ if (this.selectedScripts.has(scriptName)) {
139
+ this.selectedScripts.delete(scriptName);
140
+ } else {
141
+ this.selectedScripts.add(scriptName);
142
+ }
143
+ }
144
+ }
145
+ } else if (this.phase === 'running') {
146
+ // If in filter mode, handle filter input
147
+ if (this.isFilterMode) {
148
+ if (keyName === 'escape') {
149
+ this.isFilterMode = false;
150
+ this.filter = '';
151
+ this.buildRunningUI(); // Rebuild to clear filter
152
+ } else if (keyName === 'enter' || keyName === 'return') {
153
+ this.isFilterMode = false;
154
+ this.isPaused = true; // Pause when filter is applied
155
+ this.updateStreamPauseState();
156
+ this.buildRunningUI(); // Rebuild with filter
157
+ } else if (keyName === 'backspace') {
158
+ this.filter = this.filter.slice(0, -1);
159
+ this.buildRunningUI(); // Update UI to show filter change
160
+ } else if (keyName && keyName.length === 1 && !keyEvent.ctrl && !keyEvent.meta) {
161
+ this.filter += keyName;
162
+ this.buildRunningUI(); // Update UI to show filter change
163
+ }
164
+ } else {
165
+ // Normal mode - handle commands
166
+ if (keyName === 'q') {
167
+ this.cleanup();
168
+ this.renderer.destroy();
169
+ } else if (keyName === 'space') {
170
+ // Toggle pause output scrolling
171
+ this.isPaused = !this.isPaused;
172
+ this.updateStreamPauseState();
173
+ } else if (keyName === 'f') {
174
+ // Filter to currently selected process
175
+ const scriptName = this.scripts[this.selectedIndex]?.name;
176
+ if (scriptName) {
177
+ this.filter = scriptName;
178
+ this.isPaused = true; // Auto-pause when filtering
179
+ this.updateStreamPauseState();
180
+ this.buildRunningUI(); // Rebuild to apply filter
181
+ }
182
+ } else if (keyName === '/') {
183
+ // Enter filter mode
184
+ this.isFilterMode = true;
185
+ this.filter = '';
186
+ } else if (keyName === 'escape') {
187
+ // Clear filter and unpause
188
+ this.filter = '';
189
+ this.isPaused = false;
190
+ this.updateStreamPauseState();
191
+ this.buildRunningUI(); // Rebuild to clear filter
192
+ } else if (keyName === 'up' || keyName === 'k') {
193
+ // Navigate processes up
194
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
195
+ this.buildRunningUI(); // Rebuild to show selection change
196
+ } else if (keyName === 'down' || keyName === 'j') {
197
+ // Navigate processes down
198
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
199
+ this.buildRunningUI(); // Rebuild to show selection change
200
+ } else if (keyName === 'left' || keyName === 'h') {
201
+ // Navigate processes left
202
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
203
+ this.buildRunningUI(); // Rebuild to show selection change
204
+ } else if (keyName === 'right' || keyName === 'l') {
205
+ // Navigate processes right
206
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
207
+ this.buildRunningUI(); // Rebuild to show selection change
208
+ } else if (keyName === 'r') {
209
+ const scriptName = this.scripts[this.selectedIndex]?.name;
210
+ if (scriptName) {
211
+ this.restartProcess(scriptName);
212
+ }
213
+ } else if (keyName === 's') {
214
+ // Stop/start selected process
215
+ const scriptName = this.scripts[this.selectedIndex]?.name;
216
+ if (scriptName) {
217
+ this.toggleProcess(scriptName);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ handleMouse(mouse) {
225
+ if (this.phase === 'selection') {
226
+ // Left click or scroll wheel click
227
+ if (mouse.type === 'mousedown' && (mouse.button === 'left' || mouse.button === 'middle')) {
228
+ // Check if click is on a script line
229
+ const clickedIndex = this.scriptLinePositions.findIndex(pos => pos === mouse.y);
230
+
231
+ if (clickedIndex !== -1) {
232
+ const scriptName = this.scripts[clickedIndex]?.name;
233
+ if (scriptName) {
234
+ // Toggle selection
235
+ if (this.selectedScripts.has(scriptName)) {
236
+ this.selectedScripts.delete(scriptName);
237
+ } else {
238
+ this.selectedScripts.add(scriptName);
239
+ }
240
+ // Update focused index
241
+ this.selectedIndex = clickedIndex;
242
+ this.render();
243
+ }
244
+ }
245
+ } else if (mouse.type === 'wheeldown') {
246
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
247
+ this.render();
248
+ } else if (mouse.type === 'wheelup') {
249
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
250
+ this.render();
251
+ }
252
+ } else if (this.phase === 'running') {
253
+ // Mouse support for running phase
254
+ if (mouse.type === 'mousedown' && mouse.button === 'left') {
255
+ const clickedIndex = this.scriptLinePositions.findIndex(pos => pos === mouse.y);
256
+
257
+ if (clickedIndex !== -1) {
258
+ this.selectedIndex = clickedIndex;
259
+ this.render();
260
+ }
261
+ } else if (mouse.type === 'wheeldown') {
262
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
263
+ this.render();
264
+ } else if (mouse.type === 'wheelup') {
265
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
266
+ this.render();
267
+ }
268
+ }
269
+ }
270
+
271
+ startCountdown() {
272
+ this.countdownInterval = setInterval(() => {
273
+ this.countdown--;
274
+ this.render();
275
+
276
+ if (this.countdown <= 0) {
277
+ clearInterval(this.countdownInterval);
278
+ this.startProcesses();
279
+ }
280
+ }, 1000);
281
+ }
282
+
283
+ startProcesses() {
284
+ const selected = Array.from(this.selectedScripts);
285
+
286
+ if (selected.length === 0) {
287
+ console.log('No scripts selected.');
288
+ process.exit(0);
289
+ }
290
+
291
+ this.config.defaultSelection = selected;
292
+ saveConfig(this.config);
293
+ this.phase = 'running';
294
+ this.selectedIndex = 0;
295
+
296
+ selected.forEach(scriptName => {
297
+ this.startProcess(scriptName);
298
+ });
299
+
300
+ this.render();
301
+ }
302
+
303
+ startProcess(scriptName) {
304
+ const script = this.scripts.find(s => s.name === scriptName);
305
+ if (!script) return;
306
+
307
+ const proc = spawn('npm', ['run', scriptName], {
308
+ env: {
309
+ ...process.env,
310
+ FORCE_COLOR: '1',
311
+ COLORTERM: 'truecolor',
312
+ },
313
+ shell: true,
314
+ });
315
+
316
+ proc.stdout.on('data', (data) => {
317
+ const text = data.toString();
318
+ const lines = text.split('\n');
319
+ lines.forEach(line => {
320
+ if (line.trim()) {
321
+ this.addOutputLine(scriptName, line);
322
+ }
323
+ });
324
+ this.render();
325
+ });
326
+
327
+ proc.stderr.on('data', (data) => {
328
+ const text = data.toString();
329
+ const lines = text.split('\n');
330
+ lines.forEach(line => {
331
+ if (line.trim()) {
332
+ this.addOutputLine(scriptName, line);
333
+ }
334
+ });
335
+ this.render();
336
+ });
337
+
338
+ proc.on('exit', (code) => {
339
+ const status = code === 0 ? 'exited' : 'crashed';
340
+ this.processes.set(scriptName, { status, exitCode: code });
341
+ this.addOutputLine(scriptName, `Process exited with code ${code}`);
342
+ this.render();
343
+ });
344
+
345
+ this.processRefs.set(scriptName, proc);
346
+ this.processes.set(scriptName, { status: 'running', pid: proc.pid });
347
+ }
348
+
349
+ addOutputLine(processName, text) {
350
+ // Always store the output line, even when paused
351
+ this.outputLines.push({
352
+ process: processName,
353
+ text,
354
+ timestamp: Date.now(),
355
+ });
356
+
357
+ if (this.outputLines.length > this.maxOutputLines) {
358
+ this.outputLines = this.outputLines.slice(-this.maxOutputLines);
359
+ }
360
+
361
+ // Only render if not paused - this prevents new output from appearing
362
+ // when the user is reviewing history
363
+ if (!this.isPaused) {
364
+ this.render();
365
+ }
366
+ }
367
+
368
+ stopProcess(scriptName) {
369
+ const proc = this.processRefs.get(scriptName);
370
+ if (proc && proc.pid) {
371
+ // Use tree-kill to kill the entire process tree
372
+ kill(proc.pid, 'SIGTERM', (err) => {
373
+ if (err) {
374
+ // If SIGTERM fails, try SIGKILL
375
+ kill(proc.pid, 'SIGKILL');
376
+ }
377
+ });
378
+ this.processRefs.delete(scriptName);
379
+ this.processes.set(scriptName, { status: 'stopped' });
380
+ this.addOutputLine(scriptName, 'Process stopped');
381
+ }
382
+ }
383
+
384
+ restartProcess(scriptName) {
385
+ this.stopProcess(scriptName);
386
+ setTimeout(() => {
387
+ this.startProcess(scriptName);
388
+ this.render();
389
+ }, 100);
390
+ }
391
+
392
+ toggleProcess(scriptName) {
393
+ const proc = this.processes.get(scriptName);
394
+ if (proc?.status === 'running') {
395
+ this.stopProcess(scriptName);
396
+ } else {
397
+ this.startProcess(scriptName);
398
+ }
399
+ }
400
+
401
+ updateStreamPauseState() {
402
+ // Pause or resume all process stdout/stderr streams
403
+ for (const proc of this.processRefs.values()) {
404
+ if (proc && proc.stdout && proc.stderr) {
405
+ if (this.isPaused) {
406
+ proc.stdout.pause();
407
+ proc.stderr.pause();
408
+ } else {
409
+ proc.stdout.resume();
410
+ proc.stderr.resume();
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ cleanup() {
417
+ for (const [scriptName, proc] of this.processRefs.entries()) {
418
+ try {
419
+ if (proc.pid) {
420
+ kill(proc.pid, 'SIGKILL');
421
+ }
422
+ } catch (err) {
423
+ // Ignore
424
+ }
425
+ }
426
+ }
427
+
428
+ buildSelectionUI() {
429
+ // Remove old container if it exists
430
+ if (this.selectionContainer) {
431
+ this.renderer.root.remove(this.selectionContainer);
432
+ this.selectionContainer.destroy();
433
+ }
434
+
435
+ // Create container
436
+ this.selectionContainer = new BoxRenderable(this.renderer, {
437
+ id: 'selection-container',
438
+ flexDirection: 'column',
439
+ padding: 1,
440
+ });
441
+
442
+ // Create header
443
+ this.headerText = new TextRenderable(this.renderer, {
444
+ id: 'header',
445
+ content: this.getHeaderText(),
446
+ fg: '#00FFFF',
447
+ });
448
+ this.selectionContainer.add(this.headerText);
449
+
450
+ // Empty line
451
+ this.selectionContainer.add(new TextRenderable(this.renderer, {
452
+ id: 'spacer',
453
+ content: '',
454
+ }));
455
+
456
+ // Create script lines with colors
457
+ // Starting Y position is: padding (1) + header (1) + spacer (1) = 3
458
+ // But we need to account for 0-based indexing, so it's actually row 3 (0-indexed: 2)
459
+ let currentY = 4; // padding + header + empty line + 1 for 1-based terminal coords
460
+ this.scriptLinePositions = [];
461
+
462
+ this.scriptLines = this.scripts.map((script, index) => {
463
+ const isSelected = this.selectedScripts.has(script.name);
464
+ const isFocused = index === this.selectedIndex;
465
+ const prefix = isFocused ? '▶' : ' ';
466
+ const checkbox = isSelected ? '✓' : ' ';
467
+ const processColor = this.processColors.get(script.name) || '#FFFFFF';
468
+ const prefixColor = isFocused ? '#00FFFF' : '#FFFFFF';
469
+
470
+ // Build styled content
471
+ const content = t`${fg(prefixColor)(prefix)} [${checkbox}] ${fg(processColor)(script.displayName)}`;
472
+
473
+ const line = new TextRenderable(this.renderer, {
474
+ id: `script-${index}`,
475
+ content: content,
476
+ });
477
+ this.selectionContainer.add(line);
478
+ this.scriptLinePositions.push(currentY);
479
+ currentY++;
480
+ return line;
481
+ });
482
+
483
+ this.renderer.root.add(this.selectionContainer);
484
+ }
485
+
486
+ getHeaderText() {
487
+ return `Starting in ${this.countdown}s... [Click or Space to toggle, Enter to start, Ctrl+C to quit]`;
488
+ }
489
+
490
+ getScriptLineText(script, index) {
491
+ const isSelected = this.selectedScripts.has(script.name);
492
+ const isFocused = index === this.selectedIndex;
493
+ const prefix = isFocused ? '▶' : ' ';
494
+ const checkbox = isSelected ? '✓' : ' ';
495
+ const processColor = this.processColors.get(script.name) || '#FFFFFF';
496
+
497
+ // Use colored text for script name
498
+ return t`${prefix} [${checkbox}] ${fg(processColor)(script.displayName)}`;
499
+ }
500
+
501
+ getScriptLineColor(index) {
502
+ // Return base color for the line (prefix will be cyan when focused)
503
+ return index === this.selectedIndex ? '#00FFFF' : '#FFFFFF';
504
+ }
505
+
506
+ updateSelectionUI() {
507
+ // Rebuild the entire UI to update colors and selection state
508
+ // This is simpler and more reliable with OpenTUI Core
509
+ this.buildSelectionUI();
510
+ }
511
+
512
+ render() {
513
+ if (this.phase === 'selection') {
514
+ // For selection phase, just update the text content
515
+ this.updateSelectionUI();
516
+ } else if (this.phase === 'running') {
517
+ // For running phase, only update output, don't rebuild entire UI
518
+ this.updateRunningUI();
519
+ }
520
+ }
521
+
522
+ getProcessListContent() {
523
+ // Build process list content dynamically for any number of processes
524
+ let contentString = '';
525
+
526
+ this.scripts.forEach((script, index) => {
527
+ const proc = this.processes.get(script.name);
528
+ const status = proc?.status || 'stopped';
529
+ const icon = status === 'running' ? '●' : status === 'crashed' ? '✖' : '○';
530
+ const statusColor = status === 'running' ? '#00FF00' : status === 'crashed' ? '#FF0000' : '#666666';
531
+ const processColor = this.processColors.get(script.name) || '#FFFFFF';
532
+ const prefix = this.selectedIndex === index ? '▶' : '';
533
+
534
+ // Build the colored string for this process
535
+ if (index > 0) contentString += ' ';
536
+ contentString += prefix + script.displayName + ' ' + icon;
537
+ });
538
+
539
+ return contentString;
540
+ }
541
+
542
+ updateRunningHeader() {
543
+ // Update only the header and process list without rebuilding everything
544
+ if (!this.headerRenderable || !this.processListRenderable || !this.runningContainer) {
545
+ return;
546
+ }
547
+
548
+ // Update header (plain text works)
549
+ const selectedScript = this.scripts[this.selectedIndex];
550
+ const selectedName = selectedScript ? selectedScript.displayName : '';
551
+ const pauseIndicator = this.isPaused ? ' [PAUSED]' : '';
552
+ const filterIndicator = this.isFilterMode ? ` [FILTER: ${this.filter}_]` : (this.filter ? ` [FILTER: ${this.filter}]` : '');
553
+ const headerText = `[←→: Navigate | Space: Pause | S: Stop | R: Restart | F: Filter Selected | /: Filter Text | Q: Quit] ${selectedName}${pauseIndicator}${filterIndicator}`;
554
+
555
+ if (this.headerRenderable.setContent) {
556
+ this.headerRenderable.setContent(headerText);
557
+ }
558
+
559
+ // For process list with styled text, we need to recreate it
560
+ // Remove old one
561
+ this.runningContainer.remove(this.processListRenderable);
562
+ this.processListRenderable.destroy();
563
+
564
+ // Create new process list with current selection
565
+ let processContent;
566
+ if (this.scripts.length === 1) {
567
+ const script = this.scripts[0];
568
+ const proc = this.processes.get(script.name);
569
+ const status = proc?.status || 'stopped';
570
+ const statusIcon = status === 'running' ? '●' : status === 'crashed' ? '✖' : '○';
571
+ const statusColor = status === 'running' ? '#00FF00' : status === 'crashed' ? '#FF0000' : '#666666';
572
+ const processColor = this.processColors.get(script.name) || '#FFFFFF';
573
+ processContent = t`▶${fg(processColor)(script.displayName)} ${fg(statusColor)(statusIcon)}`;
574
+ } else if (this.scripts.length === 2) {
575
+ const s0 = this.scripts[0];
576
+ const s1 = this.scripts[1];
577
+ const proc0 = this.processes.get(s0.name);
578
+ const proc1 = this.processes.get(s1.name);
579
+ const status0 = proc0?.status || 'stopped';
580
+ const status1 = proc1?.status || 'stopped';
581
+ const icon0 = status0 === 'running' ? '●' : status0 === 'crashed' ? '✖' : '○';
582
+ const icon1 = status1 === 'running' ? '●' : status1 === 'crashed' ? '✖' : '○';
583
+ const color0 = status0 === 'running' ? '#00FF00' : status0 === 'crashed' ? '#FF0000' : '#666666';
584
+ const color1 = status1 === 'running' ? '#00FF00' : status1 === 'crashed' ? '#FF0000' : '#666666';
585
+ const pcolor0 = this.processColors.get(s0.name) || '#FFFFFF';
586
+ const pcolor1 = this.processColors.get(s1.name) || '#FFFFFF';
587
+ const prefix0 = this.selectedIndex === 0 ? '▶' : '';
588
+ const prefix1 = this.selectedIndex === 1 ? '▶' : '';
589
+ processContent = t`${prefix0}${fg(pcolor0)(s0.displayName)} ${fg(color0)(icon0)} ${prefix1}${fg(pcolor1)(s1.displayName)} ${fg(color1)(icon1)}`;
590
+ } else if (this.scripts.length === 3) {
591
+ const s0 = this.scripts[0];
592
+ const s1 = this.scripts[1];
593
+ const s2 = this.scripts[2];
594
+ const proc0 = this.processes.get(s0.name);
595
+ const proc1 = this.processes.get(s1.name);
596
+ const proc2 = this.processes.get(s2.name);
597
+ const status0 = proc0?.status || 'stopped';
598
+ const status1 = proc1?.status || 'stopped';
599
+ const status2 = proc2?.status || 'stopped';
600
+ const icon0 = status0 === 'running' ? '●' : status0 === 'crashed' ? '✖' : '○';
601
+ const icon1 = status1 === 'running' ? '●' : status1 === 'crashed' ? '✖' : '○';
602
+ const icon2 = status2 === 'running' ? '●' : status2 === 'crashed' ? '✖' : '○';
603
+ const color0 = status0 === 'running' ? '#00FF00' : status0 === 'crashed' ? '#FF0000' : '#666666';
604
+ const color1 = status1 === 'running' ? '#00FF00' : status1 === 'crashed' ? '#FF0000' : '#666666';
605
+ const color2 = status2 === 'running' ? '#00FF00' : status2 === 'crashed' ? '#FF0000' : '#666666';
606
+ const pcolor0 = this.processColors.get(s0.name) || '#FFFFFF';
607
+ const pcolor1 = this.processColors.get(s1.name) || '#FFFFFF';
608
+ const pcolor2 = this.processColors.get(s2.name) || '#FFFFFF';
609
+ const prefix0 = this.selectedIndex === 0 ? '▶' : '';
610
+ const prefix1 = this.selectedIndex === 1 ? '▶' : '';
611
+ const prefix2 = this.selectedIndex === 2 ? '▶' : '';
612
+ processContent = t`${prefix0}${fg(pcolor0)(s0.displayName)} ${fg(color0)(icon0)} ${prefix1}${fg(pcolor1)(s1.displayName)} ${fg(color1)(icon1)} ${prefix2}${fg(pcolor2)(s2.displayName)} ${fg(color2)(icon2)}`;
613
+ } else {
614
+ // 4+ processes - for now hardcode to 4, but should be dynamic
615
+ const parts = this.scripts.slice(0, 4).map((script, idx) => {
616
+ const proc = this.processes.get(script.name);
617
+ const status = proc?.status || 'stopped';
618
+ const icon = status === 'running' ? '●' : status === 'crashed' ? '✖' : '○';
619
+ const color = status === 'running' ? '#00FF00' : status === 'crashed' ? '#FF0000' : '#666666';
620
+ const pcolor = this.processColors.get(script.name) || '#FFFFFF';
621
+ const prefix = this.selectedIndex === idx ? '▶' : '';
622
+ return { prefix, name: script.displayName, icon, color, pcolor };
623
+ });
624
+ processContent = t`${parts[0].prefix}${fg(parts[0].pcolor)(parts[0].name)} ${fg(parts[0].color)(parts[0].icon)} ${parts[1].prefix}${fg(parts[1].pcolor)(parts[1].name)} ${fg(parts[1].color)(parts[1].icon)} ${parts[2].prefix}${fg(parts[2].pcolor)(parts[2].name)} ${fg(parts[2].color)(parts[2].icon)} ${parts[3].prefix}${fg(parts[3].pcolor)(parts[3].name)} ${fg(parts[3].color)(parts[3].icon)}`;
625
+ }
626
+
627
+ // Create new process list renderable
628
+ this.processListRenderable = new TextRenderable(this.renderer, {
629
+ id: 'process-list',
630
+ content: processContent,
631
+ });
632
+
633
+ // Insert it back in the right position (after header and spacer)
634
+ // This is tricky - we need to insert at position 2
635
+ // For now, just rebuild the whole UI since we can't easily insert
636
+ this.buildRunningUI();
637
+ }
638
+
639
+ updateRunningUI() {
640
+ // Just rebuild the entire UI - simpler and more reliable
641
+ // OpenTUI doesn't have great incremental update support anyway
642
+ this.buildRunningUI();
643
+ }
644
+
645
+ buildRunningUI() {
646
+ // Remove old containers if they exist
647
+ if (this.selectionContainer) {
648
+ this.renderer.root.remove(this.selectionContainer);
649
+ this.selectionContainer.destroy();
650
+ this.selectionContainer = null;
651
+ }
652
+ if (this.runningContainer) {
653
+ this.renderer.root.remove(this.runningContainer);
654
+ this.runningContainer.destroy();
655
+ }
656
+
657
+ // Create main container - full screen
658
+ const mainContainer = new BoxRenderable(this.renderer, {
659
+ id: 'running-container',
660
+ flexDirection: 'column',
661
+ width: '100%',
662
+ height: '100%',
663
+ padding: 1,
664
+ });
665
+
666
+ // Header with status
667
+ const selectedScript = this.scripts[this.selectedIndex];
668
+ const selectedName = selectedScript ? selectedScript.displayName : '';
669
+ const pauseIndicator = this.isPaused ? ' [PAUSED]' : '';
670
+ const filterIndicator = this.isFilterMode ? ` [FILTER: ${this.filter}_]` : (this.filter ? ` [FILTER: ${this.filter}]` : '');
671
+ const headerText = `[←→: Navigate | Space: Pause | S: Stop | R: Restart | F: Filter Selected | /: Filter Text | Q: Quit] ${selectedName}${pauseIndicator}${filterIndicator}`;
672
+ this.headerRenderable = new TextRenderable(this.renderer, {
673
+ id: 'running-header',
674
+ content: headerText,
675
+ fg: '#00FFFF',
676
+ });
677
+ mainContainer.add(this.headerRenderable);
678
+
679
+ // Empty line
680
+ mainContainer.add(new TextRenderable(this.renderer, {
681
+ id: 'spacer1',
682
+ content: '',
683
+ }));
684
+
685
+ // Track positions for mouse clicks
686
+ let currentY = 4; // padding + header + spacer + 1 for 1-based coords
687
+ this.scriptLinePositions = [];
688
+
689
+ // Process list - compact horizontal layout with all processes
690
+ // Create a container to hold all process items in a row
691
+ const processListContainer = new BoxRenderable(this.renderer, {
692
+ id: 'process-list-container',
693
+ flexDirection: 'row',
694
+ gap: 2,
695
+ });
696
+
697
+ // Add each process as a separate text element
698
+ this.scripts.forEach((script, index) => {
699
+ const proc = this.processes.get(script.name);
700
+ const status = proc?.status || 'stopped';
701
+ const icon = status === 'running' ? '●' : status === 'crashed' ? '✖' : '○';
702
+ const statusColor = status === 'running' ? '#00FF00' : status === 'crashed' ? '#FF0000' : '#666666';
703
+ const processColor = this.processColors.get(script.name) || '#FFFFFF';
704
+ const prefix = this.selectedIndex === index ? '▶' : '';
705
+
706
+ const processItem = new TextRenderable(this.renderer, {
707
+ id: `process-item-${index}`,
708
+ content: t`${prefix}${fg(processColor)(script.displayName)} ${fg(statusColor)(icon)}`,
709
+ });
710
+ processListContainer.add(processItem);
711
+ });
712
+
713
+ this.processListRenderable = processListContainer;
714
+ mainContainer.add(this.processListRenderable);
715
+ currentY++;
716
+
717
+ // Empty line separator
718
+ mainContainer.add(new TextRenderable(this.renderer, {
719
+ id: 'spacer2',
720
+ content: '',
721
+ }));
722
+
723
+ // Output section header
724
+ const outputHeader = new TextRenderable(this.renderer, {
725
+ id: 'output-header',
726
+ content: 'Output',
727
+ fg: '#00FFFF',
728
+ });
729
+ mainContainer.add(outputHeader);
730
+
731
+ // Calculate available height for output
732
+ // Header (1) + spacer (1) + process-list (1) + spacer (1) + output header (1) = 5 lines used
733
+ const usedLines = 5;
734
+ const availableHeight = Math.max(10, this.renderer.height - usedLines - 2); // -2 for padding
735
+
736
+ // Create output container
737
+ // Use ScrollBoxRenderable when paused (to allow scrolling), BoxRenderable when not paused
738
+ if (this.outputBox) {
739
+ this.outputBox.destroy();
740
+ }
741
+
742
+ if (this.isPaused) {
743
+ // When paused, use ScrollBoxRenderable to allow scrolling through all history
744
+ this.outputBox = new ScrollBoxRenderable(this.renderer, {
745
+ id: 'output-box',
746
+ flexGrow: 1,
747
+ showScrollbar: true, // Show scrollbar when paused
748
+ });
749
+ } else {
750
+ // When not paused, use regular BoxRenderable (no scrollbar needed)
751
+ this.outputBox = new BoxRenderable(this.renderer, {
752
+ id: 'output-box',
753
+ flexDirection: 'column',
754
+ flexGrow: 1,
755
+ overflow: 'hidden',
756
+ });
757
+ }
758
+
759
+ // Add output lines to scrollbox in reverse order (newest first)
760
+ const filteredLines = this.filter
761
+ ? this.outputLines.filter(line =>
762
+ line.process.toLowerCase().includes(this.filter.toLowerCase()) ||
763
+ line.text.toLowerCase().includes(this.filter.toLowerCase())
764
+ )
765
+ : this.outputLines;
766
+
767
+ // Decide which lines to show
768
+ let linesToShow;
769
+ if (this.isPaused) {
770
+ // When paused, show all lines (scrollable)
771
+ linesToShow = filteredLines;
772
+ } else {
773
+ // When not paused, only show most recent N lines
774
+ linesToShow = filteredLines.slice(-this.maxVisibleLines);
775
+ }
776
+
777
+ // Add lines in reverse order (newest first)
778
+ for (let i = linesToShow.length - 1; i >= 0; i--) {
779
+ const line = linesToShow[i];
780
+ const processColor = this.processColors.get(line.process) || '#FFFFFF';
781
+
782
+ // Truncate long lines to prevent wrapping (terminal width - prefix length - padding)
783
+ const maxWidth = Math.max(40, this.renderer.width - line.process.length - 10);
784
+ const truncatedText = line.text.length > maxWidth
785
+ ? line.text.substring(0, maxWidth - 3) + '...'
786
+ : line.text;
787
+
788
+ const outputLine = new TextRenderable(this.renderer, {
789
+ id: `output-${i}`,
790
+ content: t`${fg(processColor)(`[${line.process}]`)} ${truncatedText}`,
791
+ });
792
+ this.outputBox.add(outputLine);
793
+ }
794
+
795
+ this.lastRenderedLineCount = filteredLines.length;
796
+ this.wasPaused = this.isPaused;
797
+
798
+ mainContainer.add(this.outputBox);
799
+
800
+ this.renderer.root.add(mainContainer);
801
+ this.runningContainer = mainContainer;
802
+ }
803
+ }
804
+
805
+ // Main
806
+ async function main() {
807
+ const cwd = process.cwd();
808
+ const packageJsonPath = join(cwd, 'package.json');
809
+
810
+ if (!existsSync(packageJsonPath)) {
811
+ console.error(`Error: No package.json found in ${cwd}`);
812
+ process.exit(1);
813
+ }
814
+
815
+ const scripts = parseNpmScripts(packageJsonPath);
816
+
817
+ if (scripts.length === 0) {
818
+ console.error('No npm scripts found in package.json');
819
+ process.exit(1);
820
+ }
821
+
822
+ const renderer = await createCliRenderer();
823
+ const manager = new ProcessManager(renderer, scripts);
824
+
825
+ // Handle cleanup on exit
826
+ process.on('SIGINT', () => {
827
+ manager.cleanup();
828
+ renderer.destroy();
829
+ });
830
+ }
831
+
832
+ main().catch(err => {
833
+ console.error('Error:', err);
834
+ process.exit(1);
835
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "startall",
3
+ "version": "0.0.1",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "startall": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1",
11
+ "start": "bun index.js",
12
+ "demo:frontend": "node -e \"setInterval(() => console.log('Server running...'), 1000)\"",
13
+ "demo:backend": "node -e \"setInterval(() => console.log('API ready... API ready... API ready... API ready... API ready...'), 600)\"",
14
+ "demo:worker": "node -e \"setInterval(() => console.log('Processing jobs...'), 500)\"",
15
+ "demo:worker2": "node -e \"setInterval(() => console.log('Processing jobs...'), 100)\""
16
+ },
17
+ "keywords": [],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/bzbetty/startall.git"
23
+ },
24
+ "type": "module",
25
+ "dependencies": {
26
+ "@opentui/core": "^0.1.74",
27
+ "@opentui/react": "^0.1.74",
28
+ "chalk": "^5.6.2",
29
+ "react": "^19.2.3",
30
+ "strip-ansi": "^7.1.2",
31
+ "tree-kill": "^1.2.2"
32
+ }
33
+ }