start-command 0.16.0 → 0.17.1
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 +20 -0
- package/package.json +1 -1
- package/src/bin/cli.js +65 -57
- package/src/lib/docker-utils.js +89 -0
- package/src/lib/isolation.js +14 -9
- package/src/lib/output-blocks.js +35 -5
- package/test/echo-integration.test.js +767 -0
- package/test/isolation.test.js +32 -0
- package/test/output-blocks.test.js +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 82a5297: fix: Improve output uniformity and ensure echo hi works in all modes
|
|
8
|
+
- Fixed truncation of log paths, session IDs, and result messages in output blocks
|
|
9
|
+
- Added consistent empty line formatting before/after command output
|
|
10
|
+
- Ensured proper output display in screen isolation mode
|
|
11
|
+
- Added integration tests for echo command across all isolation modes
|
|
12
|
+
|
|
13
|
+
## 0.17.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 1da275c: feat: Improve output block uniformity and add OS-based Docker image detection
|
|
18
|
+
- Move isolation info lines into start block instead of printing them separately
|
|
19
|
+
- Move exit/result messages into finish block instead of printing them separately
|
|
20
|
+
- Add getDefaultDockerImage() to detect host OS and select matching Docker image
|
|
21
|
+
- Default Docker images: ubuntu, debian, archlinux, fedora, centos based on host OS
|
|
22
|
+
|
|
3
23
|
## 0.16.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
createLogFooter,
|
|
23
23
|
writeLogFile,
|
|
24
24
|
createLogPath,
|
|
25
|
+
getDefaultDockerImage,
|
|
25
26
|
} = require('../lib/isolation');
|
|
26
27
|
const {
|
|
27
28
|
createIsolatedUser,
|
|
@@ -286,6 +287,12 @@ async function runWithIsolation(
|
|
|
286
287
|
const startTime = getTimestamp();
|
|
287
288
|
const startTimeMs = Date.now();
|
|
288
289
|
|
|
290
|
+
// Use default Docker image if docker isolation is selected but no image specified
|
|
291
|
+
let effectiveImage = options.image;
|
|
292
|
+
if (environment === 'docker' && !effectiveImage) {
|
|
293
|
+
effectiveImage = getDefaultDockerImage();
|
|
294
|
+
}
|
|
295
|
+
|
|
289
296
|
// Create log file path
|
|
290
297
|
const logFilePath = createLogPath(environment || 'direct');
|
|
291
298
|
|
|
@@ -298,38 +305,8 @@ async function runWithIsolation(
|
|
|
298
305
|
const isWindows = process.platform === 'win32';
|
|
299
306
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
300
307
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
const store = getExecutionStore();
|
|
304
|
-
if (store) {
|
|
305
|
-
executionRecord = new ExecutionRecord({
|
|
306
|
-
uuid: sessionId, // Use the provided session ID
|
|
307
|
-
command: cmd,
|
|
308
|
-
logPath: logFilePath,
|
|
309
|
-
shell,
|
|
310
|
-
workingDirectory: process.cwd(),
|
|
311
|
-
options: {
|
|
312
|
-
isolated: environment,
|
|
313
|
-
isolationMode: mode,
|
|
314
|
-
sessionName,
|
|
315
|
-
image: options.image,
|
|
316
|
-
endpoint: options.endpoint,
|
|
317
|
-
user: options.user,
|
|
318
|
-
keepAlive: options.keepAlive,
|
|
319
|
-
useCommandStream,
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Print start block with session ID
|
|
325
|
-
console.log(
|
|
326
|
-
createStartBlock({
|
|
327
|
-
sessionId,
|
|
328
|
-
timestamp: startTime,
|
|
329
|
-
command: cmd,
|
|
330
|
-
})
|
|
331
|
-
);
|
|
332
|
-
console.log('');
|
|
308
|
+
// Collect isolation info lines for start block
|
|
309
|
+
const extraLines = [];
|
|
333
310
|
|
|
334
311
|
// Handle --isolated-user option: create a new user with same permissions
|
|
335
312
|
let createdUser = null;
|
|
@@ -352,9 +329,9 @@ async function runWithIsolation(
|
|
|
352
329
|
currentGroups.includes(g)
|
|
353
330
|
);
|
|
354
331
|
|
|
355
|
-
|
|
332
|
+
extraLines.push('[User Isolation] Creating new user...');
|
|
356
333
|
if (importantGroups.length > 0) {
|
|
357
|
-
|
|
334
|
+
extraLines.push(
|
|
358
335
|
`[User Isolation] Inheriting groups: ${importantGroups.join(', ')}`
|
|
359
336
|
);
|
|
360
337
|
}
|
|
@@ -369,37 +346,69 @@ async function runWithIsolation(
|
|
|
369
346
|
}
|
|
370
347
|
|
|
371
348
|
createdUser = userResult.username;
|
|
372
|
-
|
|
349
|
+
extraLines.push(`[User Isolation] Created user: ${createdUser}`);
|
|
373
350
|
if (userResult.groups && userResult.groups.length > 0) {
|
|
374
|
-
|
|
351
|
+
extraLines.push(
|
|
375
352
|
`[User Isolation] User groups: ${userResult.groups.join(', ')}`
|
|
376
353
|
);
|
|
377
354
|
}
|
|
378
355
|
if (options.keepUser) {
|
|
379
|
-
|
|
356
|
+
extraLines.push('[User Isolation] User will be kept after completion');
|
|
380
357
|
}
|
|
381
|
-
console.log('');
|
|
382
358
|
}
|
|
383
359
|
|
|
384
|
-
//
|
|
360
|
+
// Add isolation info to extra lines
|
|
385
361
|
if (environment) {
|
|
386
|
-
|
|
362
|
+
extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
|
|
387
363
|
}
|
|
388
364
|
if (options.session) {
|
|
389
|
-
|
|
365
|
+
extraLines.push(`[Isolation] Session: ${options.session}`);
|
|
390
366
|
}
|
|
391
|
-
if (
|
|
392
|
-
|
|
367
|
+
if (effectiveImage) {
|
|
368
|
+
extraLines.push(`[Isolation] Image: ${effectiveImage}`);
|
|
393
369
|
}
|
|
394
370
|
if (options.endpoint) {
|
|
395
|
-
|
|
371
|
+
extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
|
|
396
372
|
}
|
|
397
373
|
if (createdUser) {
|
|
398
|
-
|
|
374
|
+
extraLines.push(`[Isolation] User: ${createdUser} (isolated)`);
|
|
399
375
|
}
|
|
400
376
|
if (useCommandStream) {
|
|
401
|
-
|
|
377
|
+
extraLines.push('[Isolation] Using command-stream library');
|
|
402
378
|
}
|
|
379
|
+
|
|
380
|
+
// Create execution record for tracking with provided session ID
|
|
381
|
+
let executionRecord = null;
|
|
382
|
+
const store = getExecutionStore();
|
|
383
|
+
if (store) {
|
|
384
|
+
executionRecord = new ExecutionRecord({
|
|
385
|
+
uuid: sessionId, // Use the provided session ID
|
|
386
|
+
command: cmd,
|
|
387
|
+
logPath: logFilePath,
|
|
388
|
+
shell,
|
|
389
|
+
workingDirectory: process.cwd(),
|
|
390
|
+
options: {
|
|
391
|
+
isolated: environment,
|
|
392
|
+
isolationMode: mode,
|
|
393
|
+
sessionName,
|
|
394
|
+
image: effectiveImage,
|
|
395
|
+
endpoint: options.endpoint,
|
|
396
|
+
user: options.user,
|
|
397
|
+
keepAlive: options.keepAlive,
|
|
398
|
+
useCommandStream,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Print start block with session ID and isolation info
|
|
404
|
+
console.log(
|
|
405
|
+
createStartBlock({
|
|
406
|
+
sessionId,
|
|
407
|
+
timestamp: startTime,
|
|
408
|
+
command: cmd,
|
|
409
|
+
extraLines,
|
|
410
|
+
})
|
|
411
|
+
);
|
|
403
412
|
console.log('');
|
|
404
413
|
|
|
405
414
|
// Save initial execution record
|
|
@@ -421,7 +430,7 @@ async function runWithIsolation(
|
|
|
421
430
|
environment: environment || 'direct',
|
|
422
431
|
mode,
|
|
423
432
|
sessionName,
|
|
424
|
-
image:
|
|
433
|
+
image: effectiveImage,
|
|
425
434
|
user: createdUser,
|
|
426
435
|
startTime,
|
|
427
436
|
});
|
|
@@ -442,7 +451,7 @@ async function runWithIsolation(
|
|
|
442
451
|
// Future: Add command-stream support with raw() function for multiplexers
|
|
443
452
|
result = await runIsolated(environment, cmd, {
|
|
444
453
|
session: options.session,
|
|
445
|
-
image:
|
|
454
|
+
image: effectiveImage,
|
|
446
455
|
endpoint: options.endpoint,
|
|
447
456
|
detached: mode === 'detached',
|
|
448
457
|
user: createdUser,
|
|
@@ -483,30 +492,28 @@ async function runWithIsolation(
|
|
|
483
492
|
}
|
|
484
493
|
}
|
|
485
494
|
|
|
486
|
-
// Print result
|
|
487
|
-
console.log('');
|
|
488
|
-
console.log(result.message);
|
|
489
|
-
|
|
490
495
|
// Cleanup: delete the created user if we created one (unless --keep-user)
|
|
496
|
+
// This output goes to stdout but NOT inside the boxes - it's operational info
|
|
491
497
|
if (createdUser && !options.keepUser) {
|
|
492
|
-
console.log('');
|
|
493
498
|
console.log(`[User Isolation] Cleaning up user: ${createdUser}`);
|
|
494
499
|
const deleteResult = deleteUser(createdUser, { removeHome: true });
|
|
495
500
|
if (deleteResult.success) {
|
|
496
|
-
console.log(
|
|
501
|
+
console.log('[User Isolation] User deleted successfully');
|
|
497
502
|
} else {
|
|
498
503
|
console.log(`[User Isolation] Warning: ${deleteResult.message}`);
|
|
499
504
|
}
|
|
500
|
-
} else if (createdUser && options.keepUser) {
|
|
501
505
|
console.log('');
|
|
506
|
+
} else if (createdUser && options.keepUser) {
|
|
502
507
|
console.log(
|
|
503
508
|
`[User Isolation] Keeping user: ${createdUser} (use 'sudo userdel -r ${createdUser}' to delete)`
|
|
504
509
|
);
|
|
510
|
+
console.log('');
|
|
505
511
|
}
|
|
506
512
|
|
|
507
|
-
// Print finish block
|
|
508
|
-
|
|
513
|
+
// Print finish block with result message inside
|
|
514
|
+
// Add empty line before finish block for visual separation
|
|
509
515
|
console.log('');
|
|
516
|
+
const durationMs = Date.now() - startTimeMs;
|
|
510
517
|
console.log(
|
|
511
518
|
createFinishBlock({
|
|
512
519
|
sessionId,
|
|
@@ -514,6 +521,7 @@ async function runWithIsolation(
|
|
|
514
521
|
exitCode,
|
|
515
522
|
logPath: logFilePath,
|
|
516
523
|
durationMs,
|
|
524
|
+
resultMessage: result.message,
|
|
517
525
|
})
|
|
518
526
|
);
|
|
519
527
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker utilities for the start-command CLI
|
|
3
|
+
*
|
|
4
|
+
* Provides Docker-related helper functions like detecting the default
|
|
5
|
+
* Docker image based on the host operating system.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the default Docker image based on the host operating system
|
|
12
|
+
* Returns an image that matches the current OS as closely as possible:
|
|
13
|
+
* - macOS: Uses alpine (since macOS cannot run in Docker)
|
|
14
|
+
* - Ubuntu/Debian: Uses ubuntu:latest
|
|
15
|
+
* - Arch Linux: Uses archlinux:latest
|
|
16
|
+
* - Other Linux: Uses the detected distro or alpine as fallback
|
|
17
|
+
* - Windows: Uses alpine (Windows containers have limited support)
|
|
18
|
+
* @returns {string} Docker image name
|
|
19
|
+
*/
|
|
20
|
+
function getDefaultDockerImage() {
|
|
21
|
+
const platform = process.platform;
|
|
22
|
+
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
// macOS cannot run in Docker containers, use alpine as lightweight alternative
|
|
25
|
+
return 'alpine:latest';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (platform === 'win32') {
|
|
29
|
+
// Windows containers have limited support, use alpine for Linux containers
|
|
30
|
+
return 'alpine:latest';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (platform === 'linux') {
|
|
34
|
+
// Try to detect the Linux distribution
|
|
35
|
+
try {
|
|
36
|
+
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
|
|
37
|
+
|
|
38
|
+
// Check for Ubuntu
|
|
39
|
+
if (
|
|
40
|
+
osRelease.includes('ID=ubuntu') ||
|
|
41
|
+
osRelease.includes('ID_LIKE=ubuntu') ||
|
|
42
|
+
osRelease.includes('ID_LIKE=debian ubuntu')
|
|
43
|
+
) {
|
|
44
|
+
return 'ubuntu:latest';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check for Debian
|
|
48
|
+
if (
|
|
49
|
+
osRelease.includes('ID=debian') ||
|
|
50
|
+
osRelease.includes('ID_LIKE=debian')
|
|
51
|
+
) {
|
|
52
|
+
return 'debian:latest';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check for Arch Linux
|
|
56
|
+
if (osRelease.includes('ID=arch') || osRelease.includes('ID_LIKE=arch')) {
|
|
57
|
+
return 'archlinux:latest';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check for Fedora
|
|
61
|
+
if (osRelease.includes('ID=fedora')) {
|
|
62
|
+
return 'fedora:latest';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for CentOS/RHEL
|
|
66
|
+
if (
|
|
67
|
+
osRelease.includes('ID=centos') ||
|
|
68
|
+
osRelease.includes('ID=rhel') ||
|
|
69
|
+
osRelease.includes('ID_LIKE=rhel')
|
|
70
|
+
) {
|
|
71
|
+
return 'centos:latest';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for Alpine
|
|
75
|
+
if (osRelease.includes('ID=alpine')) {
|
|
76
|
+
return 'alpine:latest';
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Cannot read /etc/os-release, fall through to default
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Default fallback: use alpine as a lightweight, universal option
|
|
84
|
+
return 'alpine:latest';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
getDefaultDockerImage,
|
|
89
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolation Runners for start-command
|
|
3
|
-
*
|
|
4
|
-
* Provides execution of commands in various isolated environments:
|
|
5
|
-
* - screen: GNU Screen terminal multiplexer
|
|
6
|
-
* - tmux: tmux terminal multiplexer
|
|
7
|
-
* - docker: Docker containers
|
|
8
|
-
*/
|
|
1
|
+
/** Isolation Runners for start-command (screen, tmux, docker, ssh) */
|
|
9
2
|
|
|
10
3
|
const { execSync, spawn, spawnSync } = require('child_process');
|
|
11
4
|
const fs = require('fs');
|
|
@@ -237,9 +230,13 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
|
237
230
|
let output = '';
|
|
238
231
|
try {
|
|
239
232
|
output = fs.readFileSync(logFile, 'utf8');
|
|
240
|
-
// Display the output
|
|
233
|
+
// Display the output with surrounding empty lines for consistency
|
|
241
234
|
if (output.trim()) {
|
|
242
235
|
process.stdout.write(output);
|
|
236
|
+
// Add trailing newline if output doesn't end with one
|
|
237
|
+
if (!output.endsWith('\n')) {
|
|
238
|
+
process.stdout.write('\n');
|
|
239
|
+
}
|
|
243
240
|
}
|
|
244
241
|
} catch {
|
|
245
242
|
// Log file might not exist if command was very quick
|
|
@@ -281,6 +278,10 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
|
281
278
|
output = fs.readFileSync(logFile, 'utf8');
|
|
282
279
|
if (output.trim()) {
|
|
283
280
|
process.stdout.write(output);
|
|
281
|
+
// Add trailing newline if output doesn't end with one
|
|
282
|
+
if (!output.endsWith('\n')) {
|
|
283
|
+
process.stdout.write('\n');
|
|
284
|
+
}
|
|
284
285
|
}
|
|
285
286
|
} catch {
|
|
286
287
|
// Ignore
|
|
@@ -964,6 +965,9 @@ function canRunLinuxDockerImages() {
|
|
|
964
965
|
}
|
|
965
966
|
}
|
|
966
967
|
|
|
968
|
+
// Re-export getDefaultDockerImage from docker-utils for backwards compatibility
|
|
969
|
+
const { getDefaultDockerImage } = require('./docker-utils');
|
|
970
|
+
|
|
967
971
|
module.exports = {
|
|
968
972
|
isCommandAvailable,
|
|
969
973
|
hasTTY,
|
|
@@ -985,4 +989,5 @@ module.exports = {
|
|
|
985
989
|
supportsLogfileOption,
|
|
986
990
|
resetScreenVersionCache,
|
|
987
991
|
canRunLinuxDockerImages,
|
|
992
|
+
getDefaultDockerImage,
|
|
988
993
|
};
|
package/src/lib/output-blocks.js
CHANGED
|
@@ -85,10 +85,15 @@ function createHorizontalLine(width, style) {
|
|
|
85
85
|
* Pad or truncate text to fit a specific width
|
|
86
86
|
* @param {string} text - Text to pad
|
|
87
87
|
* @param {number} width - Target width
|
|
88
|
+
* @param {boolean} [allowOverflow=false] - If true, don't truncate long text
|
|
88
89
|
* @returns {string} Padded text
|
|
89
90
|
*/
|
|
90
|
-
function padText(text, width) {
|
|
91
|
+
function padText(text, width, allowOverflow = false) {
|
|
91
92
|
if (text.length >= width) {
|
|
93
|
+
// If overflow is allowed, return text as-is (for copyable content like paths)
|
|
94
|
+
if (allowOverflow) {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
92
97
|
return text.substring(0, width);
|
|
93
98
|
}
|
|
94
99
|
return text + ' '.repeat(width - text.length);
|
|
@@ -99,12 +104,17 @@ function padText(text, width) {
|
|
|
99
104
|
* @param {string} text - Text content
|
|
100
105
|
* @param {number} width - Total width (including borders)
|
|
101
106
|
* @param {object} style - Box style
|
|
107
|
+
* @param {boolean} [allowOverflow=false] - If true, allow text to overflow (for copyable content)
|
|
102
108
|
* @returns {string} Bordered line
|
|
103
109
|
*/
|
|
104
|
-
function createBorderedLine(text, width, style) {
|
|
110
|
+
function createBorderedLine(text, width, style, allowOverflow = false) {
|
|
105
111
|
if (style.vertical) {
|
|
106
112
|
const innerWidth = width - 4; // 2 for borders, 2 for padding
|
|
107
|
-
const paddedText = padText(text, innerWidth);
|
|
113
|
+
const paddedText = padText(text, innerWidth, allowOverflow);
|
|
114
|
+
// If text overflows, extend the right border position
|
|
115
|
+
if (allowOverflow && text.length > innerWidth) {
|
|
116
|
+
return `${style.vertical} ${paddedText} ${style.vertical}`;
|
|
117
|
+
}
|
|
108
118
|
return `${style.vertical} ${paddedText} ${style.vertical}`;
|
|
109
119
|
}
|
|
110
120
|
return text;
|
|
@@ -144,6 +154,7 @@ function createBottomBorder(width, style) {
|
|
|
144
154
|
* @param {string} options.sessionId - Session UUID
|
|
145
155
|
* @param {string} options.timestamp - Timestamp string
|
|
146
156
|
* @param {string} options.command - Command being executed
|
|
157
|
+
* @param {string[]} [options.extraLines] - Additional lines to show after the command line
|
|
147
158
|
* @param {string} [options.style] - Box style name
|
|
148
159
|
* @param {number} [options.width] - Box width
|
|
149
160
|
* @returns {string} Formatted start block
|
|
@@ -153,6 +164,7 @@ function createStartBlock(options) {
|
|
|
153
164
|
sessionId,
|
|
154
165
|
timestamp,
|
|
155
166
|
command,
|
|
167
|
+
extraLines = [],
|
|
156
168
|
style: styleName = DEFAULT_STYLE,
|
|
157
169
|
width = DEFAULT_WIDTH,
|
|
158
170
|
} = options;
|
|
@@ -165,6 +177,12 @@ function createStartBlock(options) {
|
|
|
165
177
|
lines.push(
|
|
166
178
|
createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
|
|
167
179
|
);
|
|
180
|
+
|
|
181
|
+
// Add extra lines (e.g., isolation info, docker image, etc.)
|
|
182
|
+
for (const line of extraLines) {
|
|
183
|
+
lines.push(createBorderedLine(line, width, style));
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
lines.push(createBottomBorder(width, style));
|
|
169
187
|
|
|
170
188
|
return lines.join('\n');
|
|
@@ -197,6 +215,7 @@ function formatDuration(durationMs) {
|
|
|
197
215
|
* @param {number} options.exitCode - Exit code
|
|
198
216
|
* @param {string} options.logPath - Path to log file
|
|
199
217
|
* @param {number} [options.durationMs] - Duration in milliseconds
|
|
218
|
+
* @param {string} [options.resultMessage] - Result message (e.g., "Screen session exited...")
|
|
200
219
|
* @param {string} [options.style] - Box style name
|
|
201
220
|
* @param {number} [options.width] - Box width
|
|
202
221
|
* @returns {string} Formatted finish block
|
|
@@ -208,6 +227,7 @@ function createFinishBlock(options) {
|
|
|
208
227
|
exitCode,
|
|
209
228
|
logPath,
|
|
210
229
|
durationMs,
|
|
230
|
+
resultMessage,
|
|
211
231
|
style: styleName = DEFAULT_STYLE,
|
|
212
232
|
width = DEFAULT_WIDTH,
|
|
213
233
|
} = options;
|
|
@@ -222,10 +242,20 @@ function createFinishBlock(options) {
|
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
lines.push(createTopBorder(width, style));
|
|
245
|
+
|
|
246
|
+
// Add result message first if provided (e.g., "Docker container exited...")
|
|
247
|
+
// Allow overflow so the full message is visible and copyable
|
|
248
|
+
if (resultMessage) {
|
|
249
|
+
lines.push(createBorderedLine(resultMessage, width, style, true));
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
lines.push(createBorderedLine(finishedMsg, width, style));
|
|
226
253
|
lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
|
|
227
|
-
|
|
228
|
-
lines.push(createBorderedLine(`
|
|
254
|
+
// Allow overflow for log path and session ID so they can be copied completely
|
|
255
|
+
lines.push(createBorderedLine(`Log: ${logPath}`, width, style, true));
|
|
256
|
+
lines.push(
|
|
257
|
+
createBorderedLine(`Session ID: ${sessionId}`, width, style, true)
|
|
258
|
+
);
|
|
229
259
|
lines.push(createBottomBorder(width, style));
|
|
230
260
|
|
|
231
261
|
return lines.join('\n');
|
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for echo command across all isolation modes
|
|
4
|
+
*
|
|
5
|
+
* Issue #55: Ensure `echo "hi"` works reliably in all modes with proper output
|
|
6
|
+
*
|
|
7
|
+
* These tests verify for ALL isolation modes (attached + detached):
|
|
8
|
+
* 1. Command output is captured and displayed
|
|
9
|
+
* 2. Start and finish blocks are properly formatted
|
|
10
|
+
* 3. Empty lines exist before and after command output
|
|
11
|
+
* 4. Log paths and session IDs are not truncated (fully copyable)
|
|
12
|
+
*
|
|
13
|
+
* Test coverage:
|
|
14
|
+
* - No isolation mode (direct execution)
|
|
15
|
+
* - Screen isolation: attached + detached
|
|
16
|
+
* - Tmux isolation: attached + detached
|
|
17
|
+
* - Docker isolation: attached + detached
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { describe, it } = require('node:test');
|
|
21
|
+
const assert = require('assert');
|
|
22
|
+
const { execSync } = require('child_process');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const {
|
|
25
|
+
isCommandAvailable,
|
|
26
|
+
canRunLinuxDockerImages,
|
|
27
|
+
} = require('../src/lib/isolation');
|
|
28
|
+
|
|
29
|
+
// Path to the CLI
|
|
30
|
+
const CLI_PATH = path.join(__dirname, '..', 'src', 'bin', 'cli.js');
|
|
31
|
+
|
|
32
|
+
// Helper function to run the CLI and capture output
|
|
33
|
+
function runCli(args, options = {}) {
|
|
34
|
+
const timeout = options.timeout || 30000;
|
|
35
|
+
try {
|
|
36
|
+
const result = execSync(`bun run ${CLI_PATH} ${args}`, {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
42
|
+
START_DISABLE_TRACKING: '1',
|
|
43
|
+
},
|
|
44
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
45
|
+
});
|
|
46
|
+
return { success: true, output: result };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
output: err.stdout || '',
|
|
51
|
+
stderr: err.stderr || '',
|
|
52
|
+
exitCode: err.status,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Verify output contains expected structure for attached modes (shows finish block)
|
|
58
|
+
function verifyAttachedModeOutput(output, expectedOutputText = 'hi') {
|
|
59
|
+
// Should contain start block
|
|
60
|
+
assert.ok(
|
|
61
|
+
output.includes('╭'),
|
|
62
|
+
'Output should contain start block top border'
|
|
63
|
+
);
|
|
64
|
+
assert.ok(output.includes('╰'), 'Output should contain block bottom border');
|
|
65
|
+
assert.ok(output.includes('Session ID:'), 'Output should contain Session ID');
|
|
66
|
+
assert.ok(
|
|
67
|
+
output.includes('Starting at'),
|
|
68
|
+
'Output should contain Starting at timestamp'
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Should contain command output
|
|
72
|
+
assert.ok(
|
|
73
|
+
output.includes(expectedOutputText),
|
|
74
|
+
`Output should contain the "${expectedOutputText}" command output`
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Should contain finish block (for attached modes)
|
|
78
|
+
assert.ok(
|
|
79
|
+
output.includes('Finished at'),
|
|
80
|
+
'Output should contain Finished at timestamp'
|
|
81
|
+
);
|
|
82
|
+
assert.ok(output.includes('Exit code:'), 'Output should contain Exit code');
|
|
83
|
+
assert.ok(output.includes('Log:'), 'Output should contain Log path');
|
|
84
|
+
|
|
85
|
+
// Verify there are empty lines around output (structure check)
|
|
86
|
+
const lines = output.split('\n');
|
|
87
|
+
const outputIndex = lines.findIndex((l) => l.trim() === expectedOutputText);
|
|
88
|
+
|
|
89
|
+
if (outputIndex > 0) {
|
|
90
|
+
// Check for empty line before output
|
|
91
|
+
const lineBefore = lines[outputIndex - 1];
|
|
92
|
+
// Line before should be empty or end of start block
|
|
93
|
+
assert.ok(
|
|
94
|
+
lineBefore.trim() === '' || lineBefore.includes('╰'),
|
|
95
|
+
`Expected empty line or block end before output, got: "${lineBefore}"`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (outputIndex >= 0 && outputIndex < lines.length - 1) {
|
|
100
|
+
// Check for empty line after output
|
|
101
|
+
const lineAfter = lines[outputIndex + 1];
|
|
102
|
+
// Line after should be empty or start of finish block
|
|
103
|
+
assert.ok(
|
|
104
|
+
lineAfter.trim() === '' || lineAfter.includes('╭'),
|
|
105
|
+
`Expected empty line or block start after output, got: "${lineAfter}"`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verify output for detached modes (only start block, no finish block)
|
|
111
|
+
function verifyDetachedModeOutput(output) {
|
|
112
|
+
// Should contain start block
|
|
113
|
+
assert.ok(
|
|
114
|
+
output.includes('╭'),
|
|
115
|
+
'Output should contain start block top border'
|
|
116
|
+
);
|
|
117
|
+
assert.ok(output.includes('╰'), 'Output should contain block bottom border');
|
|
118
|
+
assert.ok(output.includes('Session ID:'), 'Output should contain Session ID');
|
|
119
|
+
assert.ok(
|
|
120
|
+
output.includes('Starting at'),
|
|
121
|
+
'Output should contain Starting at timestamp'
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Should show detached mode info
|
|
125
|
+
assert.ok(
|
|
126
|
+
output.includes('Mode: detached') || output.includes('Reattach with'),
|
|
127
|
+
'Output should indicate detached mode or show reattach instructions'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Verify log path is not truncated
|
|
132
|
+
function verifyLogPathNotTruncated(output) {
|
|
133
|
+
const logMatch = output.match(/Log: (.+)/);
|
|
134
|
+
assert.ok(logMatch, 'Should have Log line');
|
|
135
|
+
const logPath = logMatch[1].trim();
|
|
136
|
+
// Remove trailing box border character if present
|
|
137
|
+
const cleanPath = logPath.replace(/\s*│\s*$/, '').trim();
|
|
138
|
+
|
|
139
|
+
// Log path should end with .log extension
|
|
140
|
+
assert.ok(
|
|
141
|
+
cleanPath.endsWith('.log'),
|
|
142
|
+
`Log path should end with .log extension, got: "${cleanPath}"`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Verify session ID is a valid UUID
|
|
147
|
+
function verifySessionId(output) {
|
|
148
|
+
const sessionMatches = output.match(/Session ID: ([a-f0-9-]+)/g);
|
|
149
|
+
assert.ok(
|
|
150
|
+
sessionMatches && sessionMatches.length >= 1,
|
|
151
|
+
'Should have Session ID'
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Extract UUID from first match
|
|
155
|
+
const uuidMatch = sessionMatches[0].match(
|
|
156
|
+
/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/
|
|
157
|
+
);
|
|
158
|
+
assert.ok(uuidMatch, 'Session ID should be a valid UUID format');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('Echo Integration Tests - Issue #55', () => {
|
|
162
|
+
// ============================================
|
|
163
|
+
// NO ISOLATION MODE (Direct Execution)
|
|
164
|
+
// ============================================
|
|
165
|
+
describe('No Isolation Mode (Direct Execution)', () => {
|
|
166
|
+
it('should execute echo hi and show output with proper formatting', () => {
|
|
167
|
+
const result = runCli('echo hi');
|
|
168
|
+
|
|
169
|
+
assert.ok(result.success, 'Command should succeed');
|
|
170
|
+
verifyAttachedModeOutput(result.output);
|
|
171
|
+
verifyLogPathNotTruncated(result.output);
|
|
172
|
+
verifySessionId(result.output);
|
|
173
|
+
|
|
174
|
+
console.log(' ✓ No isolation mode: echo hi works correctly');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should execute echo with single quotes', () => {
|
|
178
|
+
const result = runCli("'echo hi'");
|
|
179
|
+
|
|
180
|
+
assert.ok(result.success, 'Command should succeed');
|
|
181
|
+
assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
|
|
182
|
+
|
|
183
|
+
console.log(' ✓ No isolation mode: echo with single quotes works');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should execute echo with double quotes', () => {
|
|
187
|
+
const result = runCli('\'echo "hi"\'');
|
|
188
|
+
|
|
189
|
+
assert.ok(result.success, 'Command should succeed');
|
|
190
|
+
assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
|
|
191
|
+
|
|
192
|
+
console.log(' ✓ No isolation mode: echo with double quotes works');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should have consistent empty line formatting', () => {
|
|
196
|
+
const result = runCli('echo hi');
|
|
197
|
+
|
|
198
|
+
assert.ok(result.success, 'Command should succeed');
|
|
199
|
+
|
|
200
|
+
// The pattern should be:
|
|
201
|
+
// [start block]
|
|
202
|
+
// [empty line]
|
|
203
|
+
// hi
|
|
204
|
+
// [empty line]
|
|
205
|
+
// [finish block]
|
|
206
|
+
|
|
207
|
+
const lines = result.output.split('\n');
|
|
208
|
+
let foundHi = false;
|
|
209
|
+
let hiIndex = -1;
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < lines.length; i++) {
|
|
212
|
+
if (lines[i].trim() === 'hi') {
|
|
213
|
+
foundHi = true;
|
|
214
|
+
hiIndex = i;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
assert.ok(foundHi, 'Should find "hi" output on its own line');
|
|
220
|
+
|
|
221
|
+
// Check line before hi
|
|
222
|
+
if (hiIndex > 0) {
|
|
223
|
+
const prevLine = lines[hiIndex - 1].trim();
|
|
224
|
+
assert.ok(
|
|
225
|
+
prevLine === '' || prevLine.startsWith('╰'),
|
|
226
|
+
`Line before "hi" should be empty or end of start block`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check line after hi
|
|
231
|
+
if (hiIndex < lines.length - 1) {
|
|
232
|
+
const nextLine = lines[hiIndex + 1].trim();
|
|
233
|
+
assert.ok(
|
|
234
|
+
nextLine === '' || nextLine.startsWith('╭'),
|
|
235
|
+
`Line after "hi" should be empty or start of finish block`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
console.log(' ✓ Empty line formatting is consistent');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ============================================
|
|
244
|
+
// SCREEN ISOLATION MODE (Attached + Detached)
|
|
245
|
+
// ============================================
|
|
246
|
+
describe('Screen Isolation Mode', () => {
|
|
247
|
+
const screenAvailable = isCommandAvailable('screen');
|
|
248
|
+
|
|
249
|
+
if (!screenAvailable) {
|
|
250
|
+
it('should skip screen tests when screen is not installed', () => {
|
|
251
|
+
console.log(' ⚠ screen not installed, skipping screen tests');
|
|
252
|
+
assert.ok(true);
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
describe('Attached Mode', () => {
|
|
258
|
+
it('should execute echo hi in attached screen mode with proper formatting', () => {
|
|
259
|
+
const result = runCli('--isolated screen -- echo hi', {
|
|
260
|
+
timeout: 30000,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
assert.ok(
|
|
264
|
+
result.success,
|
|
265
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
266
|
+
);
|
|
267
|
+
verifyAttachedModeOutput(result.output);
|
|
268
|
+
verifyLogPathNotTruncated(result.output);
|
|
269
|
+
verifySessionId(result.output);
|
|
270
|
+
|
|
271
|
+
// Should show isolation info
|
|
272
|
+
assert.ok(
|
|
273
|
+
result.output.includes('[Isolation] Environment: screen'),
|
|
274
|
+
'Should show screen isolation info'
|
|
275
|
+
);
|
|
276
|
+
assert.ok(
|
|
277
|
+
result.output.includes('Mode: attached'),
|
|
278
|
+
'Should show attached mode'
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
console.log(' ✓ Screen isolation (attached): echo hi works correctly');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should execute echo with quotes in attached screen mode', () => {
|
|
285
|
+
const result = runCli('--isolated screen -- echo "hello world"', {
|
|
286
|
+
timeout: 30000,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
assert.ok(
|
|
290
|
+
result.success,
|
|
291
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
292
|
+
);
|
|
293
|
+
assert.ok(
|
|
294
|
+
result.output.includes('hello world'),
|
|
295
|
+
'Output should contain "hello world"'
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
console.log(' ✓ Screen isolation (attached): echo with quotes works');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should show exit code and finish block in attached screen mode', () => {
|
|
302
|
+
const result = runCli('--isolated screen -- echo hi', {
|
|
303
|
+
timeout: 30000,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
assert.ok(result.success, 'Command should succeed');
|
|
307
|
+
assert.ok(
|
|
308
|
+
result.output.includes('Exit code: 0'),
|
|
309
|
+
'Should show exit code 0'
|
|
310
|
+
);
|
|
311
|
+
assert.ok(
|
|
312
|
+
result.output.includes('exited with code 0') ||
|
|
313
|
+
result.output.includes('Finished at'),
|
|
314
|
+
'Should show completion info'
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
console.log(
|
|
318
|
+
' ✓ Screen isolation (attached): finish block displays correctly'
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('Detached Mode', () => {
|
|
324
|
+
it('should execute echo hi in detached screen mode', () => {
|
|
325
|
+
const sessionName = `test-screen-detached-${Date.now()}`;
|
|
326
|
+
const result = runCli(
|
|
327
|
+
`--isolated screen -d --session ${sessionName} -- echo hi`,
|
|
328
|
+
{ timeout: 10000 }
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Detached mode should succeed (command starts in background)
|
|
332
|
+
assert.ok(
|
|
333
|
+
result.success,
|
|
334
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
335
|
+
);
|
|
336
|
+
verifyDetachedModeOutput(result.output);
|
|
337
|
+
verifySessionId(result.output);
|
|
338
|
+
|
|
339
|
+
// Should show screen isolation info with detached mode
|
|
340
|
+
assert.ok(
|
|
341
|
+
result.output.includes('[Isolation] Environment: screen'),
|
|
342
|
+
'Should show screen isolation info'
|
|
343
|
+
);
|
|
344
|
+
assert.ok(
|
|
345
|
+
result.output.includes('Mode: detached'),
|
|
346
|
+
'Should show detached mode'
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Cleanup: kill the screen session
|
|
350
|
+
try {
|
|
351
|
+
execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' });
|
|
352
|
+
} catch {
|
|
353
|
+
// Session may have already exited
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log(
|
|
357
|
+
' ✓ Screen isolation (detached): echo hi starts correctly'
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should provide reattach instructions in detached screen mode', () => {
|
|
362
|
+
const sessionName = `test-screen-reattach-${Date.now()}`;
|
|
363
|
+
const result = runCli(
|
|
364
|
+
`--isolated screen -d --session ${sessionName} -- echo hi`,
|
|
365
|
+
{ timeout: 10000 }
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
assert.ok(result.success, 'Command should succeed');
|
|
369
|
+
assert.ok(
|
|
370
|
+
result.output.includes('Reattach with') ||
|
|
371
|
+
result.output.includes('screen -r'),
|
|
372
|
+
'Should show reattach instructions'
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Cleanup
|
|
376
|
+
try {
|
|
377
|
+
execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' });
|
|
378
|
+
} catch {
|
|
379
|
+
// Ignore
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.log(
|
|
383
|
+
' ✓ Screen isolation (detached): reattach instructions displayed'
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ============================================
|
|
390
|
+
// TMUX ISOLATION MODE (Attached + Detached)
|
|
391
|
+
// ============================================
|
|
392
|
+
describe('Tmux Isolation Mode', () => {
|
|
393
|
+
const tmuxAvailable = isCommandAvailable('tmux');
|
|
394
|
+
|
|
395
|
+
if (!tmuxAvailable) {
|
|
396
|
+
it('should skip tmux tests when tmux is not installed', () => {
|
|
397
|
+
console.log(' ⚠ tmux not installed, skipping tmux tests');
|
|
398
|
+
assert.ok(true);
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
describe('Attached Mode', () => {
|
|
404
|
+
// Note: Attached tmux mode requires a TTY, which is not available in CI
|
|
405
|
+
// We test that it properly handles no-TTY scenario
|
|
406
|
+
it('should handle attached tmux mode (may require TTY)', () => {
|
|
407
|
+
const result = runCli('--isolated tmux -- echo hi', { timeout: 30000 });
|
|
408
|
+
|
|
409
|
+
// Either succeeds or fails due to no TTY - both are valid
|
|
410
|
+
if (result.success) {
|
|
411
|
+
assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
|
|
412
|
+
assert.ok(
|
|
413
|
+
result.output.includes('[Isolation] Environment: tmux'),
|
|
414
|
+
'Should show tmux isolation info'
|
|
415
|
+
);
|
|
416
|
+
console.log(' ✓ Tmux isolation (attached): echo hi works correctly');
|
|
417
|
+
} else {
|
|
418
|
+
// May fail due to no TTY in CI
|
|
419
|
+
console.log(
|
|
420
|
+
' ⚠ Tmux isolation (attached): skipped (no TTY available)'
|
|
421
|
+
);
|
|
422
|
+
assert.ok(true);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('Detached Mode', () => {
|
|
428
|
+
it('should execute echo hi in detached tmux mode', () => {
|
|
429
|
+
const sessionName = `test-tmux-detached-${Date.now()}`;
|
|
430
|
+
const result = runCli(
|
|
431
|
+
`--isolated tmux -d --session ${sessionName} -- echo hi`,
|
|
432
|
+
{ timeout: 10000 }
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Detached mode should succeed
|
|
436
|
+
assert.ok(
|
|
437
|
+
result.success,
|
|
438
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
439
|
+
);
|
|
440
|
+
verifyDetachedModeOutput(result.output);
|
|
441
|
+
verifySessionId(result.output);
|
|
442
|
+
|
|
443
|
+
// Should show tmux isolation info
|
|
444
|
+
assert.ok(
|
|
445
|
+
result.output.includes('[Isolation] Environment: tmux'),
|
|
446
|
+
'Should show tmux isolation info'
|
|
447
|
+
);
|
|
448
|
+
assert.ok(
|
|
449
|
+
result.output.includes('Mode: detached'),
|
|
450
|
+
'Should show detached mode'
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Cleanup: kill the tmux session
|
|
454
|
+
try {
|
|
455
|
+
execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
|
|
456
|
+
} catch {
|
|
457
|
+
// Session may have already exited
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log(' ✓ Tmux isolation (detached): echo hi starts correctly');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should provide reattach instructions in detached tmux mode', () => {
|
|
464
|
+
const sessionName = `test-tmux-reattach-${Date.now()}`;
|
|
465
|
+
const result = runCli(
|
|
466
|
+
`--isolated tmux -d --session ${sessionName} -- echo hi`,
|
|
467
|
+
{ timeout: 10000 }
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
assert.ok(result.success, 'Command should succeed');
|
|
471
|
+
assert.ok(
|
|
472
|
+
result.output.includes('Reattach with') ||
|
|
473
|
+
result.output.includes('tmux attach'),
|
|
474
|
+
'Should show reattach instructions'
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// Cleanup
|
|
478
|
+
try {
|
|
479
|
+
execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
|
|
480
|
+
} catch {
|
|
481
|
+
// Ignore
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
console.log(
|
|
485
|
+
' ✓ Tmux isolation (detached): reattach instructions displayed'
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should execute echo with quotes in detached tmux mode', () => {
|
|
490
|
+
const sessionName = `test-tmux-quotes-${Date.now()}`;
|
|
491
|
+
const result = runCli(
|
|
492
|
+
`--isolated tmux -d --session ${sessionName} -- echo "hello world"`,
|
|
493
|
+
{ timeout: 10000 }
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
assert.ok(
|
|
497
|
+
result.success,
|
|
498
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Cleanup
|
|
502
|
+
try {
|
|
503
|
+
execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
|
|
504
|
+
} catch {
|
|
505
|
+
// Ignore
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
console.log(' ✓ Tmux isolation (detached): echo with quotes works');
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ============================================
|
|
514
|
+
// DOCKER ISOLATION MODE (Attached + Detached)
|
|
515
|
+
// ============================================
|
|
516
|
+
describe('Docker Isolation Mode', () => {
|
|
517
|
+
const dockerAvailable = canRunLinuxDockerImages();
|
|
518
|
+
|
|
519
|
+
if (!dockerAvailable) {
|
|
520
|
+
it('should skip docker tests when docker is not available or cannot run Linux containers', () => {
|
|
521
|
+
console.log(
|
|
522
|
+
' ⚠ docker not available or cannot run Linux containers, skipping docker tests'
|
|
523
|
+
);
|
|
524
|
+
assert.ok(true);
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
describe('Attached Mode', () => {
|
|
530
|
+
it('should execute echo hi in attached docker mode with proper formatting', () => {
|
|
531
|
+
const containerName = `test-docker-attached-${Date.now()}`;
|
|
532
|
+
const result = runCli(
|
|
533
|
+
`--isolated docker --image alpine:latest --session ${containerName} -- echo hi`,
|
|
534
|
+
{ timeout: 60000 }
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
assert.ok(
|
|
538
|
+
result.success,
|
|
539
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
540
|
+
);
|
|
541
|
+
verifyAttachedModeOutput(result.output);
|
|
542
|
+
verifyLogPathNotTruncated(result.output);
|
|
543
|
+
verifySessionId(result.output);
|
|
544
|
+
|
|
545
|
+
// Should show docker isolation info
|
|
546
|
+
assert.ok(
|
|
547
|
+
result.output.includes('[Isolation] Environment: docker'),
|
|
548
|
+
'Should show docker isolation info'
|
|
549
|
+
);
|
|
550
|
+
assert.ok(
|
|
551
|
+
result.output.includes('[Isolation] Image: alpine:latest'),
|
|
552
|
+
'Should show docker image info'
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
console.log(' ✓ Docker isolation (attached): echo hi works correctly');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should execute echo with quotes in attached docker mode', () => {
|
|
559
|
+
const containerName = `test-docker-quotes-${Date.now()}`;
|
|
560
|
+
const result = runCli(
|
|
561
|
+
`--isolated docker --image alpine:latest --session ${containerName} -- echo "hello world"`,
|
|
562
|
+
{ timeout: 60000 }
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
assert.ok(
|
|
566
|
+
result.success,
|
|
567
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
568
|
+
);
|
|
569
|
+
assert.ok(
|
|
570
|
+
result.output.includes('hello world'),
|
|
571
|
+
'Output should contain "hello world"'
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
console.log(' ✓ Docker isolation (attached): echo with quotes works');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should show exit code and finish block in attached docker mode', () => {
|
|
578
|
+
const containerName = `test-docker-finish-${Date.now()}`;
|
|
579
|
+
const result = runCli(
|
|
580
|
+
`--isolated docker --image alpine:latest --session ${containerName} -- echo hi`,
|
|
581
|
+
{ timeout: 60000 }
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
assert.ok(result.success, 'Command should succeed');
|
|
585
|
+
assert.ok(
|
|
586
|
+
result.output.includes('Exit code: 0'),
|
|
587
|
+
'Should show exit code 0'
|
|
588
|
+
);
|
|
589
|
+
assert.ok(
|
|
590
|
+
result.output.includes('exited with code 0') ||
|
|
591
|
+
result.output.includes('Finished at'),
|
|
592
|
+
'Should show completion info'
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
console.log(
|
|
596
|
+
' ✓ Docker isolation (attached): finish block displays correctly'
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe('Detached Mode', () => {
|
|
602
|
+
it('should execute echo hi in detached docker mode', () => {
|
|
603
|
+
const containerName = `test-docker-detached-${Date.now()}`;
|
|
604
|
+
const result = runCli(
|
|
605
|
+
`--isolated docker -d --image alpine:latest --session ${containerName} -- echo hi`,
|
|
606
|
+
{ timeout: 60000 }
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// Detached mode should succeed
|
|
610
|
+
assert.ok(
|
|
611
|
+
result.success,
|
|
612
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
613
|
+
);
|
|
614
|
+
verifyDetachedModeOutput(result.output);
|
|
615
|
+
verifySessionId(result.output);
|
|
616
|
+
|
|
617
|
+
// Should show docker isolation info with detached mode
|
|
618
|
+
assert.ok(
|
|
619
|
+
result.output.includes('[Isolation] Environment: docker'),
|
|
620
|
+
'Should show docker isolation info'
|
|
621
|
+
);
|
|
622
|
+
assert.ok(
|
|
623
|
+
result.output.includes('Mode: detached'),
|
|
624
|
+
'Should show detached mode'
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
// Cleanup: remove the docker container
|
|
628
|
+
try {
|
|
629
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
630
|
+
} catch {
|
|
631
|
+
// Container may have already exited
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
console.log(
|
|
635
|
+
' ✓ Docker isolation (detached): echo hi starts correctly'
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should provide reattach instructions in detached docker mode', () => {
|
|
640
|
+
const containerName = `test-docker-reattach-${Date.now()}`;
|
|
641
|
+
const result = runCli(
|
|
642
|
+
`--isolated docker -d --image alpine:latest --session ${containerName} -- echo hi`,
|
|
643
|
+
{ timeout: 60000 }
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
assert.ok(result.success, 'Command should succeed');
|
|
647
|
+
assert.ok(
|
|
648
|
+
result.output.includes('Reattach with') ||
|
|
649
|
+
result.output.includes('docker attach') ||
|
|
650
|
+
result.output.includes('docker logs'),
|
|
651
|
+
'Should show reattach instructions'
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// Cleanup
|
|
655
|
+
try {
|
|
656
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
657
|
+
} catch {
|
|
658
|
+
// Ignore
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
console.log(
|
|
662
|
+
' ✓ Docker isolation (detached): reattach instructions displayed'
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('should execute echo with quotes in detached docker mode', () => {
|
|
667
|
+
const containerName = `test-docker-quotes-detached-${Date.now()}`;
|
|
668
|
+
const result = runCli(
|
|
669
|
+
`--isolated docker -d --image alpine:latest --session ${containerName} -- echo "hello world"`,
|
|
670
|
+
{ timeout: 60000 }
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
assert.ok(
|
|
674
|
+
result.success,
|
|
675
|
+
`Command should succeed. Output: ${result.output || result.stderr}`
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// Cleanup
|
|
679
|
+
try {
|
|
680
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
681
|
+
} catch {
|
|
682
|
+
// Ignore
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
console.log(' ✓ Docker isolation (detached): echo with quotes works');
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// ============================================
|
|
691
|
+
// OUTPUT BLOCK FORMATTING (Cross-mode tests)
|
|
692
|
+
// ============================================
|
|
693
|
+
describe('Output Block Formatting', () => {
|
|
694
|
+
it('should not truncate long log paths', () => {
|
|
695
|
+
const result = runCli('echo hi');
|
|
696
|
+
|
|
697
|
+
assert.ok(result.success, 'Command should succeed');
|
|
698
|
+
|
|
699
|
+
// Get the log path line
|
|
700
|
+
const logMatch = result.output.match(/Log: (.+)/);
|
|
701
|
+
assert.ok(logMatch, 'Should have Log line');
|
|
702
|
+
|
|
703
|
+
const logLine = logMatch[0];
|
|
704
|
+
// Log line should contain full path ending in .log
|
|
705
|
+
assert.ok(
|
|
706
|
+
logLine.includes('.log'),
|
|
707
|
+
'Log path should be complete and not truncated'
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
console.log(' ✓ Log paths are not truncated');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('should show full session ID in both start and finish blocks', () => {
|
|
714
|
+
const result = runCli('echo hi');
|
|
715
|
+
|
|
716
|
+
assert.ok(result.success, 'Command should succeed');
|
|
717
|
+
|
|
718
|
+
// Get session IDs from output (should appear twice: start and finish block)
|
|
719
|
+
const sessionMatches = result.output.match(/Session ID: ([a-f0-9-]+)/g);
|
|
720
|
+
assert.ok(
|
|
721
|
+
sessionMatches && sessionMatches.length >= 2,
|
|
722
|
+
'Should have Session ID in both blocks'
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
// Extract UUID from first match
|
|
726
|
+
const uuidMatch = sessionMatches[0].match(
|
|
727
|
+
/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/
|
|
728
|
+
);
|
|
729
|
+
assert.ok(uuidMatch, 'Session ID should be a valid UUID format');
|
|
730
|
+
|
|
731
|
+
console.log(' ✓ Session IDs are complete UUIDs in both blocks');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('should have consistent exit code formatting', () => {
|
|
735
|
+
const result = runCli('echo hi');
|
|
736
|
+
|
|
737
|
+
assert.ok(result.success, 'Command should succeed');
|
|
738
|
+
assert.ok(
|
|
739
|
+
result.output.includes('Exit code: 0'),
|
|
740
|
+
'Should show "Exit code: 0" for successful command'
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
console.log(' ✓ Exit code formatting is consistent');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('should include timing information in finish block', () => {
|
|
747
|
+
const result = runCli('echo hi');
|
|
748
|
+
|
|
749
|
+
assert.ok(result.success, 'Command should succeed');
|
|
750
|
+
assert.ok(
|
|
751
|
+
result.output.includes('seconds') || result.output.includes('in 0.'),
|
|
752
|
+
'Should include timing information'
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
console.log(' ✓ Timing information is present in finish block');
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
console.log('=== Echo Integration Tests - Issue #55 ===');
|
|
761
|
+
console.log(
|
|
762
|
+
'Testing that "echo hi" works correctly across all isolation modes'
|
|
763
|
+
);
|
|
764
|
+
console.log(
|
|
765
|
+
'Coverage: No isolation, Screen (attached/detached), Tmux (attached/detached), Docker (attached/detached)'
|
|
766
|
+
);
|
|
767
|
+
console.log('');
|
package/test/isolation.test.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
getScreenVersion,
|
|
14
14
|
supportsLogfileOption,
|
|
15
15
|
resetScreenVersionCache,
|
|
16
|
+
getDefaultDockerImage,
|
|
16
17
|
} = require('../src/lib/isolation');
|
|
17
18
|
|
|
18
19
|
describe('Isolation Module', () => {
|
|
@@ -723,3 +724,34 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
723
724
|
});
|
|
724
725
|
});
|
|
725
726
|
});
|
|
727
|
+
|
|
728
|
+
describe('Default Docker Image Detection', () => {
|
|
729
|
+
describe('getDefaultDockerImage', () => {
|
|
730
|
+
it('should return a valid Docker image string', () => {
|
|
731
|
+
const image = getDefaultDockerImage();
|
|
732
|
+
assert.ok(image, 'Should return a non-empty string');
|
|
733
|
+
assert.ok(typeof image === 'string', 'Should return a string');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('should return an image with a tag', () => {
|
|
737
|
+
const image = getDefaultDockerImage();
|
|
738
|
+
assert.ok(image.includes(':'), 'Image should have a tag (e.g., :latest)');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('should return a known base image', () => {
|
|
742
|
+
const image = getDefaultDockerImage();
|
|
743
|
+
const knownImages = [
|
|
744
|
+
'alpine:latest',
|
|
745
|
+
'ubuntu:latest',
|
|
746
|
+
'debian:latest',
|
|
747
|
+
'archlinux:latest',
|
|
748
|
+
'fedora:latest',
|
|
749
|
+
'centos:latest',
|
|
750
|
+
];
|
|
751
|
+
assert.ok(
|
|
752
|
+
knownImages.includes(image),
|
|
753
|
+
`Image '${image}' should be one of the known base images: ${knownImages.join(', ')}`
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
});
|
|
@@ -77,6 +77,26 @@ describe('output-blocks module', () => {
|
|
|
77
77
|
expect(block).toContain('+');
|
|
78
78
|
expect(block).toContain('-');
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('should include extra lines when provided', () => {
|
|
82
|
+
const block = createStartBlock({
|
|
83
|
+
sessionId: 'test-uuid',
|
|
84
|
+
timestamp: '2025-01-01 00:00:00',
|
|
85
|
+
command: 'echo hello',
|
|
86
|
+
extraLines: [
|
|
87
|
+
'[Isolation] Environment: screen, Mode: attached',
|
|
88
|
+
'[Isolation] Session: my-session',
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(block).toContain('╭');
|
|
93
|
+
expect(block).toContain('╰');
|
|
94
|
+
expect(block).toContain('Session ID: test-uuid');
|
|
95
|
+
expect(block).toContain(
|
|
96
|
+
'[Isolation] Environment: screen, Mode: attached'
|
|
97
|
+
);
|
|
98
|
+
expect(block).toContain('[Isolation] Session: my-session');
|
|
99
|
+
});
|
|
80
100
|
});
|
|
81
101
|
|
|
82
102
|
describe('createFinishBlock', () => {
|
|
@@ -110,6 +130,26 @@ describe('output-blocks module', () => {
|
|
|
110
130
|
expect(block).toContain('Finished at 2025-01-01 00:00:01');
|
|
111
131
|
expect(block).not.toContain('seconds');
|
|
112
132
|
});
|
|
133
|
+
|
|
134
|
+
it('should include result message when provided', () => {
|
|
135
|
+
const block = createFinishBlock({
|
|
136
|
+
sessionId: 'test-uuid-1234',
|
|
137
|
+
timestamp: '2025-01-01 00:00:01',
|
|
138
|
+
exitCode: 0,
|
|
139
|
+
logPath: '/tmp/test.log',
|
|
140
|
+
durationMs: 17,
|
|
141
|
+
resultMessage: 'Screen session "my-session" exited with code 0',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(block).toContain('╭');
|
|
145
|
+
expect(block).toContain('╰');
|
|
146
|
+
expect(block).toContain('Screen session');
|
|
147
|
+
expect(block).toContain('exited with code 0');
|
|
148
|
+
expect(block).toContain('Session ID: test-uuid-1234');
|
|
149
|
+
expect(block).toContain(
|
|
150
|
+
'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
|
|
151
|
+
);
|
|
152
|
+
});
|
|
113
153
|
});
|
|
114
154
|
|
|
115
155
|
describe('formatDuration', () => {
|