start-command 0.24.8 → 0.25.0
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 +56 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +7 -0
- package/src/lib/isolation.js +14 -239
- package/src/lib/screen-isolation.js +406 -0
- package/test/isolation.test.js +2 -154
- package/test/screen-integration.test.js +257 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c0b3a03: fix: use screenrc-based logging for all screen versions (issue #96)
|
|
8
|
+
|
|
9
|
+
The previous fix (v0.24.9) used `-L -Logfile` for screen >= 4.5.1 and tee fallback
|
|
10
|
+
for older versions. The tee fallback failed on macOS with screen 4.00.03 because
|
|
11
|
+
tee's write buffers weren't flushed before the session ended.
|
|
12
|
+
|
|
13
|
+
The new approach uses screenrc directives (`logfile`, `logfile flush 0`, `deflog on`)
|
|
14
|
+
that work on ALL screen versions, eliminating both the version-dependent branching
|
|
15
|
+
and the unreliable tee fallback entirely.
|
|
16
|
+
|
|
17
|
+
Additional improvements:
|
|
18
|
+
- **Exit code capture**: Commands now report their actual exit code via a sidecar
|
|
19
|
+
file, instead of always reporting 0.
|
|
20
|
+
- **Enhanced retry logic**: 3 retries with increasing delays (50/100/200ms) instead
|
|
21
|
+
of a single 50ms retry.
|
|
22
|
+
- **Better debug output**: Screen isolation debug messages respond to both
|
|
23
|
+
`START_DEBUG` and `START_VERBOSE` environment variables.
|
|
24
|
+
- **New tests**: Exit code capture, stderr capture, and multi-line output verification
|
|
25
|
+
in both JavaScript and Rust.
|
|
26
|
+
|
|
27
|
+
Fixes #96
|
|
28
|
+
|
|
29
|
+
## 0.24.9
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
|
|
34
|
+
|
|
35
|
+
When running a short-lived command like `agent --version` through screen isolation:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
$ --isolated screen -- agent --version
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
the version output was silently lost — the command exited cleanly (exit code 0)
|
|
42
|
+
but no output was displayed.
|
|
43
|
+
|
|
44
|
+
**Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
|
|
45
|
+
(`log_flush = 10`). For commands that complete faster than this, the buffer may not
|
|
46
|
+
be flushed to the log file before the screen session terminates.
|
|
47
|
+
|
|
48
|
+
**Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
|
|
49
|
+
the `-c` option. This forces screen to flush the log buffer after every write,
|
|
50
|
+
eliminating the 10-second flush delay for quick-completing commands.
|
|
51
|
+
|
|
52
|
+
A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
|
|
53
|
+
to handle the TOCTOU race where the log file appears empty when first read
|
|
54
|
+
immediately after session completion.
|
|
55
|
+
|
|
56
|
+
Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
|
|
57
|
+
with equivalent test coverage added.
|
|
58
|
+
|
|
3
59
|
## 0.24.8
|
|
4
60
|
|
|
5
61
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/args-parser.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
|
|
19
19
|
* --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
|
|
20
20
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
21
|
+
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
21
22
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
22
23
|
* --output-format <format> Output format for status (links-notation, json, text)
|
|
23
24
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
@@ -395,6 +396,12 @@ function parseOption(args, index, options) {
|
|
|
395
396
|
return 1;
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
// --verbose (enable verbose/debug output, sets START_VERBOSE env var)
|
|
400
|
+
if (arg === '--verbose') {
|
|
401
|
+
process.env.START_VERBOSE = '1';
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
|
|
398
405
|
// --session-id or --session-name (alias) <uuid>
|
|
399
406
|
if (arg === '--session-id' || arg === '--session-name') {
|
|
400
407
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
package/src/lib/isolation.js
CHANGED
|
@@ -1,94 +1,20 @@
|
|
|
1
1
|
/** Isolation Runners for start-command (screen, tmux, docker, ssh) */
|
|
2
2
|
|
|
3
3
|
const { execSync, spawn, spawnSync } = require('child_process');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
4
|
const path = require('path');
|
|
7
5
|
const { generateSessionName } = require('./args-parser');
|
|
8
6
|
const outputBlocks = require('./output-blocks');
|
|
9
7
|
|
|
10
|
-
const setTimeout = globalThis.setTimeout;
|
|
11
|
-
|
|
12
8
|
// Debug mode from environment
|
|
13
9
|
const DEBUG =
|
|
14
10
|
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
23
|
-
*/
|
|
24
|
-
function getScreenVersion() {
|
|
25
|
-
if (screenVersionChecked) {
|
|
26
|
-
return cachedScreenVersion;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
screenVersionChecked = true;
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const output = execSync('screen --version', {
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
-
});
|
|
36
|
-
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
37
|
-
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
38
|
-
if (match) {
|
|
39
|
-
cachedScreenVersion = {
|
|
40
|
-
major: parseInt(match[1], 10),
|
|
41
|
-
minor: parseInt(match[2], 10),
|
|
42
|
-
patch: parseInt(match[3], 10),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
if (DEBUG) {
|
|
46
|
-
console.log(
|
|
47
|
-
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return cachedScreenVersion;
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
if (DEBUG) {
|
|
55
|
-
console.log('[DEBUG] Could not detect screen version');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Check if screen supports the -Logfile option
|
|
64
|
-
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
65
|
-
* @returns {boolean} True if -Logfile is supported
|
|
66
|
-
*/
|
|
67
|
-
function supportsLogfileOption() {
|
|
68
|
-
const version = getScreenVersion();
|
|
69
|
-
if (!version) {
|
|
70
|
-
// If we can't detect version, assume older version and use fallback
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// -Logfile was added in 4.5.1
|
|
75
|
-
// Compare: version >= 4.5.1
|
|
76
|
-
if (version.major > 4) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
if (version.major < 4) {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
// major === 4
|
|
83
|
-
if (version.minor > 5) {
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
if (version.minor < 5) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
// minor === 5
|
|
90
|
-
return version.patch >= 1;
|
|
91
|
-
}
|
|
12
|
+
const {
|
|
13
|
+
getScreenVersion,
|
|
14
|
+
supportsLogfileOption,
|
|
15
|
+
runScreenWithLogCapture: _runScreenWithLogCapture,
|
|
16
|
+
resetScreenVersionCache,
|
|
17
|
+
} = require('./screen-isolation');
|
|
92
18
|
|
|
93
19
|
/**
|
|
94
20
|
* Check if a command is available on the system
|
|
@@ -245,159 +171,14 @@ function wrapCommandWithUser(command, user) {
|
|
|
245
171
|
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
246
172
|
*/
|
|
247
173
|
function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let screenArgs;
|
|
257
|
-
// Wrap command with user switch if specified
|
|
258
|
-
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
259
|
-
|
|
260
|
-
if (useNativeLogging) {
|
|
261
|
-
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
262
|
-
// screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
|
|
263
|
-
const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
|
|
264
|
-
screenArgs = isInteractiveShellCommand(command)
|
|
265
|
-
? [...logArgs, ...command.trim().split(/\s+/)]
|
|
266
|
-
: [...logArgs, shell, shellArg, effectiveCommand];
|
|
267
|
-
|
|
268
|
-
if (DEBUG) {
|
|
269
|
-
console.log(
|
|
270
|
-
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
275
|
-
// The parentheses ensure proper grouping of the command and its stderr
|
|
276
|
-
const isBareShell = isInteractiveShellCommand(command);
|
|
277
|
-
if (!isBareShell) {
|
|
278
|
-
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
279
|
-
}
|
|
280
|
-
screenArgs = isBareShell
|
|
281
|
-
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
282
|
-
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
283
|
-
|
|
284
|
-
if (DEBUG) {
|
|
285
|
-
console.log(
|
|
286
|
-
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
292
|
-
const result = spawnSync('screen', screenArgs, {
|
|
293
|
-
stdio: 'inherit',
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
if (result.error) {
|
|
297
|
-
throw result.error;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Poll for session completion
|
|
301
|
-
const checkInterval = 100; // ms
|
|
302
|
-
const maxWait = 300000; // 5 minutes max
|
|
303
|
-
let waited = 0;
|
|
304
|
-
|
|
305
|
-
const checkCompletion = () => {
|
|
306
|
-
try {
|
|
307
|
-
// Check if session still exists
|
|
308
|
-
const sessions = execSync('screen -ls', {
|
|
309
|
-
encoding: 'utf8',
|
|
310
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
if (!sessions.includes(sessionName)) {
|
|
314
|
-
// Session ended, read output
|
|
315
|
-
let output = '';
|
|
316
|
-
try {
|
|
317
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
318
|
-
// Display the output with surrounding empty lines for consistency
|
|
319
|
-
if (output.trim()) {
|
|
320
|
-
process.stdout.write(output);
|
|
321
|
-
// Add trailing newline if output doesn't end with one
|
|
322
|
-
if (!output.endsWith('\n')) {
|
|
323
|
-
process.stdout.write('\n');
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
} catch {
|
|
327
|
-
// Log file might not exist if command was very quick
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Clean up log file
|
|
331
|
-
try {
|
|
332
|
-
fs.unlinkSync(logFile);
|
|
333
|
-
} catch {
|
|
334
|
-
// Ignore cleanup errors
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
resolve({
|
|
338
|
-
success: true,
|
|
339
|
-
sessionName,
|
|
340
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
341
|
-
exitCode: 0,
|
|
342
|
-
output,
|
|
343
|
-
});
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
waited += checkInterval;
|
|
348
|
-
if (waited >= maxWait) {
|
|
349
|
-
resolve({
|
|
350
|
-
success: false,
|
|
351
|
-
sessionName,
|
|
352
|
-
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
353
|
-
exitCode: 1,
|
|
354
|
-
});
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
setTimeout(checkCompletion, checkInterval);
|
|
359
|
-
} catch {
|
|
360
|
-
// screen -ls failed, session probably ended
|
|
361
|
-
let output = '';
|
|
362
|
-
try {
|
|
363
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
364
|
-
if (output.trim()) {
|
|
365
|
-
process.stdout.write(output);
|
|
366
|
-
// Add trailing newline if output doesn't end with one
|
|
367
|
-
if (!output.endsWith('\n')) {
|
|
368
|
-
process.stdout.write('\n');
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
} catch {
|
|
372
|
-
// Ignore
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
fs.unlinkSync(logFile);
|
|
377
|
-
} catch {
|
|
378
|
-
// Ignore
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
resolve({
|
|
382
|
-
success: true,
|
|
383
|
-
sessionName,
|
|
384
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
385
|
-
exitCode: 0,
|
|
386
|
-
output,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// Start checking after a brief delay
|
|
392
|
-
setTimeout(checkCompletion, checkInterval);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
resolve({
|
|
395
|
-
success: false,
|
|
396
|
-
sessionName,
|
|
397
|
-
message: `Failed to run in screen: ${err.message}`,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
});
|
|
174
|
+
return _runScreenWithLogCapture(
|
|
175
|
+
command,
|
|
176
|
+
sessionName,
|
|
177
|
+
shellInfo,
|
|
178
|
+
user,
|
|
179
|
+
wrapCommandWithUser,
|
|
180
|
+
isInteractiveShellCommand
|
|
181
|
+
);
|
|
401
182
|
}
|
|
402
183
|
|
|
403
184
|
/**
|
|
@@ -934,12 +715,6 @@ function runIsolated(backend, command, options = {}) {
|
|
|
934
715
|
}
|
|
935
716
|
}
|
|
936
717
|
|
|
937
|
-
/** Reset screen version cache (useful for testing) */
|
|
938
|
-
function resetScreenVersionCache() {
|
|
939
|
-
cachedScreenVersion = null;
|
|
940
|
-
screenVersionChecked = false;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
718
|
const {
|
|
944
719
|
getTimestamp,
|
|
945
720
|
generateLogFilename,
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/** Screen-specific isolation helpers extracted from isolation.js */
|
|
2
|
+
|
|
3
|
+
const { execSync, spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const setTimeout = globalThis.setTimeout;
|
|
9
|
+
|
|
10
|
+
// Debug mode from environment (START_DEBUG or START_VERBOSE).
|
|
11
|
+
// Evaluated as a function so that env vars set after module load (e.g., by --verbose flag) are respected.
|
|
12
|
+
function isDebug() {
|
|
13
|
+
return (
|
|
14
|
+
process.env.START_DEBUG === '1' ||
|
|
15
|
+
process.env.START_DEBUG === 'true' ||
|
|
16
|
+
process.env.START_VERBOSE === '1' ||
|
|
17
|
+
process.env.START_VERBOSE === 'true'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cache for screen version detection
|
|
22
|
+
let cachedScreenVersion = null;
|
|
23
|
+
let screenVersionChecked = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the installed screen version
|
|
27
|
+
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
28
|
+
*/
|
|
29
|
+
function getScreenVersion() {
|
|
30
|
+
if (screenVersionChecked) {
|
|
31
|
+
return cachedScreenVersion;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
screenVersionChecked = true;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const output = execSync('screen --version', {
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
42
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
43
|
+
if (match) {
|
|
44
|
+
cachedScreenVersion = {
|
|
45
|
+
major: parseInt(match[1], 10),
|
|
46
|
+
minor: parseInt(match[2], 10),
|
|
47
|
+
patch: parseInt(match[3], 10),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (isDebug()) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[screen-isolation] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return cachedScreenVersion;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
if (isDebug()) {
|
|
60
|
+
console.error('[screen-isolation] Could not detect screen version');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if screen supports the -Logfile option
|
|
69
|
+
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
70
|
+
* @returns {boolean} True if -Logfile is supported
|
|
71
|
+
*/
|
|
72
|
+
function supportsLogfileOption() {
|
|
73
|
+
const version = getScreenVersion();
|
|
74
|
+
if (!version) {
|
|
75
|
+
// If we can't detect version, assume older version and use fallback
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// -Logfile was added in 4.5.1
|
|
80
|
+
// Compare: version >= 4.5.1
|
|
81
|
+
if (version.major > 4) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (version.major < 4) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// major === 4
|
|
88
|
+
if (version.minor > 5) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (version.minor < 5) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
// minor === 5
|
|
95
|
+
return version.patch >= 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run command in GNU Screen using detached mode with log capture.
|
|
100
|
+
*
|
|
101
|
+
* Uses a unified approach combining the `-L` flag with screenrc directives:
|
|
102
|
+
* - `-L` flag enables logging for the initial window (available on ALL screen versions)
|
|
103
|
+
* - `logfile <path>` in screenrc sets the log file path (replaces `-Logfile` CLI option)
|
|
104
|
+
* - `logfile flush 0` forces immediate flushing (no 10-second delay)
|
|
105
|
+
* - `deflog on` enables logging for any additional windows
|
|
106
|
+
*
|
|
107
|
+
* Key insight: `deflog on` only applies to windows created AFTER screenrc processing,
|
|
108
|
+
* but the default window is created BEFORE screenrc is processed. The `-L` flag is
|
|
109
|
+
* needed to enable logging for that initial window. Without it, output is silently
|
|
110
|
+
* lost on macOS screen 4.00.03 (issue #96).
|
|
111
|
+
*
|
|
112
|
+
* This replaces the previous version-dependent approach that used:
|
|
113
|
+
* - `-L -Logfile` for screen >= 4.5.1 (native logging)
|
|
114
|
+
* - `tee` fallback for screen < 4.5.1 (e.g., macOS bundled 4.0.3)
|
|
115
|
+
*
|
|
116
|
+
* The tee fallback had reliability issues on macOS because:
|
|
117
|
+
* - tee's write buffers may not be flushed before the session ends
|
|
118
|
+
* - The TOCTOU race between session detection and file read was hard to mitigate
|
|
119
|
+
*
|
|
120
|
+
* @param {string} command - Command to execute
|
|
121
|
+
* @param {string} sessionName - Session name
|
|
122
|
+
* @param {object} shellInfo - Shell info from getShell()
|
|
123
|
+
* @param {string|null} user - Username to run command as (optional)
|
|
124
|
+
* @param {Function} wrapCommandWithUser - Function to wrap command with user
|
|
125
|
+
* @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
|
|
126
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
|
|
127
|
+
*/
|
|
128
|
+
function runScreenWithLogCapture(
|
|
129
|
+
command,
|
|
130
|
+
sessionName,
|
|
131
|
+
shellInfo,
|
|
132
|
+
user = null,
|
|
133
|
+
wrapCommandWithUser,
|
|
134
|
+
isInteractiveShellCommand
|
|
135
|
+
) {
|
|
136
|
+
const { shell, shellArg } = shellInfo;
|
|
137
|
+
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
138
|
+
const exitCodeFile = path.join(
|
|
139
|
+
os.tmpdir(),
|
|
140
|
+
`screen-exit-${sessionName}.code`
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
try {
|
|
145
|
+
// Wrap command with user switch if specified
|
|
146
|
+
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
147
|
+
|
|
148
|
+
// Wrap command to capture exit code in a sidecar file.
|
|
149
|
+
// We save $? after the command completes so we can report the real exit code
|
|
150
|
+
// instead of always assuming 0 (previous behavior).
|
|
151
|
+
const isBareShell = isInteractiveShellCommand(command);
|
|
152
|
+
if (!isBareShell) {
|
|
153
|
+
effectiveCommand = `${effectiveCommand}; echo $? > "${exitCodeFile}"`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Create temporary screenrc with logging configuration.
|
|
157
|
+
// Combined with the -L flag (which enables logging for the initial window),
|
|
158
|
+
// these directives work on ALL screen versions (including macOS 4.00.03):
|
|
159
|
+
// - `logfile <path>` sets the output log path (replaces -Logfile CLI option)
|
|
160
|
+
// - `logfile flush 0` forces immediate buffer flush (prevents output loss)
|
|
161
|
+
// - `deflog on` enables logging for any subsequently created windows
|
|
162
|
+
const screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
|
|
163
|
+
const screenrcContent = [
|
|
164
|
+
`logfile ${logFile}`,
|
|
165
|
+
'logfile flush 0',
|
|
166
|
+
'deflog on',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
fs.writeFileSync(screenrcFile, screenrcContent);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (isDebug()) {
|
|
174
|
+
console.error(
|
|
175
|
+
`[screen-isolation] Failed to create screenrc: ${err.message}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
resolve({
|
|
179
|
+
success: false,
|
|
180
|
+
sessionName,
|
|
181
|
+
message: `Failed to create screenrc for logging: ${err.message}`,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Build screen arguments:
|
|
187
|
+
// screen -dmS <session> -L -c <screenrc> <shell> -c '<command>'
|
|
188
|
+
//
|
|
189
|
+
// The -L flag explicitly enables logging for the initial window.
|
|
190
|
+
// Without -L, `deflog on` in screenrc only applies to windows created
|
|
191
|
+
// AFTER the screenrc is processed — but the default window is created
|
|
192
|
+
// BEFORE screenrc processing. This caused output to be silently lost
|
|
193
|
+
// on macOS screen 4.00.03 (issue #96).
|
|
194
|
+
//
|
|
195
|
+
// The -L flag is available on ALL screen versions (including 4.00.03).
|
|
196
|
+
// Combined with `logfile <path>` in screenrc, -L logs to our custom path
|
|
197
|
+
// instead of the default `screenlog.0`.
|
|
198
|
+
const screenArgs = isBareShell
|
|
199
|
+
? [
|
|
200
|
+
'-dmS',
|
|
201
|
+
sessionName,
|
|
202
|
+
'-L',
|
|
203
|
+
'-c',
|
|
204
|
+
screenrcFile,
|
|
205
|
+
...command.trim().split(/\s+/),
|
|
206
|
+
]
|
|
207
|
+
: [
|
|
208
|
+
'-dmS',
|
|
209
|
+
sessionName,
|
|
210
|
+
'-L',
|
|
211
|
+
'-c',
|
|
212
|
+
screenrcFile,
|
|
213
|
+
shell,
|
|
214
|
+
shellArg,
|
|
215
|
+
effectiveCommand,
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
if (isDebug()) {
|
|
219
|
+
console.error(
|
|
220
|
+
`[screen-isolation] Running: screen ${screenArgs.join(' ')}`
|
|
221
|
+
);
|
|
222
|
+
console.error(`[screen-isolation] screenrc: ${screenrcContent.trim()}`);
|
|
223
|
+
console.error(`[screen-isolation] Log file: ${logFile}`);
|
|
224
|
+
console.error(`[screen-isolation] Exit code file: ${exitCodeFile}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
228
|
+
const result = spawnSync('screen', screenArgs, {
|
|
229
|
+
stdio: 'inherit',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (result.error) {
|
|
233
|
+
throw result.error;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Helper to read log file output and write to stdout.
|
|
237
|
+
// Uses multiple retries with increasing delays to handle the race condition
|
|
238
|
+
// where the screen session disappears from `screen -ls` but the log file
|
|
239
|
+
// hasn't been fully flushed yet (issue #96).
|
|
240
|
+
const readAndDisplayOutput = (retryCount = 0) => {
|
|
241
|
+
const MAX_RETRIES = 3;
|
|
242
|
+
const RETRY_DELAYS = [50, 100, 200]; // ms
|
|
243
|
+
|
|
244
|
+
let output = '';
|
|
245
|
+
try {
|
|
246
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
247
|
+
} catch {
|
|
248
|
+
// Log file might not exist if command produced no output
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// If output is empty and we haven't exhausted retries, wait and retry.
|
|
252
|
+
if (!output.trim() && retryCount < MAX_RETRIES) {
|
|
253
|
+
const delay = RETRY_DELAYS[retryCount] || 200;
|
|
254
|
+
if (isDebug()) {
|
|
255
|
+
console.error(
|
|
256
|
+
`[screen-isolation] Log file empty, retry ${retryCount + 1}/${MAX_RETRIES} after ${delay}ms`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return new Promise((resolveRetry) => {
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
resolveRetry(readAndDisplayOutput(retryCount + 1));
|
|
262
|
+
}, delay);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (isDebug() && !output.trim()) {
|
|
267
|
+
console.error(
|
|
268
|
+
`[screen-isolation] Log file still empty after ${MAX_RETRIES} retries`
|
|
269
|
+
);
|
|
270
|
+
// Check if log file exists at all
|
|
271
|
+
try {
|
|
272
|
+
const stats = fs.statSync(logFile);
|
|
273
|
+
console.error(
|
|
274
|
+
`[screen-isolation] Log file exists, size: ${stats.size} bytes`
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
console.error(`[screen-isolation] Log file does not exist`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Display the output
|
|
282
|
+
if (output.trim()) {
|
|
283
|
+
process.stdout.write(output);
|
|
284
|
+
// Add trailing newline if output doesn't end with one
|
|
285
|
+
if (!output.endsWith('\n')) {
|
|
286
|
+
process.stdout.write('\n');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return Promise.resolve(output);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Read exit code from sidecar file
|
|
293
|
+
const readExitCode = () => {
|
|
294
|
+
if (isBareShell) {
|
|
295
|
+
return 0; // Can't capture exit code for interactive shells
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const content = fs.readFileSync(exitCodeFile, 'utf8').trim();
|
|
299
|
+
const code = parseInt(content, 10);
|
|
300
|
+
if (isDebug()) {
|
|
301
|
+
console.error(`[screen-isolation] Captured exit code: ${code}`);
|
|
302
|
+
}
|
|
303
|
+
return isNaN(code) ? 0 : code;
|
|
304
|
+
} catch {
|
|
305
|
+
if (isDebug()) {
|
|
306
|
+
console.error(
|
|
307
|
+
`[screen-isolation] Could not read exit code file, defaulting to 0`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Clean up temp files
|
|
315
|
+
const cleanupTempFiles = () => {
|
|
316
|
+
for (const f of [logFile, screenrcFile, exitCodeFile]) {
|
|
317
|
+
try {
|
|
318
|
+
fs.unlinkSync(f);
|
|
319
|
+
} catch {
|
|
320
|
+
// Ignore cleanup errors
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Poll for session completion
|
|
326
|
+
const checkInterval = 100; // ms
|
|
327
|
+
const maxWait = 300000; // 5 minutes max
|
|
328
|
+
let waited = 0;
|
|
329
|
+
|
|
330
|
+
const checkCompletion = () => {
|
|
331
|
+
try {
|
|
332
|
+
// Check if session still exists
|
|
333
|
+
const sessions = execSync('screen -ls', {
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (!sessions.includes(sessionName)) {
|
|
339
|
+
// Session ended, read output and exit code
|
|
340
|
+
readAndDisplayOutput().then((output) => {
|
|
341
|
+
const exitCode = readExitCode();
|
|
342
|
+
cleanupTempFiles();
|
|
343
|
+
resolve({
|
|
344
|
+
success: exitCode === 0,
|
|
345
|
+
sessionName,
|
|
346
|
+
message: `Screen session "${sessionName}" exited with code ${exitCode}`,
|
|
347
|
+
exitCode,
|
|
348
|
+
output,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
waited += checkInterval;
|
|
355
|
+
if (waited >= maxWait) {
|
|
356
|
+
cleanupTempFiles();
|
|
357
|
+
resolve({
|
|
358
|
+
success: false,
|
|
359
|
+
sessionName,
|
|
360
|
+
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
361
|
+
exitCode: 1,
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
setTimeout(checkCompletion, checkInterval);
|
|
367
|
+
} catch {
|
|
368
|
+
// screen -ls failed, session probably ended
|
|
369
|
+
readAndDisplayOutput().then((output) => {
|
|
370
|
+
const exitCode = readExitCode();
|
|
371
|
+
cleanupTempFiles();
|
|
372
|
+
resolve({
|
|
373
|
+
success: exitCode === 0,
|
|
374
|
+
sessionName,
|
|
375
|
+
message: `Screen session "${sessionName}" exited with code ${exitCode}`,
|
|
376
|
+
exitCode,
|
|
377
|
+
output,
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Start checking after a brief delay
|
|
384
|
+
setTimeout(checkCompletion, checkInterval);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
resolve({
|
|
387
|
+
success: false,
|
|
388
|
+
sessionName,
|
|
389
|
+
message: `Failed to run in screen: ${err.message}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Reset screen version cache (useful for testing) */
|
|
396
|
+
function resetScreenVersionCache() {
|
|
397
|
+
cachedScreenVersion = null;
|
|
398
|
+
screenVersionChecked = false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
module.exports = {
|
|
402
|
+
getScreenVersion,
|
|
403
|
+
supportsLogfileOption,
|
|
404
|
+
runScreenWithLogCapture,
|
|
405
|
+
resetScreenVersionCache,
|
|
406
|
+
};
|
package/test/isolation.test.js
CHANGED
|
@@ -525,160 +525,8 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
525
525
|
} = require('../src/lib/isolation');
|
|
526
526
|
const { execSync } = require('child_process');
|
|
527
527
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (!isCommandAvailable('screen')) {
|
|
531
|
-
console.log(' Skipping: screen not installed');
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const result = await runInScreen('echo "test from screen"', {
|
|
536
|
-
session: `test-session-${Date.now()}`,
|
|
537
|
-
detached: true,
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
assert.strictEqual(result.success, true);
|
|
541
|
-
assert.ok(result.sessionName);
|
|
542
|
-
assert.ok(result.message.includes('screen'));
|
|
543
|
-
assert.ok(result.message.includes('Reattach with'));
|
|
544
|
-
|
|
545
|
-
// Clean up the session
|
|
546
|
-
try {
|
|
547
|
-
execSync(`screen -S ${result.sessionName} -X quit`, {
|
|
548
|
-
stdio: 'ignore',
|
|
549
|
-
});
|
|
550
|
-
} catch {
|
|
551
|
-
// Session may have already exited
|
|
552
|
-
}
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it('should run command in attached mode and capture output (issue #15)', async () => {
|
|
556
|
-
if (!isCommandAvailable('screen')) {
|
|
557
|
-
console.log(' Skipping: screen not installed');
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Test attached mode - this should work without TTY using log capture fallback
|
|
562
|
-
const result = await runInScreen('echo hello', {
|
|
563
|
-
session: `test-attached-${Date.now()}`,
|
|
564
|
-
detached: false,
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
assert.strictEqual(result.success, true);
|
|
568
|
-
assert.ok(result.sessionName);
|
|
569
|
-
assert.ok(result.message.includes('exited with code 0'));
|
|
570
|
-
// The output property should exist when using log capture
|
|
571
|
-
if (result.output !== undefined) {
|
|
572
|
-
console.log(` Captured output: "${result.output.trim()}"`);
|
|
573
|
-
assert.ok(
|
|
574
|
-
result.output.includes('hello'),
|
|
575
|
-
'Output should contain the expected message'
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('should handle multi-line output in attached mode', async () => {
|
|
581
|
-
if (!isCommandAvailable('screen')) {
|
|
582
|
-
console.log(' Skipping: screen not installed');
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const result = await runInScreen(
|
|
587
|
-
"echo 'line1'; echo 'line2'; echo 'line3'",
|
|
588
|
-
{
|
|
589
|
-
session: `test-multiline-${Date.now()}`,
|
|
590
|
-
detached: false,
|
|
591
|
-
}
|
|
592
|
-
);
|
|
593
|
-
|
|
594
|
-
assert.strictEqual(result.success, true);
|
|
595
|
-
if (result.output !== undefined) {
|
|
596
|
-
console.log(
|
|
597
|
-
` Captured multi-line output: "${result.output.trim().replace(/\n/g, '\\n')}"`
|
|
598
|
-
);
|
|
599
|
-
assert.ok(result.output.includes('line1'));
|
|
600
|
-
assert.ok(result.output.includes('line2'));
|
|
601
|
-
assert.ok(result.output.includes('line3'));
|
|
602
|
-
}
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
it('should capture output from commands with quoted strings (issue #25)', async () => {
|
|
606
|
-
if (!isCommandAvailable('screen')) {
|
|
607
|
-
console.log(' Skipping: screen not installed');
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// This is the exact scenario from issue #25:
|
|
612
|
-
// $ --isolated screen --verbose -- echo "hello"
|
|
613
|
-
// Previously failed because of shell quoting issues with execSync
|
|
614
|
-
const result = await runInScreen('echo "hello"', {
|
|
615
|
-
session: `test-quoted-${Date.now()}`,
|
|
616
|
-
detached: false,
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
assert.strictEqual(result.success, true);
|
|
620
|
-
assert.ok(result.sessionName);
|
|
621
|
-
assert.ok(result.message.includes('exited with code 0'));
|
|
622
|
-
if (result.output !== undefined) {
|
|
623
|
-
console.log(` Captured quoted output: "${result.output.trim()}"`);
|
|
624
|
-
assert.ok(
|
|
625
|
-
result.output.includes('hello'),
|
|
626
|
-
'Output should contain "hello" (issue #25 regression test)'
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it('should capture output from commands with complex quoted strings', async () => {
|
|
632
|
-
if (!isCommandAvailable('screen')) {
|
|
633
|
-
console.log(' Skipping: screen not installed');
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Test more complex quoting scenarios
|
|
638
|
-
const result = await runInScreen('echo "hello from attached mode"', {
|
|
639
|
-
session: `test-complex-quote-${Date.now()}`,
|
|
640
|
-
detached: false,
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
assert.strictEqual(result.success, true);
|
|
644
|
-
if (result.output !== undefined) {
|
|
645
|
-
console.log(
|
|
646
|
-
` Captured complex quote output: "${result.output.trim()}"`
|
|
647
|
-
);
|
|
648
|
-
assert.ok(
|
|
649
|
-
result.output.includes('hello from attached mode'),
|
|
650
|
-
'Output should contain the full message with spaces'
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it('should always return output property in attached mode (issue #25 fix verification)', async () => {
|
|
656
|
-
if (!isCommandAvailable('screen')) {
|
|
657
|
-
console.log(' Skipping: screen not installed');
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// This test verifies that attached mode always uses log capture,
|
|
662
|
-
// ensuring output is never lost even for quick commands.
|
|
663
|
-
// This is the core fix for issue #25 where output was lost on macOS
|
|
664
|
-
// because screen's virtual terminal was destroyed before output could be seen.
|
|
665
|
-
const result = await runInScreen('echo "quick command output"', {
|
|
666
|
-
session: `test-output-guaranteed-${Date.now()}`,
|
|
667
|
-
detached: false,
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
assert.strictEqual(result.success, true);
|
|
671
|
-
assert.ok(
|
|
672
|
-
result.output !== undefined,
|
|
673
|
-
'Attached mode should always return output property'
|
|
674
|
-
);
|
|
675
|
-
assert.ok(
|
|
676
|
-
result.output.includes('quick command output'),
|
|
677
|
-
'Output should be captured (issue #25 fix verification)'
|
|
678
|
-
);
|
|
679
|
-
console.log(` Verified output capture: "${result.output.trim()}"`);
|
|
680
|
-
});
|
|
681
|
-
});
|
|
528
|
+
// Screen integration tests moved to screen-integration.test.js
|
|
529
|
+
// to keep file under the 1000-line limit.
|
|
682
530
|
|
|
683
531
|
describe('runInTmux (if available)', () => {
|
|
684
532
|
it('should run command in detached tmux session', async () => {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for screen isolation
|
|
4
|
+
* Tests actual screen session behavior including output capture, exit codes, and edge cases.
|
|
5
|
+
* Extracted from isolation.test.js to keep file sizes under the 1000-line limit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { describe, it } = require('node:test');
|
|
9
|
+
const assert = require('assert');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const { isCommandAvailable, runInScreen } = require('../src/lib/isolation');
|
|
12
|
+
|
|
13
|
+
describe('Screen Integration Tests', () => {
|
|
14
|
+
describe('runInScreen (if available)', () => {
|
|
15
|
+
it('should run command in detached screen session', async () => {
|
|
16
|
+
if (!isCommandAvailable('screen')) {
|
|
17
|
+
console.log(' Skipping: screen not installed');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = await runInScreen('echo "test from screen"', {
|
|
22
|
+
session: `test-session-${Date.now()}`,
|
|
23
|
+
detached: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.strictEqual(result.success, true);
|
|
27
|
+
assert.ok(result.sessionName);
|
|
28
|
+
assert.ok(result.message.includes('screen'));
|
|
29
|
+
assert.ok(result.message.includes('Reattach with'));
|
|
30
|
+
|
|
31
|
+
// Clean up the session
|
|
32
|
+
try {
|
|
33
|
+
execSync(`screen -S ${result.sessionName} -X quit`, {
|
|
34
|
+
stdio: 'ignore',
|
|
35
|
+
});
|
|
36
|
+
} catch {
|
|
37
|
+
// Session may have already exited
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should run command in attached mode and capture output (issue #15)', async () => {
|
|
42
|
+
if (!isCommandAvailable('screen')) {
|
|
43
|
+
console.log(' Skipping: screen not installed');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Test attached mode - this should work without TTY using log capture fallback
|
|
48
|
+
const result = await runInScreen('echo hello', {
|
|
49
|
+
session: `test-attached-${Date.now()}`,
|
|
50
|
+
detached: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.strictEqual(result.success, true);
|
|
54
|
+
assert.ok(result.sessionName);
|
|
55
|
+
assert.ok(result.message.includes('exited with code 0'));
|
|
56
|
+
// The output property should exist when using log capture
|
|
57
|
+
if (result.output !== undefined) {
|
|
58
|
+
console.log(` Captured output: "${result.output.trim()}"`);
|
|
59
|
+
assert.ok(
|
|
60
|
+
result.output.includes('hello'),
|
|
61
|
+
'Output should contain the expected message'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should handle multi-line output in attached mode', async () => {
|
|
67
|
+
if (!isCommandAvailable('screen')) {
|
|
68
|
+
console.log(' Skipping: screen not installed');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = await runInScreen(
|
|
73
|
+
"echo 'line1'; echo 'line2'; echo 'line3'",
|
|
74
|
+
{
|
|
75
|
+
session: `test-multiline-${Date.now()}`,
|
|
76
|
+
detached: false,
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(result.success, true);
|
|
81
|
+
if (result.output !== undefined) {
|
|
82
|
+
console.log(
|
|
83
|
+
` Captured multi-line output: "${result.output.trim().replace(/\n/g, '\\n')}"`
|
|
84
|
+
);
|
|
85
|
+
assert.ok(result.output.includes('line1'));
|
|
86
|
+
assert.ok(result.output.includes('line2'));
|
|
87
|
+
assert.ok(result.output.includes('line3'));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should capture output from commands with quoted strings (issue #25)', async () => {
|
|
92
|
+
if (!isCommandAvailable('screen')) {
|
|
93
|
+
console.log(' Skipping: screen not installed');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = await runInScreen('echo "hello"', {
|
|
98
|
+
session: `test-quoted-${Date.now()}`,
|
|
99
|
+
detached: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(result.success, true);
|
|
103
|
+
assert.ok(result.sessionName);
|
|
104
|
+
assert.ok(result.message.includes('exited with code 0'));
|
|
105
|
+
if (result.output !== undefined) {
|
|
106
|
+
console.log(` Captured quoted output: "${result.output.trim()}"`);
|
|
107
|
+
assert.ok(
|
|
108
|
+
result.output.includes('hello'),
|
|
109
|
+
'Output should contain "hello" (issue #25 regression test)'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should capture output from commands with complex quoted strings', async () => {
|
|
115
|
+
if (!isCommandAvailable('screen')) {
|
|
116
|
+
console.log(' Skipping: screen not installed');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = await runInScreen('echo "hello from attached mode"', {
|
|
121
|
+
session: `test-complex-quote-${Date.now()}`,
|
|
122
|
+
detached: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.strictEqual(result.success, true);
|
|
126
|
+
if (result.output !== undefined) {
|
|
127
|
+
console.log(
|
|
128
|
+
` Captured complex quote output: "${result.output.trim()}"`
|
|
129
|
+
);
|
|
130
|
+
assert.ok(
|
|
131
|
+
result.output.includes('hello from attached mode'),
|
|
132
|
+
'Output should contain the full message with spaces'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should always return output property in attached mode (issue #25 fix verification)', async () => {
|
|
138
|
+
if (!isCommandAvailable('screen')) {
|
|
139
|
+
console.log(' Skipping: screen not installed');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = await runInScreen('echo "quick command output"', {
|
|
144
|
+
session: `test-output-guaranteed-${Date.now()}`,
|
|
145
|
+
detached: false,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
assert.strictEqual(result.success, true);
|
|
149
|
+
assert.ok(
|
|
150
|
+
result.output !== undefined,
|
|
151
|
+
'Attached mode should always return output property'
|
|
152
|
+
);
|
|
153
|
+
assert.ok(
|
|
154
|
+
result.output.includes('quick command output'),
|
|
155
|
+
'Output should be captured (issue #25 fix verification)'
|
|
156
|
+
);
|
|
157
|
+
console.log(` Verified output capture: "${result.output.trim()}"`);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should capture output from version-flag commands (issue #96)', async () => {
|
|
161
|
+
if (!isCommandAvailable('screen')) {
|
|
162
|
+
console.log(' Skipping: screen not installed');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const result = await runInScreen('node --version', {
|
|
167
|
+
session: `test-version-flag-${Date.now()}`,
|
|
168
|
+
detached: false,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
172
|
+
assert.ok(
|
|
173
|
+
result.output !== undefined,
|
|
174
|
+
'Attached mode should always return output property'
|
|
175
|
+
);
|
|
176
|
+
assert.ok(
|
|
177
|
+
result.output.trim().length > 0,
|
|
178
|
+
'Output should not be empty (issue #96: version output was silently lost)'
|
|
179
|
+
);
|
|
180
|
+
assert.ok(
|
|
181
|
+
result.output.includes('v') || /\d+\.\d+/.test(result.output),
|
|
182
|
+
'Output should contain version string'
|
|
183
|
+
);
|
|
184
|
+
console.log(` Captured version output: "${result.output.trim()}"`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should capture exit code from failed commands (issue #96)', async () => {
|
|
188
|
+
if (!isCommandAvailable('screen')) {
|
|
189
|
+
console.log(' Skipping: screen not installed');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await runInScreen('nonexistent_command_12345', {
|
|
194
|
+
session: `test-exit-code-${Date.now()}`,
|
|
195
|
+
detached: false,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
assert.strictEqual(
|
|
199
|
+
result.success,
|
|
200
|
+
false,
|
|
201
|
+
'Command should fail (command not found)'
|
|
202
|
+
);
|
|
203
|
+
assert.ok(result.exitCode !== undefined, 'Exit code should be captured');
|
|
204
|
+
assert.ok(
|
|
205
|
+
result.exitCode !== 0,
|
|
206
|
+
`Exit code should be non-zero for failed command, got: ${result.exitCode}`
|
|
207
|
+
);
|
|
208
|
+
console.log(` Captured exit code: ${result.exitCode}`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should capture stderr output in screen isolation (issue #96)', async () => {
|
|
212
|
+
if (!isCommandAvailable('screen')) {
|
|
213
|
+
console.log(' Skipping: screen not installed');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = await runInScreen('echo "stderr-test" >&2', {
|
|
218
|
+
session: `test-stderr-${Date.now()}`,
|
|
219
|
+
detached: false,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
223
|
+
assert.ok(result.output !== undefined, 'Output should be captured');
|
|
224
|
+
assert.ok(
|
|
225
|
+
result.output.includes('stderr-test'),
|
|
226
|
+
'stderr output should be captured via screen logging'
|
|
227
|
+
);
|
|
228
|
+
console.log(` Captured stderr output: "${result.output.trim()}"`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should capture multi-line output with correct exit code (issue #96)', async () => {
|
|
232
|
+
if (!isCommandAvailable('screen')) {
|
|
233
|
+
console.log(' Skipping: screen not installed');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result = await runInScreen(
|
|
238
|
+
'echo "line1" && echo "line2" && echo "line3"',
|
|
239
|
+
{
|
|
240
|
+
session: `test-multiline-exit-${Date.now()}`,
|
|
241
|
+
detached: false,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
246
|
+
assert.strictEqual(
|
|
247
|
+
result.exitCode,
|
|
248
|
+
0,
|
|
249
|
+
'Exit code should be 0 for successful command'
|
|
250
|
+
);
|
|
251
|
+
assert.ok(result.output.includes('line1'), 'Should contain line1');
|
|
252
|
+
assert.ok(result.output.includes('line2'), 'Should contain line2');
|
|
253
|
+
assert.ok(result.output.includes('line3'), 'Should contain line3');
|
|
254
|
+
console.log(` Multi-line output with exit code 0: verified`);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|