start-command 0.24.9 → 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 +26 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +7 -0
- package/src/lib/screen-isolation.js +177 -80
- package/test/isolation.test.js +2 -187
- package/test/screen-integration.test.js +257 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
3
29
|
## 0.24.9
|
|
4
30
|
|
|
5
31
|
### 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('-')) {
|
|
@@ -7,9 +7,16 @@ const path = require('path');
|
|
|
7
7
|
|
|
8
8
|
const setTimeout = globalThis.setTimeout;
|
|
9
9
|
|
|
10
|
-
// Debug mode from environment
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
}
|
|
13
20
|
|
|
14
21
|
// Cache for screen version detection
|
|
15
22
|
let cachedScreenVersion = null;
|
|
@@ -40,17 +47,17 @@ function getScreenVersion() {
|
|
|
40
47
|
patch: parseInt(match[3], 10),
|
|
41
48
|
};
|
|
42
49
|
|
|
43
|
-
if (
|
|
44
|
-
console.
|
|
45
|
-
`[
|
|
50
|
+
if (isDebug()) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[screen-isolation] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
46
53
|
);
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
return cachedScreenVersion;
|
|
50
57
|
}
|
|
51
58
|
} catch {
|
|
52
|
-
if (
|
|
53
|
-
console.
|
|
59
|
+
if (isDebug()) {
|
|
60
|
+
console.error('[screen-isolation] Could not detect screen version');
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -90,14 +97,33 @@ function supportsLogfileOption() {
|
|
|
90
97
|
|
|
91
98
|
/**
|
|
92
99
|
* Run command in GNU Screen using detached mode with log capture.
|
|
93
|
-
*
|
|
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
|
+
*
|
|
94
120
|
* @param {string} command - Command to execute
|
|
95
121
|
* @param {string} sessionName - Session name
|
|
96
122
|
* @param {object} shellInfo - Shell info from getShell()
|
|
97
123
|
* @param {string|null} user - Username to run command as (optional)
|
|
98
124
|
* @param {Function} wrapCommandWithUser - Function to wrap command with user
|
|
99
125
|
* @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
|
|
100
|
-
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
126
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
|
|
101
127
|
*/
|
|
102
128
|
function runScreenWithLogCapture(
|
|
103
129
|
command,
|
|
@@ -109,63 +135,93 @@ function runScreenWithLogCapture(
|
|
|
109
135
|
) {
|
|
110
136
|
const { shell, shellArg } = shellInfo;
|
|
111
137
|
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
const exitCodeFile = path.join(
|
|
139
|
+
os.tmpdir(),
|
|
140
|
+
`screen-exit-${sessionName}.code`
|
|
141
|
+
);
|
|
115
142
|
|
|
116
143
|
return new Promise((resolve) => {
|
|
117
144
|
try {
|
|
118
|
-
let screenArgs;
|
|
119
145
|
// Wrap command with user switch if specified
|
|
120
146
|
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
121
147
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
130
|
-
// Use a temporary screenrc with `logfile flush 0` to force immediate log flushing
|
|
131
|
-
// (issue #96: quick commands like `agent --version` lose output without this)
|
|
132
|
-
screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
|
|
133
|
-
try {
|
|
134
|
-
fs.writeFileSync(screenrcFile, 'logfile flush 0\n');
|
|
135
|
-
} catch {
|
|
136
|
-
// If we can't create the screenrc, proceed without it (best effort)
|
|
137
|
-
screenrcFile = null;
|
|
138
|
-
}
|
|
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
|
+
}
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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}`
|
|
151
176
|
);
|
|
152
177
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
screenArgs = isBareShell
|
|
161
|
-
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
162
|
-
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
178
|
+
resolve({
|
|
179
|
+
success: false,
|
|
180
|
+
sessionName,
|
|
181
|
+
message: `Failed to create screenrc for logging: ${err.message}`,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
163
185
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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}`);
|
|
169
225
|
}
|
|
170
226
|
|
|
171
227
|
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
@@ -177,11 +233,14 @@ function runScreenWithLogCapture(
|
|
|
177
233
|
throw result.error;
|
|
178
234
|
}
|
|
179
235
|
|
|
180
|
-
// Helper to read log file output and write to stdout
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// (issue #96)
|
|
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).
|
|
184
240
|
const readAndDisplayOutput = (retryCount = 0) => {
|
|
241
|
+
const MAX_RETRIES = 3;
|
|
242
|
+
const RETRY_DELAYS = [50, 100, 200]; // ms
|
|
243
|
+
|
|
185
244
|
let output = '';
|
|
186
245
|
try {
|
|
187
246
|
output = fs.readFileSync(logFile, 'utf8');
|
|
@@ -189,17 +248,36 @@ function runScreenWithLogCapture(
|
|
|
189
248
|
// Log file might not exist if command produced no output
|
|
190
249
|
}
|
|
191
250
|
|
|
192
|
-
// If output is empty and we haven't
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
259
|
return new Promise((resolveRetry) => {
|
|
197
260
|
setTimeout(() => {
|
|
198
|
-
resolveRetry(readAndDisplayOutput(1));
|
|
199
|
-
},
|
|
261
|
+
resolveRetry(readAndDisplayOutput(retryCount + 1));
|
|
262
|
+
}, delay);
|
|
200
263
|
});
|
|
201
264
|
}
|
|
202
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
|
+
|
|
203
281
|
// Display the output
|
|
204
282
|
if (output.trim()) {
|
|
205
283
|
process.stdout.write(output);
|
|
@@ -211,16 +289,33 @@ function runScreenWithLogCapture(
|
|
|
211
289
|
return Promise.resolve(output);
|
|
212
290
|
};
|
|
213
291
|
|
|
214
|
-
//
|
|
215
|
-
const
|
|
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
|
+
}
|
|
216
297
|
try {
|
|
217
|
-
fs.
|
|
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;
|
|
218
304
|
} catch {
|
|
219
|
-
|
|
305
|
+
if (isDebug()) {
|
|
306
|
+
console.error(
|
|
307
|
+
`[screen-isolation] Could not read exit code file, defaulting to 0`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
220
311
|
}
|
|
221
|
-
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Clean up temp files
|
|
315
|
+
const cleanupTempFiles = () => {
|
|
316
|
+
for (const f of [logFile, screenrcFile, exitCodeFile]) {
|
|
222
317
|
try {
|
|
223
|
-
fs.unlinkSync(
|
|
318
|
+
fs.unlinkSync(f);
|
|
224
319
|
} catch {
|
|
225
320
|
// Ignore cleanup errors
|
|
226
321
|
}
|
|
@@ -241,14 +336,15 @@ function runScreenWithLogCapture(
|
|
|
241
336
|
});
|
|
242
337
|
|
|
243
338
|
if (!sessions.includes(sessionName)) {
|
|
244
|
-
// Session ended, read output
|
|
339
|
+
// Session ended, read output and exit code
|
|
245
340
|
readAndDisplayOutput().then((output) => {
|
|
341
|
+
const exitCode = readExitCode();
|
|
246
342
|
cleanupTempFiles();
|
|
247
343
|
resolve({
|
|
248
|
-
success:
|
|
344
|
+
success: exitCode === 0,
|
|
249
345
|
sessionName,
|
|
250
|
-
message: `Screen session "${sessionName}" exited with code
|
|
251
|
-
exitCode
|
|
346
|
+
message: `Screen session "${sessionName}" exited with code ${exitCode}`,
|
|
347
|
+
exitCode,
|
|
252
348
|
output,
|
|
253
349
|
});
|
|
254
350
|
});
|
|
@@ -271,12 +367,13 @@ function runScreenWithLogCapture(
|
|
|
271
367
|
} catch {
|
|
272
368
|
// screen -ls failed, session probably ended
|
|
273
369
|
readAndDisplayOutput().then((output) => {
|
|
370
|
+
const exitCode = readExitCode();
|
|
274
371
|
cleanupTempFiles();
|
|
275
372
|
resolve({
|
|
276
|
-
success:
|
|
373
|
+
success: exitCode === 0,
|
|
277
374
|
sessionName,
|
|
278
|
-
message: `Screen session "${sessionName}" exited with code
|
|
279
|
-
exitCode
|
|
375
|
+
message: `Screen session "${sessionName}" exited with code ${exitCode}`,
|
|
376
|
+
exitCode,
|
|
280
377
|
output,
|
|
281
378
|
});
|
|
282
379
|
});
|
package/test/isolation.test.js
CHANGED
|
@@ -525,193 +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
|
-
|
|
682
|
-
it('should capture output from version-flag commands (issue #96)', async () => {
|
|
683
|
-
if (!isCommandAvailable('screen')) {
|
|
684
|
-
console.log(' Skipping: screen not installed');
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// This test verifies that screen isolation captures output from quick-completing
|
|
689
|
-
// commands like `agent --version` or `node --version`.
|
|
690
|
-
// Issue #96: output was silently lost because screen's internal log buffer was
|
|
691
|
-
// not flushed before the session terminated (default 10s flush interval).
|
|
692
|
-
// Fix: use a temporary screenrc with `logfile flush 0` to force immediate flushing.
|
|
693
|
-
const result = await runInScreen('node --version', {
|
|
694
|
-
session: `test-version-flag-${Date.now()}`,
|
|
695
|
-
detached: false,
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
699
|
-
assert.ok(
|
|
700
|
-
result.output !== undefined,
|
|
701
|
-
'Attached mode should always return output property'
|
|
702
|
-
);
|
|
703
|
-
assert.ok(
|
|
704
|
-
result.output.trim().length > 0,
|
|
705
|
-
'Output should not be empty (issue #96: version output was silently lost)'
|
|
706
|
-
);
|
|
707
|
-
// node --version outputs something like "v20.0.0"
|
|
708
|
-
assert.ok(
|
|
709
|
-
result.output.includes('v') || /\d+\.\d+/.test(result.output),
|
|
710
|
-
'Output should contain version string'
|
|
711
|
-
);
|
|
712
|
-
console.log(` Captured version output: "${result.output.trim()}"`);
|
|
713
|
-
});
|
|
714
|
-
});
|
|
528
|
+
// Screen integration tests moved to screen-integration.test.js
|
|
529
|
+
// to keep file under the 1000-line limit.
|
|
715
530
|
|
|
716
531
|
describe('runInTmux (if available)', () => {
|
|
717
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
|
+
});
|