77 writeFileSync ,
88} from 'node:fs' ;
99import { homedir } from 'node:os' ;
10- import { join } from 'node:path' ;
10+ import { basename , join } from 'node:path' ;
11+ import { getServerRegistry } from '../tdd/server-registry.js' ;
1112import * as output from '../utils/output.js' ;
1213import { tddCommand } from './tdd.js' ;
1314
@@ -23,37 +24,78 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
2324 color : ! globalOptions . noColor ,
2425 } ) ;
2526
26- // Check if server already running
27- if ( await isServerRunning ( options . port || 47392 ) ) {
28- const port = options . port || 47392 ;
29- let colors = output . getColors ( ) ;
27+ let registry = getServerRegistry ( ) ;
28+ let colors = output . getColors ( ) ;
3029
31- output . header ( 'tdd' , 'local' ) ;
32- output . print ( ` ${ output . statusDot ( 'success' ) } Already running` ) ;
33- output . blank ( ) ;
34- output . printBox (
35- colors . brand . info ( colors . underline ( `http://localhost:${ port } ` ) ) ,
36- {
37- title : 'Dashboard' ,
38- style : 'branded' ,
30+ // Check if THIS directory already has a server running
31+ let existingServer = registry . find ( { directory : process . cwd ( ) } ) ;
32+ if ( existingServer ) {
33+ // Verify it's actually running
34+ if ( await isServerRunning ( existingServer . port ) ) {
35+ output . header ( 'tdd' , 'local' ) ;
36+ output . print ( ` ${ output . statusDot ( 'success' ) } Already running` ) ;
37+ output . blank ( ) ;
38+ output . printBox (
39+ colors . brand . info (
40+ colors . underline ( `http://localhost:${ existingServer . port } ` )
41+ ) ,
42+ {
43+ title : 'Dashboard' ,
44+ style : 'branded' ,
45+ }
46+ ) ;
47+
48+ if ( options . open ) {
49+ openDashboard ( existingServer . port ) ;
3950 }
40- ) ;
51+ return ;
52+ } else {
53+ // Stale entry - clean it up (registry and local files)
54+ registry . unregister ( { directory : process . cwd ( ) } ) ;
4155
42- if ( options . open ) {
43- openDashboard ( port ) ;
56+ let vizzlyDir = join ( process . cwd ( ) , '.vizzly' ) ;
57+ let pidFile = join ( vizzlyDir , 'server.pid' ) ;
58+ let serverFile = join ( vizzlyDir , 'server.json' ) ;
59+ if ( existsSync ( pidFile ) ) unlinkSync ( pidFile ) ;
60+ if ( existsSync ( serverFile ) ) unlinkSync ( serverFile ) ;
4461 }
45- return ;
62+ }
63+
64+ // Determine port: user-specified or auto-allocate
65+ let port ;
66+ let autoAllocated = false ;
67+
68+ if ( options . port ) {
69+ // User specified a port - use it (will fail if busy)
70+ port = options . port ;
71+
72+ // Check if user-specified port is in use
73+ if ( await isServerRunning ( port ) ) {
74+ output . header ( 'tdd' , 'local' ) ;
75+ output . print (
76+ ` ${ output . statusDot ( 'error' ) } Port ${ port } is already in use`
77+ ) ;
78+ output . blank ( ) ;
79+ output . hint ( 'Try a different port: vizzly tdd start --port 47393' ) ;
80+ output . hint ( 'Or let Vizzly auto-allocate: vizzly tdd start' ) ;
81+ return ;
82+ }
83+ } else {
84+ // Auto-allocate an available port
85+ // Note: There's a small race window between finding a port and binding.
86+ // The registry acts as a soft reservation, and findAvailablePort does
87+ // an actual TCP bind test to minimize this window.
88+ port = await registry . findAvailablePort ( ) ;
89+ autoAllocated = port !== 47392 ;
4690 }
4791
4892 try {
4993 // Ensure .vizzly directory exists
50- const vizzlyDir = join ( process . cwd ( ) , '.vizzly' ) ;
94+ let vizzlyDir = join ( process . cwd ( ) , '.vizzly' ) ;
5195 if ( ! existsSync ( vizzlyDir ) ) {
5296 mkdirSync ( vizzlyDir , { recursive : true } ) ;
5397 }
5498
55- const port = options . port || 47392 ;
56-
5799 // Show header first so debug messages appear below it
58100 output . header ( 'tdd' , 'local' ) ;
59101
@@ -163,7 +205,28 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
163205 process . exit ( 1 ) ;
164206 }
165207
166- // Write server info to global location for SDK discovery (iOS/Swift can read this)
208+ // Register server in global registry (for menubar app)
209+ try {
210+ let registry = getServerRegistry ( ) ;
211+
212+ // Clean up any stale servers first
213+ registry . cleanupStale ( ) ;
214+
215+ // Register this server with log file path for menubar to read
216+ let serverLogFile = join ( process . cwd ( ) , '.vizzly' , 'server.log' ) ;
217+ registry . register ( {
218+ pid : child . pid ,
219+ port : port ,
220+ directory : process . cwd ( ) ,
221+ name : basename ( process . cwd ( ) ) ,
222+ startedAt : new Date ( ) . toISOString ( ) ,
223+ logFile : serverLogFile ,
224+ } ) ;
225+ } catch {
226+ // Non-fatal
227+ }
228+
229+ // Also write legacy server.json for SDK discovery (backwards compatibility)
167230 try {
168231 const globalVizzlyDir = join ( homedir ( ) , '.vizzly' ) ;
169232 if ( ! existsSync ( globalVizzlyDir ) ) {
@@ -180,8 +243,13 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
180243 // Non-fatal, SDK can still use health check
181244 }
182245
183- // Get colors for styled output
184- let colors = output . getColors ( ) ;
246+ // Show auto-allocated port message if applicable
247+ if ( autoAllocated ) {
248+ output . print (
249+ ` ${ output . statusDot ( 'info' ) } Auto-assigned port ${ colors . brand . textTertiary ( `:${ port } ` ) } `
250+ ) ;
251+ output . blank ( ) ;
252+ }
185253
186254 // Show dashboard URL in a branded box
187255 let dashboardUrl = `http://localhost:${ port } ` ;
@@ -227,6 +295,17 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
227295 const vizzlyDir = join ( process . cwd ( ) , '.vizzly' ) ;
228296 const port = options . port || 47392 ;
229297
298+ // Set up log file for menubar app to read
299+ const logFile = join ( vizzlyDir , 'server.log' ) ;
300+
301+ // Configure output to write JSON logs to file (before tddCommand configures it)
302+ output . configure ( {
303+ logFile,
304+ json : globalOptions . json ,
305+ verbose : globalOptions . verbose ,
306+ color : ! globalOptions . noColor ,
307+ } ) ;
308+
230309 try {
231310 // Use existing tddCommand but with daemon mode
232311 const { cleanup } = await tddCommand (
@@ -252,6 +331,7 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
252331 port : port ,
253332 startTime : Date . now ( ) ,
254333 failOnDiff : options . failOnDiff || false ,
334+ logFile : logFile ,
255335 } ;
256336 writeFileSync (
257337 join ( vizzlyDir , 'server.json' ) ,
@@ -266,7 +346,15 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
266346 const serverFile = join ( vizzlyDir , 'server.json' ) ;
267347 if ( existsSync ( serverFile ) ) unlinkSync ( serverFile ) ;
268348
269- // Clean up global server file
349+ // Unregister from global registry (for menubar app)
350+ try {
351+ let registry = getServerRegistry ( ) ;
352+ registry . unregister ( { port : port , directory : process . cwd ( ) } ) ;
353+ } catch {
354+ // Non-fatal
355+ }
356+
357+ // Clean up legacy global server file
270358 try {
271359 const globalServerFile = join ( homedir ( ) , '.vizzly' , 'server.json' ) ;
272360 if ( existsSync ( globalServerFile ) ) unlinkSync ( globalServerFile ) ;
@@ -389,12 +477,30 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
389477 // Clean up files
390478 if ( existsSync ( pidFile ) ) unlinkSync ( pidFile ) ;
391479 if ( existsSync ( serverFile ) ) unlinkSync ( serverFile ) ;
480+
481+ // Unregister from global registry (for menubar app)
482+ try {
483+ let registry = getServerRegistry ( ) ;
484+ registry . unregister ( { port : port , directory : process . cwd ( ) } ) ;
485+ } catch {
486+ // Non-fatal
487+ }
488+
489+ output . print ( ` ${ output . statusDot ( 'success' ) } Server stopped` ) ;
392490 } catch ( error ) {
393491 if ( error . code === 'ESRCH' ) {
394492 // Process not found - clean up stale files
395493 output . warn ( 'TDD server was not running (cleaning up stale files)' ) ;
396494 if ( existsSync ( pidFile ) ) unlinkSync ( pidFile ) ;
397495 if ( existsSync ( serverFile ) ) unlinkSync ( serverFile ) ;
496+
497+ // Still unregister from registry
498+ try {
499+ let registry = getServerRegistry ( ) ;
500+ registry . unregister ( { port : port , directory : process . cwd ( ) } ) ;
501+ } catch {
502+ // Non-fatal
503+ }
398504 } else {
399505 output . error ( 'Error stopping TDD server' , error ) ;
400506 }
@@ -542,3 +648,79 @@ function openDashboard(port = 47392) {
542648 stdio : 'ignore' ,
543649 } ) . unref ( ) ;
544650}
651+
652+ /**
653+ * List all running TDD servers from the global registry
654+ * @param {Object } options - Command options
655+ * @param {Object } globalOptions - Global CLI options
656+ */
657+ export async function tddListCommand ( _options , globalOptions = { } ) {
658+ output . configure ( {
659+ json : globalOptions . json ,
660+ verbose : globalOptions . verbose ,
661+ color : ! globalOptions . noColor ,
662+ } ) ;
663+
664+ let registry = getServerRegistry ( ) ;
665+
666+ // Clean up stale servers first
667+ let cleaned = registry . cleanupStale ( ) ;
668+ if ( cleaned > 0 && globalOptions . verbose ) {
669+ output . debug ( 'tdd' , `Cleaned up ${ cleaned } stale server(s)` ) ;
670+ }
671+
672+ let servers = registry . list ( ) ;
673+
674+ // JSON output
675+ if ( globalOptions . json ) {
676+ console . log ( JSON . stringify ( { servers } , null , 2 ) ) ;
677+ return ;
678+ }
679+
680+ // No servers
681+ if ( servers . length === 0 ) {
682+ output . info ( 'No TDD servers running' ) ;
683+ output . hint ( 'Start one with: vizzly tdd start' ) ;
684+ return ;
685+ }
686+
687+ // Table output
688+ let colors = output . getColors ( ) ;
689+
690+ output . header ( 'tdd' , 'servers' ) ;
691+ output . blank ( ) ;
692+
693+ for ( let server of servers ) {
694+ let uptimeStr = '' ;
695+ if ( server . startedAt ) {
696+ let startTime = new Date ( server . startedAt ) . getTime ( ) ;
697+ let uptime = Math . floor ( ( Date . now ( ) - startTime ) / 1000 ) ;
698+ let hours = Math . floor ( uptime / 3600 ) ;
699+ let minutes = Math . floor ( ( uptime % 3600 ) / 60 ) ;
700+ if ( hours > 0 ) uptimeStr += `${ hours } h ` ;
701+ if ( minutes > 0 || hours > 0 ) uptimeStr += `${ minutes } m` ;
702+ else uptimeStr = '<1m' ;
703+ }
704+
705+ let name = server . name || basename ( server . directory ) ;
706+ let portStr = colors . brand . textTertiary ( `:${ server . port } ` ) ;
707+ let uptimeLabel = uptimeStr
708+ ? colors . brand . textMuted ( ` · ${ uptimeStr } ` )
709+ : '' ;
710+
711+ output . print (
712+ ` ${ output . statusDot ( 'success' ) } ${ name } ${ portStr } ${ uptimeLabel } `
713+ ) ;
714+ output . print ( ` ${ colors . brand . textMuted ( server . directory ) } ` ) ;
715+
716+ if ( globalOptions . verbose ) {
717+ output . print ( ` ${ colors . brand . textMuted ( `PID: ${ server . pid } ` ) } ` ) ;
718+ }
719+
720+ output . blank ( ) ;
721+ }
722+
723+ output . print (
724+ ` ${ colors . brand . textTertiary ( `${ servers . length } server(s) running` ) } `
725+ ) ;
726+ }
0 commit comments