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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
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
- // Create execution record for tracking with provided session ID
300
- let executionRecord = null;
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
- console.log(`[User Isolation] Creating new user with same permissions...`);
332
+ extraLines.push('[User Isolation] Creating new user...');
348
333
  if (importantGroups.length > 0) {
349
- console.log(
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
- console.log(`[User Isolation] Created user: ${createdUser}`);
349
+ extraLines.push(`[User Isolation] Created user: ${createdUser}`);
365
350
  if (userResult.groups && userResult.groups.length > 0) {
366
- console.log(
351
+ extraLines.push(
367
352
  `[User Isolation] User groups: ${userResult.groups.join(', ')}`
368
353
  );
369
354
  }
370
355
  if (options.keepUser) {
371
- console.log(`[User Isolation] User will be kept after command completes`);
356
+ extraLines.push('[User Isolation] User will be kept after completion');
372
357
  }
373
- console.log('');
374
358
  }
375
359
 
376
- // Print start message (unified format)
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
- console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
362
+ extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
383
363
  }
384
364
  if (options.session) {
385
- console.log(`[Isolation] Session: ${options.session}`);
365
+ extraLines.push(`[Isolation] Session: ${options.session}`);
386
366
  }
387
- if (options.image) {
388
- console.log(`[Isolation] Image: ${options.image}`);
367
+ if (effectiveImage) {
368
+ extraLines.push(`[Isolation] Image: ${effectiveImage}`);
389
369
  }
390
370
  if (options.endpoint) {
391
- console.log(`[Isolation] Endpoint: ${options.endpoint}`);
371
+ extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
392
372
  }
393
373
  if (createdUser) {
394
- console.log(`[Isolation] User: ${createdUser} (isolated)`);
374
+ extraLines.push(`[Isolation] User: ${createdUser} (isolated)`);
395
375
  }
396
376
  if (useCommandStream) {
397
- console.log(`[Isolation] Using command-stream library`);
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: options.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: options.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(`[User Isolation] User deleted successfully`);
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 session UUID at end
508
- console.log('');
509
- console.log(sessionId);
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 session UUID at start
578
- console.log(sessionId);
579
- console.log('');
580
-
581
- // Print start message to console
582
- if (substitutionResult && substitutionResult.matched) {
583
- console.log(`[${startTime}] Input: ${parsedCommand}`);
584
- console.log(`[${startTime}] Executing: ${cmd}`);
585
- } else {
586
- console.log(`[${startTime}] Starting: ${cmd}`);
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 footer to console
656
- console.log('');
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
- // Print session UUID at end
662
- console.log(sessionId);
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(`[${endTime}] Finished`);
706
- console.log(`Exit code: 1`);
707
- console.log(`Log saved: ${logFilePath}`);
708
- console.log('');
709
- // Print session UUID at end
710
- console.log(sessionId);
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 session UUID at start
794
- console.log(sessionId);
795
- console.log('');
796
-
797
- // Print start message to console
798
- if (subResult && subResult.matched) {
799
- console.log(`[${startTime}] Input: ${parsedCmd}`);
800
- console.log(`[${startTime}] Executing: ${cmd}`);
801
- } else {
802
- console.log(`[${startTime}] Starting: ${cmd}`);
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 footer to console
880
- console.log('');
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
- // Print session UUID at end
886
- console.log(sessionId);
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
+ };
@@ -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
- // Format value based on type
27
- let formattedValue;
28
- if (typeof value === 'object') {
29
- formattedValue = JSON.stringify(value);
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 = String(value);
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
- if (Object.keys(obj.options).length > 0) {
66
- lines.push(`Options: ${JSON.stringify(obj.options)}`);
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');
@@ -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 "${testRecord.uuid}"`);
87
- expect(result.stdout).toContain(' status "executed"');
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
- expect(result.stdout).toContain(` uuid "${testRecord.uuid}"`);
103
+ // UUID without special chars is not quoted
104
+ expect(result.stdout).toContain(` uuid ${testRecord.uuid}`);
103
105
  });
104
106
  });
105
107