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.
- package/.github/workflows/publish.yml +23 -0
- package/README.md +136 -0
- package/index.js +835 -0
- package/package.json +33 -0
|
@@ -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
|
+
}
|