start-command 0.15.0 → 0.17.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 +20 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -102
- package/src/lib/docker-utils.js +89 -0
- package/src/lib/isolation.js +4 -0
- package/src/lib/output-blocks.js +373 -0
- package/src/lib/status-formatter.js +37 -10
- package/test/isolation.test.js +32 -0
- package/test/output-blocks.test.js +237 -0
- package/test/status-query.test.js +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1da275c: feat: Improve output block uniformity and add OS-based Docker image detection
|
|
8
|
+
- Move isolation info lines into start block instead of printing them separately
|
|
9
|
+
- Move exit/result messages into finish block instead of printing them separately
|
|
10
|
+
- Add getDefaultDockerImage() to detect host OS and select matching Docker image
|
|
11
|
+
- Default Docker images: ubuntu, debian, archlinux, fedora, centos based on host OS
|
|
12
|
+
|
|
13
|
+
## 0.16.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 35f3505: feat: Improve command output formatting with human-readable timestamps and duration
|
|
18
|
+
- Changed timestamp format from `[timestamp] Starting:` to `Starting at timestamp:`
|
|
19
|
+
- Changed finish message from `[timestamp] Finished` to `Finished at timestamp in X.XXX seconds`
|
|
20
|
+
- Added performance metric showing command execution duration
|
|
21
|
+
- Added `formatDuration` helper function for consistent duration formatting
|
|
22
|
+
|
|
3
23
|
## 0.15.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,
|
|
@@ -33,6 +34,7 @@ const { handleFailure } = require('../lib/failure-handler');
|
|
|
33
34
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
34
35
|
const { queryStatus } = require('../lib/status-formatter');
|
|
35
36
|
const { printVersion } = require('../lib/version');
|
|
37
|
+
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
36
38
|
|
|
37
39
|
// Configuration from environment variables
|
|
38
40
|
const config = {
|
|
@@ -283,6 +285,13 @@ async function runWithIsolation(
|
|
|
283
285
|
const environment = options.isolated;
|
|
284
286
|
const mode = getEffectiveMode(options);
|
|
285
287
|
const startTime = getTimestamp();
|
|
288
|
+
const startTimeMs = Date.now();
|
|
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
|
+
}
|
|
286
295
|
|
|
287
296
|
// Create log file path
|
|
288
297
|
const logFilePath = createLogPath(environment || 'direct');
|
|
@@ -296,32 +305,8 @@ async function runWithIsolation(
|
|
|
296
305
|
const isWindows = process.platform === 'win32';
|
|
297
306
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
298
307
|
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
const store = getExecutionStore();
|
|
302
|
-
if (store) {
|
|
303
|
-
executionRecord = new ExecutionRecord({
|
|
304
|
-
uuid: sessionId, // Use the provided session ID
|
|
305
|
-
command: cmd,
|
|
306
|
-
logPath: logFilePath,
|
|
307
|
-
shell,
|
|
308
|
-
workingDirectory: process.cwd(),
|
|
309
|
-
options: {
|
|
310
|
-
isolated: environment,
|
|
311
|
-
isolationMode: mode,
|
|
312
|
-
sessionName,
|
|
313
|
-
image: options.image,
|
|
314
|
-
endpoint: options.endpoint,
|
|
315
|
-
user: options.user,
|
|
316
|
-
keepAlive: options.keepAlive,
|
|
317
|
-
useCommandStream,
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Print session UUID at start
|
|
323
|
-
console.log(sessionId);
|
|
324
|
-
console.log('');
|
|
308
|
+
// Collect isolation info lines for start block
|
|
309
|
+
const extraLines = [];
|
|
325
310
|
|
|
326
311
|
// Handle --isolated-user option: create a new user with same permissions
|
|
327
312
|
let createdUser = null;
|
|
@@ -344,9 +329,9 @@ async function runWithIsolation(
|
|
|
344
329
|
currentGroups.includes(g)
|
|
345
330
|
);
|
|
346
331
|
|
|
347
|
-
|
|
332
|
+
extraLines.push('[User Isolation] Creating new user...');
|
|
348
333
|
if (importantGroups.length > 0) {
|
|
349
|
-
|
|
334
|
+
extraLines.push(
|
|
350
335
|
`[User Isolation] Inheriting groups: ${importantGroups.join(', ')}`
|
|
351
336
|
);
|
|
352
337
|
}
|
|
@@ -361,41 +346,69 @@ async function runWithIsolation(
|
|
|
361
346
|
}
|
|
362
347
|
|
|
363
348
|
createdUser = userResult.username;
|
|
364
|
-
|
|
349
|
+
extraLines.push(`[User Isolation] Created user: ${createdUser}`);
|
|
365
350
|
if (userResult.groups && userResult.groups.length > 0) {
|
|
366
|
-
|
|
351
|
+
extraLines.push(
|
|
367
352
|
`[User Isolation] User groups: ${userResult.groups.join(', ')}`
|
|
368
353
|
);
|
|
369
354
|
}
|
|
370
355
|
if (options.keepUser) {
|
|
371
|
-
|
|
356
|
+
extraLines.push('[User Isolation] User will be kept after completion');
|
|
372
357
|
}
|
|
373
|
-
console.log('');
|
|
374
358
|
}
|
|
375
359
|
|
|
376
|
-
//
|
|
377
|
-
console.log(`[${startTime}] Starting: ${cmd}`);
|
|
378
|
-
console.log('');
|
|
379
|
-
|
|
380
|
-
// Log isolation info
|
|
360
|
+
// Add isolation info to extra lines
|
|
381
361
|
if (environment) {
|
|
382
|
-
|
|
362
|
+
extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
|
|
383
363
|
}
|
|
384
364
|
if (options.session) {
|
|
385
|
-
|
|
365
|
+
extraLines.push(`[Isolation] Session: ${options.session}`);
|
|
386
366
|
}
|
|
387
|
-
if (
|
|
388
|
-
|
|
367
|
+
if (effectiveImage) {
|
|
368
|
+
extraLines.push(`[Isolation] Image: ${effectiveImage}`);
|
|
389
369
|
}
|
|
390
370
|
if (options.endpoint) {
|
|
391
|
-
|
|
371
|
+
extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
|
|
392
372
|
}
|
|
393
373
|
if (createdUser) {
|
|
394
|
-
|
|
374
|
+
extraLines.push(`[Isolation] User: ${createdUser} (isolated)`);
|
|
395
375
|
}
|
|
396
376
|
if (useCommandStream) {
|
|
397
|
-
|
|
377
|
+
extraLines.push('[Isolation] Using command-stream library');
|
|
398
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
|
+
);
|
|
399
412
|
console.log('');
|
|
400
413
|
|
|
401
414
|
// Save initial execution record
|
|
@@ -417,7 +430,7 @@ async function runWithIsolation(
|
|
|
417
430
|
environment: environment || 'direct',
|
|
418
431
|
mode,
|
|
419
432
|
sessionName,
|
|
420
|
-
image:
|
|
433
|
+
image: effectiveImage,
|
|
421
434
|
user: createdUser,
|
|
422
435
|
startTime,
|
|
423
436
|
});
|
|
@@ -438,7 +451,7 @@ async function runWithIsolation(
|
|
|
438
451
|
// Future: Add command-stream support with raw() function for multiplexers
|
|
439
452
|
result = await runIsolated(environment, cmd, {
|
|
440
453
|
session: options.session,
|
|
441
|
-
image:
|
|
454
|
+
image: effectiveImage,
|
|
442
455
|
endpoint: options.endpoint,
|
|
443
456
|
detached: mode === 'detached',
|
|
444
457
|
user: createdUser,
|
|
@@ -479,34 +492,36 @@ async function runWithIsolation(
|
|
|
479
492
|
}
|
|
480
493
|
}
|
|
481
494
|
|
|
482
|
-
// Print result and footer (unified format)
|
|
483
|
-
console.log('');
|
|
484
|
-
console.log(result.message);
|
|
485
|
-
console.log('');
|
|
486
|
-
console.log(`[${endTime}] Finished`);
|
|
487
|
-
console.log(`Exit code: ${exitCode}`);
|
|
488
|
-
console.log(`Log saved: ${logFilePath}`);
|
|
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
|
|
508
|
-
|
|
509
|
-
console.log(
|
|
513
|
+
// Print finish block with result message inside
|
|
514
|
+
const durationMs = Date.now() - startTimeMs;
|
|
515
|
+
console.log(
|
|
516
|
+
createFinishBlock({
|
|
517
|
+
sessionId,
|
|
518
|
+
timestamp: endTime,
|
|
519
|
+
exitCode,
|
|
520
|
+
logPath: logFilePath,
|
|
521
|
+
durationMs,
|
|
522
|
+
resultMessage: result.message,
|
|
523
|
+
})
|
|
524
|
+
);
|
|
510
525
|
|
|
511
526
|
process.exit(exitCode);
|
|
512
527
|
}
|
|
@@ -532,6 +547,7 @@ function runDirect(cmd, sessionId) {
|
|
|
532
547
|
|
|
533
548
|
let logContent = '';
|
|
534
549
|
const startTime = getTimestamp();
|
|
550
|
+
const startTimeMs = Date.now();
|
|
535
551
|
|
|
536
552
|
// Get runtime information
|
|
537
553
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
|
|
@@ -574,17 +590,18 @@ function runDirect(cmd, sessionId) {
|
|
|
574
590
|
logContent += `Working Directory: ${process.cwd()}\n`;
|
|
575
591
|
logContent += `${'='.repeat(50)}\n\n`;
|
|
576
592
|
|
|
577
|
-
// Print
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
593
|
+
// Print start block with session ID
|
|
594
|
+
const displayCommand =
|
|
595
|
+
substitutionResult && substitutionResult.matched
|
|
596
|
+
? `${parsedCommand} -> ${cmd}`
|
|
597
|
+
: cmd;
|
|
598
|
+
console.log(
|
|
599
|
+
createStartBlock({
|
|
600
|
+
sessionId,
|
|
601
|
+
timestamp: startTime,
|
|
602
|
+
command: displayCommand,
|
|
603
|
+
})
|
|
604
|
+
);
|
|
588
605
|
console.log('');
|
|
589
606
|
|
|
590
607
|
// Execute the command with captured output
|
|
@@ -652,14 +669,18 @@ function runDirect(cmd, sessionId) {
|
|
|
652
669
|
}
|
|
653
670
|
}
|
|
654
671
|
|
|
655
|
-
// Print
|
|
656
|
-
|
|
657
|
-
console.log(`[${endTime}] Finished`);
|
|
658
|
-
console.log(`Exit code: ${exitCode}`);
|
|
659
|
-
console.log(`Log saved: ${logFilePath}`);
|
|
672
|
+
// Print finish block
|
|
673
|
+
const durationMs = Date.now() - startTimeMs;
|
|
660
674
|
console.log('');
|
|
661
|
-
|
|
662
|
-
|
|
675
|
+
console.log(
|
|
676
|
+
createFinishBlock({
|
|
677
|
+
sessionId,
|
|
678
|
+
timestamp: endTime,
|
|
679
|
+
exitCode,
|
|
680
|
+
logPath: logFilePath,
|
|
681
|
+
durationMs,
|
|
682
|
+
})
|
|
683
|
+
);
|
|
663
684
|
|
|
664
685
|
// If command failed, try to auto-report
|
|
665
686
|
if (exitCode !== 0) {
|
|
@@ -672,6 +693,7 @@ function runDirect(cmd, sessionId) {
|
|
|
672
693
|
// Handle spawn errors
|
|
673
694
|
child.on('error', (err) => {
|
|
674
695
|
const endTime = getTimestamp();
|
|
696
|
+
const durationMs = Date.now() - startTimeMs;
|
|
675
697
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
676
698
|
|
|
677
699
|
logContent += `\n${errorMessage}\n`;
|
|
@@ -702,12 +724,15 @@ function runDirect(cmd, sessionId) {
|
|
|
702
724
|
|
|
703
725
|
console.error(`\n${errorMessage}`);
|
|
704
726
|
console.log('');
|
|
705
|
-
console.log(
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
727
|
+
console.log(
|
|
728
|
+
createFinishBlock({
|
|
729
|
+
sessionId,
|
|
730
|
+
timestamp: endTime,
|
|
731
|
+
exitCode: 1,
|
|
732
|
+
logPath: logFilePath,
|
|
733
|
+
durationMs,
|
|
734
|
+
})
|
|
735
|
+
);
|
|
711
736
|
|
|
712
737
|
handleFailure(config, commandName, cmd, 1, logFilePath);
|
|
713
738
|
|
|
@@ -746,6 +771,7 @@ async function runDirectWithCommandStream(
|
|
|
746
771
|
|
|
747
772
|
let logContent = '';
|
|
748
773
|
const startTime = getTimestamp();
|
|
774
|
+
const startTimeMs = Date.now();
|
|
749
775
|
|
|
750
776
|
// Get runtime information
|
|
751
777
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
|
|
@@ -790,17 +816,16 @@ async function runDirectWithCommandStream(
|
|
|
790
816
|
logContent += `Working Directory: ${process.cwd()}\n`;
|
|
791
817
|
logContent += `${'='.repeat(50)}\n\n`;
|
|
792
818
|
|
|
793
|
-
// Print
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
819
|
+
// Print start block with session ID
|
|
820
|
+
const displayCmd =
|
|
821
|
+
subResult && subResult.matched ? `${parsedCmd} -> ${cmd}` : cmd;
|
|
822
|
+
console.log(
|
|
823
|
+
createStartBlock({
|
|
824
|
+
sessionId,
|
|
825
|
+
timestamp: startTime,
|
|
826
|
+
command: displayCmd,
|
|
827
|
+
})
|
|
828
|
+
);
|
|
804
829
|
console.log('[command-stream] Using command-stream library');
|
|
805
830
|
console.log('');
|
|
806
831
|
|
|
@@ -876,14 +901,18 @@ async function runDirectWithCommandStream(
|
|
|
876
901
|
}
|
|
877
902
|
}
|
|
878
903
|
|
|
879
|
-
// Print
|
|
880
|
-
|
|
881
|
-
console.log(`[${endTime}] Finished`);
|
|
882
|
-
console.log(`Exit code: ${exitCode}`);
|
|
883
|
-
console.log(`Log saved: ${logFilePath}`);
|
|
904
|
+
// Print finish block
|
|
905
|
+
const durationMs = Date.now() - startTimeMs;
|
|
884
906
|
console.log('');
|
|
885
|
-
|
|
886
|
-
|
|
907
|
+
console.log(
|
|
908
|
+
createFinishBlock({
|
|
909
|
+
sessionId,
|
|
910
|
+
timestamp: endTime,
|
|
911
|
+
exitCode,
|
|
912
|
+
logPath: logFilePath,
|
|
913
|
+
durationMs,
|
|
914
|
+
})
|
|
915
|
+
);
|
|
887
916
|
|
|
888
917
|
// If command failed, try to auto-report
|
|
889
918
|
if (exitCode !== 0) {
|
|
@@ -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
|
@@ -964,6 +964,9 @@ function canRunLinuxDockerImages() {
|
|
|
964
964
|
}
|
|
965
965
|
}
|
|
966
966
|
|
|
967
|
+
// Re-export getDefaultDockerImage from docker-utils for backwards compatibility
|
|
968
|
+
const { getDefaultDockerImage } = require('./docker-utils');
|
|
969
|
+
|
|
967
970
|
module.exports = {
|
|
968
971
|
isCommandAvailable,
|
|
969
972
|
hasTTY,
|
|
@@ -985,4 +988,5 @@ module.exports = {
|
|
|
985
988
|
supportsLogfileOption,
|
|
986
989
|
resetScreenVersionCache,
|
|
987
990
|
canRunLinuxDockerImages,
|
|
991
|
+
getDefaultDockerImage,
|
|
988
992
|
};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting utilities for nicely rendered command blocks
|
|
3
|
+
*
|
|
4
|
+
* Provides various styles for start/finish blocks to distinguish
|
|
5
|
+
* command output from the $ wrapper output.
|
|
6
|
+
*
|
|
7
|
+
* Available styles:
|
|
8
|
+
* 1. 'rounded' (default): Rounded unicode box borders (╭─╮ ╰─╯)
|
|
9
|
+
* 2. 'heavy': Heavy unicode box borders (┏━┓ ┗━┛)
|
|
10
|
+
* 3. 'double': Double line box borders (╔═╗ ╚═╝)
|
|
11
|
+
* 4. 'simple': Simple dash lines (────────)
|
|
12
|
+
* 5. 'ascii': Pure ASCII compatible (-------- +------+)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Box drawing characters for different styles
|
|
16
|
+
const BOX_STYLES = {
|
|
17
|
+
rounded: {
|
|
18
|
+
topLeft: '╭',
|
|
19
|
+
topRight: '╮',
|
|
20
|
+
bottomLeft: '╰',
|
|
21
|
+
bottomRight: '╯',
|
|
22
|
+
horizontal: '─',
|
|
23
|
+
vertical: '│',
|
|
24
|
+
},
|
|
25
|
+
heavy: {
|
|
26
|
+
topLeft: '┏',
|
|
27
|
+
topRight: '┓',
|
|
28
|
+
bottomLeft: '┗',
|
|
29
|
+
bottomRight: '┛',
|
|
30
|
+
horizontal: '━',
|
|
31
|
+
vertical: '┃',
|
|
32
|
+
},
|
|
33
|
+
double: {
|
|
34
|
+
topLeft: '╔',
|
|
35
|
+
topRight: '╗',
|
|
36
|
+
bottomLeft: '╚',
|
|
37
|
+
bottomRight: '╝',
|
|
38
|
+
horizontal: '═',
|
|
39
|
+
vertical: '║',
|
|
40
|
+
},
|
|
41
|
+
simple: {
|
|
42
|
+
topLeft: '',
|
|
43
|
+
topRight: '',
|
|
44
|
+
bottomLeft: '',
|
|
45
|
+
bottomRight: '',
|
|
46
|
+
horizontal: '─',
|
|
47
|
+
vertical: '',
|
|
48
|
+
},
|
|
49
|
+
ascii: {
|
|
50
|
+
topLeft: '+',
|
|
51
|
+
topRight: '+',
|
|
52
|
+
bottomLeft: '+',
|
|
53
|
+
bottomRight: '+',
|
|
54
|
+
horizontal: '-',
|
|
55
|
+
vertical: '|',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Default style (can be overridden via environment variable)
|
|
60
|
+
const DEFAULT_STYLE = process.env.START_OUTPUT_STYLE || 'rounded';
|
|
61
|
+
|
|
62
|
+
// Default block width
|
|
63
|
+
const DEFAULT_WIDTH = 60;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the box style configuration
|
|
67
|
+
* @param {string} [styleName] - Style name (rounded, heavy, double, simple, ascii)
|
|
68
|
+
* @returns {object} Box style configuration
|
|
69
|
+
*/
|
|
70
|
+
function getBoxStyle(styleName = DEFAULT_STYLE) {
|
|
71
|
+
return BOX_STYLES[styleName] || BOX_STYLES.rounded;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a horizontal line
|
|
76
|
+
* @param {number} width - Line width
|
|
77
|
+
* @param {object} style - Box style
|
|
78
|
+
* @returns {string} Horizontal line
|
|
79
|
+
*/
|
|
80
|
+
function createHorizontalLine(width, style) {
|
|
81
|
+
return style.horizontal.repeat(width);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pad or truncate text to fit a specific width
|
|
86
|
+
* @param {string} text - Text to pad
|
|
87
|
+
* @param {number} width - Target width
|
|
88
|
+
* @returns {string} Padded text
|
|
89
|
+
*/
|
|
90
|
+
function padText(text, width) {
|
|
91
|
+
if (text.length >= width) {
|
|
92
|
+
return text.substring(0, width);
|
|
93
|
+
}
|
|
94
|
+
return text + ' '.repeat(width - text.length);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a bordered line with text
|
|
99
|
+
* @param {string} text - Text content
|
|
100
|
+
* @param {number} width - Total width (including borders)
|
|
101
|
+
* @param {object} style - Box style
|
|
102
|
+
* @returns {string} Bordered line
|
|
103
|
+
*/
|
|
104
|
+
function createBorderedLine(text, width, style) {
|
|
105
|
+
if (style.vertical) {
|
|
106
|
+
const innerWidth = width - 4; // 2 for borders, 2 for padding
|
|
107
|
+
const paddedText = padText(text, innerWidth);
|
|
108
|
+
return `${style.vertical} ${paddedText} ${style.vertical}`;
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create the top border of a box
|
|
115
|
+
* @param {number} width - Box width
|
|
116
|
+
* @param {object} style - Box style
|
|
117
|
+
* @returns {string} Top border
|
|
118
|
+
*/
|
|
119
|
+
function createTopBorder(width, style) {
|
|
120
|
+
if (style.topLeft) {
|
|
121
|
+
const lineWidth = width - 2; // Subtract corners
|
|
122
|
+
return `${style.topLeft}${createHorizontalLine(lineWidth, style)}${style.topRight}`;
|
|
123
|
+
}
|
|
124
|
+
return createHorizontalLine(width, style);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create the bottom border of a box
|
|
129
|
+
* @param {number} width - Box width
|
|
130
|
+
* @param {object} style - Box style
|
|
131
|
+
* @returns {string} Bottom border
|
|
132
|
+
*/
|
|
133
|
+
function createBottomBorder(width, style) {
|
|
134
|
+
if (style.bottomLeft) {
|
|
135
|
+
const lineWidth = width - 2; // Subtract corners
|
|
136
|
+
return `${style.bottomLeft}${createHorizontalLine(lineWidth, style)}${style.bottomRight}`;
|
|
137
|
+
}
|
|
138
|
+
return createHorizontalLine(width, style);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a start block for command execution
|
|
143
|
+
* @param {object} options - Options for the block
|
|
144
|
+
* @param {string} options.sessionId - Session UUID
|
|
145
|
+
* @param {string} options.timestamp - Timestamp string
|
|
146
|
+
* @param {string} options.command - Command being executed
|
|
147
|
+
* @param {string[]} [options.extraLines] - Additional lines to show after the command line
|
|
148
|
+
* @param {string} [options.style] - Box style name
|
|
149
|
+
* @param {number} [options.width] - Box width
|
|
150
|
+
* @returns {string} Formatted start block
|
|
151
|
+
*/
|
|
152
|
+
function createStartBlock(options) {
|
|
153
|
+
const {
|
|
154
|
+
sessionId,
|
|
155
|
+
timestamp,
|
|
156
|
+
command,
|
|
157
|
+
extraLines = [],
|
|
158
|
+
style: styleName = DEFAULT_STYLE,
|
|
159
|
+
width = DEFAULT_WIDTH,
|
|
160
|
+
} = options;
|
|
161
|
+
|
|
162
|
+
const style = getBoxStyle(styleName);
|
|
163
|
+
const lines = [];
|
|
164
|
+
|
|
165
|
+
lines.push(createTopBorder(width, style));
|
|
166
|
+
lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
|
|
167
|
+
lines.push(
|
|
168
|
+
createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Add extra lines (e.g., isolation info, docker image, etc.)
|
|
172
|
+
for (const line of extraLines) {
|
|
173
|
+
lines.push(createBorderedLine(line, width, style));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push(createBottomBorder(width, style));
|
|
177
|
+
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format duration in seconds with appropriate precision
|
|
183
|
+
* @param {number} durationMs - Duration in milliseconds
|
|
184
|
+
* @returns {string} Formatted duration string
|
|
185
|
+
*/
|
|
186
|
+
function formatDuration(durationMs) {
|
|
187
|
+
const seconds = durationMs / 1000;
|
|
188
|
+
if (seconds < 0.001) {
|
|
189
|
+
return '0.001';
|
|
190
|
+
} else if (seconds < 10) {
|
|
191
|
+
// For durations under 10 seconds, show 3 decimal places
|
|
192
|
+
return seconds.toFixed(3);
|
|
193
|
+
} else if (seconds < 100) {
|
|
194
|
+
return seconds.toFixed(2);
|
|
195
|
+
} else {
|
|
196
|
+
return seconds.toFixed(1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create a finish block for command execution
|
|
202
|
+
* @param {object} options - Options for the block
|
|
203
|
+
* @param {string} options.sessionId - Session UUID
|
|
204
|
+
* @param {string} options.timestamp - Timestamp string
|
|
205
|
+
* @param {number} options.exitCode - Exit code
|
|
206
|
+
* @param {string} options.logPath - Path to log file
|
|
207
|
+
* @param {number} [options.durationMs] - Duration in milliseconds
|
|
208
|
+
* @param {string} [options.resultMessage] - Result message (e.g., "Screen session exited...")
|
|
209
|
+
* @param {string} [options.style] - Box style name
|
|
210
|
+
* @param {number} [options.width] - Box width
|
|
211
|
+
* @returns {string} Formatted finish block
|
|
212
|
+
*/
|
|
213
|
+
function createFinishBlock(options) {
|
|
214
|
+
const {
|
|
215
|
+
sessionId,
|
|
216
|
+
timestamp,
|
|
217
|
+
exitCode,
|
|
218
|
+
logPath,
|
|
219
|
+
durationMs,
|
|
220
|
+
resultMessage,
|
|
221
|
+
style: styleName = DEFAULT_STYLE,
|
|
222
|
+
width = DEFAULT_WIDTH,
|
|
223
|
+
} = options;
|
|
224
|
+
|
|
225
|
+
const style = getBoxStyle(styleName);
|
|
226
|
+
const lines = [];
|
|
227
|
+
|
|
228
|
+
// Format the finished message with optional duration
|
|
229
|
+
let finishedMsg = `Finished at ${timestamp}`;
|
|
230
|
+
if (durationMs !== undefined && durationMs !== null) {
|
|
231
|
+
finishedMsg += ` in ${formatDuration(durationMs)} seconds`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lines.push(createTopBorder(width, style));
|
|
235
|
+
|
|
236
|
+
// Add result message first if provided (e.g., "Docker container exited...")
|
|
237
|
+
if (resultMessage) {
|
|
238
|
+
lines.push(createBorderedLine(resultMessage, width, style));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
lines.push(createBorderedLine(finishedMsg, width, style));
|
|
242
|
+
lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
|
|
243
|
+
lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
|
|
244
|
+
lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
|
|
245
|
+
lines.push(createBottomBorder(width, style));
|
|
246
|
+
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Escape a value for Links notation
|
|
252
|
+
* Smart quoting: uses single or double quotes based on content
|
|
253
|
+
* @param {string} str - String to escape
|
|
254
|
+
* @returns {string} Escaped string
|
|
255
|
+
*/
|
|
256
|
+
function escapeForLinksNotation(str) {
|
|
257
|
+
if (str === null || str === undefined) {
|
|
258
|
+
return 'null';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const value = String(str);
|
|
262
|
+
|
|
263
|
+
// Check for characters that need quoting
|
|
264
|
+
const hasColon = value.includes(':');
|
|
265
|
+
const hasDoubleQuotes = value.includes('"');
|
|
266
|
+
const hasSingleQuotes = value.includes("'");
|
|
267
|
+
const hasParens = value.includes('(') || value.includes(')');
|
|
268
|
+
const hasNewline = value.includes('\n');
|
|
269
|
+
const hasSpace = value.includes(' ');
|
|
270
|
+
|
|
271
|
+
const needsQuoting =
|
|
272
|
+
hasColon ||
|
|
273
|
+
hasDoubleQuotes ||
|
|
274
|
+
hasSingleQuotes ||
|
|
275
|
+
hasParens ||
|
|
276
|
+
hasNewline ||
|
|
277
|
+
hasSpace;
|
|
278
|
+
|
|
279
|
+
if (!needsQuoting) {
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (hasDoubleQuotes && !hasSingleQuotes) {
|
|
284
|
+
// Has " but not ' → use single quotes
|
|
285
|
+
return `'${value}'`;
|
|
286
|
+
} else if (hasSingleQuotes && !hasDoubleQuotes) {
|
|
287
|
+
// Has ' but not " → use double quotes
|
|
288
|
+
return `"${value}"`;
|
|
289
|
+
} else if (hasDoubleQuotes && hasSingleQuotes) {
|
|
290
|
+
// Has both " and ' → choose wrapper with fewer escapes
|
|
291
|
+
const doubleQuoteCount = (value.match(/"/g) || []).length;
|
|
292
|
+
const singleQuoteCount = (value.match(/'/g) || []).length;
|
|
293
|
+
|
|
294
|
+
if (singleQuoteCount <= doubleQuoteCount) {
|
|
295
|
+
// Escape single quotes by doubling them
|
|
296
|
+
const escaped = value.replace(/'/g, "''");
|
|
297
|
+
return `'${escaped}'`;
|
|
298
|
+
} else {
|
|
299
|
+
// Escape double quotes by doubling them
|
|
300
|
+
const escaped = value.replace(/"/g, '""');
|
|
301
|
+
return `"${escaped}"`;
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
// Has colon, parentheses, newlines, or spaces but no quotes
|
|
305
|
+
return `"${value}"`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Format an object as nested Links notation
|
|
311
|
+
* @param {object} obj - Object to format
|
|
312
|
+
* @param {number} [indent=2] - Indentation level (spaces)
|
|
313
|
+
* @param {number} [depth=0] - Current depth
|
|
314
|
+
* @returns {string} Links notation formatted string
|
|
315
|
+
*/
|
|
316
|
+
function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
|
|
317
|
+
if (obj === null || obj === undefined) {
|
|
318
|
+
return 'null';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (typeof obj !== 'object') {
|
|
322
|
+
return escapeForLinksNotation(obj);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Array.isArray(obj)) {
|
|
326
|
+
// Format arrays
|
|
327
|
+
if (obj.length === 0) {
|
|
328
|
+
return '()';
|
|
329
|
+
}
|
|
330
|
+
const indentStr = ' '.repeat(indent * (depth + 1));
|
|
331
|
+
const items = obj.map((item) => {
|
|
332
|
+
const formatted = formatAsNestedLinksNotation(item, indent, depth + 1);
|
|
333
|
+
return `${indentStr}${formatted}`;
|
|
334
|
+
});
|
|
335
|
+
return `(\n${items.join('\n')}\n${' '.repeat(indent * depth)})`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Format objects
|
|
339
|
+
const entries = Object.entries(obj);
|
|
340
|
+
if (entries.length === 0) {
|
|
341
|
+
return '()';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const indentStr = ' '.repeat(indent * (depth + 1));
|
|
345
|
+
const lines = entries
|
|
346
|
+
.filter(([, value]) => value !== null && value !== undefined)
|
|
347
|
+
.map(([key, value]) => {
|
|
348
|
+
if (typeof value === 'object') {
|
|
349
|
+
const nested = formatAsNestedLinksNotation(value, indent, depth + 1);
|
|
350
|
+
return `${indentStr}${key}\n${nested}`;
|
|
351
|
+
}
|
|
352
|
+
const formattedValue = escapeForLinksNotation(value);
|
|
353
|
+
return `${indentStr}${key} ${formattedValue}`;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return lines.join('\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
BOX_STYLES,
|
|
361
|
+
DEFAULT_STYLE,
|
|
362
|
+
DEFAULT_WIDTH,
|
|
363
|
+
getBoxStyle,
|
|
364
|
+
createHorizontalLine,
|
|
365
|
+
createBorderedLine,
|
|
366
|
+
createTopBorder,
|
|
367
|
+
createBottomBorder,
|
|
368
|
+
createStartBlock,
|
|
369
|
+
createFinishBlock,
|
|
370
|
+
formatDuration,
|
|
371
|
+
escapeForLinksNotation,
|
|
372
|
+
formatAsNestedLinksNotation,
|
|
373
|
+
};
|
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
* - Text: Human-readable text format
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
const {
|
|
11
|
+
escapeForLinksNotation,
|
|
12
|
+
formatAsNestedLinksNotation,
|
|
13
|
+
} = require('./output-blocks');
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Format execution record as Links Notation (indented style)
|
|
17
|
+
* Uses nested Links notation for object values (like options) instead of JSON
|
|
18
|
+
*
|
|
12
19
|
* @param {Object} record - The execution record with toObject() method
|
|
13
20
|
* @returns {string} Links Notation formatted string in indented style
|
|
14
21
|
*
|
|
15
22
|
* Output format:
|
|
16
23
|
* <uuid>
|
|
17
24
|
* <key> "<value>"
|
|
25
|
+
* options
|
|
26
|
+
* <nested_key> <nested_value>
|
|
18
27
|
* ...
|
|
19
28
|
*/
|
|
20
29
|
function formatRecordAsLinksNotation(record) {
|
|
@@ -23,16 +32,27 @@ function formatRecordAsLinksNotation(record) {
|
|
|
23
32
|
|
|
24
33
|
for (const [key, value] of Object.entries(obj)) {
|
|
25
34
|
if (value !== null && value !== undefined) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
if (key === 'options' && typeof value === 'object') {
|
|
36
|
+
// Format options as nested Links notation
|
|
37
|
+
const optionEntries = Object.entries(value).filter(
|
|
38
|
+
([, v]) => v !== null && v !== undefined
|
|
39
|
+
);
|
|
40
|
+
if (optionEntries.length > 0) {
|
|
41
|
+
lines.push(' options');
|
|
42
|
+
for (const [optKey, optValue] of optionEntries) {
|
|
43
|
+
const formattedOptValue = escapeForLinksNotation(optValue);
|
|
44
|
+
lines.push(` ${optKey} ${formattedOptValue}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (typeof value === 'object') {
|
|
48
|
+
// For other objects, still format as nested Links notation
|
|
49
|
+
lines.push(` ${key}`);
|
|
50
|
+
const nested = formatAsNestedLinksNotation(value, 2, 2);
|
|
51
|
+
lines.push(nested);
|
|
30
52
|
} else {
|
|
31
|
-
formattedValue =
|
|
53
|
+
const formattedValue = escapeForLinksNotation(value);
|
|
54
|
+
lines.push(` ${key} ${formattedValue}`);
|
|
32
55
|
}
|
|
33
|
-
// Escape quotes in the value
|
|
34
|
-
const escapedValue = formattedValue.replace(/"/g, '\\"');
|
|
35
|
-
lines.push(` ${key} "${escapedValue}"`);
|
|
36
56
|
}
|
|
37
57
|
}
|
|
38
58
|
|
|
@@ -62,8 +82,15 @@ function formatRecordAsText(record) {
|
|
|
62
82
|
`Log Path: ${obj.logPath}`,
|
|
63
83
|
];
|
|
64
84
|
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
// Format options as nested list instead of JSON
|
|
86
|
+
const optionEntries = Object.entries(obj.options || {}).filter(
|
|
87
|
+
([, v]) => v !== null && v !== undefined
|
|
88
|
+
);
|
|
89
|
+
if (optionEntries.length > 0) {
|
|
90
|
+
lines.push(`Options:`);
|
|
91
|
+
for (const [key, value] of optionEntries) {
|
|
92
|
+
lines.push(` ${key}: ${value}`);
|
|
93
|
+
}
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
return lines.join('\n');
|
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
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for output-blocks module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { describe, it, expect } = require('bun:test');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
BOX_STYLES,
|
|
9
|
+
DEFAULT_STYLE,
|
|
10
|
+
DEFAULT_WIDTH,
|
|
11
|
+
getBoxStyle,
|
|
12
|
+
createStartBlock,
|
|
13
|
+
createFinishBlock,
|
|
14
|
+
formatDuration,
|
|
15
|
+
escapeForLinksNotation,
|
|
16
|
+
formatAsNestedLinksNotation,
|
|
17
|
+
} = require('../src/lib/output-blocks');
|
|
18
|
+
|
|
19
|
+
describe('output-blocks module', () => {
|
|
20
|
+
describe('BOX_STYLES', () => {
|
|
21
|
+
it('should have all expected styles', () => {
|
|
22
|
+
expect(BOX_STYLES).toHaveProperty('rounded');
|
|
23
|
+
expect(BOX_STYLES).toHaveProperty('heavy');
|
|
24
|
+
expect(BOX_STYLES).toHaveProperty('double');
|
|
25
|
+
expect(BOX_STYLES).toHaveProperty('simple');
|
|
26
|
+
expect(BOX_STYLES).toHaveProperty('ascii');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should have correct rounded style characters', () => {
|
|
30
|
+
expect(BOX_STYLES.rounded.topLeft).toBe('╭');
|
|
31
|
+
expect(BOX_STYLES.rounded.topRight).toBe('╮');
|
|
32
|
+
expect(BOX_STYLES.rounded.bottomLeft).toBe('╰');
|
|
33
|
+
expect(BOX_STYLES.rounded.bottomRight).toBe('╯');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('getBoxStyle', () => {
|
|
38
|
+
it('should return rounded style by default', () => {
|
|
39
|
+
const style = getBoxStyle();
|
|
40
|
+
expect(style).toEqual(BOX_STYLES.rounded);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return requested style', () => {
|
|
44
|
+
expect(getBoxStyle('heavy')).toEqual(BOX_STYLES.heavy);
|
|
45
|
+
expect(getBoxStyle('double')).toEqual(BOX_STYLES.double);
|
|
46
|
+
expect(getBoxStyle('ascii')).toEqual(BOX_STYLES.ascii);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return rounded for unknown style', () => {
|
|
50
|
+
const style = getBoxStyle('unknown');
|
|
51
|
+
expect(style).toEqual(BOX_STYLES.rounded);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('createStartBlock', () => {
|
|
56
|
+
it('should create a start block with session ID', () => {
|
|
57
|
+
const block = createStartBlock({
|
|
58
|
+
sessionId: 'test-uuid-1234',
|
|
59
|
+
timestamp: '2025-01-01 00:00:00',
|
|
60
|
+
command: 'echo hello',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(block).toContain('╭');
|
|
64
|
+
expect(block).toContain('╰');
|
|
65
|
+
expect(block).toContain('Session ID: test-uuid-1234');
|
|
66
|
+
expect(block).toContain('Starting at 2025-01-01 00:00:00: echo hello');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should use specified style', () => {
|
|
70
|
+
const block = createStartBlock({
|
|
71
|
+
sessionId: 'test-uuid',
|
|
72
|
+
timestamp: '2025-01-01 00:00:00',
|
|
73
|
+
command: 'echo hello',
|
|
74
|
+
style: 'ascii',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(block).toContain('+');
|
|
78
|
+
expect(block).toContain('-');
|
|
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
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('createFinishBlock', () => {
|
|
103
|
+
it('should create a finish block with session ID and exit code', () => {
|
|
104
|
+
const block = createFinishBlock({
|
|
105
|
+
sessionId: 'test-uuid-1234',
|
|
106
|
+
timestamp: '2025-01-01 00:00:01',
|
|
107
|
+
exitCode: 0,
|
|
108
|
+
logPath: '/tmp/test.log',
|
|
109
|
+
durationMs: 17,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(block).toContain('╭');
|
|
113
|
+
expect(block).toContain('╰');
|
|
114
|
+
expect(block).toContain('Session ID: test-uuid-1234');
|
|
115
|
+
expect(block).toContain(
|
|
116
|
+
'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
|
|
117
|
+
);
|
|
118
|
+
expect(block).toContain('Exit code: 0');
|
|
119
|
+
expect(block).toContain('Log: /tmp/test.log');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should create a finish block without duration when not provided', () => {
|
|
123
|
+
const block = createFinishBlock({
|
|
124
|
+
sessionId: 'test-uuid-1234',
|
|
125
|
+
timestamp: '2025-01-01 00:00:01',
|
|
126
|
+
exitCode: 0,
|
|
127
|
+
logPath: '/tmp/test.log',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(block).toContain('Finished at 2025-01-01 00:00:01');
|
|
131
|
+
expect(block).not.toContain('seconds');
|
|
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
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('formatDuration', () => {
|
|
156
|
+
it('should format very small durations', () => {
|
|
157
|
+
expect(formatDuration(0.5)).toBe('0.001');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should format millisecond durations', () => {
|
|
161
|
+
expect(formatDuration(17)).toBe('0.017');
|
|
162
|
+
expect(formatDuration(500)).toBe('0.500');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should format second durations', () => {
|
|
166
|
+
expect(formatDuration(1000)).toBe('1.000');
|
|
167
|
+
expect(formatDuration(5678)).toBe('5.678');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should format longer durations with less precision', () => {
|
|
171
|
+
expect(formatDuration(12345)).toBe('12.35');
|
|
172
|
+
expect(formatDuration(123456)).toBe('123.5');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('escapeForLinksNotation', () => {
|
|
177
|
+
it('should not quote simple values', () => {
|
|
178
|
+
expect(escapeForLinksNotation('simple')).toBe('simple');
|
|
179
|
+
expect(escapeForLinksNotation('123')).toBe('123');
|
|
180
|
+
expect(escapeForLinksNotation('true')).toBe('true');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should quote values with spaces', () => {
|
|
184
|
+
expect(escapeForLinksNotation('hello world')).toBe('"hello world"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should quote values with colons', () => {
|
|
188
|
+
expect(escapeForLinksNotation('key:value')).toBe('"key:value"');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should use single quotes for values with double quotes', () => {
|
|
192
|
+
expect(escapeForLinksNotation('say "hello"')).toBe('\'say "hello"\'');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should use double quotes for values with single quotes', () => {
|
|
196
|
+
expect(escapeForLinksNotation("it's cool")).toBe('"it\'s cool"');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should escape quotes when both types are present', () => {
|
|
200
|
+
const result = escapeForLinksNotation('say "hello" it\'s');
|
|
201
|
+
// Should wrap in one quote type and escape the other
|
|
202
|
+
expect(result).toMatch(/^["'].*["']$/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should handle null values', () => {
|
|
206
|
+
expect(escapeForLinksNotation(null)).toBe('null');
|
|
207
|
+
expect(escapeForLinksNotation(undefined)).toBe('null');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('formatAsNestedLinksNotation', () => {
|
|
212
|
+
it('should format simple objects', () => {
|
|
213
|
+
const obj = { key: 'value', number: 123 };
|
|
214
|
+
const result = formatAsNestedLinksNotation(obj);
|
|
215
|
+
|
|
216
|
+
expect(result).toContain('key value');
|
|
217
|
+
expect(result).toContain('number 123');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should quote values with spaces', () => {
|
|
221
|
+
const obj = { message: 'hello world' };
|
|
222
|
+
const result = formatAsNestedLinksNotation(obj);
|
|
223
|
+
|
|
224
|
+
expect(result).toContain('message "hello world"');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should handle empty objects', () => {
|
|
228
|
+
expect(formatAsNestedLinksNotation({})).toBe('()');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should handle null', () => {
|
|
232
|
+
expect(formatAsNestedLinksNotation(null)).toBe('null');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
console.log('=== Output Blocks Unit Tests ===');
|
|
@@ -82,9 +82,10 @@ describe('--status query functionality', () => {
|
|
|
82
82
|
expect(result.exitCode).toBe(0);
|
|
83
83
|
// Should start with UUID on its own line
|
|
84
84
|
expect(result.stdout).toMatch(new RegExp(`^${testRecord.uuid}\\n`));
|
|
85
|
-
// Should have indented properties
|
|
86
|
-
expect(result.stdout).toContain(` uuid
|
|
87
|
-
expect(result.stdout).toContain(' status
|
|
85
|
+
// Should have indented properties (values without special chars are not quoted)
|
|
86
|
+
expect(result.stdout).toContain(` uuid ${testRecord.uuid}`);
|
|
87
|
+
expect(result.stdout).toContain(' status executed');
|
|
88
|
+
// Command with space should be quoted
|
|
88
89
|
expect(result.stdout).toContain(' command "echo hello world"');
|
|
89
90
|
});
|
|
90
91
|
|
|
@@ -99,7 +100,8 @@ describe('--status query functionality', () => {
|
|
|
99
100
|
expect(result.exitCode).toBe(0);
|
|
100
101
|
// Should start with UUID on its own line
|
|
101
102
|
expect(result.stdout).toMatch(new RegExp(`^${testRecord.uuid}\\n`));
|
|
102
|
-
|
|
103
|
+
// UUID without special chars is not quoted
|
|
104
|
+
expect(result.stdout).toContain(` uuid ${testRecord.uuid}`);
|
|
103
105
|
});
|
|
104
106
|
});
|
|
105
107
|
|