start-command 0.7.4 → 0.7.5
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/CHANGELOG.md +13 -0
- package/docs/case-studies/issue-25/README.md +25 -18
- package/experiments/screen-output-test.js +265 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +15 -43
- package/test/isolation.test.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.7.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 31a67fc: fix: Screen isolation output always captured in attached mode
|
|
8
|
+
|
|
9
|
+
Changed attached mode to always use log capture instead of direct screen invocation.
|
|
10
|
+
This ensures command output is never lost, even for quick commands that would
|
|
11
|
+
otherwise have their output disappear when the screen session terminates rapidly.
|
|
12
|
+
|
|
13
|
+
Fixes #25: Output from `$ --isolated screen -- echo "hello"` is now properly
|
|
14
|
+
displayed instead of being lost with only "[screen is terminating]" shown.
|
|
15
|
+
|
|
3
16
|
## 0.7.4
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -126,17 +126,11 @@ Node.js/Bun's `spawnSync` with array arguments:
|
|
|
126
126
|
|
|
127
127
|
## The Solution
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
The issue has two parts that were fixed:
|
|
130
130
|
|
|
131
|
-
1
|
|
131
|
+
### Part 1: Shell Quoting Issues (Already Fixed)
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
const { execSync, spawn, spawnSync } = require('child_process');
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
2. **Replaced `execSync` with `spawnSync` in two locations:**
|
|
138
|
-
|
|
139
|
-
**Location 1: `runScreenWithLogCapture` function (attached mode with log capture)**
|
|
133
|
+
Replaced `execSync` with `spawnSync` for proper argument handling:
|
|
140
134
|
|
|
141
135
|
```javascript
|
|
142
136
|
// Before (broken):
|
|
@@ -145,21 +139,34 @@ execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
|
145
139
|
});
|
|
146
140
|
|
|
147
141
|
// After (fixed):
|
|
148
|
-
const result = spawnSync('screen', screenArgs, {
|
|
149
|
-
stdio: 'inherit',
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
if (result.error) {
|
|
153
|
-
throw result.error;
|
|
154
|
-
}
|
|
142
|
+
const result = spawnSync('screen', screenArgs, { stdio: 'inherit' });
|
|
155
143
|
```
|
|
156
144
|
|
|
157
|
-
|
|
145
|
+
### Part 2: TTY Mode Output Loss (New Fix)
|
|
146
|
+
|
|
147
|
+
The attached mode with TTY was using direct screen invocation which loses output for quick commands:
|
|
158
148
|
|
|
159
149
|
```javascript
|
|
160
|
-
//
|
|
150
|
+
// Before (broken) - in runInScreen attached mode with TTY:
|
|
151
|
+
if (hasTTY()) {
|
|
152
|
+
const screenArgs = ['-S', sessionName, shell, shellArg, command];
|
|
153
|
+
const child = spawn('screen', screenArgs, { stdio: 'inherit' });
|
|
154
|
+
// Output goes to screen's virtual terminal, lost when session ends quickly
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// After (fixed) - always use log capture for attached mode:
|
|
158
|
+
// For attached mode, always use detached mode with log capture
|
|
159
|
+
// This ensures output is captured and displayed correctly, even for quick commands
|
|
160
|
+
return runScreenWithLogCapture(command, sessionName, shellInfo);
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
**Why the TTY path was broken:**
|
|
164
|
+
|
|
165
|
+
1. Screen creates a virtual terminal for the session
|
|
166
|
+
2. Command output goes to that virtual terminal
|
|
167
|
+
3. When the command exits quickly (like `echo "hello"`), screen shows `[screen is terminating]`
|
|
168
|
+
4. The virtual terminal is destroyed and output is lost
|
|
169
|
+
|
|
163
170
|
## Testing Strategy
|
|
164
171
|
|
|
165
172
|
### New Regression Tests Added
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Experiment: Test screen output capture behavior
|
|
4
|
+
*
|
|
5
|
+
* This experiment tests different approaches to capture output from GNU screen
|
|
6
|
+
* sessions, specifically addressing issue #25 where output is lost on macOS
|
|
7
|
+
* with screen 4.00.03.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync, spawn, spawnSync } = require('child_process');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Check if screen is available
|
|
16
|
+
function isScreenAvailable() {
|
|
17
|
+
try {
|
|
18
|
+
execSync('which screen', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Get screen version
|
|
26
|
+
function getScreenVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const output = execSync('screen --version', {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
33
|
+
if (match) {
|
|
34
|
+
return {
|
|
35
|
+
major: parseInt(match[1], 10),
|
|
36
|
+
minor: parseInt(match[2], 10),
|
|
37
|
+
patch: parseInt(match[3], 10),
|
|
38
|
+
raw: output.trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if -Logfile is supported (screen >= 4.5.1)
|
|
48
|
+
function supportsLogfileOption(version) {
|
|
49
|
+
if (!version) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (version.major > 4) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (version.major < 4) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (version.minor > 5) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (version.minor < 5) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return version.patch >= 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Test 1: Direct screen invocation (current approach for TTY)
|
|
68
|
+
async function testDirectScreen(command) {
|
|
69
|
+
console.log('\n=== Test 1: Direct screen invocation (TTY mode) ===');
|
|
70
|
+
const sessionName = `test-direct-${Date.now()}`;
|
|
71
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
72
|
+
|
|
73
|
+
console.log(`Command: screen -S ${sessionName} ${shell} -c '${command}'`);
|
|
74
|
+
console.log('Note: This approach loses output for quick commands');
|
|
75
|
+
|
|
76
|
+
// This is what happens currently with TTY
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const child = spawn('screen', ['-S', sessionName, shell, '-c', command], {
|
|
79
|
+
stdio: 'inherit',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.on('exit', (code) => {
|
|
83
|
+
console.log(`Exit code: ${code}`);
|
|
84
|
+
resolve({ success: code === 0, output: '(output not captured)' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
child.on('error', (err) => {
|
|
88
|
+
console.error(`Error: ${err.message}`);
|
|
89
|
+
resolve({ success: false, output: '' });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test 2: Detached screen with log file (current approach for no-TTY)
|
|
95
|
+
async function testDetachedWithLog(command) {
|
|
96
|
+
console.log('\n=== Test 2: Detached screen with log capture ===');
|
|
97
|
+
const sessionName = `test-detached-${Date.now()}`;
|
|
98
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
99
|
+
const logFile = path.join(os.tmpdir(), `screen-test-${sessionName}.log`);
|
|
100
|
+
|
|
101
|
+
const version = getScreenVersion();
|
|
102
|
+
const useNativeLogging = supportsLogfileOption(version);
|
|
103
|
+
|
|
104
|
+
console.log(`Screen version: ${version ? version.raw : 'unknown'}`);
|
|
105
|
+
console.log(`Supports -Logfile: ${useNativeLogging}`);
|
|
106
|
+
|
|
107
|
+
let screenArgs;
|
|
108
|
+
let effectiveCommand = command;
|
|
109
|
+
|
|
110
|
+
if (useNativeLogging) {
|
|
111
|
+
// Modern screen
|
|
112
|
+
screenArgs = [
|
|
113
|
+
'-dmS',
|
|
114
|
+
sessionName,
|
|
115
|
+
'-L',
|
|
116
|
+
'-Logfile',
|
|
117
|
+
logFile,
|
|
118
|
+
shell,
|
|
119
|
+
'-c',
|
|
120
|
+
command,
|
|
121
|
+
];
|
|
122
|
+
} else {
|
|
123
|
+
// Older screen - use tee fallback
|
|
124
|
+
effectiveCommand = `(${command}) 2>&1 | tee "${logFile}"`;
|
|
125
|
+
screenArgs = ['-dmS', sessionName, shell, '-c', effectiveCommand];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`Command: screen ${screenArgs.join(' ')}`);
|
|
129
|
+
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
try {
|
|
132
|
+
const result = spawnSync('screen', screenArgs, { stdio: 'inherit' });
|
|
133
|
+
|
|
134
|
+
if (result.error) {
|
|
135
|
+
console.error(`Error: ${result.error.message}`);
|
|
136
|
+
resolve({ success: false, output: '' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Poll for session completion
|
|
141
|
+
const checkInterval = 100;
|
|
142
|
+
const maxWait = 10000;
|
|
143
|
+
let waited = 0;
|
|
144
|
+
|
|
145
|
+
const checkCompletion = () => {
|
|
146
|
+
try {
|
|
147
|
+
const sessions = execSync('screen -ls', {
|
|
148
|
+
encoding: 'utf8',
|
|
149
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!sessions.includes(sessionName)) {
|
|
153
|
+
// Session ended
|
|
154
|
+
let output = '';
|
|
155
|
+
try {
|
|
156
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
157
|
+
console.log(`Captured output: "${output.trim()}"`);
|
|
158
|
+
fs.unlinkSync(logFile);
|
|
159
|
+
} catch {
|
|
160
|
+
console.log('Log file not found or empty');
|
|
161
|
+
}
|
|
162
|
+
resolve({ success: true, output });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
waited += checkInterval;
|
|
167
|
+
if (waited >= maxWait) {
|
|
168
|
+
resolve({ success: false, output: 'timeout' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setTimeout(checkCompletion, checkInterval);
|
|
173
|
+
} catch {
|
|
174
|
+
let output = '';
|
|
175
|
+
try {
|
|
176
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
177
|
+
console.log(`Captured output: "${output.trim()}"`);
|
|
178
|
+
fs.unlinkSync(logFile);
|
|
179
|
+
} catch {
|
|
180
|
+
// Ignore
|
|
181
|
+
}
|
|
182
|
+
resolve({ success: true, output });
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
setTimeout(checkCompletion, checkInterval);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(`Error: ${err.message}`);
|
|
189
|
+
resolve({ success: false, output: '' });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Test 3: Script command for output capture (alternative approach)
|
|
195
|
+
async function testScriptCapture(command) {
|
|
196
|
+
console.log('\n=== Test 3: Using script command for output capture ===');
|
|
197
|
+
const logFile = path.join(os.tmpdir(), `script-test-${Date.now()}.log`);
|
|
198
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
199
|
+
|
|
200
|
+
// Use 'script' command which is available on both macOS and Linux
|
|
201
|
+
// script -q logfile command (macOS/BSD)
|
|
202
|
+
// script -q -c command logfile (Linux)
|
|
203
|
+
const isMac = process.platform === 'darwin';
|
|
204
|
+
|
|
205
|
+
let scriptArgs;
|
|
206
|
+
if (isMac) {
|
|
207
|
+
scriptArgs = ['-q', logFile, shell, '-c', command];
|
|
208
|
+
} else {
|
|
209
|
+
scriptArgs = ['-q', '-c', `${shell} -c '${command}'`, logFile];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(`Command: script ${scriptArgs.join(' ')}`);
|
|
213
|
+
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const child = spawn('script', scriptArgs, {
|
|
216
|
+
stdio: 'inherit',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
child.on('exit', (code) => {
|
|
220
|
+
let output = '';
|
|
221
|
+
try {
|
|
222
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
223
|
+
console.log(`Captured output: "${output.trim()}"`);
|
|
224
|
+
fs.unlinkSync(logFile);
|
|
225
|
+
} catch {
|
|
226
|
+
console.log('Log file not found');
|
|
227
|
+
}
|
|
228
|
+
resolve({ success: code === 0, output });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
child.on('error', (err) => {
|
|
232
|
+
console.error(`Error: ${err.message}`);
|
|
233
|
+
resolve({ success: false, output: '' });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Main
|
|
239
|
+
async function main() {
|
|
240
|
+
console.log('Screen Output Capture Experiment');
|
|
241
|
+
console.log('=================================');
|
|
242
|
+
console.log(`Platform: ${process.platform}`);
|
|
243
|
+
console.log(
|
|
244
|
+
`TTY: stdin=${process.stdin.isTTY}, stdout=${process.stdout.isTTY}`
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (!isScreenAvailable()) {
|
|
248
|
+
console.log('Screen is not installed. Exiting.');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const version = getScreenVersion();
|
|
253
|
+
console.log(`Screen version: ${version ? version.raw : 'unknown'}`);
|
|
254
|
+
|
|
255
|
+
const testCommand = 'echo "hello from screen"';
|
|
256
|
+
|
|
257
|
+
// Test 2 is the recommended approach
|
|
258
|
+
const result = await testDetachedWithLog(testCommand);
|
|
259
|
+
console.log('\n=== Summary ===');
|
|
260
|
+
console.log(
|
|
261
|
+
`Test 2 (detached with log): Success=${result.success}, Output captured=${result.output.includes('hello')}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
main().catch(console.error);
|
package/package.json
CHANGED
package/src/lib/isolation.js
CHANGED
|
@@ -342,50 +342,22 @@ function runInScreen(command, options = {}) {
|
|
|
342
342
|
message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
|
|
343
343
|
});
|
|
344
344
|
} else {
|
|
345
|
-
// Attached mode:
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
stdio: 'inherit',
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
child.on('exit', (code) => {
|
|
362
|
-
resolve({
|
|
363
|
-
success: code === 0,
|
|
364
|
-
sessionName,
|
|
365
|
-
message: `Screen session "${sessionName}" exited with code ${code}`,
|
|
366
|
-
exitCode: code,
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
child.on('error', (err) => {
|
|
371
|
-
resolve({
|
|
372
|
-
success: false,
|
|
373
|
-
sessionName,
|
|
374
|
-
message: `Failed to start screen: ${err.message}`,
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
} else {
|
|
379
|
-
// No TTY available - use detached mode with log capture
|
|
380
|
-
// This allows screen to run without a terminal while still capturing output
|
|
381
|
-
if (DEBUG) {
|
|
382
|
-
console.log(
|
|
383
|
-
`[DEBUG] No TTY available, using detached mode with log capture`
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return runScreenWithLogCapture(command, sessionName, shellInfo);
|
|
345
|
+
// Attached mode: always use detached mode with log capture
|
|
346
|
+
// This ensures output is captured and displayed correctly, even for quick commands
|
|
347
|
+
// that would otherwise have their output lost in a rapidly-terminating screen session.
|
|
348
|
+
// Direct screen invocation (screen -S session shell -c command) loses output because:
|
|
349
|
+
// 1. Screen creates a virtual terminal for the session
|
|
350
|
+
// 2. Command output goes to that virtual terminal
|
|
351
|
+
// 3. When the command exits quickly, screen shows "[screen is terminating]"
|
|
352
|
+
// 4. The virtual terminal is destroyed and output is lost
|
|
353
|
+
// See issue #25 for details: https://github.com/link-foundation/start/issues/25
|
|
354
|
+
if (DEBUG) {
|
|
355
|
+
console.log(
|
|
356
|
+
`[DEBUG] Using detached mode with log capture for reliable output`
|
|
357
|
+
);
|
|
388
358
|
}
|
|
359
|
+
|
|
360
|
+
return runScreenWithLogCapture(command, sessionName, shellInfo);
|
|
389
361
|
}
|
|
390
362
|
} catch (err) {
|
|
391
363
|
return Promise.resolve({
|
package/test/isolation.test.js
CHANGED
|
@@ -380,6 +380,33 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
380
380
|
);
|
|
381
381
|
}
|
|
382
382
|
});
|
|
383
|
+
|
|
384
|
+
it('should always return output property in attached mode (issue #25 fix verification)', async () => {
|
|
385
|
+
if (!isCommandAvailable('screen')) {
|
|
386
|
+
console.log(' Skipping: screen not installed');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// This test verifies that attached mode always uses log capture,
|
|
391
|
+
// ensuring output is never lost even for quick commands.
|
|
392
|
+
// This is the core fix for issue #25 where output was lost on macOS
|
|
393
|
+
// because screen's virtual terminal was destroyed before output could be seen.
|
|
394
|
+
const result = await runInScreen('echo "quick command output"', {
|
|
395
|
+
session: `test-output-guaranteed-${Date.now()}`,
|
|
396
|
+
detached: false,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
assert.strictEqual(result.success, true);
|
|
400
|
+
assert.ok(
|
|
401
|
+
result.output !== undefined,
|
|
402
|
+
'Attached mode should always return output property'
|
|
403
|
+
);
|
|
404
|
+
assert.ok(
|
|
405
|
+
result.output.includes('quick command output'),
|
|
406
|
+
'Output should be captured (issue #25 fix verification)'
|
|
407
|
+
);
|
|
408
|
+
console.log(` Verified output capture: "${result.output.trim()}"`);
|
|
409
|
+
});
|
|
383
410
|
});
|
|
384
411
|
|
|
385
412
|
describe('runInTmux (if available)', () => {
|