33 * Standardized across all socket-* repositories.
44 */
55
6- import { spawn } from 'node:child_process'
7- import readline from 'node:readline'
8-
9- import { spinner } from './spinner.mjs'
10-
11- // Will import from registry once built:
12- // import { attachOutputMask, clearLine, writeOutput } from '@socketsecurity/lib/stdio/mask'
6+ import { runWithMask } from '@socketsecurity/lib/stdio/mask'
137
148/**
159 * Run a command with interactive output control.
@@ -29,208 +23,16 @@ export async function runWithOutput(command, args = [], options = {}) {
2923 cwd = process . cwd ( ) ,
3024 env = process . env ,
3125 message = 'Running' ,
32- showOnError = true ,
3326 toggleText = 'to see output' ,
3427 verbose = false ,
3528 } = options
3629
37- return new Promise ( ( resolve , reject ) => {
38- let isSpinning = false
39- let outputBuffer = [ ]
40- let showOutput = verbose
41- let hasTestFailures = false
42- let hasWorkerTerminationError = false
43-
44- // Start spinner if not verbose and TTY
45- if ( ! showOutput && process . stdout . isTTY ) {
46- spinner . start ( `${ message } (ctrl+o ${ toggleText } )` )
47- isSpinning = true
48- }
49-
50- const child = spawn ( command , args , {
51- cwd,
52- env,
53- stdio : [ 'inherit' , 'pipe' , 'pipe' ] ,
54- } )
55-
56- // Setup keyboard handling for TTY
57- if ( process . stdin . isTTY && ! verbose ) {
58- readline . emitKeypressEvents ( process . stdin )
59- process . stdin . setRawMode ( true )
60-
61- const keypressHandler = ( _str , key ) => {
62- // ctrl+o toggles output
63- if ( key ?. ctrl && key . name === 'o' ) {
64- showOutput = ! showOutput
65-
66- if ( showOutput ) {
67- // Stop spinner and show buffered output
68- if ( isSpinning ) {
69- spinner . stop ( )
70- isSpinning = false
71- }
72-
73- // Clear line and show buffer
74- process . stdout . write ( '\r\x1b[K' )
75- if ( outputBuffer . length > 0 ) {
76- console . log ( '--- Showing output ---' )
77- outputBuffer . forEach ( line => {
78- process . stdout . write ( line )
79- } )
80- outputBuffer = [ ]
81- }
82- } else {
83- // Hide output and restart spinner
84- process . stdout . write ( '\r\x1b[K' )
85- if ( ! isSpinning ) {
86- spinner . start ( `${ message } (ctrl+o ${ toggleText } )` )
87- isSpinning = true
88- }
89- }
90- }
91- // ctrl+c to cancel
92- else if ( key ?. ctrl && key . name === 'c' ) {
93- child . kill ( 'SIGTERM' )
94- if ( process . stdin . isTTY ) {
95- process . stdin . setRawMode ( false )
96- }
97- process . exit ( 130 )
98- }
99- }
100-
101- process . stdin . on ( 'keypress' , keypressHandler )
102-
103- // Cleanup on exit
104- child . on ( 'exit' , ( ) => {
105- if ( process . stdin . isTTY ) {
106- process . stdin . setRawMode ( false )
107- process . stdin . removeListener ( 'keypress' , keypressHandler )
108- }
109- } )
110- }
111-
112- // Handle stdout
113- if ( child . stdout ) {
114- child . stdout . on ( 'data' , data => {
115- const text = data . toString ( )
116-
117- // Filter out known non-fatal warnings (can appear in stdout too)
118- const isFilteredWarning =
119- text . includes ( 'Terminating worker thread' ) ||
120- text . includes ( 'Unhandled Rejection' ) ||
121- text . includes ( 'Object.ThreadTermination' ) ||
122- text . includes ( 'tinypool@' )
123-
124- if ( isFilteredWarning ) {
125- hasWorkerTerminationError = true
126- // Skip these warnings - they're non-fatal cleanup messages
127- // But continue to check for test failures in the same output
128- }
129-
130- // Check for test failures in vitest output
131- if (
132- text . includes ( 'FAIL' ) ||
133- text . match ( / T e s t F i l e s .* \d + f a i l e d / ) ||
134- text . match ( / T e s t s \s + \d + f a i l e d / )
135- ) {
136- hasTestFailures = true
137- }
138-
139- // Don't write filtered warnings to output
140- if ( isFilteredWarning ) {
141- return
142- }
143-
144- if ( showOutput ) {
145- process . stdout . write ( text )
146- } else {
147- outputBuffer . push ( text )
148- // Keep buffer reasonable (last 1000 lines)
149- const lines = outputBuffer . join ( '' ) . split ( '\n' )
150- if ( lines . length > 1000 ) {
151- outputBuffer = [ lines . slice ( - 1000 ) . join ( '\n' ) ]
152- }
153- }
154- } )
155- }
156-
157- // Handle stderr
158- if ( child . stderr ) {
159- child . stderr . on ( 'data' , data => {
160- const text = data . toString ( )
161- // Filter out known non-fatal warnings
162- const isFilteredWarning =
163- text . includes ( 'Terminating worker thread' ) ||
164- text . includes ( 'Unhandled Rejection' ) ||
165- text . includes ( 'Object.ThreadTermination' ) ||
166- text . includes ( 'tinypool@' )
167-
168- if ( isFilteredWarning ) {
169- hasWorkerTerminationError = true
170- // Skip these warnings - they're non-fatal cleanup messages
171- return
172- }
173-
174- // Check for test failures
175- if (
176- text . includes ( 'FAIL' ) ||
177- text . match ( / T e s t F i l e s .* \d + f a i l e d / ) ||
178- text . match ( / T e s t s \s + \d + f a i l e d / )
179- ) {
180- hasTestFailures = true
181- }
182-
183- if ( showOutput ) {
184- process . stderr . write ( text )
185- } else {
186- outputBuffer . push ( text )
187- }
188- } )
189- }
190-
191- child . on ( 'exit' , code => {
192- // Cleanup keyboard if needed
193- if ( process . stdin . isTTY && ! verbose ) {
194- process . stdin . setRawMode ( false )
195- }
196-
197- // Override exit code if we only have worker termination errors
198- // and no actual test failures
199- let finalCode = code || 0
200- if ( code !== 0 && hasWorkerTerminationError && ! hasTestFailures ) {
201- // This is the known non-fatal worker thread cleanup issue
202- // All tests passed, so return success
203- finalCode = 0
204- }
205-
206- if ( isSpinning ) {
207- if ( finalCode === 0 ) {
208- spinner . success ( `${ message } completed` )
209- } else {
210- spinner . fail ( `${ message } failed` )
211- // Show output on error if configured
212- if ( showOnError && outputBuffer . length > 0 ) {
213- console . log ( '\n--- Output ---' )
214- outputBuffer . forEach ( line => {
215- process . stdout . write ( line )
216- } )
217- }
218- }
219- }
220-
221- resolve ( finalCode )
222- } )
223-
224- child . on ( 'error' , error => {
225- if ( process . stdin . isTTY && ! verbose ) {
226- process . stdin . setRawMode ( false )
227- }
228-
229- if ( isSpinning ) {
230- spinner . fail ( `${ message } error: ${ error . message } ` )
231- }
232- reject ( error )
233- } )
30+ return runWithMask ( command , args , {
31+ cwd,
32+ env,
33+ message,
34+ showOutput : verbose ,
35+ toggleText,
23436 } )
23537}
23638
0 commit comments