start-command 0.15.0 → 0.16.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 +10 -0
- package/package.json +1 -1
- package/src/bin/cli.js +78 -55
- package/src/lib/output-blocks.js +357 -0
- package/src/lib/status-formatter.js +37 -10
- package/test/output-blocks.test.js +197 -0
- package/test/status-query.test.js +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 35f3505: feat: Improve command output formatting with human-readable timestamps and duration
|
|
8
|
+
- Changed timestamp format from `[timestamp] Starting:` to `Starting at timestamp:`
|
|
9
|
+
- Changed finish message from `[timestamp] Finished` to `Finished at timestamp in X.XXX seconds`
|
|
10
|
+
- Added performance metric showing command execution duration
|
|
11
|
+
- Added `formatDuration` helper function for consistent duration formatting
|
|
12
|
+
|
|
3
13
|
## 0.15.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -33,6 +33,7 @@ const { handleFailure } = require('../lib/failure-handler');
|
|
|
33
33
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
34
34
|
const { queryStatus } = require('../lib/status-formatter');
|
|
35
35
|
const { printVersion } = require('../lib/version');
|
|
36
|
+
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
36
37
|
|
|
37
38
|
// Configuration from environment variables
|
|
38
39
|
const config = {
|
|
@@ -283,6 +284,7 @@ async function runWithIsolation(
|
|
|
283
284
|
const environment = options.isolated;
|
|
284
285
|
const mode = getEffectiveMode(options);
|
|
285
286
|
const startTime = getTimestamp();
|
|
287
|
+
const startTimeMs = Date.now();
|
|
286
288
|
|
|
287
289
|
// Create log file path
|
|
288
290
|
const logFilePath = createLogPath(environment || 'direct');
|
|
@@ -319,8 +321,14 @@ async function runWithIsolation(
|
|
|
319
321
|
});
|
|
320
322
|
}
|
|
321
323
|
|
|
322
|
-
// Print
|
|
323
|
-
console.log(
|
|
324
|
+
// Print start block with session ID
|
|
325
|
+
console.log(
|
|
326
|
+
createStartBlock({
|
|
327
|
+
sessionId,
|
|
328
|
+
timestamp: startTime,
|
|
329
|
+
command: cmd,
|
|
330
|
+
})
|
|
331
|
+
);
|
|
324
332
|
console.log('');
|
|
325
333
|
|
|
326
334
|
// Handle --isolated-user option: create a new user with same permissions
|
|
@@ -373,10 +381,6 @@ async function runWithIsolation(
|
|
|
373
381
|
console.log('');
|
|
374
382
|
}
|
|
375
383
|
|
|
376
|
-
// Print start message (unified format)
|
|
377
|
-
console.log(`[${startTime}] Starting: ${cmd}`);
|
|
378
|
-
console.log('');
|
|
379
|
-
|
|
380
384
|
// Log isolation info
|
|
381
385
|
if (environment) {
|
|
382
386
|
console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
|
|
@@ -479,13 +483,9 @@ async function runWithIsolation(
|
|
|
479
483
|
}
|
|
480
484
|
}
|
|
481
485
|
|
|
482
|
-
// Print result
|
|
486
|
+
// Print result
|
|
483
487
|
console.log('');
|
|
484
488
|
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
489
|
|
|
490
490
|
// Cleanup: delete the created user if we created one (unless --keep-user)
|
|
491
491
|
if (createdUser && !options.keepUser) {
|
|
@@ -504,9 +504,18 @@ async function runWithIsolation(
|
|
|
504
504
|
);
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
-
// Print
|
|
507
|
+
// Print finish block
|
|
508
|
+
const durationMs = Date.now() - startTimeMs;
|
|
508
509
|
console.log('');
|
|
509
|
-
console.log(
|
|
510
|
+
console.log(
|
|
511
|
+
createFinishBlock({
|
|
512
|
+
sessionId,
|
|
513
|
+
timestamp: endTime,
|
|
514
|
+
exitCode,
|
|
515
|
+
logPath: logFilePath,
|
|
516
|
+
durationMs,
|
|
517
|
+
})
|
|
518
|
+
);
|
|
510
519
|
|
|
511
520
|
process.exit(exitCode);
|
|
512
521
|
}
|
|
@@ -532,6 +541,7 @@ function runDirect(cmd, sessionId) {
|
|
|
532
541
|
|
|
533
542
|
let logContent = '';
|
|
534
543
|
const startTime = getTimestamp();
|
|
544
|
+
const startTimeMs = Date.now();
|
|
535
545
|
|
|
536
546
|
// Get runtime information
|
|
537
547
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
|
|
@@ -574,17 +584,18 @@ function runDirect(cmd, sessionId) {
|
|
|
574
584
|
logContent += `Working Directory: ${process.cwd()}\n`;
|
|
575
585
|
logContent += `${'='.repeat(50)}\n\n`;
|
|
576
586
|
|
|
577
|
-
// Print
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
587
|
+
// Print start block with session ID
|
|
588
|
+
const displayCommand =
|
|
589
|
+
substitutionResult && substitutionResult.matched
|
|
590
|
+
? `${parsedCommand} -> ${cmd}`
|
|
591
|
+
: cmd;
|
|
592
|
+
console.log(
|
|
593
|
+
createStartBlock({
|
|
594
|
+
sessionId,
|
|
595
|
+
timestamp: startTime,
|
|
596
|
+
command: displayCommand,
|
|
597
|
+
})
|
|
598
|
+
);
|
|
588
599
|
console.log('');
|
|
589
600
|
|
|
590
601
|
// Execute the command with captured output
|
|
@@ -652,14 +663,18 @@ function runDirect(cmd, sessionId) {
|
|
|
652
663
|
}
|
|
653
664
|
}
|
|
654
665
|
|
|
655
|
-
// Print
|
|
656
|
-
|
|
657
|
-
console.log(`[${endTime}] Finished`);
|
|
658
|
-
console.log(`Exit code: ${exitCode}`);
|
|
659
|
-
console.log(`Log saved: ${logFilePath}`);
|
|
666
|
+
// Print finish block
|
|
667
|
+
const durationMs = Date.now() - startTimeMs;
|
|
660
668
|
console.log('');
|
|
661
|
-
|
|
662
|
-
|
|
669
|
+
console.log(
|
|
670
|
+
createFinishBlock({
|
|
671
|
+
sessionId,
|
|
672
|
+
timestamp: endTime,
|
|
673
|
+
exitCode,
|
|
674
|
+
logPath: logFilePath,
|
|
675
|
+
durationMs,
|
|
676
|
+
})
|
|
677
|
+
);
|
|
663
678
|
|
|
664
679
|
// If command failed, try to auto-report
|
|
665
680
|
if (exitCode !== 0) {
|
|
@@ -672,6 +687,7 @@ function runDirect(cmd, sessionId) {
|
|
|
672
687
|
// Handle spawn errors
|
|
673
688
|
child.on('error', (err) => {
|
|
674
689
|
const endTime = getTimestamp();
|
|
690
|
+
const durationMs = Date.now() - startTimeMs;
|
|
675
691
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
676
692
|
|
|
677
693
|
logContent += `\n${errorMessage}\n`;
|
|
@@ -702,12 +718,15 @@ function runDirect(cmd, sessionId) {
|
|
|
702
718
|
|
|
703
719
|
console.error(`\n${errorMessage}`);
|
|
704
720
|
console.log('');
|
|
705
|
-
console.log(
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
721
|
+
console.log(
|
|
722
|
+
createFinishBlock({
|
|
723
|
+
sessionId,
|
|
724
|
+
timestamp: endTime,
|
|
725
|
+
exitCode: 1,
|
|
726
|
+
logPath: logFilePath,
|
|
727
|
+
durationMs,
|
|
728
|
+
})
|
|
729
|
+
);
|
|
711
730
|
|
|
712
731
|
handleFailure(config, commandName, cmd, 1, logFilePath);
|
|
713
732
|
|
|
@@ -746,6 +765,7 @@ async function runDirectWithCommandStream(
|
|
|
746
765
|
|
|
747
766
|
let logContent = '';
|
|
748
767
|
const startTime = getTimestamp();
|
|
768
|
+
const startTimeMs = Date.now();
|
|
749
769
|
|
|
750
770
|
// Get runtime information
|
|
751
771
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
|
|
@@ -790,17 +810,16 @@ async function runDirectWithCommandStream(
|
|
|
790
810
|
logContent += `Working Directory: ${process.cwd()}\n`;
|
|
791
811
|
logContent += `${'='.repeat(50)}\n\n`;
|
|
792
812
|
|
|
793
|
-
// Print
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
813
|
+
// Print start block with session ID
|
|
814
|
+
const displayCmd =
|
|
815
|
+
subResult && subResult.matched ? `${parsedCmd} -> ${cmd}` : cmd;
|
|
816
|
+
console.log(
|
|
817
|
+
createStartBlock({
|
|
818
|
+
sessionId,
|
|
819
|
+
timestamp: startTime,
|
|
820
|
+
command: displayCmd,
|
|
821
|
+
})
|
|
822
|
+
);
|
|
804
823
|
console.log('[command-stream] Using command-stream library');
|
|
805
824
|
console.log('');
|
|
806
825
|
|
|
@@ -876,14 +895,18 @@ async function runDirectWithCommandStream(
|
|
|
876
895
|
}
|
|
877
896
|
}
|
|
878
897
|
|
|
879
|
-
// Print
|
|
898
|
+
// Print finish block
|
|
899
|
+
const durationMs = Date.now() - startTimeMs;
|
|
880
900
|
console.log('');
|
|
881
|
-
console.log(
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
901
|
+
console.log(
|
|
902
|
+
createFinishBlock({
|
|
903
|
+
sessionId,
|
|
904
|
+
timestamp: endTime,
|
|
905
|
+
exitCode,
|
|
906
|
+
logPath: logFilePath,
|
|
907
|
+
durationMs,
|
|
908
|
+
})
|
|
909
|
+
);
|
|
887
910
|
|
|
888
911
|
// If command failed, try to auto-report
|
|
889
912
|
if (exitCode !== 0) {
|
|
@@ -0,0 +1,357 @@
|
|
|
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.style] - Box style name
|
|
148
|
+
* @param {number} [options.width] - Box width
|
|
149
|
+
* @returns {string} Formatted start block
|
|
150
|
+
*/
|
|
151
|
+
function createStartBlock(options) {
|
|
152
|
+
const {
|
|
153
|
+
sessionId,
|
|
154
|
+
timestamp,
|
|
155
|
+
command,
|
|
156
|
+
style: styleName = DEFAULT_STYLE,
|
|
157
|
+
width = DEFAULT_WIDTH,
|
|
158
|
+
} = options;
|
|
159
|
+
|
|
160
|
+
const style = getBoxStyle(styleName);
|
|
161
|
+
const lines = [];
|
|
162
|
+
|
|
163
|
+
lines.push(createTopBorder(width, style));
|
|
164
|
+
lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
|
|
165
|
+
lines.push(
|
|
166
|
+
createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
|
|
167
|
+
);
|
|
168
|
+
lines.push(createBottomBorder(width, style));
|
|
169
|
+
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Format duration in seconds with appropriate precision
|
|
175
|
+
* @param {number} durationMs - Duration in milliseconds
|
|
176
|
+
* @returns {string} Formatted duration string
|
|
177
|
+
*/
|
|
178
|
+
function formatDuration(durationMs) {
|
|
179
|
+
const seconds = durationMs / 1000;
|
|
180
|
+
if (seconds < 0.001) {
|
|
181
|
+
return '0.001';
|
|
182
|
+
} else if (seconds < 10) {
|
|
183
|
+
// For durations under 10 seconds, show 3 decimal places
|
|
184
|
+
return seconds.toFixed(3);
|
|
185
|
+
} else if (seconds < 100) {
|
|
186
|
+
return seconds.toFixed(2);
|
|
187
|
+
} else {
|
|
188
|
+
return seconds.toFixed(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a finish block for command execution
|
|
194
|
+
* @param {object} options - Options for the block
|
|
195
|
+
* @param {string} options.sessionId - Session UUID
|
|
196
|
+
* @param {string} options.timestamp - Timestamp string
|
|
197
|
+
* @param {number} options.exitCode - Exit code
|
|
198
|
+
* @param {string} options.logPath - Path to log file
|
|
199
|
+
* @param {number} [options.durationMs] - Duration in milliseconds
|
|
200
|
+
* @param {string} [options.style] - Box style name
|
|
201
|
+
* @param {number} [options.width] - Box width
|
|
202
|
+
* @returns {string} Formatted finish block
|
|
203
|
+
*/
|
|
204
|
+
function createFinishBlock(options) {
|
|
205
|
+
const {
|
|
206
|
+
sessionId,
|
|
207
|
+
timestamp,
|
|
208
|
+
exitCode,
|
|
209
|
+
logPath,
|
|
210
|
+
durationMs,
|
|
211
|
+
style: styleName = DEFAULT_STYLE,
|
|
212
|
+
width = DEFAULT_WIDTH,
|
|
213
|
+
} = options;
|
|
214
|
+
|
|
215
|
+
const style = getBoxStyle(styleName);
|
|
216
|
+
const lines = [];
|
|
217
|
+
|
|
218
|
+
// Format the finished message with optional duration
|
|
219
|
+
let finishedMsg = `Finished at ${timestamp}`;
|
|
220
|
+
if (durationMs !== undefined && durationMs !== null) {
|
|
221
|
+
finishedMsg += ` in ${formatDuration(durationMs)} seconds`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lines.push(createTopBorder(width, style));
|
|
225
|
+
lines.push(createBorderedLine(finishedMsg, width, style));
|
|
226
|
+
lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
|
|
227
|
+
lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
|
|
228
|
+
lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
|
|
229
|
+
lines.push(createBottomBorder(width, style));
|
|
230
|
+
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Escape a value for Links notation
|
|
236
|
+
* Smart quoting: uses single or double quotes based on content
|
|
237
|
+
* @param {string} str - String to escape
|
|
238
|
+
* @returns {string} Escaped string
|
|
239
|
+
*/
|
|
240
|
+
function escapeForLinksNotation(str) {
|
|
241
|
+
if (str === null || str === undefined) {
|
|
242
|
+
return 'null';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const value = String(str);
|
|
246
|
+
|
|
247
|
+
// Check for characters that need quoting
|
|
248
|
+
const hasColon = value.includes(':');
|
|
249
|
+
const hasDoubleQuotes = value.includes('"');
|
|
250
|
+
const hasSingleQuotes = value.includes("'");
|
|
251
|
+
const hasParens = value.includes('(') || value.includes(')');
|
|
252
|
+
const hasNewline = value.includes('\n');
|
|
253
|
+
const hasSpace = value.includes(' ');
|
|
254
|
+
|
|
255
|
+
const needsQuoting =
|
|
256
|
+
hasColon ||
|
|
257
|
+
hasDoubleQuotes ||
|
|
258
|
+
hasSingleQuotes ||
|
|
259
|
+
hasParens ||
|
|
260
|
+
hasNewline ||
|
|
261
|
+
hasSpace;
|
|
262
|
+
|
|
263
|
+
if (!needsQuoting) {
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (hasDoubleQuotes && !hasSingleQuotes) {
|
|
268
|
+
// Has " but not ' → use single quotes
|
|
269
|
+
return `'${value}'`;
|
|
270
|
+
} else if (hasSingleQuotes && !hasDoubleQuotes) {
|
|
271
|
+
// Has ' but not " → use double quotes
|
|
272
|
+
return `"${value}"`;
|
|
273
|
+
} else if (hasDoubleQuotes && hasSingleQuotes) {
|
|
274
|
+
// Has both " and ' → choose wrapper with fewer escapes
|
|
275
|
+
const doubleQuoteCount = (value.match(/"/g) || []).length;
|
|
276
|
+
const singleQuoteCount = (value.match(/'/g) || []).length;
|
|
277
|
+
|
|
278
|
+
if (singleQuoteCount <= doubleQuoteCount) {
|
|
279
|
+
// Escape single quotes by doubling them
|
|
280
|
+
const escaped = value.replace(/'/g, "''");
|
|
281
|
+
return `'${escaped}'`;
|
|
282
|
+
} else {
|
|
283
|
+
// Escape double quotes by doubling them
|
|
284
|
+
const escaped = value.replace(/"/g, '""');
|
|
285
|
+
return `"${escaped}"`;
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
// Has colon, parentheses, newlines, or spaces but no quotes
|
|
289
|
+
return `"${value}"`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Format an object as nested Links notation
|
|
295
|
+
* @param {object} obj - Object to format
|
|
296
|
+
* @param {number} [indent=2] - Indentation level (spaces)
|
|
297
|
+
* @param {number} [depth=0] - Current depth
|
|
298
|
+
* @returns {string} Links notation formatted string
|
|
299
|
+
*/
|
|
300
|
+
function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
|
|
301
|
+
if (obj === null || obj === undefined) {
|
|
302
|
+
return 'null';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (typeof obj !== 'object') {
|
|
306
|
+
return escapeForLinksNotation(obj);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (Array.isArray(obj)) {
|
|
310
|
+
// Format arrays
|
|
311
|
+
if (obj.length === 0) {
|
|
312
|
+
return '()';
|
|
313
|
+
}
|
|
314
|
+
const indentStr = ' '.repeat(indent * (depth + 1));
|
|
315
|
+
const items = obj.map((item) => {
|
|
316
|
+
const formatted = formatAsNestedLinksNotation(item, indent, depth + 1);
|
|
317
|
+
return `${indentStr}${formatted}`;
|
|
318
|
+
});
|
|
319
|
+
return `(\n${items.join('\n')}\n${' '.repeat(indent * depth)})`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Format objects
|
|
323
|
+
const entries = Object.entries(obj);
|
|
324
|
+
if (entries.length === 0) {
|
|
325
|
+
return '()';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const indentStr = ' '.repeat(indent * (depth + 1));
|
|
329
|
+
const lines = entries
|
|
330
|
+
.filter(([, value]) => value !== null && value !== undefined)
|
|
331
|
+
.map(([key, value]) => {
|
|
332
|
+
if (typeof value === 'object') {
|
|
333
|
+
const nested = formatAsNestedLinksNotation(value, indent, depth + 1);
|
|
334
|
+
return `${indentStr}${key}\n${nested}`;
|
|
335
|
+
}
|
|
336
|
+
const formattedValue = escapeForLinksNotation(value);
|
|
337
|
+
return `${indentStr}${key} ${formattedValue}`;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return lines.join('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
BOX_STYLES,
|
|
345
|
+
DEFAULT_STYLE,
|
|
346
|
+
DEFAULT_WIDTH,
|
|
347
|
+
getBoxStyle,
|
|
348
|
+
createHorizontalLine,
|
|
349
|
+
createBorderedLine,
|
|
350
|
+
createTopBorder,
|
|
351
|
+
createBottomBorder,
|
|
352
|
+
createStartBlock,
|
|
353
|
+
createFinishBlock,
|
|
354
|
+
formatDuration,
|
|
355
|
+
escapeForLinksNotation,
|
|
356
|
+
formatAsNestedLinksNotation,
|
|
357
|
+
};
|
|
@@ -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');
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
|
|
82
|
+
describe('createFinishBlock', () => {
|
|
83
|
+
it('should create a finish block with session ID and exit code', () => {
|
|
84
|
+
const block = createFinishBlock({
|
|
85
|
+
sessionId: 'test-uuid-1234',
|
|
86
|
+
timestamp: '2025-01-01 00:00:01',
|
|
87
|
+
exitCode: 0,
|
|
88
|
+
logPath: '/tmp/test.log',
|
|
89
|
+
durationMs: 17,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(block).toContain('╭');
|
|
93
|
+
expect(block).toContain('╰');
|
|
94
|
+
expect(block).toContain('Session ID: test-uuid-1234');
|
|
95
|
+
expect(block).toContain(
|
|
96
|
+
'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
|
|
97
|
+
);
|
|
98
|
+
expect(block).toContain('Exit code: 0');
|
|
99
|
+
expect(block).toContain('Log: /tmp/test.log');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should create a finish block without duration when not provided', () => {
|
|
103
|
+
const block = createFinishBlock({
|
|
104
|
+
sessionId: 'test-uuid-1234',
|
|
105
|
+
timestamp: '2025-01-01 00:00:01',
|
|
106
|
+
exitCode: 0,
|
|
107
|
+
logPath: '/tmp/test.log',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(block).toContain('Finished at 2025-01-01 00:00:01');
|
|
111
|
+
expect(block).not.toContain('seconds');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('formatDuration', () => {
|
|
116
|
+
it('should format very small durations', () => {
|
|
117
|
+
expect(formatDuration(0.5)).toBe('0.001');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should format millisecond durations', () => {
|
|
121
|
+
expect(formatDuration(17)).toBe('0.017');
|
|
122
|
+
expect(formatDuration(500)).toBe('0.500');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should format second durations', () => {
|
|
126
|
+
expect(formatDuration(1000)).toBe('1.000');
|
|
127
|
+
expect(formatDuration(5678)).toBe('5.678');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should format longer durations with less precision', () => {
|
|
131
|
+
expect(formatDuration(12345)).toBe('12.35');
|
|
132
|
+
expect(formatDuration(123456)).toBe('123.5');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('escapeForLinksNotation', () => {
|
|
137
|
+
it('should not quote simple values', () => {
|
|
138
|
+
expect(escapeForLinksNotation('simple')).toBe('simple');
|
|
139
|
+
expect(escapeForLinksNotation('123')).toBe('123');
|
|
140
|
+
expect(escapeForLinksNotation('true')).toBe('true');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should quote values with spaces', () => {
|
|
144
|
+
expect(escapeForLinksNotation('hello world')).toBe('"hello world"');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should quote values with colons', () => {
|
|
148
|
+
expect(escapeForLinksNotation('key:value')).toBe('"key:value"');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should use single quotes for values with double quotes', () => {
|
|
152
|
+
expect(escapeForLinksNotation('say "hello"')).toBe('\'say "hello"\'');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should use double quotes for values with single quotes', () => {
|
|
156
|
+
expect(escapeForLinksNotation("it's cool")).toBe('"it\'s cool"');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should escape quotes when both types are present', () => {
|
|
160
|
+
const result = escapeForLinksNotation('say "hello" it\'s');
|
|
161
|
+
// Should wrap in one quote type and escape the other
|
|
162
|
+
expect(result).toMatch(/^["'].*["']$/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle null values', () => {
|
|
166
|
+
expect(escapeForLinksNotation(null)).toBe('null');
|
|
167
|
+
expect(escapeForLinksNotation(undefined)).toBe('null');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('formatAsNestedLinksNotation', () => {
|
|
172
|
+
it('should format simple objects', () => {
|
|
173
|
+
const obj = { key: 'value', number: 123 };
|
|
174
|
+
const result = formatAsNestedLinksNotation(obj);
|
|
175
|
+
|
|
176
|
+
expect(result).toContain('key value');
|
|
177
|
+
expect(result).toContain('number 123');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should quote values with spaces', () => {
|
|
181
|
+
const obj = { message: 'hello world' };
|
|
182
|
+
const result = formatAsNestedLinksNotation(obj);
|
|
183
|
+
|
|
184
|
+
expect(result).toContain('message "hello world"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should handle empty objects', () => {
|
|
188
|
+
expect(formatAsNestedLinksNotation({})).toBe('()');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle null', () => {
|
|
192
|
+
expect(formatAsNestedLinksNotation(null)).toBe('null');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
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
|
|