start-command 0.7.4 → 0.7.6
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 +25 -0
- package/README.md +23 -0
- package/docs/PIPES.md +243 -0
- package/docs/USAGE.md +194 -0
- package/docs/case-studies/issue-25/README.md +25 -18
- package/docs/case-studies/issue-28/README.md +405 -0
- package/docs/case-studies/issue-28/issue-data.json +105 -0
- package/docs/case-studies/issue-28/raw-issue-data.md +92 -0
- package/experiments/screen-output-test.js +265 -0
- package/package.json +1 -1
- package/src/bin/cli.js +10 -0
- package/src/lib/isolation.js +15 -43
- package/test/isolation.test.js +27 -0
|
@@ -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/bin/cli.js
CHANGED
|
@@ -237,6 +237,16 @@ function printUsage() {
|
|
|
237
237
|
console.log(' $ -i screen -d bun start');
|
|
238
238
|
console.log(' $ --isolated docker --image oven/bun:latest -- bun install');
|
|
239
239
|
console.log('');
|
|
240
|
+
console.log('Piping with $:');
|
|
241
|
+
console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
|
|
242
|
+
console.log(
|
|
243
|
+
' $ \'echo "hi" | agent\' # Alternative - quote entire pipeline'
|
|
244
|
+
);
|
|
245
|
+
console.log('');
|
|
246
|
+
console.log('Quoting for special characters:');
|
|
247
|
+
console.log(" $ 'npm test && npm build' # Wrap for logical operators");
|
|
248
|
+
console.log(" $ 'cat file > output.txt' # Wrap for redirections");
|
|
249
|
+
console.log('');
|
|
240
250
|
console.log('Features:');
|
|
241
251
|
console.log(' - Logs all output to temporary directory');
|
|
242
252
|
console.log(' - Displays timestamps and exit codes');
|
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)', () => {
|