@vizzly-testing/cli 0.25.0 → 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.
@@ -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
+ }
@@ -307,7 +307,7 @@ export function getPullRequestBaseSha() {
307
307
  return process.env.CI_MERGE_REQUEST_TARGET_BRANCH_SHA ||
308
308
  // GitLab CI
309
309
  null // Most CIs don't provide this
310
- ;
310
+ ;
311
311
  }
312
312
 
313
313
  /**
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",