git-watchtower 1.9.20 → 1.10.1

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,537 @@
1
+ /**
2
+ * Web dashboard server for Git Watchtower.
3
+ *
4
+ * Runs alongside the TUI, serving a browser-based dashboard that mirrors
5
+ * (and extends) the terminal UI. Uses SSE to push state updates to the
6
+ * browser and accepts POST actions from the web frontend.
7
+ *
8
+ * Zero dependencies — uses only Node built-in http module.
9
+ *
10
+ * @module server/web
11
+ */
12
+
13
+ const http = require('http');
14
+ const { getWebDashboardHtml } = require('./web-ui');
15
+ const { version: PACKAGE_VERSION } = require('../../package.json');
16
+ const sessionStats = require('../stats/session');
17
+
18
+ /**
19
+ * Default web dashboard port
20
+ */
21
+ const DEFAULT_WEB_PORT = 4000;
22
+
23
+ /**
24
+ * How often to push state to SSE clients (ms)
25
+ */
26
+ const STATE_PUSH_INTERVAL = 500;
27
+
28
+ /**
29
+ * Maximum number of port retries on EADDRINUSE
30
+ */
31
+ const MAX_PORT_RETRIES = 20;
32
+
33
+ /**
34
+ * SSE keepalive interval (ms) — prevents proxies from dropping idle connections
35
+ */
36
+ const SSE_KEEPALIVE_INTERVAL = 15000;
37
+
38
+ /**
39
+ * @typedef {Object} WebDashboardOptions
40
+ * @property {number} [port=4000] - Port to listen on
41
+ * @property {import('../state/store').Store} store - State store instance
42
+ * @property {function} [onAction] - Callback for web UI actions
43
+ * @property {function} [getExtraState] - Returns additional state to merge
44
+ */
45
+
46
+ /**
47
+ * Web dashboard server.
48
+ * Manages an HTTP server, SSE connections, and state broadcasting.
49
+ */
50
+ class WebDashboardServer {
51
+ /**
52
+ * @param {WebDashboardOptions} options
53
+ */
54
+ constructor(options) {
55
+ this.port = options.port || DEFAULT_WEB_PORT;
56
+ this.store = options.store;
57
+ this.onAction = options.onAction || (() => {});
58
+ this.getExtraState = options.getExtraState || (() => ({}));
59
+
60
+ /** @type {Set<import('http').ServerResponse>} */
61
+ this.clients = new Set();
62
+ this.server = null;
63
+ this.pushInterval = null;
64
+ this.lastPushedJson = '';
65
+
66
+ // Multi-project support (populated by coordinator)
67
+ /** @type {Map<string, Object>} */
68
+ this.projects = new Map();
69
+ this.localProjectId = null;
70
+
71
+ /** @type {string|null} Repository web URL for building links */
72
+ this.repoWebUrl = null;
73
+
74
+ // Cache the HTML (regenerated only if port changes)
75
+ this._cachedHtml = getWebDashboardHtml(this.port);
76
+ }
77
+
78
+ /**
79
+ * Build a JSON-serializable snapshot of the store state.
80
+ * Converts Maps to plain objects for JSON.stringify.
81
+ * @returns {Object}
82
+ */
83
+ getSerializableState() {
84
+ const s = this.store.getState();
85
+ const extra = this.getExtraState();
86
+
87
+ // Convert Maps to plain objects
88
+ const sparklineCache = {};
89
+ if (s.sparklineCache instanceof Map) {
90
+ s.sparklineCache.forEach((v, k) => { sparklineCache[k] = v; });
91
+ }
92
+
93
+ const branchPrStatusMap = {};
94
+ if (s.branchPrStatusMap instanceof Map) {
95
+ s.branchPrStatusMap.forEach((v, k) => { branchPrStatusMap[k] = v; });
96
+ }
97
+
98
+ const aheadBehindCache = {};
99
+ if (s.aheadBehindCache instanceof Map) {
100
+ s.aheadBehindCache.forEach((v, k) => { aheadBehindCache[k] = v; });
101
+ }
102
+
103
+ return {
104
+ // Git state
105
+ branches: s.branches,
106
+ currentBranch: s.currentBranch,
107
+ isDetachedHead: s.isDetachedHead,
108
+ hasMergeConflict: s.hasMergeConflict,
109
+
110
+ // Polling
111
+ pollingStatus: s.pollingStatus,
112
+ isOffline: s.isOffline,
113
+
114
+ // Server
115
+ serverMode: s.serverMode,
116
+ serverRunning: s.serverRunning,
117
+ serverCrashed: s.serverCrashed,
118
+ port: s.port,
119
+
120
+ // UI
121
+ soundEnabled: s.soundEnabled,
122
+ projectName: s.projectName,
123
+
124
+ // Activity
125
+ activityLog: s.activityLog,
126
+ switchHistory: s.switchHistory,
127
+
128
+ // Server logs
129
+ serverLogBuffer: s.serverLogBuffer || [],
130
+
131
+ // Caches (as plain objects)
132
+ sparklineCache,
133
+ branchPrStatusMap,
134
+ aheadBehindCache,
135
+
136
+ // Metadata
137
+ version: PACKAGE_VERSION,
138
+
139
+ // Version update
140
+ updateAvailable: s.updateAvailable || null,
141
+ updateInProgress: s.updateInProgress || false,
142
+
143
+ // Server info
144
+ noServer: s.noServer || false,
145
+ clientCount: this.clients.size,
146
+
147
+ // Session stats
148
+ sessionStats: sessionStats.getStats(),
149
+
150
+ // Multi-project data
151
+ projects: this._getProjectsList(),
152
+ activeProjectId: this.localProjectId,
153
+
154
+ // Repository web URL for building links in the web UI
155
+ repoWebUrl: this.repoWebUrl || null,
156
+
157
+ // Extra state from the main process
158
+ ...extra,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Update the full projects list (called by coordinator).
164
+ * @param {Array<{id: string, projectName: string, projectPath: string, state: Object}>} projects
165
+ */
166
+ setProjects(projects) {
167
+ this.projects.clear();
168
+ for (const p of projects) {
169
+ this.projects.set(p.id, p);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Set the local project ID.
175
+ * @param {string} id
176
+ */
177
+ setLocalProjectId(id) {
178
+ this.localProjectId = id;
179
+ }
180
+
181
+ /**
182
+ * Set the repository web URL for link building in the web UI.
183
+ * @param {string|null} url - e.g. https://github.com/user/repo
184
+ */
185
+ setRepoWebUrl(url) {
186
+ this.repoWebUrl = url || null;
187
+ }
188
+
189
+ /**
190
+ * Get a serializable state for a specific project (by ID).
191
+ * @param {string} projectId
192
+ * @returns {Object|null}
193
+ */
194
+ getProjectState(projectId) {
195
+ if (projectId === this.localProjectId) {
196
+ return this.getSerializableState();
197
+ }
198
+ const project = this.projects.get(projectId);
199
+ return project ? project.state : null;
200
+ }
201
+
202
+ /**
203
+ * Get projects list for the frontend.
204
+ * @returns {Array<{id: string, name: string, active: boolean}>}
205
+ * @private
206
+ */
207
+ _getProjectsList() {
208
+ const list = [];
209
+ for (const [id, p] of this.projects) {
210
+ list.push({
211
+ id,
212
+ name: p.projectName,
213
+ path: p.projectPath,
214
+ active: id === this.localProjectId,
215
+ });
216
+ }
217
+ // If no projects from coordinator, at least show ourselves
218
+ if (list.length === 0 && this.localProjectId) {
219
+ const s = this.store.getState();
220
+ list.push({
221
+ id: this.localProjectId,
222
+ name: s.projectName || 'unknown',
223
+ path: '',
224
+ active: true,
225
+ });
226
+ }
227
+ return list;
228
+ }
229
+
230
+ /**
231
+ * Start the web dashboard server.
232
+ * @returns {Promise<{port: number}>} Resolves when the server is listening
233
+ */
234
+ start() {
235
+ return new Promise((resolve, reject) => {
236
+ let retries = 0;
237
+
238
+ this.server = http.createServer((req, res) => {
239
+ this._handleRequest(req, res);
240
+ });
241
+
242
+ this.server.on('error', (/** @type {Error & {code?: string}} */ err) => {
243
+ if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
244
+ retries++;
245
+ this.port++;
246
+ this._cachedHtml = getWebDashboardHtml(this.port);
247
+ this.server.listen(this.port, '127.0.0.1');
248
+ } else {
249
+ reject(err);
250
+ }
251
+ });
252
+
253
+ this.server.listen(this.port, '127.0.0.1', () => {
254
+ // Start pushing state to clients
255
+ this.pushInterval = setInterval(() => {
256
+ this._pushState();
257
+ }, STATE_PUSH_INTERVAL);
258
+
259
+ resolve({ port: this.port });
260
+ });
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Stop the web dashboard server.
266
+ */
267
+ stop() {
268
+ if (this.pushInterval) {
269
+ clearInterval(this.pushInterval);
270
+ this.pushInterval = null;
271
+ }
272
+
273
+ // Close all SSE connections
274
+ for (const client of this.clients) {
275
+ try { client.end(); } catch (e) { /* ignore */ }
276
+ }
277
+ this.clients.clear();
278
+
279
+ if (this.server) {
280
+ this.server.close();
281
+ this.server = null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Push a flash message to all connected web clients.
287
+ * @param {string} text
288
+ * @param {string} [type='info']
289
+ */
290
+ flash(text, type) {
291
+ const data = JSON.stringify({ text, type: type || 'info' });
292
+ for (const client of this.clients) {
293
+ try {
294
+ client.write('event: flash\n');
295
+ client.write('data: ' + data + '\n\n');
296
+ } catch (e) { /* ignore dead clients */ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Send preview data to all connected clients.
302
+ * @param {Object} data - Preview data
303
+ */
304
+ sendPreview(data) {
305
+ const json = JSON.stringify(data);
306
+ for (const client of this.clients) {
307
+ try {
308
+ client.write('event: preview\n');
309
+ client.write('data: ' + json + '\n\n');
310
+ } catch (e) { /* ignore dead clients */ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Send action result feedback to all connected clients.
316
+ * @param {Object} result - { action, success, message, type }
317
+ */
318
+ sendActionResult(result) {
319
+ const json = JSON.stringify(result);
320
+ for (const client of this.clients) {
321
+ try {
322
+ client.write('event: actionResult\n');
323
+ client.write('data: ' + json + '\n\n');
324
+ } catch (e) { /* ignore dead clients */ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Get the number of connected web clients.
330
+ * @returns {number}
331
+ */
332
+ getClientCount() {
333
+ return this.clients.size;
334
+ }
335
+
336
+ // ─── Private ───────────────────────────────────────────────────
337
+
338
+ /**
339
+ * Handle an incoming HTTP request.
340
+ * @param {import('http').IncomingMessage} req
341
+ * @param {import('http').ServerResponse} res
342
+ * @private
343
+ */
344
+ _handleRequest(req, res) {
345
+ const url = new URL(req.url, `http://localhost:${this.port}`);
346
+ const pathname = url.pathname;
347
+
348
+ // CORS — restrict to own origin
349
+ const origin = `http://localhost:${this.port}`;
350
+ res.setHeader('Access-Control-Allow-Origin', origin);
351
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
352
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
353
+
354
+ if (req.method === 'OPTIONS') {
355
+ res.writeHead(204);
356
+ res.end();
357
+ return;
358
+ }
359
+
360
+ // Routes
361
+ if (pathname === '/' && req.method === 'GET') {
362
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
363
+ res.end(this._cachedHtml);
364
+ return;
365
+ }
366
+
367
+ if (pathname === '/api/state' && req.method === 'GET') {
368
+ const state = this.getSerializableState();
369
+ res.writeHead(200, { 'Content-Type': 'application/json' });
370
+ res.end(JSON.stringify(state));
371
+ return;
372
+ }
373
+
374
+ if (pathname === '/api/events' && req.method === 'GET') {
375
+ this._handleSSE(req, res);
376
+ return;
377
+ }
378
+
379
+ if (pathname === '/api/projects' && req.method === 'GET') {
380
+ res.writeHead(200, { 'Content-Type': 'application/json' });
381
+ res.end(JSON.stringify(this._getProjectsList()));
382
+ return;
383
+ }
384
+
385
+ // /api/projects/:id/state
386
+ const projectStateMatch = pathname.match(/^\/api\/projects\/([a-zA-Z0-9_-]+)\/state$/);
387
+ if (projectStateMatch && req.method === 'GET') {
388
+ const projectState = this.getProjectState(projectStateMatch[1]);
389
+ if (projectState) {
390
+ res.writeHead(200, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify(projectState));
392
+ } else {
393
+ res.writeHead(404, { 'Content-Type': 'application/json' });
394
+ res.end(JSON.stringify({ error: 'Project not found' }));
395
+ }
396
+ return;
397
+ }
398
+
399
+ if (pathname === '/api/action' && req.method === 'POST') {
400
+ this._handleAction(req, res);
401
+ return;
402
+ }
403
+
404
+ // 404
405
+ res.writeHead(404, { 'Content-Type': 'application/json' });
406
+ res.end(JSON.stringify({ error: 'Not found' }));
407
+ }
408
+
409
+ /**
410
+ * Set up an SSE connection.
411
+ * @param {import('http').IncomingMessage} req
412
+ * @param {import('http').ServerResponse} res
413
+ * @private
414
+ */
415
+ _handleSSE(req, res) {
416
+ res.writeHead(200, {
417
+ 'Content-Type': 'text/event-stream',
418
+ 'Cache-Control': 'no-cache',
419
+ 'Connection': 'keep-alive',
420
+ 'X-Accel-Buffering': 'no',
421
+ });
422
+
423
+ // Send initial state immediately
424
+ const state = this.getSerializableState();
425
+ res.write('event: state\n');
426
+ res.write('data: ' + JSON.stringify(state) + '\n\n');
427
+
428
+ this.clients.add(res);
429
+
430
+ // Keepalive heartbeat to prevent proxy/LB timeouts
431
+ const keepalive = setInterval(() => {
432
+ try { res.write(': keepalive\\n\\n'); } catch (e) { clearInterval(keepalive); }
433
+ }, SSE_KEEPALIVE_INTERVAL);
434
+
435
+ req.on('close', () => {
436
+ clearInterval(keepalive);
437
+ this.clients.delete(res);
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Handle a POST action from the web UI.
443
+ * @param {import('http').IncomingMessage} req
444
+ * @param {import('http').ServerResponse} res
445
+ * @private
446
+ */
447
+ _handleAction(req, res) {
448
+ let body = '';
449
+ let aborted = false;
450
+ req.on('data', (chunk) => {
451
+ body += chunk;
452
+ // Limit body size to 10KB
453
+ if (body.length > 10240 && !aborted) {
454
+ aborted = true;
455
+ res.writeHead(413, { 'Content-Type': 'application/json' });
456
+ res.end(JSON.stringify({ error: 'Payload too large' }));
457
+ req.destroy();
458
+ }
459
+ });
460
+
461
+ req.on('end', () => {
462
+ if (aborted) return;
463
+ try {
464
+ const data = JSON.parse(body);
465
+ const action = data.action;
466
+ const payload = data.payload || {};
467
+
468
+ if (!action || typeof action !== 'string') {
469
+ res.writeHead(400, { 'Content-Type': 'application/json' });
470
+ res.end(JSON.stringify({ error: 'Missing action' }));
471
+ return;
472
+ }
473
+
474
+ // Whitelist allowed actions
475
+ const allowedActions = [
476
+ 'switchBranch', 'pull', 'fetch', 'undo',
477
+ 'toggleSound', 'preview',
478
+ 'restartServer', 'reloadBrowsers', 'toggleCasino',
479
+ 'openBrowser',
480
+ 'stash', 'stashPop', 'deleteBranches', 'checkUpdate',
481
+ ];
482
+
483
+ if (!allowedActions.includes(action)) {
484
+ res.writeHead(400, { 'Content-Type': 'application/json' });
485
+ res.end(JSON.stringify({ error: 'Unknown action: ' + action }));
486
+ return;
487
+ }
488
+
489
+ // Include projectId if provided (for multi-project routing)
490
+ const projectId = data.projectId || this.localProjectId;
491
+ payload._projectId = projectId;
492
+
493
+ // Dispatch to the main process
494
+ this.onAction(action, payload);
495
+
496
+ res.writeHead(200, { 'Content-Type': 'application/json' });
497
+ res.end(JSON.stringify({ ok: true }));
498
+ } catch (e) {
499
+ res.writeHead(400, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
501
+ }
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Push current state to all SSE clients (if changed).
507
+ * @private
508
+ */
509
+ _pushState() {
510
+ if (this.clients.size === 0) {
511
+ this.lastPushedJson = ''; // Invalidate so next client gets immediate state
512
+ return;
513
+ }
514
+
515
+ const state = this.getSerializableState();
516
+ const json = JSON.stringify(state);
517
+
518
+ // Only push if state changed
519
+ if (json === this.lastPushedJson) return;
520
+ this.lastPushedJson = json;
521
+
522
+ const message = 'event: state\ndata: ' + json + '\n\n';
523
+ for (const client of this.clients) {
524
+ try {
525
+ client.write(message);
526
+ } catch (e) {
527
+ // Dead client — will be cleaned up on 'close'
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ module.exports = {
534
+ WebDashboardServer,
535
+ DEFAULT_WEB_PORT,
536
+ STATE_PUSH_INTERVAL,
537
+ };
@@ -154,6 +154,8 @@ function getNormalModeAction(key) {
154
154
  return 'toggle_casino';
155
155
  case 'd':
156
156
  return 'cleanup_branches';
157
+ case 'W':
158
+ return 'toggle_web';
157
159
  case 'q':
158
160
  case KEYS.CTRL_C:
159
161
  return 'quit';
@@ -538,6 +538,7 @@ function renderFooter(state, write) {
538
538
  }
539
539
 
540
540
  write(ansi.gray + '[d]' + ansi.reset + ansi.bgBlack + ' Cleanup ');
541
+ write(ansi.gray + '[W]' + ansi.reset + ansi.bgBlack + ' Web ');
541
542
  write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
542
543
  write(ansi.reset);
543
544
  }