git-watchtower 1.9.19 → 1.10.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,519 @@
1
+ /**
2
+ * Multi-instance coordinator for Git Watchtower web dashboard.
3
+ *
4
+ * Manages a shared web server across multiple git-watchtower instances.
5
+ * The first instance becomes the "coordinator" and starts the web server.
6
+ * Subsequent instances connect as workers via Unix domain socket IPC
7
+ * and push their project state to the coordinator.
8
+ *
9
+ * Lock file: ~/.watchtower/web.lock { pid, port, socketPath }
10
+ * Socket: ~/.watchtower/web.sock
11
+ * Registry: in-memory, rebuilt from live connections
12
+ *
13
+ * Zero dependencies — uses only Node built-in modules (net, fs, path, os, crypto).
14
+ *
15
+ * @module server/coordinator
16
+ */
17
+
18
+ const net = require('net');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+
24
+ /**
25
+ * Directory for watchtower runtime files
26
+ */
27
+ const WATCHTOWER_DIR = path.join(os.homedir(), '.watchtower');
28
+
29
+ /**
30
+ * Lock file path
31
+ */
32
+ const LOCK_FILE = path.join(WATCHTOWER_DIR, 'web.lock');
33
+
34
+ /**
35
+ * Socket path
36
+ */
37
+ const SOCKET_PATH = path.join(WATCHTOWER_DIR, 'web.sock');
38
+
39
+ /**
40
+ * Ensure the ~/.watchtower directory exists.
41
+ */
42
+ function ensureDir() {
43
+ if (!fs.existsSync(WATCHTOWER_DIR)) {
44
+ fs.mkdirSync(WATCHTOWER_DIR, { recursive: true });
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Check if a process with the given PID is alive.
50
+ * @param {number} pid
51
+ * @returns {boolean}
52
+ */
53
+ function isProcessAlive(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (e) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read the lock file.
64
+ * @returns {{ pid: number, port: number, socketPath: string } | null}
65
+ */
66
+ function readLock() {
67
+ try {
68
+ if (!fs.existsSync(LOCK_FILE)) return null;
69
+ const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
70
+ if (!data || !data.pid || !data.port) return null;
71
+ return data;
72
+ } catch (e) {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Write the lock file.
79
+ * @param {number} pid
80
+ * @param {number} port
81
+ * @param {string} socketPath
82
+ */
83
+ function writeLock(pid, port, socketPath) {
84
+ ensureDir();
85
+ fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid, port, socketPath }, null, 2) + '\n', 'utf8');
86
+ }
87
+
88
+ /**
89
+ * Remove the lock file.
90
+ */
91
+ function removeLock() {
92
+ try { fs.unlinkSync(LOCK_FILE); } catch (e) { /* ignore */ }
93
+ }
94
+
95
+ /**
96
+ * Remove stale socket file.
97
+ */
98
+ function removeSocket() {
99
+ try { fs.unlinkSync(SOCKET_PATH); } catch (e) { /* ignore */ }
100
+ }
101
+
102
+ /**
103
+ * Check if a coordinator is already running.
104
+ * Cleans up stale lock if the process is dead.
105
+ * @returns {{ pid: number, port: number, socketPath: string } | null}
106
+ */
107
+ function getActiveCoordinator() {
108
+ const lock = readLock();
109
+ if (!lock) return null;
110
+ if (isProcessAlive(lock.pid)) return lock;
111
+ // Stale lock — clean up
112
+ removeLock();
113
+ removeSocket();
114
+ return null;
115
+ }
116
+
117
+ // ─── Coordinator (first instance) ────────────────────────────────
118
+
119
+ /**
120
+ * @typedef {Object} ProjectState
121
+ * @property {string} id - Unique project identifier
122
+ * @property {string} projectPath - Absolute path to project
123
+ * @property {string} projectName - Directory name
124
+ * @property {Object} state - Serializable state snapshot
125
+ * @property {number} lastUpdate - Timestamp of last state push
126
+ */
127
+
128
+ /**
129
+ * Coordinator server — manages worker connections and aggregates state.
130
+ */
131
+ class Coordinator {
132
+ /**
133
+ * @param {Object} [options]
134
+ * @param {string} [options.socketPath] - Unix socket path
135
+ */
136
+ constructor(options = {}) {
137
+ this.socketPath = options.socketPath || SOCKET_PATH;
138
+ /** @type {Map<string, ProjectState>} */
139
+ this.projects = new Map();
140
+ /** @type {Map<string, net.Socket>} */
141
+ this.workerSockets = new Map();
142
+ /** @type {net.Server|null} */
143
+ this.ipcServer = null;
144
+ /** @type {Function|null} */
145
+ this.onProjectsChanged = null;
146
+ /** @type {Function|null} */
147
+ this.onActionRequest = null;
148
+ }
149
+
150
+ /**
151
+ * Start the IPC server.
152
+ * @returns {Promise<void>}
153
+ */
154
+ start() {
155
+ return new Promise((resolve, reject) => {
156
+ ensureDir();
157
+ removeSocket();
158
+
159
+ this.ipcServer = net.createServer((socket) => {
160
+ this._handleWorkerConnection(socket);
161
+ });
162
+
163
+ this.ipcServer.on('error', (err) => {
164
+ reject(err);
165
+ });
166
+
167
+ this.ipcServer.listen(this.socketPath, () => {
168
+ resolve();
169
+ });
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Stop the IPC server and clean up.
175
+ */
176
+ stop() {
177
+ // Close all worker sockets
178
+ for (const socket of this.workerSockets.values()) {
179
+ try { socket.destroy(); } catch (e) { /* ignore */ }
180
+ }
181
+ this.workerSockets.clear();
182
+ this.projects.clear();
183
+
184
+ if (this.ipcServer) {
185
+ this.ipcServer.close();
186
+ this.ipcServer = null;
187
+ }
188
+
189
+ removeLock();
190
+ removeSocket();
191
+ }
192
+
193
+ /**
194
+ * Register the coordinator's own project (local instance).
195
+ * @param {string} id
196
+ * @param {string} projectPath
197
+ * @param {string} projectName
198
+ * @param {Object} state
199
+ */
200
+ registerLocal(id, projectPath, projectName, state) {
201
+ this.projects.set(id, {
202
+ id,
203
+ projectPath,
204
+ projectName,
205
+ state: state || {},
206
+ lastUpdate: Date.now(),
207
+ });
208
+ this._notifyProjectsChanged();
209
+ }
210
+
211
+ /**
212
+ * Update the coordinator's own project state.
213
+ * @param {string} id
214
+ * @param {Object} state
215
+ */
216
+ updateLocal(id, state) {
217
+ const project = this.projects.get(id);
218
+ if (project) {
219
+ project.state = state;
220
+ project.lastUpdate = Date.now();
221
+ this._notifyProjectsChanged();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Get all project states.
227
+ * @returns {ProjectState[]}
228
+ */
229
+ getProjects() {
230
+ return Array.from(this.projects.values());
231
+ }
232
+
233
+ /**
234
+ * Get a specific project.
235
+ * @param {string} id
236
+ * @returns {ProjectState|undefined}
237
+ */
238
+ getProject(id) {
239
+ return this.projects.get(id);
240
+ }
241
+
242
+ /**
243
+ * Send a command to a worker.
244
+ * @param {string} projectId
245
+ * @param {string} action
246
+ * @param {Object} payload
247
+ */
248
+ sendCommand(projectId, action, payload) {
249
+ const socket = this.workerSockets.get(projectId);
250
+ if (socket) {
251
+ this._sendMessage(socket, { type: 'command', action, payload });
252
+ } else if (this.onActionRequest) {
253
+ // Local project — handle directly
254
+ this.onActionRequest(projectId, action, payload);
255
+ }
256
+ }
257
+
258
+ // ─── Private ─────────────────────────────────────────────────
259
+
260
+ /**
261
+ * Handle a new worker connection.
262
+ * @param {net.Socket} socket
263
+ * @private
264
+ */
265
+ _handleWorkerConnection(socket) {
266
+ let workerId = null;
267
+ let buffer = '';
268
+
269
+ socket.on('data', (data) => {
270
+ buffer += data.toString();
271
+ let newlineIdx;
272
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
273
+ const line = buffer.slice(0, newlineIdx);
274
+ buffer = buffer.slice(newlineIdx + 1);
275
+ if (line.trim()) {
276
+ try {
277
+ const msg = JSON.parse(line);
278
+ this._handleWorkerMessage(socket, msg, (id) => { workerId = id; }, () => workerId);
279
+ } catch (e) { /* ignore bad JSON */ }
280
+ }
281
+ }
282
+ });
283
+
284
+ socket.on('close', () => {
285
+ if (workerId) {
286
+ this.projects.delete(workerId);
287
+ this.workerSockets.delete(workerId);
288
+ this._notifyProjectsChanged();
289
+ }
290
+ });
291
+
292
+ socket.on('error', () => {
293
+ if (workerId) {
294
+ this.projects.delete(workerId);
295
+ this.workerSockets.delete(workerId);
296
+ this._notifyProjectsChanged();
297
+ }
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Handle a message from a worker.
303
+ * @param {net.Socket} socket
304
+ * @param {Object} msg
305
+ * @param {Function} setWorkerId
306
+ * @private
307
+ */
308
+ _handleWorkerMessage(socket, msg, setWorkerId, getWorkerId) {
309
+ switch (msg.type) {
310
+ case 'register':
311
+ setWorkerId(msg.id);
312
+ this.workerSockets.set(msg.id, socket);
313
+ this.projects.set(msg.id, {
314
+ id: msg.id,
315
+ projectPath: msg.projectPath,
316
+ projectName: msg.projectName,
317
+ state: msg.state || {},
318
+ lastUpdate: Date.now(),
319
+ });
320
+ this._sendMessage(socket, { type: 'registered', id: msg.id });
321
+ this._notifyProjectsChanged();
322
+ break;
323
+
324
+ case 'state': {
325
+ // Validate sender — only accept state for the worker's own registered ID
326
+ const registeredId = getWorkerId();
327
+ if (msg.id && msg.id === registeredId && this.projects.has(msg.id)) {
328
+ this.projects.get(msg.id).state = msg.state;
329
+ this.projects.get(msg.id).lastUpdate = Date.now();
330
+ this._notifyProjectsChanged();
331
+ }
332
+ break;
333
+ }
334
+
335
+ case 'unregister': {
336
+ const regId = getWorkerId();
337
+ if (msg.id && msg.id === regId) {
338
+ this.projects.delete(msg.id);
339
+ this.workerSockets.delete(msg.id);
340
+ this._notifyProjectsChanged();
341
+ }
342
+ break;
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Send a JSON message over a socket.
349
+ * @param {net.Socket} socket
350
+ * @param {Object} msg
351
+ * @private
352
+ */
353
+ _sendMessage(socket, msg) {
354
+ try {
355
+ socket.write(JSON.stringify(msg) + '\n');
356
+ } catch (e) { /* ignore write errors on dead sockets */ }
357
+ }
358
+
359
+ /**
360
+ * Notify that projects changed.
361
+ * @private
362
+ */
363
+ _notifyProjectsChanged() {
364
+ if (this.onProjectsChanged) {
365
+ this.onProjectsChanged(this.getProjects());
366
+ }
367
+ }
368
+ }
369
+
370
+ // ─── Worker (subsequent instances) ───────────────────────────────
371
+
372
+ /**
373
+ * Worker client — connects to the coordinator and pushes state.
374
+ */
375
+ class Worker {
376
+ /**
377
+ * @param {Object} options
378
+ * @param {string} options.id - Unique project ID
379
+ * @param {string} options.projectPath - Absolute path
380
+ * @param {string} options.projectName - Directory name
381
+ * @param {string} [options.socketPath] - Unix socket path
382
+ */
383
+ constructor(options) {
384
+ this.id = options.id;
385
+ this.projectPath = options.projectPath;
386
+ this.projectName = options.projectName;
387
+ this.socketPath = options.socketPath || SOCKET_PATH;
388
+ /** @type {net.Socket|null} */
389
+ this.socket = null;
390
+ /** @type {Function|null} */
391
+ this.onCommand = null;
392
+ this._connected = false;
393
+ this._buffer = '';
394
+ }
395
+
396
+ /**
397
+ * Connect to the coordinator.
398
+ * @returns {Promise<void>}
399
+ */
400
+ connect() {
401
+ return new Promise((resolve, reject) => {
402
+ this.socket = net.createConnection(this.socketPath, () => {
403
+ this._connected = true;
404
+ // Register with coordinator
405
+ this._send({
406
+ type: 'register',
407
+ id: this.id,
408
+ projectPath: this.projectPath,
409
+ projectName: this.projectName,
410
+ });
411
+ resolve();
412
+ });
413
+
414
+ this.socket.on('data', (data) => {
415
+ this._buffer += data.toString();
416
+ let idx;
417
+ while ((idx = this._buffer.indexOf('\n')) !== -1) {
418
+ const line = this._buffer.slice(0, idx);
419
+ this._buffer = this._buffer.slice(idx + 1);
420
+ if (line.trim()) {
421
+ try {
422
+ const msg = JSON.parse(line);
423
+ this._handleMessage(msg);
424
+ } catch (e) { /* ignore */ }
425
+ }
426
+ }
427
+ });
428
+
429
+ this.socket.on('error', (err) => {
430
+ this._connected = false;
431
+ reject(err);
432
+ });
433
+
434
+ this.socket.on('close', () => {
435
+ this._connected = false;
436
+ });
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Push state update to the coordinator.
442
+ * @param {Object} state - Serializable state
443
+ */
444
+ pushState(state) {
445
+ if (!this._connected) return;
446
+ this._send({ type: 'state', id: this.id, state });
447
+ }
448
+
449
+ /**
450
+ * Disconnect from the coordinator.
451
+ */
452
+ disconnect() {
453
+ if (this.socket) {
454
+ if (this._connected) {
455
+ this._send({ type: 'unregister', id: this.id });
456
+ }
457
+ this.socket.destroy();
458
+ this.socket = null;
459
+ this._connected = false;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Check if connected.
465
+ * @returns {boolean}
466
+ */
467
+ isConnected() {
468
+ return this._connected;
469
+ }
470
+
471
+ // ─── Private ─────────────────────────────────────────────────
472
+
473
+ /**
474
+ * @param {Object} msg
475
+ * @private
476
+ */
477
+ _send(msg) {
478
+ if (this.socket && this._connected) {
479
+ try {
480
+ this.socket.write(JSON.stringify(msg) + '\n');
481
+ } catch (e) { /* ignore */ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * @param {Object} msg
487
+ * @private
488
+ */
489
+ _handleMessage(msg) {
490
+ if (msg.type === 'command' && this.onCommand) {
491
+ this.onCommand(msg.action, msg.payload);
492
+ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Generate a unique project ID from the project path.
498
+ * @param {string} projectPath
499
+ * @returns {string}
500
+ */
501
+ function generateProjectId(projectPath) {
502
+ return crypto.createHash('md5').update(projectPath).digest('hex').slice(0, 12);
503
+ }
504
+
505
+ module.exports = {
506
+ Coordinator,
507
+ Worker,
508
+ generateProjectId,
509
+ getActiveCoordinator,
510
+ readLock,
511
+ writeLock,
512
+ removeLock,
513
+ removeSocket,
514
+ isProcessAlive,
515
+ ensureDir,
516
+ WATCHTOWER_DIR,
517
+ LOCK_FILE,
518
+ SOCKET_PATH,
519
+ };