Skip to content

Commit 5d80e31

Browse files
authored
✨ Add menubar app support with server registry & auto-port allocation (#200)
## Summary Adds CLI-side infrastructure for the macOS menubar companion app (VIZ-110): - **Global server registry** at `~/.vizzly/servers.json` tracks all running TDD servers - **Auto-port allocation** - `tdd start` finds an available port when default is busy - **JSON log output** - Daemon writes logs to `.vizzly/server.log` for menubar to display - **User PATH saving** - Saves shell PATH so menubar can find `npx`/`node` - **`tdd list` command** - Lists all running servers with port, uptime, directory ### Auto-port allocation When starting multiple projects, ports are automatically assigned: ```bash cd ~/projects/app-a && vizzly tdd start # → :47392 cd ~/projects/app-b && vizzly tdd start # → Auto-assigned :47393 ``` ### Server registry The menubar app watches `~/.vizzly/servers.json` for real-time updates: ```json { "version": 1, "servers": [ { "id": "abc123", "port": 47392, "pid": 12345, "directory": "/projects/my-app", "name": "my-app", "logFile": "/projects/my-app/.vizzly/server.log", "startedAt": "2024-02-02T..." } ] } ``` ## Test plan - [ ] `vizzly tdd start` in one project, verify registry updated - [ ] `vizzly tdd start` in second project, verify auto-port allocation - [ ] `vizzly tdd list` shows both servers - [ ] `vizzly tdd stop` removes from registry - [ ] Check `.vizzly/server.log` has JSON lines after starting daemon - [ ] Menubar app can read server list and logs
1 parent 4cfc428 commit 5d80e31

5 files changed

Lines changed: 781 additions & 24 deletions

File tree

src/cli.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { statusCommand, validateStatusOptions } from './commands/status.js';
2222
import { tddCommand, validateTddOptions } from './commands/tdd.js';
2323
import {
2424
runDaemonChild,
25+
tddListCommand,
2526
tddStartCommand,
2627
tddStatusCommand,
2728
tddStopCommand,
@@ -39,6 +40,7 @@ import { openBrowser } from './utils/browser.js';
3940
import { colors } from './utils/colors.js';
4041
import { loadConfig } from './utils/config-loader.js';
4142
import { getContext } from './utils/context.js';
43+
import { saveUserPath } from './utils/global-config.js';
4244
import * as output from './utils/output.js';
4345
import { getPackageVersion } from './utils/package-info.js';
4446

@@ -406,6 +408,15 @@ tddCmd
406408
await tddStatusCommand(options, globalOptions);
407409
});
408410

411+
// TDD List - List all running servers (for menubar app integration)
412+
tddCmd
413+
.command('list')
414+
.description('List all running TDD servers')
415+
.action(async options => {
416+
const globalOptions = program.opts();
417+
await tddListCommand(options, globalOptions);
418+
});
419+
409420
// TDD Run - One-off test run with ephemeral server (generates static report)
410421
tddCmd
411422
.command('run <command>')
@@ -752,4 +763,8 @@ program
752763
await projectRemoveCommand(options, globalOptions);
753764
});
754765

766+
// Save user's PATH for menubar app (non-blocking, runs in background)
767+
// This auto-configures the menubar app so it can find npx/node
768+
saveUserPath().catch(() => {});
769+
755770
program.parse();

src/commands/tdd-daemon.js

Lines changed: 206 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
writeFileSync,
88
} from 'node:fs';
99
import { 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';
1112
import * as output from '../utils/output.js';
1213
import { 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

Comments
 (0)