start-command 0.5.1 → 0.5.3
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/release.yml +8 -0
- package/CHANGELOG.md +27 -0
- package/docs/case-studies/issue-15/README.md +208 -0
- package/eslint.config.mjs +9 -0
- package/experiments/test-screen-attached.js +126 -0
- package/experiments/test-screen-logfile.js +286 -0
- package/experiments/test-screen-modes.js +128 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +296 -23
- package/test/isolation.test.js +158 -1
|
@@ -108,6 +108,14 @@ jobs:
|
|
|
108
108
|
with:
|
|
109
109
|
bun-version: latest
|
|
110
110
|
|
|
111
|
+
- name: Install screen (Linux)
|
|
112
|
+
if: runner.os == 'Linux'
|
|
113
|
+
run: sudo apt-get update && sudo apt-get install -y screen
|
|
114
|
+
|
|
115
|
+
- name: Install screen (macOS)
|
|
116
|
+
if: runner.os == 'macOS'
|
|
117
|
+
run: brew install screen
|
|
118
|
+
|
|
111
119
|
- name: Install dependencies
|
|
112
120
|
run: bun install
|
|
113
121
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.5.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 20d0c1c: Fix screen isolation not capturing output on macOS (issue #15)
|
|
8
|
+
- Added version detection for GNU Screen to handle differences between versions
|
|
9
|
+
- Screen >= 4.5.1 uses native `-L -Logfile` for log capture
|
|
10
|
+
- Screen < 4.5.1 (like macOS bundled 4.0.3) uses `tee` command fallback
|
|
11
|
+
- Added tests for version detection and -Logfile support checking
|
|
12
|
+
- Updated case study documentation with root cause analysis
|
|
13
|
+
|
|
14
|
+
## 0.5.2
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- bdf77c7: Fix screen isolation environment not capturing command output in attached mode
|
|
19
|
+
|
|
20
|
+
When running commands with `--isolated screen` in attached mode, the command output was not being displayed (only "screen is terminating" was shown). This was because GNU Screen requires a TTY to run in attached mode, which is not available when spawning from Node.js without a terminal.
|
|
21
|
+
|
|
22
|
+
The fix implements a fallback mechanism that:
|
|
23
|
+
- Checks if a TTY is available before spawning screen
|
|
24
|
+
- If no TTY is available, uses detached mode with log capture to run the command and display its output
|
|
25
|
+
- Polls for session completion and reads the captured log file
|
|
26
|
+
- Displays the output to the user just as if it was running in attached mode
|
|
27
|
+
|
|
28
|
+
This ensures that `$ --isolated screen -- echo "hello"` now correctly displays "hello" even when running from environments without a TTY (like CI/CD pipelines, scripts, or when piping output).
|
|
29
|
+
|
|
3
30
|
## 0.5.1
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Case Study: Issue #15 - Screen Isolation Not Working As Expected
|
|
2
|
+
|
|
3
|
+
## Issue Summary
|
|
4
|
+
|
|
5
|
+
**Issue URL:** https://github.com/link-foundation/start/issues/15
|
|
6
|
+
**Date Reported:** 2025-12-22
|
|
7
|
+
**Reporter:** @konard
|
|
8
|
+
**Status:** Investigating
|
|
9
|
+
|
|
10
|
+
### Problem Statement
|
|
11
|
+
|
|
12
|
+
The screen isolation environment does not display command output when running in attached mode. When using `$ --isolated screen -- echo "hello"`, the expected output "hello" is not shown - instead, only `[screen is terminating]` appears.
|
|
13
|
+
|
|
14
|
+
### Environment
|
|
15
|
+
|
|
16
|
+
- **Platform:** macOS (reported), Linux (reproduced)
|
|
17
|
+
- **Package:** start-command@0.5.1
|
|
18
|
+
- **Screen version:** macOS bundled 4.0.3, Linux 4.09.01
|
|
19
|
+
|
|
20
|
+
## Timeline of Events
|
|
21
|
+
|
|
22
|
+
1. User installs start-command: `bun install -g start-command`
|
|
23
|
+
2. Direct command execution works: `$ echo "hello"` shows "hello"
|
|
24
|
+
3. Docker isolation works: `$ --isolated docker --image alpine -- echo "hello"` shows "hello"
|
|
25
|
+
4. Screen isolation fails: `$ --isolated screen -- echo "hello"` shows only `[screen is terminating]`
|
|
26
|
+
|
|
27
|
+
## Reproduction
|
|
28
|
+
|
|
29
|
+
### Observed Behavior
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
$ echo "hello"
|
|
33
|
+
[2025-12-22 13:45:05.245] Starting: echo hello
|
|
34
|
+
hello
|
|
35
|
+
[2025-12-22 13:45:05.254] Finished
|
|
36
|
+
Exit code: 0
|
|
37
|
+
|
|
38
|
+
$ --isolated docker --image alpine -- echo "hello"
|
|
39
|
+
[2025-12-22 13:45:07.847] Starting: echo hello
|
|
40
|
+
[Isolation] Environment: docker, Mode: attached
|
|
41
|
+
[Isolation] Image: alpine
|
|
42
|
+
hello
|
|
43
|
+
Docker container "docker-..." exited with code 0
|
|
44
|
+
[2025-12-22 13:45:08.066] Finished
|
|
45
|
+
Exit code: 0
|
|
46
|
+
|
|
47
|
+
$ --isolated screen -- echo "hello"
|
|
48
|
+
[2025-12-22 13:45:11.134] Starting: echo hello
|
|
49
|
+
[Isolation] Environment: screen, Mode: attached
|
|
50
|
+
[screen is terminating]
|
|
51
|
+
Screen session "screen-..." exited with code 0
|
|
52
|
+
[2025-12-22 13:45:11.199] Finished
|
|
53
|
+
Exit code: 0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Notice:** No "hello" output in the screen isolation case, though exit code is 0.
|
|
57
|
+
|
|
58
|
+
## Root Cause Analysis
|
|
59
|
+
|
|
60
|
+
### PRIMARY ROOT CAUSE: macOS Screen Version Incompatibility
|
|
61
|
+
|
|
62
|
+
**macOS ships with GNU Screen version 4.0.3, which does NOT support the `-Logfile` option.**
|
|
63
|
+
|
|
64
|
+
The `-Logfile` option was introduced in **GNU Screen 4.5.1** (released February 2017).
|
|
65
|
+
|
|
66
|
+
| Platform | Screen Version | `-Logfile` Support |
|
|
67
|
+
| --------------- | -------------- | ------------------ |
|
|
68
|
+
| macOS (bundled) | 4.0.3 | **NO** |
|
|
69
|
+
| Linux (CI/Test) | 4.09.01 | YES |
|
|
70
|
+
|
|
71
|
+
The current implementation uses:
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
const screenArgs = [
|
|
75
|
+
'-dmS',
|
|
76
|
+
sessionName,
|
|
77
|
+
'-L',
|
|
78
|
+
'-Logfile',
|
|
79
|
+
logFile, // <-- NOT SUPPORTED on macOS bundled screen
|
|
80
|
+
shell,
|
|
81
|
+
shellArg,
|
|
82
|
+
command,
|
|
83
|
+
];
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
On macOS with screen 4.0.3:
|
|
87
|
+
|
|
88
|
+
1. The `-Logfile` option is silently ignored or treated as a command argument
|
|
89
|
+
2. The `-L` flag alone creates a log file named `screenlog.0` in the current directory
|
|
90
|
+
3. The code tries to read from the wrong file path (`/tmp/screen-output-*.log`)
|
|
91
|
+
4. Result: No output is captured or displayed
|
|
92
|
+
|
|
93
|
+
### Secondary Root Cause: TTY Requirement
|
|
94
|
+
|
|
95
|
+
When TTY is available, the code attempts attached mode which fails:
|
|
96
|
+
|
|
97
|
+
1. **TTY Requirement**: The GNU Screen command requires a connected terminal (TTY/PTY) to run in attached mode.
|
|
98
|
+
|
|
99
|
+
2. **Node.js spawn behavior**: When spawning processes with `child_process.spawn()`, even with `stdio: 'inherit'`, Node.js does not always provide a proper pseudo-terminal (PTY) that screen requires.
|
|
100
|
+
|
|
101
|
+
3. **Error in non-TTY environments**: Running `screen -S session shell -c command` without a TTY results in:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Must be connected to a terminal.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
4. **Detached mode works**: Running `screen -dmS session shell -c command` works because it doesn't require an attached terminal.
|
|
108
|
+
|
|
109
|
+
### Experimental Evidence
|
|
110
|
+
|
|
111
|
+
Testing revealed:
|
|
112
|
+
|
|
113
|
+
- `process.stdin.isTTY` and `process.stdout.isTTY` are `undefined` when running from Node.js
|
|
114
|
+
- Detached mode with logging (`screen -dmS ... -L -Logfile ...`) captures output correctly **on Linux only**
|
|
115
|
+
- Using `script -q -c "screen ..." /dev/null` can provide a PTY but includes terminal escape codes
|
|
116
|
+
- On macOS with screen 4.0.3, the `-Logfile` option is unknown
|
|
117
|
+
|
|
118
|
+
### Version Check Evidence
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# Linux (works)
|
|
122
|
+
$ screen --version
|
|
123
|
+
Screen version 4.09.01 (GNU) 20-Aug-23
|
|
124
|
+
|
|
125
|
+
# macOS bundled (broken)
|
|
126
|
+
$ screen --version
|
|
127
|
+
Screen version 4.00.03 (FAU) 23-Oct-06
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Comparison with Docker
|
|
131
|
+
|
|
132
|
+
Docker isolation works because:
|
|
133
|
+
|
|
134
|
+
1. Docker run with `-it` flags handles terminal attachment
|
|
135
|
+
2. Docker spawns an isolated container that manages its own pseudo-terminal
|
|
136
|
+
3. The command output flows through Docker's I/O handling
|
|
137
|
+
|
|
138
|
+
## Solution: Version Detection with Fallback
|
|
139
|
+
|
|
140
|
+
### Approach
|
|
141
|
+
|
|
142
|
+
1. **Detect screen version** at runtime
|
|
143
|
+
2. **Version >= 4.5.1**: Use `-L -Logfile` approach
|
|
144
|
+
3. **Version < 4.5.1**: Use output redirection (`tee`) approach within the command
|
|
145
|
+
|
|
146
|
+
### Implementation
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
function getScreenVersion() {
|
|
150
|
+
try {
|
|
151
|
+
const output = execSync('screen --version', { encoding: 'utf8' });
|
|
152
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
153
|
+
if (match) {
|
|
154
|
+
return {
|
|
155
|
+
major: parseInt(match[1]),
|
|
156
|
+
minor: parseInt(match[2]),
|
|
157
|
+
patch: parseInt(match[3]),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function supportsLogfileOption() {
|
|
167
|
+
const version = getScreenVersion();
|
|
168
|
+
if (!version) return false;
|
|
169
|
+
// -Logfile was added in 4.5.1
|
|
170
|
+
return (
|
|
171
|
+
version.major > 4 ||
|
|
172
|
+
(version.major === 4 && version.minor > 5) ||
|
|
173
|
+
(version.major === 4 && version.minor === 5 && version.patch >= 1)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
For older versions, wrap command with tee:
|
|
179
|
+
|
|
180
|
+
```javascript
|
|
181
|
+
const wrappedCommand = `(${command}) 2>&1 | tee "${logFile}"`;
|
|
182
|
+
const screenArgs = ['-dmS', sessionName, shell, shellArg, wrappedCommand];
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Testing Strategy
|
|
186
|
+
|
|
187
|
+
1. **Unit tests**: Test version detection logic
|
|
188
|
+
2. **Unit tests**: Test screen version comparison
|
|
189
|
+
3. **Integration tests**: Test output capture for both code paths
|
|
190
|
+
4. **Regression tests**: Verify existing tests still pass
|
|
191
|
+
5. **CI tests**: Ensure output is verified in assertions (not just exit code)
|
|
192
|
+
|
|
193
|
+
## References
|
|
194
|
+
|
|
195
|
+
- [GNU Screen v.4.5.1 changelog](https://lists.gnu.org/archive/html/info-gnu/2017-02/msg00000.html) - Introduction of `-Logfile` option
|
|
196
|
+
- [GitHub Issue: RHEL7 screen does not know the Logfile option](https://github.com/distributed-system-analysis/pbench/issues/1558)
|
|
197
|
+
- [How to install GNU Screen on OS X using Homebrew](https://gist.github.com/bigeasy/2327150)
|
|
198
|
+
- [GNU Screen Manual](https://www.gnu.org/software/screen/manual/screen.html)
|
|
199
|
+
- [script command man page](https://man7.org/linux/man-pages/man1/script.1.html)
|
|
200
|
+
|
|
201
|
+
## Appendix: Test Logs
|
|
202
|
+
|
|
203
|
+
See accompanying log files:
|
|
204
|
+
|
|
205
|
+
- `test-output-1.log` - Initial reproduction
|
|
206
|
+
- `screen-modes-test.log` - Screen modes investigation
|
|
207
|
+
- `screen-attached-approaches.log` - Solution approaches testing
|
|
208
|
+
- `test-screen-logfile.js` - Version compatibility testing
|
package/eslint.config.mjs
CHANGED
|
@@ -105,9 +105,18 @@ export default [
|
|
|
105
105
|
'**/*.test.js',
|
|
106
106
|
'experiments/**/*.js',
|
|
107
107
|
],
|
|
108
|
+
languageOptions: {
|
|
109
|
+
globals: {
|
|
110
|
+
setTimeout: 'readonly',
|
|
111
|
+
setInterval: 'readonly',
|
|
112
|
+
clearTimeout: 'readonly',
|
|
113
|
+
clearInterval: 'readonly',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
108
116
|
rules: {
|
|
109
117
|
'require-await': 'off', // Async functions without await are common in tests
|
|
110
118
|
'no-unused-vars': 'warn', // Relax for experiments
|
|
119
|
+
'no-empty': 'off', // Empty catch blocks are common in experiments
|
|
111
120
|
},
|
|
112
121
|
},
|
|
113
122
|
{
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Experiment to test different approaches for running screen in attached mode
|
|
4
|
+
* from Node.js without a TTY
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { spawn, spawnSync, execSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
async function testApproaches() {
|
|
10
|
+
console.log('=== Testing Attached Mode Approaches ===\n');
|
|
11
|
+
|
|
12
|
+
// Approach 1: Using script command as wrapper
|
|
13
|
+
console.log('Approach 1: script -q -c "screen ..." /dev/null');
|
|
14
|
+
try {
|
|
15
|
+
const sessionName = `approach1-${Date.now()}`;
|
|
16
|
+
const result = spawnSync(
|
|
17
|
+
'script',
|
|
18
|
+
[
|
|
19
|
+
'-q',
|
|
20
|
+
'-c',
|
|
21
|
+
`screen -S ${sessionName} /bin/sh -c "echo hello; exit 0"`,
|
|
22
|
+
'/dev/null',
|
|
23
|
+
],
|
|
24
|
+
{
|
|
25
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
26
|
+
timeout: 5000,
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
console.log(
|
|
30
|
+
` stdout: "${result.stdout.toString().trim().replace(/\n/g, '\\n')}"`
|
|
31
|
+
);
|
|
32
|
+
console.log(
|
|
33
|
+
` stderr: "${result.stderr.toString().trim().replace(/\n/g, '\\n')}"`
|
|
34
|
+
);
|
|
35
|
+
console.log(` exit: ${result.status}`);
|
|
36
|
+
// Cleanup
|
|
37
|
+
try {
|
|
38
|
+
execSync(`screen -S ${sessionName} -X quit 2>/dev/null`);
|
|
39
|
+
} catch {}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.log(` Error: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
console.log('');
|
|
44
|
+
|
|
45
|
+
// Approach 2: Using detached mode + wait for completion + capture output via log
|
|
46
|
+
console.log('Approach 2: detached + log capture');
|
|
47
|
+
try {
|
|
48
|
+
const sessionName = `approach2-${Date.now()}`;
|
|
49
|
+
const logFile = `/tmp/screen-${sessionName}.log`;
|
|
50
|
+
|
|
51
|
+
// Start with logging
|
|
52
|
+
execSync(
|
|
53
|
+
`screen -dmS ${sessionName} -L -Logfile ${logFile} /bin/sh -c "echo 'hello from approach2'"`,
|
|
54
|
+
{
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Wait for completion
|
|
60
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
61
|
+
|
|
62
|
+
// Read log
|
|
63
|
+
const output = execSync(`cat ${logFile}`, { encoding: 'utf8' });
|
|
64
|
+
console.log(` Output: "${output.trim()}"`);
|
|
65
|
+
console.log(` Status: SUCCESS`);
|
|
66
|
+
|
|
67
|
+
// Cleanup
|
|
68
|
+
try {
|
|
69
|
+
execSync(`rm ${logFile} 2>/dev/null`);
|
|
70
|
+
} catch {}
|
|
71
|
+
try {
|
|
72
|
+
execSync(`screen -S ${sessionName} -X quit 2>/dev/null`);
|
|
73
|
+
} catch {}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.log(` Error: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
console.log('');
|
|
78
|
+
|
|
79
|
+
// Approach 3: Using stdio: 'inherit' with proper terminal allocation via script
|
|
80
|
+
console.log('Approach 3: Run through script, inherit all stdio');
|
|
81
|
+
try {
|
|
82
|
+
const sessionName = `approach3-${Date.now()}`;
|
|
83
|
+
const result = spawnSync(
|
|
84
|
+
'script',
|
|
85
|
+
[
|
|
86
|
+
'-q',
|
|
87
|
+
'-e',
|
|
88
|
+
'-c',
|
|
89
|
+
`screen -S ${sessionName} /bin/sh -c "echo hello_approach3"`,
|
|
90
|
+
'/dev/null',
|
|
91
|
+
],
|
|
92
|
+
{
|
|
93
|
+
stdio: 'inherit',
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
console.log(` exit: ${result.status}`);
|
|
98
|
+
// Cleanup
|
|
99
|
+
try {
|
|
100
|
+
execSync(`screen -S ${sessionName} -X quit 2>/dev/null`);
|
|
101
|
+
} catch {}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.log(` Error: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
// Approach 4: Direct run without screen for attached mode (fallback)
|
|
108
|
+
console.log(
|
|
109
|
+
'Approach 4: Just spawn the shell command directly (fallback, no screen)'
|
|
110
|
+
);
|
|
111
|
+
try {
|
|
112
|
+
const result = spawnSync('/bin/sh', ['-c', 'echo hello_direct'], {
|
|
113
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
});
|
|
116
|
+
console.log(` stdout: "${result.stdout.toString().trim()}"`);
|
|
117
|
+
console.log(` exit: ${result.status}`);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.log(` Error: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
console.log('');
|
|
122
|
+
|
|
123
|
+
console.log('=== Approaches Tested ===');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
testApproaches();
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Experiment to test screen's logfile capture functionality
|
|
4
|
+
* to understand the root cause of issue #15
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { execSync, spawnSync } = require('child_process');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
async function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function testScreenLogfile() {
|
|
17
|
+
console.log('=== Testing Screen Logfile Capture ===\n');
|
|
18
|
+
|
|
19
|
+
// Test environment info
|
|
20
|
+
console.log('Environment:');
|
|
21
|
+
console.log(` Platform: ${process.platform}`);
|
|
22
|
+
console.log(` Node: ${process.version}`);
|
|
23
|
+
try {
|
|
24
|
+
const screenVersion = execSync('screen --version', {
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
}).trim();
|
|
27
|
+
console.log(` Screen: ${screenVersion}`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.log(` Screen: Not available - ${e.message}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(
|
|
33
|
+
` TTY: stdin=${process.stdin.isTTY}, stdout=${process.stdout.isTTY}`
|
|
34
|
+
);
|
|
35
|
+
console.log('');
|
|
36
|
+
|
|
37
|
+
// Test 1: Basic logfile capture with -L -Logfile
|
|
38
|
+
console.log('Test 1: Basic -L -Logfile capture');
|
|
39
|
+
const sessionName1 = `logtest-${Date.now()}`;
|
|
40
|
+
const logFile1 = path.join(os.tmpdir(), `screen-log-${sessionName1}.log`);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Run screen with logging
|
|
44
|
+
const screenArgs = [
|
|
45
|
+
'-dmS',
|
|
46
|
+
sessionName1,
|
|
47
|
+
'-L',
|
|
48
|
+
'-Logfile',
|
|
49
|
+
logFile1,
|
|
50
|
+
'/bin/sh',
|
|
51
|
+
'-c',
|
|
52
|
+
'echo "TESTOUTPUT123"',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
console.log(` Command: screen ${screenArgs.join(' ')}`);
|
|
56
|
+
|
|
57
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
58
|
+
stdio: 'inherit',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Wait for completion (screen runs command and exits)
|
|
62
|
+
await sleep(500);
|
|
63
|
+
|
|
64
|
+
// Check if session still exists
|
|
65
|
+
let sessionExists = false;
|
|
66
|
+
try {
|
|
67
|
+
const sessions = execSync('screen -ls', {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
sessionExists = sessions.includes(sessionName1);
|
|
72
|
+
} catch {
|
|
73
|
+
// screen -ls returns non-zero if no sessions
|
|
74
|
+
}
|
|
75
|
+
console.log(` Session exists after 500ms: ${sessionExists}`);
|
|
76
|
+
|
|
77
|
+
// Check log file
|
|
78
|
+
if (fs.existsSync(logFile1)) {
|
|
79
|
+
const content = fs.readFileSync(logFile1, 'utf8');
|
|
80
|
+
console.log(` Log file exists: YES`);
|
|
81
|
+
console.log(` Log file size: ${content.length} bytes`);
|
|
82
|
+
console.log(
|
|
83
|
+
` Log content: "${content.trim().replace(/\n/g, '\\n').slice(0, 200)}"`
|
|
84
|
+
);
|
|
85
|
+
console.log(
|
|
86
|
+
` Contains expected output: ${content.includes('TESTOUTPUT123') ? 'YES ✓' : 'NO ✗'}`
|
|
87
|
+
);
|
|
88
|
+
fs.unlinkSync(logFile1);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(` Log file exists: NO ✗`);
|
|
91
|
+
console.log(` Expected path: ${logFile1}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cleanup
|
|
95
|
+
try {
|
|
96
|
+
execSync(`screen -S ${sessionName1} -X quit 2>/dev/null`);
|
|
97
|
+
} catch {}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.log(` Error: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
|
|
103
|
+
// Test 2: Test with sleep to ensure buffer flush
|
|
104
|
+
console.log('Test 2: With sleep for buffer flush');
|
|
105
|
+
const sessionName2 = `logtest2-${Date.now()}`;
|
|
106
|
+
const logFile2 = path.join(os.tmpdir(), `screen-log-${sessionName2}.log`);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const screenArgs = [
|
|
110
|
+
'-dmS',
|
|
111
|
+
sessionName2,
|
|
112
|
+
'-L',
|
|
113
|
+
'-Logfile',
|
|
114
|
+
logFile2,
|
|
115
|
+
'/bin/sh',
|
|
116
|
+
'-c',
|
|
117
|
+
'echo "FLUSHED_OUTPUT" && sleep 0.5',
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
console.log(` Command: screen ${screenArgs.join(' ')}`);
|
|
121
|
+
|
|
122
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
123
|
+
stdio: 'inherit',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Wait longer for flush (default is 10 seconds)
|
|
127
|
+
await sleep(1500);
|
|
128
|
+
|
|
129
|
+
// Check log file
|
|
130
|
+
if (fs.existsSync(logFile2)) {
|
|
131
|
+
const content = fs.readFileSync(logFile2, 'utf8');
|
|
132
|
+
console.log(` Log file exists: YES`);
|
|
133
|
+
console.log(` Log file size: ${content.length} bytes`);
|
|
134
|
+
console.log(
|
|
135
|
+
` Contains expected output: ${content.includes('FLUSHED_OUTPUT') ? 'YES ✓' : 'NO ✗'}`
|
|
136
|
+
);
|
|
137
|
+
fs.unlinkSync(logFile2);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(` Log file exists: NO ✗`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Cleanup
|
|
143
|
+
try {
|
|
144
|
+
execSync(`screen -S ${sessionName2} -X quit 2>/dev/null`);
|
|
145
|
+
} catch {}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.log(` Error: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
console.log('');
|
|
150
|
+
|
|
151
|
+
// Test 3: Alternative - using hardstatus/output redirection
|
|
152
|
+
console.log('Test 3: Direct command output capture (no screen logging)');
|
|
153
|
+
const sessionName3 = `logtest3-${Date.now()}`;
|
|
154
|
+
const logFile3 = path.join(os.tmpdir(), `screen-log-${sessionName3}.log`);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Run command through screen but capture output to file within the command
|
|
158
|
+
const command = `echo "DIRECT_CAPTURE" | tee ${logFile3}`;
|
|
159
|
+
const screenArgs = ['-dmS', sessionName3, '/bin/sh', '-c', command];
|
|
160
|
+
|
|
161
|
+
console.log(` Command: screen ${screenArgs.join(' ')}`);
|
|
162
|
+
|
|
163
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
164
|
+
stdio: 'inherit',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await sleep(500);
|
|
168
|
+
|
|
169
|
+
// Check log file
|
|
170
|
+
if (fs.existsSync(logFile3)) {
|
|
171
|
+
const content = fs.readFileSync(logFile3, 'utf8');
|
|
172
|
+
console.log(` Log file exists: YES`);
|
|
173
|
+
console.log(
|
|
174
|
+
` Contains expected output: ${content.includes('DIRECT_CAPTURE') ? 'YES ✓' : 'NO ✗'}`
|
|
175
|
+
);
|
|
176
|
+
fs.unlinkSync(logFile3);
|
|
177
|
+
} else {
|
|
178
|
+
console.log(` Log file exists: NO ✗`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Cleanup
|
|
182
|
+
try {
|
|
183
|
+
execSync(`screen -S ${sessionName3} -X quit 2>/dev/null`);
|
|
184
|
+
} catch {}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.log(` Error: ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
console.log('');
|
|
189
|
+
|
|
190
|
+
// Test 4: Script command approach
|
|
191
|
+
console.log('Test 4: Using script command to capture output');
|
|
192
|
+
const sessionName4 = `logtest4-${Date.now()}`;
|
|
193
|
+
const logFile4 = path.join(os.tmpdir(), `script-log-${sessionName4}.log`);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Use script to capture output
|
|
197
|
+
const result = spawnSync(
|
|
198
|
+
'script',
|
|
199
|
+
[
|
|
200
|
+
'-q',
|
|
201
|
+
logFile4,
|
|
202
|
+
'-c',
|
|
203
|
+
`screen -dmS ${sessionName4} /bin/sh -c "echo SCRIPT_CAPTURE"`,
|
|
204
|
+
],
|
|
205
|
+
{
|
|
206
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
207
|
+
timeout: 5000,
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
await sleep(500);
|
|
212
|
+
|
|
213
|
+
console.log(` Exit code: ${result.status}`);
|
|
214
|
+
|
|
215
|
+
// Check log file
|
|
216
|
+
if (fs.existsSync(logFile4)) {
|
|
217
|
+
const content = fs.readFileSync(logFile4, 'utf8');
|
|
218
|
+
console.log(` Log file exists: YES`);
|
|
219
|
+
console.log(` Log file size: ${content.length} bytes`);
|
|
220
|
+
fs.unlinkSync(logFile4);
|
|
221
|
+
} else {
|
|
222
|
+
console.log(` Log file exists: NO`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Cleanup
|
|
226
|
+
try {
|
|
227
|
+
execSync(`screen -S ${sessionName4} -X quit 2>/dev/null`);
|
|
228
|
+
} catch {}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.log(` Error: ${e.message}`);
|
|
231
|
+
}
|
|
232
|
+
console.log('');
|
|
233
|
+
|
|
234
|
+
// Test 5: Test with -T option (terminal type)
|
|
235
|
+
console.log('Test 5: With explicit terminal type -T xterm');
|
|
236
|
+
const sessionName5 = `logtest5-${Date.now()}`;
|
|
237
|
+
const logFile5 = path.join(os.tmpdir(), `screen-log-${sessionName5}.log`);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const screenArgs = [
|
|
241
|
+
'-T',
|
|
242
|
+
'xterm',
|
|
243
|
+
'-dmS',
|
|
244
|
+
sessionName5,
|
|
245
|
+
'-L',
|
|
246
|
+
'-Logfile',
|
|
247
|
+
logFile5,
|
|
248
|
+
'/bin/sh',
|
|
249
|
+
'-c',
|
|
250
|
+
'echo "TERMINAL_OUTPUT" && sleep 0.3',
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
console.log(` Command: screen ${screenArgs.join(' ')}`);
|
|
254
|
+
|
|
255
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
256
|
+
stdio: 'inherit',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await sleep(1000);
|
|
260
|
+
|
|
261
|
+
// Check log file
|
|
262
|
+
if (fs.existsSync(logFile5)) {
|
|
263
|
+
const content = fs.readFileSync(logFile5, 'utf8');
|
|
264
|
+
console.log(` Log file exists: YES`);
|
|
265
|
+
console.log(` Log file size: ${content.length} bytes`);
|
|
266
|
+
console.log(
|
|
267
|
+
` Contains expected output: ${content.includes('TERMINAL_OUTPUT') ? 'YES ✓' : 'NO ✗'}`
|
|
268
|
+
);
|
|
269
|
+
fs.unlinkSync(logFile5);
|
|
270
|
+
} else {
|
|
271
|
+
console.log(` Log file exists: NO ✗`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Cleanup
|
|
275
|
+
try {
|
|
276
|
+
execSync(`screen -S ${sessionName5} -X quit 2>/dev/null`);
|
|
277
|
+
} catch {}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
console.log(` Error: ${e.message}`);
|
|
280
|
+
}
|
|
281
|
+
console.log('');
|
|
282
|
+
|
|
283
|
+
console.log('=== Tests Complete ===');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
testScreenLogfile();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Experiment to test different screen invocation modes
|
|
4
|
+
* This helps understand how screen behaves in different contexts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { spawn, execSync, spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
async function testScreenModes() {
|
|
10
|
+
console.log('=== Testing Screen Modes ===\n');
|
|
11
|
+
|
|
12
|
+
// Test 1: Check if running in a terminal
|
|
13
|
+
console.log('1. Terminal check:');
|
|
14
|
+
console.log(` process.stdin.isTTY: ${process.stdin.isTTY}`);
|
|
15
|
+
console.log(` process.stdout.isTTY: ${process.stdout.isTTY}`);
|
|
16
|
+
console.log(` TERM: ${process.env.TERM || 'not set'}`);
|
|
17
|
+
console.log('');
|
|
18
|
+
|
|
19
|
+
// Test 2: Detached mode should work
|
|
20
|
+
console.log('2. Testing detached mode (screen -dmS):');
|
|
21
|
+
try {
|
|
22
|
+
const sessionName = `test-${Date.now()}`;
|
|
23
|
+
execSync(
|
|
24
|
+
`screen -dmS ${sessionName} /bin/sh -c "echo hello > /tmp/screen-test-${sessionName}.txt"`,
|
|
25
|
+
{
|
|
26
|
+
stdio: 'inherit',
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
// Wait a bit for the command to complete
|
|
30
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
31
|
+
|
|
32
|
+
const output = execSync(
|
|
33
|
+
`cat /tmp/screen-test-${sessionName}.txt 2>/dev/null || echo "file not found"`,
|
|
34
|
+
{ encoding: 'utf8' }
|
|
35
|
+
);
|
|
36
|
+
console.log(` Output: ${output.trim()}`);
|
|
37
|
+
|
|
38
|
+
// Cleanup
|
|
39
|
+
try {
|
|
40
|
+
execSync(`screen -S ${sessionName} -X quit 2>/dev/null`);
|
|
41
|
+
} catch {}
|
|
42
|
+
try {
|
|
43
|
+
execSync(`rm /tmp/screen-test-${sessionName}.txt 2>/dev/null`);
|
|
44
|
+
} catch {}
|
|
45
|
+
|
|
46
|
+
console.log(' Status: SUCCESS');
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.log(` Status: FAILED - ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
console.log('');
|
|
51
|
+
|
|
52
|
+
// Test 3: Attached mode with spawn (current implementation)
|
|
53
|
+
console.log('3. Testing attached mode with spawn (current implementation):');
|
|
54
|
+
try {
|
|
55
|
+
const sessionName = `test-${Date.now()}`;
|
|
56
|
+
const result = spawnSync(
|
|
57
|
+
'screen',
|
|
58
|
+
['-S', sessionName, '/bin/sh', '-c', 'echo hello'],
|
|
59
|
+
{
|
|
60
|
+
stdio: 'inherit',
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
console.log(` Exit code: ${result.status}`);
|
|
64
|
+
console.log(` Status: ${result.status === 0 ? 'SUCCESS' : 'FAILED'}`);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.log(` Status: FAILED - ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
console.log('');
|
|
69
|
+
|
|
70
|
+
// Test 4: Try to use script command to allocate PTY
|
|
71
|
+
console.log('4. Testing with script command to allocate PTY:');
|
|
72
|
+
try {
|
|
73
|
+
const result = spawnSync(
|
|
74
|
+
'script',
|
|
75
|
+
['-q', '-c', 'echo hello', '/dev/null'],
|
|
76
|
+
{
|
|
77
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
console.log(` Output: ${result.stdout.toString().trim()}`);
|
|
81
|
+
console.log(` Exit code: ${result.status}`);
|
|
82
|
+
console.log(` Status: SUCCESS`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.log(` Status: FAILED - ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|
|
87
|
+
|
|
88
|
+
// Test 5: Detached mode with log capture
|
|
89
|
+
console.log('5. Testing detached mode with log capture:');
|
|
90
|
+
try {
|
|
91
|
+
const sessionName = `test-${Date.now()}`;
|
|
92
|
+
const logFile = `/tmp/screen-log-${sessionName}.txt`;
|
|
93
|
+
|
|
94
|
+
// Create detached session with logging
|
|
95
|
+
execSync(
|
|
96
|
+
`screen -dmS ${sessionName} -L -Logfile ${logFile} /bin/sh -c "echo 'hello from screen'; sleep 0.2"`,
|
|
97
|
+
{
|
|
98
|
+
stdio: 'inherit',
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Wait for command to complete
|
|
103
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
104
|
+
|
|
105
|
+
const output = execSync(
|
|
106
|
+
`cat ${logFile} 2>/dev/null || echo "log not found"`,
|
|
107
|
+
{ encoding: 'utf8' }
|
|
108
|
+
);
|
|
109
|
+
console.log(` Log content: ${output.trim().replace(/\n/g, '\\n')}`);
|
|
110
|
+
|
|
111
|
+
// Cleanup
|
|
112
|
+
try {
|
|
113
|
+
execSync(`screen -S ${sessionName} -X quit 2>/dev/null`);
|
|
114
|
+
} catch {}
|
|
115
|
+
try {
|
|
116
|
+
execSync(`rm ${logFile} 2>/dev/null`);
|
|
117
|
+
} catch {}
|
|
118
|
+
|
|
119
|
+
console.log(' Status: SUCCESS');
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.log(` Status: FAILED - ${e.message}`);
|
|
122
|
+
}
|
|
123
|
+
console.log('');
|
|
124
|
+
|
|
125
|
+
console.log('=== Tests Complete ===');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
testScreenModes();
|
package/package.json
CHANGED
package/src/lib/isolation.js
CHANGED
|
@@ -14,10 +14,89 @@ const os = require('os');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { generateSessionName } = require('./args-parser');
|
|
16
16
|
|
|
17
|
+
const setTimeout = globalThis.setTimeout;
|
|
18
|
+
|
|
17
19
|
// Debug mode from environment
|
|
18
20
|
const DEBUG =
|
|
19
21
|
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
20
22
|
|
|
23
|
+
// Cache for screen version detection
|
|
24
|
+
let cachedScreenVersion = null;
|
|
25
|
+
let screenVersionChecked = false;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the installed screen version
|
|
29
|
+
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
30
|
+
*/
|
|
31
|
+
function getScreenVersion() {
|
|
32
|
+
if (screenVersionChecked) {
|
|
33
|
+
return cachedScreenVersion;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
screenVersionChecked = true;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const output = execSync('screen --version', {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
42
|
+
});
|
|
43
|
+
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
44
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
45
|
+
if (match) {
|
|
46
|
+
cachedScreenVersion = {
|
|
47
|
+
major: parseInt(match[1], 10),
|
|
48
|
+
minor: parseInt(match[2], 10),
|
|
49
|
+
patch: parseInt(match[3], 10),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (DEBUG) {
|
|
53
|
+
console.log(
|
|
54
|
+
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return cachedScreenVersion;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
if (DEBUG) {
|
|
62
|
+
console.log('[DEBUG] Could not detect screen version');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if screen supports the -Logfile option
|
|
71
|
+
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
72
|
+
* @returns {boolean} True if -Logfile is supported
|
|
73
|
+
*/
|
|
74
|
+
function supportsLogfileOption() {
|
|
75
|
+
const version = getScreenVersion();
|
|
76
|
+
if (!version) {
|
|
77
|
+
// If we can't detect version, assume older version and use fallback
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -Logfile was added in 4.5.1
|
|
82
|
+
// Compare: version >= 4.5.1
|
|
83
|
+
if (version.major > 4) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (version.major < 4) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
// major === 4
|
|
90
|
+
if (version.minor > 5) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (version.minor < 5) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
// minor === 5
|
|
97
|
+
return version.patch >= 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
21
100
|
/**
|
|
22
101
|
* Check if a command is available on the system
|
|
23
102
|
* @param {string} command - Command to check
|
|
@@ -45,6 +124,171 @@ function getShell() {
|
|
|
45
124
|
return { shell, shellArg };
|
|
46
125
|
}
|
|
47
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Check if the current process has a TTY attached
|
|
129
|
+
* @returns {boolean} True if TTY is available
|
|
130
|
+
*/
|
|
131
|
+
function hasTTY() {
|
|
132
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Run command in GNU Screen using detached mode with log capture
|
|
137
|
+
* This is a workaround for environments without TTY
|
|
138
|
+
*
|
|
139
|
+
* Supports two methods based on screen version:
|
|
140
|
+
* - screen >= 4.5.1: Uses -L -Logfile option for native log capture
|
|
141
|
+
* - screen < 4.5.1: Uses tee command within the wrapped command for output capture
|
|
142
|
+
*
|
|
143
|
+
* @param {string} command - Command to execute
|
|
144
|
+
* @param {string} sessionName - Session name
|
|
145
|
+
* @param {object} shellInfo - Shell info from getShell()
|
|
146
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
147
|
+
*/
|
|
148
|
+
function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
149
|
+
const { shell, shellArg } = shellInfo;
|
|
150
|
+
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
151
|
+
|
|
152
|
+
// Check if screen supports -Logfile option (added in 4.5.1)
|
|
153
|
+
const useNativeLogging = supportsLogfileOption();
|
|
154
|
+
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
try {
|
|
157
|
+
let screenArgs;
|
|
158
|
+
let effectiveCommand = command;
|
|
159
|
+
|
|
160
|
+
if (useNativeLogging) {
|
|
161
|
+
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
162
|
+
// screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
|
|
163
|
+
screenArgs = [
|
|
164
|
+
'-dmS',
|
|
165
|
+
sessionName,
|
|
166
|
+
'-L',
|
|
167
|
+
'-Logfile',
|
|
168
|
+
logFile,
|
|
169
|
+
shell,
|
|
170
|
+
shellArg,
|
|
171
|
+
command,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
if (DEBUG) {
|
|
175
|
+
console.log(
|
|
176
|
+
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
181
|
+
// Wrap the command to capture output using tee
|
|
182
|
+
// The parentheses ensure proper grouping of the command and its stderr
|
|
183
|
+
effectiveCommand = `(${command}) 2>&1 | tee "${logFile}"`;
|
|
184
|
+
screenArgs = ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
185
|
+
|
|
186
|
+
if (DEBUG) {
|
|
187
|
+
console.log(
|
|
188
|
+
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
|
|
194
|
+
stdio: 'inherit',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Poll for session completion
|
|
198
|
+
const checkInterval = 100; // ms
|
|
199
|
+
const maxWait = 300000; // 5 minutes max
|
|
200
|
+
let waited = 0;
|
|
201
|
+
|
|
202
|
+
const checkCompletion = () => {
|
|
203
|
+
try {
|
|
204
|
+
// Check if session still exists
|
|
205
|
+
const sessions = execSync('screen -ls', {
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!sessions.includes(sessionName)) {
|
|
211
|
+
// Session ended, read output
|
|
212
|
+
let output = '';
|
|
213
|
+
try {
|
|
214
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
215
|
+
// Display the output
|
|
216
|
+
if (output.trim()) {
|
|
217
|
+
process.stdout.write(output);
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Log file might not exist if command was very quick
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Clean up log file
|
|
224
|
+
try {
|
|
225
|
+
fs.unlinkSync(logFile);
|
|
226
|
+
} catch {
|
|
227
|
+
// Ignore cleanup errors
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
resolve({
|
|
231
|
+
success: true,
|
|
232
|
+
sessionName,
|
|
233
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
234
|
+
exitCode: 0,
|
|
235
|
+
output,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
waited += checkInterval;
|
|
241
|
+
if (waited >= maxWait) {
|
|
242
|
+
resolve({
|
|
243
|
+
success: false,
|
|
244
|
+
sessionName,
|
|
245
|
+
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
246
|
+
exitCode: 1,
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setTimeout(checkCompletion, checkInterval);
|
|
252
|
+
} catch {
|
|
253
|
+
// screen -ls failed, session probably ended
|
|
254
|
+
let output = '';
|
|
255
|
+
try {
|
|
256
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
257
|
+
if (output.trim()) {
|
|
258
|
+
process.stdout.write(output);
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
// Ignore
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
fs.unlinkSync(logFile);
|
|
266
|
+
} catch {
|
|
267
|
+
// Ignore
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
resolve({
|
|
271
|
+
success: true,
|
|
272
|
+
sessionName,
|
|
273
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
274
|
+
exitCode: 0,
|
|
275
|
+
output,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Start checking after a brief delay
|
|
281
|
+
setTimeout(checkCompletion, checkInterval);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
resolve({
|
|
284
|
+
success: false,
|
|
285
|
+
sessionName,
|
|
286
|
+
message: `Failed to run in screen: ${err.message}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
48
292
|
/**
|
|
49
293
|
* Run command in GNU Screen
|
|
50
294
|
* @param {string} command - Command to execute
|
|
@@ -62,7 +306,8 @@ function runInScreen(command, options = {}) {
|
|
|
62
306
|
}
|
|
63
307
|
|
|
64
308
|
const sessionName = options.session || generateSessionName('screen');
|
|
65
|
-
const
|
|
309
|
+
const shellInfo = getShell();
|
|
310
|
+
const { shell, shellArg } = shellInfo;
|
|
66
311
|
|
|
67
312
|
try {
|
|
68
313
|
if (options.detached) {
|
|
@@ -83,35 +328,50 @@ function runInScreen(command, options = {}) {
|
|
|
83
328
|
message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
|
|
84
329
|
});
|
|
85
330
|
} else {
|
|
86
|
-
// Attached mode:
|
|
87
|
-
const screenArgs = ['-S', sessionName, shell, shellArg, command];
|
|
331
|
+
// Attached mode: need TTY for screen to work properly
|
|
88
332
|
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
333
|
+
// Check if we have a TTY
|
|
334
|
+
if (hasTTY()) {
|
|
335
|
+
// We have a TTY, use direct screen invocation
|
|
336
|
+
const screenArgs = ['-S', sessionName, shell, shellArg, command];
|
|
92
337
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
338
|
+
if (DEBUG) {
|
|
339
|
+
console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
|
|
340
|
+
}
|
|
97
341
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
sessionName,
|
|
102
|
-
message: `Screen session "${sessionName}" exited with code ${code}`,
|
|
103
|
-
exitCode: code,
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const child = spawn('screen', screenArgs, {
|
|
344
|
+
stdio: 'inherit',
|
|
104
345
|
});
|
|
105
|
-
});
|
|
106
346
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
347
|
+
child.on('exit', (code) => {
|
|
348
|
+
resolve({
|
|
349
|
+
success: code === 0,
|
|
350
|
+
sessionName,
|
|
351
|
+
message: `Screen session "${sessionName}" exited with code ${code}`,
|
|
352
|
+
exitCode: code,
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
child.on('error', (err) => {
|
|
357
|
+
resolve({
|
|
358
|
+
success: false,
|
|
359
|
+
sessionName,
|
|
360
|
+
message: `Failed to start screen: ${err.message}`,
|
|
361
|
+
});
|
|
112
362
|
});
|
|
113
363
|
});
|
|
114
|
-
}
|
|
364
|
+
} else {
|
|
365
|
+
// No TTY available - use detached mode with log capture
|
|
366
|
+
// This allows screen to run without a terminal while still capturing output
|
|
367
|
+
if (DEBUG) {
|
|
368
|
+
console.log(
|
|
369
|
+
`[DEBUG] No TTY available, using detached mode with log capture`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return runScreenWithLogCapture(command, sessionName, shellInfo);
|
|
374
|
+
}
|
|
115
375
|
}
|
|
116
376
|
} catch (err) {
|
|
117
377
|
return Promise.resolve({
|
|
@@ -507,8 +767,17 @@ function createLogPath(environment) {
|
|
|
507
767
|
return path.join(logDir, logFilename);
|
|
508
768
|
}
|
|
509
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Reset screen version cache (useful for testing)
|
|
772
|
+
*/
|
|
773
|
+
function resetScreenVersionCache() {
|
|
774
|
+
cachedScreenVersion = null;
|
|
775
|
+
screenVersionChecked = false;
|
|
776
|
+
}
|
|
777
|
+
|
|
510
778
|
module.exports = {
|
|
511
779
|
isCommandAvailable,
|
|
780
|
+
hasTTY,
|
|
512
781
|
runInScreen,
|
|
513
782
|
runInTmux,
|
|
514
783
|
runInZellij,
|
|
@@ -522,4 +791,8 @@ module.exports = {
|
|
|
522
791
|
writeLogFile,
|
|
523
792
|
getLogDir,
|
|
524
793
|
createLogPath,
|
|
794
|
+
// Export screen version utilities for testing and debugging
|
|
795
|
+
getScreenVersion,
|
|
796
|
+
supportsLogfileOption,
|
|
797
|
+
resetScreenVersionCache,
|
|
525
798
|
};
|
package/test/isolation.test.js
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
const { describe, it } = require('node:test');
|
|
9
9
|
const assert = require('assert');
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
isCommandAvailable,
|
|
12
|
+
hasTTY,
|
|
13
|
+
getScreenVersion,
|
|
14
|
+
supportsLogfileOption,
|
|
15
|
+
resetScreenVersionCache,
|
|
16
|
+
} = require('../src/lib/isolation');
|
|
11
17
|
|
|
12
18
|
describe('Isolation Module', () => {
|
|
13
19
|
describe('isCommandAvailable', () => {
|
|
@@ -34,6 +40,21 @@ describe('Isolation Module', () => {
|
|
|
34
40
|
});
|
|
35
41
|
});
|
|
36
42
|
|
|
43
|
+
describe('hasTTY', () => {
|
|
44
|
+
it('should return a boolean', () => {
|
|
45
|
+
const result = hasTTY();
|
|
46
|
+
assert.strictEqual(typeof result, 'boolean');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return false when running in test environment (no TTY)', () => {
|
|
50
|
+
// When running tests, we typically don't have a TTY
|
|
51
|
+
const result = hasTTY();
|
|
52
|
+
// This should be false in CI/test environments
|
|
53
|
+
console.log(` hasTTY: ${result}`);
|
|
54
|
+
assert.ok(typeof result === 'boolean');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
37
58
|
describe('isolation backend checks', () => {
|
|
38
59
|
// These tests check if specific backends are available
|
|
39
60
|
// They don't fail if not installed, just report status
|
|
@@ -62,6 +83,92 @@ describe('Isolation Module', () => {
|
|
|
62
83
|
assert.ok(typeof result === 'boolean');
|
|
63
84
|
});
|
|
64
85
|
});
|
|
86
|
+
|
|
87
|
+
describe('getScreenVersion', () => {
|
|
88
|
+
it('should return version object or null', () => {
|
|
89
|
+
// Reset cache before testing
|
|
90
|
+
resetScreenVersionCache();
|
|
91
|
+
const version = getScreenVersion();
|
|
92
|
+
|
|
93
|
+
if (isCommandAvailable('screen')) {
|
|
94
|
+
// If screen is installed, we should get a version object
|
|
95
|
+
assert.ok(
|
|
96
|
+
version !== null,
|
|
97
|
+
'Should return version object when screen is installed'
|
|
98
|
+
);
|
|
99
|
+
assert.ok(typeof version.major === 'number', 'major should be number');
|
|
100
|
+
assert.ok(typeof version.minor === 'number', 'minor should be number');
|
|
101
|
+
assert.ok(typeof version.patch === 'number', 'patch should be number');
|
|
102
|
+
console.log(
|
|
103
|
+
` Detected screen version: ${version.major}.${version.minor}.${version.patch}`
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
// If screen is not installed, we should get null
|
|
107
|
+
assert.strictEqual(
|
|
108
|
+
version,
|
|
109
|
+
null,
|
|
110
|
+
'Should return null when screen is not installed'
|
|
111
|
+
);
|
|
112
|
+
console.log(' screen not installed, version is null');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should cache the version result', () => {
|
|
117
|
+
// Reset cache first
|
|
118
|
+
resetScreenVersionCache();
|
|
119
|
+
|
|
120
|
+
// Call twice
|
|
121
|
+
const version1 = getScreenVersion();
|
|
122
|
+
const version2 = getScreenVersion();
|
|
123
|
+
|
|
124
|
+
// Results should be identical (same object reference if cached)
|
|
125
|
+
assert.strictEqual(
|
|
126
|
+
version1,
|
|
127
|
+
version2,
|
|
128
|
+
'Cached version should return same object'
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('supportsLogfileOption', () => {
|
|
134
|
+
it('should return boolean', () => {
|
|
135
|
+
// Reset cache before testing
|
|
136
|
+
resetScreenVersionCache();
|
|
137
|
+
const result = supportsLogfileOption();
|
|
138
|
+
assert.ok(typeof result === 'boolean', 'Should return a boolean');
|
|
139
|
+
console.log(` supportsLogfileOption: ${result}`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return true for screen >= 4.5.1', () => {
|
|
143
|
+
// This tests the logic by checking the current system
|
|
144
|
+
resetScreenVersionCache();
|
|
145
|
+
const version = getScreenVersion();
|
|
146
|
+
|
|
147
|
+
if (version) {
|
|
148
|
+
const expected =
|
|
149
|
+
version.major > 4 ||
|
|
150
|
+
(version.major === 4 && version.minor > 5) ||
|
|
151
|
+
(version.major === 4 && version.minor === 5 && version.patch >= 1);
|
|
152
|
+
const result = supportsLogfileOption();
|
|
153
|
+
assert.strictEqual(
|
|
154
|
+
result,
|
|
155
|
+
expected,
|
|
156
|
+
`Version ${version.major}.${version.minor}.${version.patch} should ${expected ? 'support' : 'not support'} -Logfile`
|
|
157
|
+
);
|
|
158
|
+
console.log(
|
|
159
|
+
` Version ${version.major}.${version.minor}.${version.patch}: -Logfile supported = ${result}`
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
// If no version detected, should return false (fallback to safe method)
|
|
163
|
+
const result = supportsLogfileOption();
|
|
164
|
+
assert.strictEqual(
|
|
165
|
+
result,
|
|
166
|
+
false,
|
|
167
|
+
'Should return false when version cannot be detected'
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
65
172
|
});
|
|
66
173
|
|
|
67
174
|
describe('Isolation Runner Error Handling', () => {
|
|
@@ -197,6 +304,56 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
197
304
|
// Session may have already exited
|
|
198
305
|
}
|
|
199
306
|
});
|
|
307
|
+
|
|
308
|
+
it('should run command in attached mode and capture output (issue #15)', async () => {
|
|
309
|
+
if (!isCommandAvailable('screen')) {
|
|
310
|
+
console.log(' Skipping: screen not installed');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Test attached mode - this should work without TTY using log capture fallback
|
|
315
|
+
const result = await runInScreen('echo hello', {
|
|
316
|
+
session: `test-attached-${Date.now()}`,
|
|
317
|
+
detached: false,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
assert.strictEqual(result.success, true);
|
|
321
|
+
assert.ok(result.sessionName);
|
|
322
|
+
assert.ok(result.message.includes('exited with code 0'));
|
|
323
|
+
// The output property should exist when using log capture
|
|
324
|
+
if (result.output !== undefined) {
|
|
325
|
+
console.log(` Captured output: "${result.output.trim()}"`);
|
|
326
|
+
assert.ok(
|
|
327
|
+
result.output.includes('hello'),
|
|
328
|
+
'Output should contain the expected message'
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should handle multi-line output in attached mode', async () => {
|
|
334
|
+
if (!isCommandAvailable('screen')) {
|
|
335
|
+
console.log(' Skipping: screen not installed');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const result = await runInScreen(
|
|
340
|
+
"echo 'line1'; echo 'line2'; echo 'line3'",
|
|
341
|
+
{
|
|
342
|
+
session: `test-multiline-${Date.now()}`,
|
|
343
|
+
detached: false,
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
assert.strictEqual(result.success, true);
|
|
348
|
+
if (result.output !== undefined) {
|
|
349
|
+
console.log(
|
|
350
|
+
` Captured multi-line output: "${result.output.trim().replace(/\n/g, '\\n')}"`
|
|
351
|
+
);
|
|
352
|
+
assert.ok(result.output.includes('line1'));
|
|
353
|
+
assert.ok(result.output.includes('line2'));
|
|
354
|
+
assert.ok(result.output.includes('line3'));
|
|
355
|
+
}
|
|
356
|
+
});
|
|
200
357
|
});
|
|
201
358
|
|
|
202
359
|
describe('runInTmux (if available)', () => {
|