@vizzly-testing/cli 0.25.1 → 0.26.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/dist/cli.js +12 -1
- package/dist/commands/tdd-daemon.js +195 -22
- package/dist/tdd/server-registry.js +245 -0
- package/dist/utils/global-config.js +26 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { projectListCommand, projectRemoveCommand, projectSelectCommand, project
|
|
|
11
11
|
import { runCommand, validateRunOptions } from './commands/run.js';
|
|
12
12
|
import { statusCommand, validateStatusOptions } from './commands/status.js';
|
|
13
13
|
import { tddCommand, validateTddOptions } from './commands/tdd.js';
|
|
14
|
-
import { runDaemonChild, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
|
|
14
|
+
import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
|
|
15
15
|
import { uploadCommand, validateUploadOptions } from './commands/upload.js';
|
|
16
16
|
import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
|
|
17
17
|
import { createPluginServices } from './plugin-api.js';
|
|
@@ -22,6 +22,7 @@ import { openBrowser } from './utils/browser.js';
|
|
|
22
22
|
import { colors } from './utils/colors.js';
|
|
23
23
|
import { loadConfig } from './utils/config-loader.js';
|
|
24
24
|
import { getContext } from './utils/context.js';
|
|
25
|
+
import { saveUserPath } from './utils/global-config.js';
|
|
25
26
|
import * as output from './utils/output.js';
|
|
26
27
|
import { getPackageVersion } from './utils/package-info.js';
|
|
27
28
|
|
|
@@ -300,6 +301,12 @@ tddCmd.command('status').description('Check TDD server status').action(async opt
|
|
|
300
301
|
await tddStatusCommand(options, globalOptions);
|
|
301
302
|
});
|
|
302
303
|
|
|
304
|
+
// TDD List - List all running servers (for menubar app integration)
|
|
305
|
+
tddCmd.command('list').description('List all running TDD servers').action(async options => {
|
|
306
|
+
const globalOptions = program.opts();
|
|
307
|
+
await tddListCommand(options, globalOptions);
|
|
308
|
+
});
|
|
309
|
+
|
|
303
310
|
// TDD Run - One-off test run with ephemeral server (generates static report)
|
|
304
311
|
tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual comparisons').option('--port <port>', 'Port for TDD server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
|
|
305
312
|
const globalOptions = program.opts();
|
|
@@ -521,4 +528,8 @@ program.command('project:remove').description('Remove project configuration for
|
|
|
521
528
|
const globalOptions = program.opts();
|
|
522
529
|
await projectRemoveCommand(options, globalOptions);
|
|
523
530
|
});
|
|
531
|
+
|
|
532
|
+
// Save user's PATH for menubar app (non-blocking, runs in background)
|
|
533
|
+
// This auto-configures the menubar app so it can find npx/node
|
|
534
|
+
saveUserPath().catch(() => {});
|
|
524
535
|
program.parse();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { getServerRegistry } from '../tdd/server-registry.js';
|
|
5
6
|
import * as output from '../utils/output.js';
|
|
6
7
|
import { tddCommand } from './tdd.js';
|
|
7
8
|
|
|
@@ -16,32 +17,72 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
16
17
|
verbose: globalOptions.verbose,
|
|
17
18
|
color: !globalOptions.noColor
|
|
18
19
|
});
|
|
20
|
+
let registry = getServerRegistry();
|
|
21
|
+
let colors = output.getColors();
|
|
19
22
|
|
|
20
|
-
// Check if
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
// Check if THIS directory already has a server running
|
|
24
|
+
let existingServer = registry.find({
|
|
25
|
+
directory: process.cwd()
|
|
26
|
+
});
|
|
27
|
+
if (existingServer) {
|
|
28
|
+
// Verify it's actually running
|
|
29
|
+
if (await isServerRunning(existingServer.port)) {
|
|
30
|
+
output.header('tdd', 'local');
|
|
31
|
+
output.print(` ${output.statusDot('success')} Already running`);
|
|
32
|
+
output.blank();
|
|
33
|
+
output.printBox(colors.brand.info(colors.underline(`http://localhost:${existingServer.port}`)), {
|
|
34
|
+
title: 'Dashboard',
|
|
35
|
+
style: 'branded'
|
|
36
|
+
});
|
|
37
|
+
if (options.open) {
|
|
38
|
+
openDashboard(existingServer.port);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
} else {
|
|
42
|
+
// Stale entry - clean it up (registry and local files)
|
|
43
|
+
registry.unregister({
|
|
44
|
+
directory: process.cwd()
|
|
45
|
+
});
|
|
46
|
+
let vizzlyDir = join(process.cwd(), '.vizzly');
|
|
47
|
+
let pidFile = join(vizzlyDir, 'server.pid');
|
|
48
|
+
let serverFile = join(vizzlyDir, 'server.json');
|
|
49
|
+
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
50
|
+
if (existsSync(serverFile)) unlinkSync(serverFile);
|
|
33
51
|
}
|
|
34
|
-
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Determine port: user-specified or auto-allocate
|
|
55
|
+
let port;
|
|
56
|
+
let autoAllocated = false;
|
|
57
|
+
if (options.port) {
|
|
58
|
+
// User specified a port - use it (will fail if busy)
|
|
59
|
+
port = options.port;
|
|
60
|
+
|
|
61
|
+
// Check if user-specified port is in use
|
|
62
|
+
if (await isServerRunning(port)) {
|
|
63
|
+
output.header('tdd', 'local');
|
|
64
|
+
output.print(` ${output.statusDot('error')} Port ${port} is already in use`);
|
|
65
|
+
output.blank();
|
|
66
|
+
output.hint('Try a different port: vizzly tdd start --port 47393');
|
|
67
|
+
output.hint('Or let Vizzly auto-allocate: vizzly tdd start');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Auto-allocate an available port
|
|
72
|
+
// Note: There's a small race window between finding a port and binding.
|
|
73
|
+
// The registry acts as a soft reservation, and findAvailablePort does
|
|
74
|
+
// an actual TCP bind test to minimize this window.
|
|
75
|
+
port = await registry.findAvailablePort();
|
|
76
|
+
autoAllocated = port !== 47392;
|
|
35
77
|
}
|
|
36
78
|
try {
|
|
37
79
|
// Ensure .vizzly directory exists
|
|
38
|
-
|
|
80
|
+
let vizzlyDir = join(process.cwd(), '.vizzly');
|
|
39
81
|
if (!existsSync(vizzlyDir)) {
|
|
40
82
|
mkdirSync(vizzlyDir, {
|
|
41
83
|
recursive: true
|
|
42
84
|
});
|
|
43
85
|
}
|
|
44
|
-
const port = options.port || 47392;
|
|
45
86
|
|
|
46
87
|
// Show header first so debug messages appear below it
|
|
47
88
|
output.header('tdd', 'local');
|
|
@@ -119,7 +160,28 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
119
160
|
process.exit(1);
|
|
120
161
|
}
|
|
121
162
|
|
|
122
|
-
//
|
|
163
|
+
// Register server in global registry (for menubar app)
|
|
164
|
+
try {
|
|
165
|
+
let registry = getServerRegistry();
|
|
166
|
+
|
|
167
|
+
// Clean up any stale servers first
|
|
168
|
+
registry.cleanupStale();
|
|
169
|
+
|
|
170
|
+
// Register this server with log file path for menubar to read
|
|
171
|
+
let serverLogFile = join(process.cwd(), '.vizzly', 'server.log');
|
|
172
|
+
registry.register({
|
|
173
|
+
pid: child.pid,
|
|
174
|
+
port: port,
|
|
175
|
+
directory: process.cwd(),
|
|
176
|
+
name: basename(process.cwd()),
|
|
177
|
+
startedAt: new Date().toISOString(),
|
|
178
|
+
logFile: serverLogFile
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
// Non-fatal
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Also write legacy server.json for SDK discovery (backwards compatibility)
|
|
123
185
|
try {
|
|
124
186
|
const globalVizzlyDir = join(homedir(), '.vizzly');
|
|
125
187
|
if (!existsSync(globalVizzlyDir)) {
|
|
@@ -138,8 +200,11 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
138
200
|
// Non-fatal, SDK can still use health check
|
|
139
201
|
}
|
|
140
202
|
|
|
141
|
-
//
|
|
142
|
-
|
|
203
|
+
// Show auto-allocated port message if applicable
|
|
204
|
+
if (autoAllocated) {
|
|
205
|
+
output.print(` ${output.statusDot('info')} Auto-assigned port ${colors.brand.textTertiary(`:${port}`)}`);
|
|
206
|
+
output.blank();
|
|
207
|
+
}
|
|
143
208
|
|
|
144
209
|
// Show dashboard URL in a branded box
|
|
145
210
|
let dashboardUrl = `http://localhost:${port}`;
|
|
@@ -177,6 +242,17 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
177
242
|
export async function runDaemonChild(options = {}, globalOptions = {}) {
|
|
178
243
|
const vizzlyDir = join(process.cwd(), '.vizzly');
|
|
179
244
|
const port = options.port || 47392;
|
|
245
|
+
|
|
246
|
+
// Set up log file for menubar app to read
|
|
247
|
+
const logFile = join(vizzlyDir, 'server.log');
|
|
248
|
+
|
|
249
|
+
// Configure output to write JSON logs to file (before tddCommand configures it)
|
|
250
|
+
output.configure({
|
|
251
|
+
logFile,
|
|
252
|
+
json: globalOptions.json,
|
|
253
|
+
verbose: globalOptions.verbose,
|
|
254
|
+
color: !globalOptions.noColor
|
|
255
|
+
});
|
|
180
256
|
try {
|
|
181
257
|
// Use existing tddCommand but with daemon mode
|
|
182
258
|
const {
|
|
@@ -200,7 +276,8 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
|
|
|
200
276
|
pid: process.pid,
|
|
201
277
|
port: port,
|
|
202
278
|
startTime: Date.now(),
|
|
203
|
-
failOnDiff: options.failOnDiff || false
|
|
279
|
+
failOnDiff: options.failOnDiff || false,
|
|
280
|
+
logFile: logFile
|
|
204
281
|
};
|
|
205
282
|
writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
|
|
206
283
|
|
|
@@ -212,7 +289,18 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
|
|
|
212
289
|
const serverFile = join(vizzlyDir, 'server.json');
|
|
213
290
|
if (existsSync(serverFile)) unlinkSync(serverFile);
|
|
214
291
|
|
|
215
|
-
//
|
|
292
|
+
// Unregister from global registry (for menubar app)
|
|
293
|
+
try {
|
|
294
|
+
let registry = getServerRegistry();
|
|
295
|
+
registry.unregister({
|
|
296
|
+
port: port,
|
|
297
|
+
directory: process.cwd()
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
// Non-fatal
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Clean up legacy global server file
|
|
216
304
|
try {
|
|
217
305
|
const globalServerFile = join(homedir(), '.vizzly', 'server.json');
|
|
218
306
|
if (existsSync(globalServerFile)) unlinkSync(globalServerFile);
|
|
@@ -329,12 +417,35 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
|
|
|
329
417
|
// Clean up files
|
|
330
418
|
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
331
419
|
if (existsSync(serverFile)) unlinkSync(serverFile);
|
|
420
|
+
|
|
421
|
+
// Unregister from global registry (for menubar app)
|
|
422
|
+
try {
|
|
423
|
+
let registry = getServerRegistry();
|
|
424
|
+
registry.unregister({
|
|
425
|
+
port: port,
|
|
426
|
+
directory: process.cwd()
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
// Non-fatal
|
|
430
|
+
}
|
|
431
|
+
output.print(` ${output.statusDot('success')} Server stopped`);
|
|
332
432
|
} catch (error) {
|
|
333
433
|
if (error.code === 'ESRCH') {
|
|
334
434
|
// Process not found - clean up stale files
|
|
335
435
|
output.warn('TDD server was not running (cleaning up stale files)');
|
|
336
436
|
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
337
437
|
if (existsSync(serverFile)) unlinkSync(serverFile);
|
|
438
|
+
|
|
439
|
+
// Still unregister from registry
|
|
440
|
+
try {
|
|
441
|
+
let registry = getServerRegistry();
|
|
442
|
+
registry.unregister({
|
|
443
|
+
port: port,
|
|
444
|
+
directory: process.cwd()
|
|
445
|
+
});
|
|
446
|
+
} catch {
|
|
447
|
+
// Non-fatal
|
|
448
|
+
}
|
|
338
449
|
} else {
|
|
339
450
|
output.error('Error stopping TDD server', error);
|
|
340
451
|
}
|
|
@@ -475,4 +586,66 @@ function openDashboard(port = 47392) {
|
|
|
475
586
|
detached: true,
|
|
476
587
|
stdio: 'ignore'
|
|
477
588
|
}).unref();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* List all running TDD servers from the global registry
|
|
593
|
+
* @param {Object} options - Command options
|
|
594
|
+
* @param {Object} globalOptions - Global CLI options
|
|
595
|
+
*/
|
|
596
|
+
export async function tddListCommand(_options, globalOptions = {}) {
|
|
597
|
+
output.configure({
|
|
598
|
+
json: globalOptions.json,
|
|
599
|
+
verbose: globalOptions.verbose,
|
|
600
|
+
color: !globalOptions.noColor
|
|
601
|
+
});
|
|
602
|
+
let registry = getServerRegistry();
|
|
603
|
+
|
|
604
|
+
// Clean up stale servers first
|
|
605
|
+
let cleaned = registry.cleanupStale();
|
|
606
|
+
if (cleaned > 0 && globalOptions.verbose) {
|
|
607
|
+
output.debug('tdd', `Cleaned up ${cleaned} stale server(s)`);
|
|
608
|
+
}
|
|
609
|
+
let servers = registry.list();
|
|
610
|
+
|
|
611
|
+
// JSON output
|
|
612
|
+
if (globalOptions.json) {
|
|
613
|
+
console.log(JSON.stringify({
|
|
614
|
+
servers
|
|
615
|
+
}, null, 2));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// No servers
|
|
620
|
+
if (servers.length === 0) {
|
|
621
|
+
output.info('No TDD servers running');
|
|
622
|
+
output.hint('Start one with: vizzly tdd start');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Table output
|
|
627
|
+
let colors = output.getColors();
|
|
628
|
+
output.header('tdd', 'servers');
|
|
629
|
+
output.blank();
|
|
630
|
+
for (let server of servers) {
|
|
631
|
+
let uptimeStr = '';
|
|
632
|
+
if (server.startedAt) {
|
|
633
|
+
let startTime = new Date(server.startedAt).getTime();
|
|
634
|
+
let uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
635
|
+
let hours = Math.floor(uptime / 3600);
|
|
636
|
+
let minutes = Math.floor(uptime % 3600 / 60);
|
|
637
|
+
if (hours > 0) uptimeStr += `${hours}h `;
|
|
638
|
+
if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m`;else uptimeStr = '<1m';
|
|
639
|
+
}
|
|
640
|
+
let name = server.name || basename(server.directory);
|
|
641
|
+
let portStr = colors.brand.textTertiary(`:${server.port}`);
|
|
642
|
+
let uptimeLabel = uptimeStr ? colors.brand.textMuted(` · ${uptimeStr}`) : '';
|
|
643
|
+
output.print(` ${output.statusDot('success')} ${name}${portStr}${uptimeLabel}`);
|
|
644
|
+
output.print(` ${colors.brand.textMuted(server.directory)}`);
|
|
645
|
+
if (globalOptions.verbose) {
|
|
646
|
+
output.print(` ${colors.brand.textMuted(`PID: ${server.pid}`)}`);
|
|
647
|
+
}
|
|
648
|
+
output.blank();
|
|
649
|
+
}
|
|
650
|
+
output.print(` ${colors.brand.textTertiary(`${servers.length} server(s) running`)}`);
|
|
478
651
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages a global registry of running TDD servers at ~/.vizzly/servers.json
|
|
9
|
+
* Enables the menubar app to discover and manage multiple concurrent servers.
|
|
10
|
+
*/
|
|
11
|
+
export class ServerRegistry {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly');
|
|
14
|
+
this.registryPath = join(this.vizzlyHome, 'servers.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure the registry directory exists
|
|
19
|
+
*/
|
|
20
|
+
ensureDirectory() {
|
|
21
|
+
if (!existsSync(this.vizzlyHome)) {
|
|
22
|
+
mkdirSync(this.vizzlyHome, {
|
|
23
|
+
recursive: true
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the current registry, returning empty if it doesn't exist
|
|
30
|
+
*/
|
|
31
|
+
read() {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(this.registryPath)) {
|
|
34
|
+
let data = JSON.parse(readFileSync(this.registryPath, 'utf8'));
|
|
35
|
+
return {
|
|
36
|
+
version: data.version || 1,
|
|
37
|
+
servers: data.servers || []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
} catch (_err) {
|
|
41
|
+
// Corrupted file, start fresh
|
|
42
|
+
console.warn('Warning: Could not read server registry, starting fresh');
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
servers: []
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write the registry to disk
|
|
52
|
+
*/
|
|
53
|
+
write(registry) {
|
|
54
|
+
this.ensureDirectory();
|
|
55
|
+
writeFileSync(this.registryPath, JSON.stringify(registry, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a new server in the registry
|
|
60
|
+
*/
|
|
61
|
+
register(serverInfo) {
|
|
62
|
+
// Validate required fields
|
|
63
|
+
if (!serverInfo.pid || !serverInfo.port || !serverInfo.directory) {
|
|
64
|
+
throw new Error('Missing required fields: pid, port, directory');
|
|
65
|
+
}
|
|
66
|
+
let port = Number(serverInfo.port);
|
|
67
|
+
let pid = Number(serverInfo.pid);
|
|
68
|
+
if (Number.isNaN(port) || Number.isNaN(pid)) {
|
|
69
|
+
throw new Error('Invalid port or pid - must be numbers');
|
|
70
|
+
}
|
|
71
|
+
let registry = this.read();
|
|
72
|
+
|
|
73
|
+
// Remove any existing entry for this port or directory (shouldn't happen, but be safe)
|
|
74
|
+
registry.servers = registry.servers.filter(s => s.port !== port && s.directory !== serverInfo.directory);
|
|
75
|
+
|
|
76
|
+
// Add the new server
|
|
77
|
+
registry.servers.push({
|
|
78
|
+
id: serverInfo.id || randomBytes(8).toString('hex'),
|
|
79
|
+
port,
|
|
80
|
+
pid,
|
|
81
|
+
directory: serverInfo.directory,
|
|
82
|
+
startedAt: serverInfo.startedAt || new Date().toISOString(),
|
|
83
|
+
configPath: serverInfo.configPath || null,
|
|
84
|
+
name: serverInfo.name || null,
|
|
85
|
+
logFile: serverInfo.logFile || null
|
|
86
|
+
});
|
|
87
|
+
this.write(registry);
|
|
88
|
+
this.notifyMenubar();
|
|
89
|
+
return registry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Unregister a server by port and/or directory
|
|
94
|
+
* When both are provided, matches servers with BOTH criteria (AND logic)
|
|
95
|
+
* When only one is provided, matches servers with that criteria
|
|
96
|
+
*/
|
|
97
|
+
unregister({
|
|
98
|
+
port,
|
|
99
|
+
directory
|
|
100
|
+
}) {
|
|
101
|
+
let registry = this.read();
|
|
102
|
+
let initialCount = registry.servers.length;
|
|
103
|
+
if (port && directory) {
|
|
104
|
+
// Both specified - match servers with both port AND directory
|
|
105
|
+
registry.servers = registry.servers.filter(s => !(s.port === port && s.directory === directory));
|
|
106
|
+
} else if (port) {
|
|
107
|
+
registry.servers = registry.servers.filter(s => s.port !== port);
|
|
108
|
+
} else if (directory) {
|
|
109
|
+
registry.servers = registry.servers.filter(s => s.directory !== directory);
|
|
110
|
+
}
|
|
111
|
+
if (registry.servers.length !== initialCount) {
|
|
112
|
+
this.write(registry);
|
|
113
|
+
this.notifyMenubar();
|
|
114
|
+
}
|
|
115
|
+
return registry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Find a server by port or directory
|
|
120
|
+
*/
|
|
121
|
+
find({
|
|
122
|
+
port,
|
|
123
|
+
directory
|
|
124
|
+
}) {
|
|
125
|
+
let registry = this.read();
|
|
126
|
+
if (port) {
|
|
127
|
+
return registry.servers.find(s => s.port === port);
|
|
128
|
+
}
|
|
129
|
+
if (directory) {
|
|
130
|
+
return registry.servers.find(s => s.directory === directory);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get all registered servers
|
|
137
|
+
*/
|
|
138
|
+
list() {
|
|
139
|
+
return this.read().servers;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Remove servers whose PIDs no longer exist (stale entries)
|
|
144
|
+
*/
|
|
145
|
+
cleanupStale() {
|
|
146
|
+
let registry = this.read();
|
|
147
|
+
let initialCount = registry.servers.length;
|
|
148
|
+
registry.servers = registry.servers.filter(server => {
|
|
149
|
+
try {
|
|
150
|
+
// Signal 0 doesn't kill, just checks if process exists
|
|
151
|
+
process.kill(server.pid, 0);
|
|
152
|
+
return true;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// ESRCH = process doesn't exist, EPERM = exists but no permission (still valid)
|
|
155
|
+
return err.code === 'EPERM';
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
if (registry.servers.length !== initialCount) {
|
|
159
|
+
this.write(registry);
|
|
160
|
+
this.notifyMenubar();
|
|
161
|
+
return initialCount - registry.servers.length;
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Notify the menubar app that the registry changed
|
|
168
|
+
*
|
|
169
|
+
* NOTE: The menubar app primarily uses FSEvents file watching on servers.json.
|
|
170
|
+
* This method is a placeholder for future notification mechanisms (e.g., XPC).
|
|
171
|
+
* For now, file watching provides reliable, immediate updates.
|
|
172
|
+
*/
|
|
173
|
+
notifyMenubar() {
|
|
174
|
+
// File watching on servers.json is the primary notification mechanism.
|
|
175
|
+
// This method exists for future enhancements (XPC, etc.) but is currently a no-op.
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get all ports currently in use by registered servers
|
|
180
|
+
* @returns {Set<number>} Set of ports in use
|
|
181
|
+
*/
|
|
182
|
+
getUsedPorts() {
|
|
183
|
+
let registry = this.read();
|
|
184
|
+
return new Set(registry.servers.map(s => s.port));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Find an available port starting from the default
|
|
189
|
+
* @param {number} startPort - Port to start searching from (default: 47392)
|
|
190
|
+
* @param {number} maxAttempts - Maximum ports to try (default: 100)
|
|
191
|
+
* @returns {Promise<number>} Available port
|
|
192
|
+
*/
|
|
193
|
+
async findAvailablePort(startPort = 47392, maxAttempts = 100) {
|
|
194
|
+
// Clean up stale entries first
|
|
195
|
+
this.cleanupStale();
|
|
196
|
+
let usedPorts = this.getUsedPorts();
|
|
197
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
198
|
+
let port = startPort + i;
|
|
199
|
+
|
|
200
|
+
// Skip if registered in our registry
|
|
201
|
+
if (usedPorts.has(port)) continue;
|
|
202
|
+
|
|
203
|
+
// Check if port is actually free (not used by other apps)
|
|
204
|
+
let isFree = await isPortFree(port);
|
|
205
|
+
if (isFree) {
|
|
206
|
+
return port;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fallback to default if nothing found (will fail later with clear error)
|
|
211
|
+
return startPort;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if a port is free (not in use by any process)
|
|
217
|
+
* @param {number} port - Port to check
|
|
218
|
+
* @returns {Promise<boolean>} True if port is free
|
|
219
|
+
*/
|
|
220
|
+
async function isPortFree(port) {
|
|
221
|
+
return new Promise(resolve => {
|
|
222
|
+
let server = createServer();
|
|
223
|
+
server.once('error', err => {
|
|
224
|
+
if (err.code === 'EADDRINUSE') {
|
|
225
|
+
resolve(false);
|
|
226
|
+
} else {
|
|
227
|
+
// Other errors - assume port is free
|
|
228
|
+
resolve(true);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
server.once('listening', () => {
|
|
232
|
+
server.close(() => resolve(true));
|
|
233
|
+
});
|
|
234
|
+
server.listen(port, '127.0.0.1');
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Singleton instance
|
|
239
|
+
let registryInstance = null;
|
|
240
|
+
export function getServerRegistry() {
|
|
241
|
+
if (!registryInstance) {
|
|
242
|
+
registryInstance = new ServerRegistry();
|
|
243
|
+
}
|
|
244
|
+
return registryInstance;
|
|
245
|
+
}
|
|
@@ -100,6 +100,32 @@ export async function clearGlobalConfig() {
|
|
|
100
100
|
await saveGlobalConfig({});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Save user's PATH for menubar app to use
|
|
105
|
+
* This auto-configures the menubar app so it can find npx/node
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
export async function saveUserPath() {
|
|
109
|
+
let config = await loadGlobalConfig();
|
|
110
|
+
let userPath = process.env.PATH;
|
|
111
|
+
|
|
112
|
+
// Only update if PATH has changed
|
|
113
|
+
if (config.userPath === userPath) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
config.userPath = userPath;
|
|
117
|
+
await saveGlobalConfig(config);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get stored user PATH for external tools (like menubar app)
|
|
122
|
+
* @returns {Promise<string|null>} PATH string or null if not configured
|
|
123
|
+
*/
|
|
124
|
+
export async function getUserPath() {
|
|
125
|
+
let config = await loadGlobalConfig();
|
|
126
|
+
return config.userPath || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/**
|
|
104
130
|
* Get authentication tokens from global config
|
|
105
131
|
* @returns {Promise<Object|null>} Token object with accessToken, refreshToken, expiresAt, user, or null if not found
|