66
77import { createServer } from 'node:http' ;
88import { readFile } from 'node:fs/promises' ;
9- import { statSync , writeFileSync , unlinkSync } from 'node:fs' ;
9+ import { appendFileSync , statSync , writeFileSync , unlinkSync } from 'node:fs' ;
1010import { spawn , execSync } from 'node:child_process' ;
1111import { join , extname , resolve , sep } from 'node:path' ;
1212import { tmpdir } from 'node:os' ;
@@ -113,6 +113,51 @@ let lastHeartbeat = Date.now();
113113const HEARTBEAT_CHECK_MS = 5000 ;
114114const HEARTBEAT_STALE_MS = 15000 ;
115115
116+ // ── GUI Logger ──────────────────────────────────────────────────────
117+ // Writes to nightytidy-gui.log in the project directory once selected.
118+ // Before selection, entries buffer in memory and flush on setGuiLogDir().
119+ const GUI_LOG_FILE = 'nightytidy-gui.log' ;
120+ let guiLogFilePath = null ;
121+ const guiLogBuffer = [ ] ;
122+ const MAX_BUFFER = 500 ;
123+
124+ function guiLog ( level , message ) {
125+ const timestamp = new Date ( ) . toISOString ( ) ;
126+ const tag = level . toUpperCase ( ) . padEnd ( 5 ) ;
127+ const line = `[${ timestamp } ] [${ tag } ] ${ message } \n` ;
128+
129+ if ( guiLogFilePath ) {
130+ try {
131+ appendFileSync ( guiLogFilePath , line , 'utf8' ) ;
132+ } catch {
133+ if ( guiLogBuffer . length < MAX_BUFFER ) guiLogBuffer . push ( line ) ;
134+ }
135+ } else {
136+ if ( guiLogBuffer . length < MAX_BUFFER ) guiLogBuffer . push ( line ) ;
137+ }
138+ }
139+
140+ function setGuiLogDir ( projectDir ) {
141+ guiLogFilePath = join ( projectDir , GUI_LOG_FILE ) ;
142+ try {
143+ writeFileSync ( guiLogFilePath , '' , 'utf8' ) ;
144+ } catch ( err ) {
145+ console . error ( `Failed to create GUI log file: ${ err . message } ` ) ;
146+ return ;
147+ }
148+ flushGuiLogBuffer ( ) ;
149+ }
150+
151+ function flushGuiLogBuffer ( ) {
152+ if ( ! guiLogFilePath || guiLogBuffer . length === 0 ) return ;
153+ try {
154+ appendFileSync ( guiLogFilePath , guiLogBuffer . join ( '' ) , 'utf8' ) ;
155+ guiLogBuffer . length = 0 ;
156+ } catch {
157+ // Silently fail — don't crash the server over logging
158+ }
159+ }
160+
116161function killProcess ( proc ) {
117162 if ( process . platform === 'win32' ) {
118163 execSync ( `taskkill /pid ${ proc . pid } /T /F` , { windowsHide : true } ) ;
@@ -138,6 +183,7 @@ async function serveStatic(res, urlPath) {
138183 // confusion (e.g. "resources-extra" matching "resources")
139184 const boundary = RESOURCES_DIR . endsWith ( sep ) ? RESOURCES_DIR : RESOURCES_DIR + sep ;
140185 if ( ! filePath . startsWith ( boundary ) && filePath !== RESOURCES_DIR ) {
186+ guiLog ( 'warn' , `Blocked path traversal attempt: ${ urlPath } ` ) ;
141187 res . writeHead ( 403 , { 'Content-Type' : 'text/plain' , ...SECURITY_HEADERS } ) ;
142188 res . end ( 'Forbidden' ) ;
143189 return ;
@@ -211,10 +257,17 @@ async function handleSelectFolder(res) {
211257 }
212258 }
213259
260+ if ( folder ) {
261+ guiLog ( 'info' , `Folder selected: ${ folder } ` ) ;
262+ setGuiLogDir ( folder ) ;
263+ } else {
264+ guiLog ( 'info' , 'Folder dialog closed without selection' ) ;
265+ }
214266 sendJson ( res , { ok : true , folder } ) ;
215267 } catch ( err ) {
216268 // User cancelled or dialog failed — still refresh heartbeat after blocking execSync
217269 lastHeartbeat = Date . now ( ) ;
270+ guiLog ( 'info' , 'Folder dialog cancelled or failed' ) ;
218271 sendJson ( res , { ok : true , folder : null } ) ;
219272 }
220273}
@@ -231,6 +284,7 @@ async function handleRunCommand(req, res) {
231284 }
232285
233286 try {
287+ guiLog ( 'info' , `Spawning process id=${ id || 'none' } ` ) ;
234288 const proc = spawn ( command , [ ] , {
235289 stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
236290 windowsHide : true ,
@@ -248,14 +302,17 @@ async function handleRunCommand(req, res) {
248302
249303 proc . on ( 'close' , ( exitCode ) => {
250304 if ( id ) activeProcesses . delete ( id ) ;
305+ guiLog ( 'info' , `Process ${ id || 'unknown' } exited code=${ exitCode ?? 1 } ` ) ;
251306 sendJson ( res , { ok : true , exitCode : exitCode ?? 1 , stdout, stderr } ) ;
252307 } ) ;
253308
254309 proc . on ( 'error' , ( err ) => {
255310 if ( id ) activeProcesses . delete ( id ) ;
311+ guiLog ( 'error' , `Process ${ id || 'unknown' } error: ${ err . message } ` ) ;
256312 sendJson ( res , { ok : false , error : err . message } ) ;
257313 } ) ;
258314 } catch ( err ) {
315+ guiLog ( 'error' , `Spawn failed: ${ err . message } ` ) ;
259316 sendJson ( res , { ok : false , error : err . message } ) ;
260317 }
261318}
@@ -276,8 +333,10 @@ async function handleKillProcess(req, res) {
276333 try {
277334 killProcess ( proc ) ;
278335 activeProcesses . delete ( id ) ;
336+ guiLog ( 'info' , `Killed process ${ id } ` ) ;
279337 sendJson ( res , { ok : true } ) ;
280338 } catch ( err ) {
339+ guiLog ( 'error' , `Failed to kill process ${ id } : ${ err . message } ` ) ;
281340 sendJson ( res , { ok : false , error : err . message } ) ;
282341 }
283342 } else {
@@ -338,9 +397,32 @@ function handleHeartbeat(res) {
338397 sendJson ( res , { ok : true } ) ;
339398}
340399
400+ // ── API: Log Error (from frontend) ─────────────────────────────────
401+
402+ async function handleLogError ( req , res ) {
403+ const body = await readBody ( req ) ;
404+ const { level, message } = body ;
405+
406+ if ( ! message ) {
407+ sendJson ( res , { ok : false , error : 'No message provided' } , 400 ) ;
408+ return ;
409+ }
410+
411+ const safeLevel = [ 'error' , 'warn' , 'info' ] . includes ( level ) ? level : 'error' ;
412+ guiLog ( safeLevel , `[frontend] ${ message } ` ) ;
413+ sendJson ( res , { ok : true } ) ;
414+ }
415+
416+ // ── API: Log Path ──────────────────────────────────────────────────
417+
418+ function handleLogPath ( res ) {
419+ sendJson ( res , { ok : true , path : guiLogFilePath } ) ;
420+ }
421+
341422// ── API: Shutdown ──────────────────────────────────────────────────
342423
343424function handleExit ( res ) {
425+ guiLog ( 'info' , 'Exit requested by frontend' ) ;
344426 sendJson ( res , { ok : true } ) ;
345427 killAllProcesses ( ) ;
346428 setTimeout ( ( ) => process . exit ( 0 ) , 200 ) ;
@@ -384,6 +466,11 @@ function sendJson(res, data, status = 200) {
384466function handleRequest ( req , res ) {
385467 const url = new URL ( req . url , `http://localhost` ) ;
386468
469+ // Log API requests (skip heartbeat — too noisy)
470+ if ( url . pathname . startsWith ( '/api/' ) && url . pathname !== '/api/heartbeat' ) {
471+ guiLog ( 'debug' , `${ req . method } ${ url . pathname } ` ) ;
472+ }
473+
387474 // API routes
388475 if ( url . pathname === '/api/config' && req . method === 'POST' ) {
389476 return handleConfig ( res ) ;
@@ -406,6 +493,12 @@ function handleRequest(req, res) {
406493 if ( url . pathname === '/api/heartbeat' && req . method === 'POST' ) {
407494 return handleHeartbeat ( res ) ;
408495 }
496+ if ( url . pathname === '/api/log-error' && req . method === 'POST' ) {
497+ return handleLogError ( req , res ) ;
498+ }
499+ if ( url . pathname === '/api/log-path' && req . method === 'POST' ) {
500+ return handleLogPath ( res ) ;
501+ }
409502 if ( url . pathname === '/api/exit' && req . method === 'POST' ) {
410503 return handleExit ( res ) ;
411504 }
@@ -494,13 +587,15 @@ server.headersTimeout = 15_000; // 15s — max time to receive headers
494587server . listen ( 0 , '127.0.0.1' , ( ) => {
495588 const { port } = server . address ( ) ;
496589 const url = `http://127.0.0.1:${ port } ` ;
590+ guiLog ( 'info' , `GUI server started on ${ url } ` ) ;
497591 console . log ( `NightyTidy GUI server running on ${ url } ` ) ;
498592 launchChrome ( url ) ;
499593
500594 // Watchdog: self-terminate if no heartbeat from the browser for 15s.
501595 // Catches cases where Chrome crashes or is force-killed (unload never fires).
502596 const watchdog = setInterval ( ( ) => {
503597 if ( Date . now ( ) - lastHeartbeat > HEARTBEAT_STALE_MS ) {
598+ guiLog ( 'warn' , 'No heartbeat from browser — shutting down' ) ;
504599 console . log ( 'No heartbeat from browser — shutting down.' ) ;
505600 clearInterval ( watchdog ) ;
506601 cleanup ( ) ;
@@ -517,12 +612,23 @@ server.listen(0, '127.0.0.1', () => {
517612const SHUTDOWN_FORCE_EXIT_MS = 5000 ;
518613
519614function shutdownHandler ( ) {
615+ guiLog ( 'info' , 'Shutdown signal received' ) ;
520616 cleanup ( ) ;
521617 const forceTimer = setTimeout ( ( ) => process . exit ( 1 ) , SHUTDOWN_FORCE_EXIT_MS ) ;
522618 forceTimer . unref ( ) ;
523619 process . exit ( 0 ) ;
524620}
525621
622+ process . on ( 'uncaughtException' , ( err ) => {
623+ guiLog ( 'error' , `Uncaught exception: ${ err . stack || err . message } ` ) ;
624+ process . exit ( 1 ) ;
625+ } ) ;
626+
627+ process . on ( 'unhandledRejection' , ( reason ) => {
628+ const msg = reason instanceof Error ? reason . stack : String ( reason ) ;
629+ guiLog ( 'error' , `Unhandled rejection: ${ msg } ` ) ;
630+ } ) ;
631+
526632process . on ( 'SIGINT' , shutdownHandler ) ;
527633process . on ( 'SIGTERM' , shutdownHandler ) ;
528634process . on ( 'SIGHUP' , shutdownHandler ) ;
0 commit comments