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.
@@ -48,6 +48,7 @@
48
48
  * s - Toggle sound notifications
49
49
  * c - Toggle casino mode (Vegas-style feedback)
50
50
  * i - Show server info (port, connections)
51
+ * W - Toggle web dashboard (starts server + opens browser)
51
52
  * 1-0 - Set visible branch count (1-10)
52
53
  * +/- - Increase/decrease visible branches
53
54
  * q/Esc - Quit (Esc also clears search)
@@ -98,6 +99,10 @@ const { getConfigPath, loadConfig: loadConfigFile, saveConfig: saveConfigFile, C
98
99
  const { Store } = require('../src/state/store');
99
100
  const store = new Store();
100
101
 
102
+ // Web dashboard server
103
+ const { WebDashboardServer } = require('../src/server/web');
104
+ const { Coordinator, Worker, generateProjectId, getActiveCoordinator, writeLock, removeLock } = require('../src/server/coordinator');
105
+
101
106
  const PROJECT_ROOT = process.cwd();
102
107
 
103
108
  function loadConfig() {
@@ -383,6 +388,15 @@ let sessionStartTime = null;
383
388
  // Server process management (for command mode)
384
389
  let serverProcess = null;
385
390
 
391
+ // Web dashboard
392
+ let WEB_ENABLED = false;
393
+ let WEB_PORT = 4000;
394
+ let webDashboard = null;
395
+ let coordinator = null;
396
+ let worker = null;
397
+ let projectId = null;
398
+ let webStateInterval = null;
399
+
386
400
  function applyConfig(config) {
387
401
  // Server settings
388
402
  SERVER_MODE = config.server?.mode || 'static';
@@ -415,6 +429,12 @@ function applyConfig(config) {
415
429
  if (casinoEnabled) {
416
430
  casino.enable();
417
431
  }
432
+
433
+ // Web dashboard
434
+ if (config.web) {
435
+ WEB_ENABLED = config.web.enabled === true;
436
+ WEB_PORT = config.web.port || 4000;
437
+ }
418
438
  }
419
439
 
420
440
  // Server log management
@@ -1413,9 +1433,11 @@ async function switchToBranch(branchName, recordHistory = true) {
1413
1433
  store.setState({ currentBranch: safeBranchName, isDetachedHead: false });
1414
1434
 
1415
1435
  // Clear NEW flag when branch becomes current
1416
- const branchInfo = store.get('branches').find(b => b.name === safeBranchName);
1436
+ const branches = store.get('branches');
1437
+ const branchInfo = branches.find(b => b.name === safeBranchName);
1417
1438
  if (branchInfo && branchInfo.isNew) {
1418
1439
  branchInfo.isNew = false;
1440
+ store.setState({ branches: [...branches] });
1419
1441
  }
1420
1442
 
1421
1443
  // Record in history (for undo)
@@ -1870,10 +1892,10 @@ async function pollGitChanges() {
1870
1892
  await execGit(['pull', REMOTE_NAME, autoPullBranchName], { cwd: PROJECT_ROOT, timeout: 60000 });
1871
1893
  addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
1872
1894
  currentInfo.hasUpdates = false;
1873
- store.setState({ hasMergeConflict: false });
1874
1895
  // Update the stored commit to the new one
1875
1896
  const newCommit = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1876
1897
  currentInfo.commit = newCommit.stdout.trim();
1898
+ store.setState({ hasMergeConflict: false, branches: [...store.get('branches')] });
1877
1899
  previousBranchStates.set(autoPullBranchName, newCommit.stdout.trim());
1878
1900
  // Reload browsers
1879
1901
  notifyClients();
@@ -2773,6 +2795,20 @@ function setupKeyboardInput() {
2773
2795
  break;
2774
2796
  }
2775
2797
 
2798
+ case 'W': { // Toggle web dashboard
2799
+ if (webDashboard || worker) {
2800
+ const wasPort = stopWebDashboard();
2801
+ addLog(`Web dashboard stopped (was on :${wasPort})`, 'info');
2802
+ showFlash('Web dashboard stopped');
2803
+ render();
2804
+ } else {
2805
+ startWebDashboard(true).then(() => {
2806
+ showFlash(`Web dashboard on :${WEB_PORT}`);
2807
+ }).catch(() => {});
2808
+ }
2809
+ break;
2810
+ }
2811
+
2776
2812
  // Number keys to set visible branch count
2777
2813
  case '1': case '2': case '3': case '4': case '5':
2778
2814
  case '6': case '7': case '8': case '9':
@@ -2828,6 +2864,237 @@ function setupKeyboardInput() {
2828
2864
  });
2829
2865
  }
2830
2866
 
2867
+ // ============================================================================
2868
+ // Web Dashboard
2869
+ // ============================================================================
2870
+
2871
+ /**
2872
+ * Handle an action from the web dashboard.
2873
+ */
2874
+ async function handleWebAction(action, payload) {
2875
+ const sendResult = (success, message) => {
2876
+ if (webDashboard) webDashboard.sendActionResult({ action, success, message });
2877
+ };
2878
+
2879
+ try {
2880
+ switch (action) {
2881
+ case 'switchBranch':
2882
+ if (payload.branch && payload.branch !== store.get('currentBranch')) {
2883
+ await switchToBranch(payload.branch);
2884
+ await pollGitChanges();
2885
+ sendResult(true, `Switched to ${payload.branch}`);
2886
+ }
2887
+ break;
2888
+ case 'pull':
2889
+ addLog('Force pulling (from web)...', 'update');
2890
+ render();
2891
+ await pullCurrentBranch();
2892
+ await pollGitChanges();
2893
+ sendResult(true, 'Pull complete');
2894
+ break;
2895
+ case 'fetch':
2896
+ addLog('Fetching all branches (from web)...', 'info');
2897
+ render();
2898
+ await pollGitChanges();
2899
+ await refreshAllSparklines();
2900
+ render();
2901
+ sendResult(true, 'Fetch complete');
2902
+ break;
2903
+ case 'undo': {
2904
+ const last = store.getLastSwitch();
2905
+ if (last) {
2906
+ await switchToBranch(last.from);
2907
+ store.popHistory();
2908
+ await pollGitChanges();
2909
+ sendResult(true, `Switched back to ${last.from}`);
2910
+ } else {
2911
+ sendResult(false, 'No switch to undo');
2912
+ }
2913
+ break;
2914
+ }
2915
+ case 'toggleSound': {
2916
+ const current = store.get('soundEnabled');
2917
+ store.setState({ soundEnabled: !current });
2918
+ render();
2919
+ sendResult(true, current ? 'Sound off' : 'Sound on');
2920
+ break;
2921
+ }
2922
+ case 'toggleCasino': {
2923
+ const casinoOn = store.get('casinoModeEnabled');
2924
+ store.setState({ casinoModeEnabled: !casinoOn });
2925
+ if (!casinoOn) casino.enable(); else casino.disable();
2926
+ render();
2927
+ sendResult(true, casinoOn ? 'Casino mode off' : 'Casino mode on');
2928
+ break;
2929
+ }
2930
+ case 'restartServer':
2931
+ if (SERVER_MODE === 'command') {
2932
+ addLog('Restarting server (from web)...', 'update');
2933
+ restartServerProcess();
2934
+ render();
2935
+ sendResult(true, 'Server restarting');
2936
+ } else {
2937
+ sendResult(false, 'Not in command mode');
2938
+ }
2939
+ break;
2940
+ case 'reloadBrowsers':
2941
+ if (SERVER_MODE === 'static') {
2942
+ addLog('Force reloading browsers (from web)...', 'update');
2943
+ notifyClients();
2944
+ render();
2945
+ sendResult(true, 'Browsers reloaded');
2946
+ } else {
2947
+ sendResult(false, 'Not in static mode');
2948
+ }
2949
+ break;
2950
+ case 'openBrowser':
2951
+ if (!NO_SERVER) {
2952
+ openInBrowser(`http://localhost:${PORT}`);
2953
+ sendResult(true, 'Opened in browser');
2954
+ }
2955
+ break;
2956
+ case 'preview':
2957
+ if (payload.branch) {
2958
+ const pvData = await getPreviewData(payload.branch);
2959
+ if (webDashboard) {
2960
+ webDashboard.sendPreview({ branch: payload.branch, ...pvData });
2961
+ }
2962
+ }
2963
+ break;
2964
+ }
2965
+ } catch (err) {
2966
+ addLog(`Web action error: ${err.message}`, 'error');
2967
+ sendResult(false, err.message);
2968
+ render();
2969
+ }
2970
+ }
2971
+
2972
+ /**
2973
+ * Create and start the web dashboard, with coordinator support.
2974
+ * @param {boolean} openBrowser - Whether to auto-open the browser
2975
+ */
2976
+ async function startWebDashboard(openBrowser) {
2977
+ projectId = generateProjectId(PROJECT_ROOT);
2978
+
2979
+ webDashboard = new WebDashboardServer({
2980
+ port: WEB_PORT,
2981
+ store,
2982
+ getExtraState: () => ({
2983
+ clientCount: clients.size,
2984
+ sessionStats: sessionStats.getStats(),
2985
+ }),
2986
+ onAction: handleWebAction,
2987
+ });
2988
+ webDashboard.setLocalProjectId(projectId);
2989
+
2990
+ // Resolve and cache the repo web URL for link building in the web UI
2991
+ getRemoteWebUrl(null).then((url) => {
2992
+ if (url) webDashboard.setRepoWebUrl(url);
2993
+ }).catch(() => {});
2994
+
2995
+ // Check if a coordinator is already running
2996
+ const existing = getActiveCoordinator();
2997
+
2998
+ if (existing) {
2999
+ // Connect as a worker to the existing coordinator
3000
+ try {
3001
+ worker = new Worker({
3002
+ id: projectId,
3003
+ projectPath: PROJECT_ROOT,
3004
+ projectName: path.basename(PROJECT_ROOT),
3005
+ socketPath: existing.socketPath,
3006
+ });
3007
+ worker.onCommand = (action, payload) => handleWebAction(action, payload);
3008
+ await worker.connect();
3009
+ addLog(`Joined web dashboard at http://localhost:${existing.port} (tab)`, 'success');
3010
+
3011
+ // Push state periodically
3012
+ webStateInterval = setInterval(() => {
3013
+ if (worker && worker.isConnected()) {
3014
+ worker.pushState(webDashboard.getSerializableState());
3015
+ } else {
3016
+ clearInterval(webStateInterval);
3017
+ webStateInterval = null;
3018
+ }
3019
+ }, 500);
3020
+
3021
+ // Don't start our own server — piggyback on the coordinator's.
3022
+ // Don't open browser either — the existing tab will show this project automatically.
3023
+ WEB_PORT = existing.port;
3024
+ render();
3025
+ return;
3026
+ } catch (err) {
3027
+ // Couldn't connect — become coordinator instead
3028
+ worker = null;
3029
+ }
3030
+ }
3031
+
3032
+ // We are the coordinator
3033
+ try {
3034
+ coordinator = new Coordinator();
3035
+ coordinator.onProjectsChanged = (projects) => {
3036
+ if (webDashboard) webDashboard.setProjects(projects);
3037
+ };
3038
+ coordinator.onActionRequest = (pId, action, payload) => {
3039
+ if (pId === projectId) {
3040
+ handleWebAction(action, payload);
3041
+ }
3042
+ };
3043
+ await coordinator.start();
3044
+ coordinator.registerLocal(projectId, PROJECT_ROOT, path.basename(PROJECT_ROOT), webDashboard.getSerializableState());
3045
+
3046
+ // Update coordinator with our latest state periodically
3047
+ webStateInterval = setInterval(() => {
3048
+ if (coordinator && webDashboard) {
3049
+ coordinator.updateLocal(projectId, webDashboard.getSerializableState());
3050
+ } else {
3051
+ clearInterval(webStateInterval);
3052
+ webStateInterval = null;
3053
+ }
3054
+ }, 500);
3055
+
3056
+ const { port } = await webDashboard.start();
3057
+ WEB_PORT = port;
3058
+ writeLock(process.pid, port, coordinator.socketPath);
3059
+
3060
+ addLog(`Web dashboard: http://localhost:${port}`, 'success');
3061
+ if (openBrowser) openInBrowser(`http://localhost:${port}`);
3062
+ render();
3063
+ } catch (err) {
3064
+ addLog(`Web dashboard failed: ${err.message}`, 'error');
3065
+ webDashboard = null;
3066
+ coordinator = null;
3067
+ render();
3068
+ }
3069
+ }
3070
+
3071
+ /**
3072
+ * Stop the web dashboard and coordinator/worker.
3073
+ */
3074
+ function stopWebDashboard() {
3075
+ const wasPort = webDashboard ? webDashboard.port : WEB_PORT;
3076
+
3077
+ if (webStateInterval) {
3078
+ clearInterval(webStateInterval);
3079
+ webStateInterval = null;
3080
+ }
3081
+ if (worker) {
3082
+ worker.disconnect();
3083
+ worker = null;
3084
+ }
3085
+ if (coordinator) {
3086
+ coordinator.stop();
3087
+ coordinator = null;
3088
+ }
3089
+ if (webDashboard) {
3090
+ webDashboard.stop();
3091
+ webDashboard = null;
3092
+ }
3093
+ projectId = null;
3094
+
3095
+ return wasPort;
3096
+ }
3097
+
2831
3098
  // ============================================================================
2832
3099
  // Shutdown
2833
3100
  // ============================================================================
@@ -2862,6 +3129,9 @@ async function shutdown() {
2862
3129
  await Promise.race([serverClosePromise, timeoutPromise]);
2863
3130
  }
2864
3131
 
3132
+ // Stop web dashboard and coordinator
3133
+ stopWebDashboard();
3134
+
2865
3135
  // Flush telemetry
2866
3136
  telemetry.capture('session_ended', {
2867
3137
  duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
@@ -3030,6 +3300,11 @@ async function start() {
3030
3300
  setupFileWatcher();
3031
3301
  }
3032
3302
 
3303
+ // Start web dashboard if enabled
3304
+ if (WEB_ENABLED) {
3305
+ await startWebDashboard(true);
3306
+ }
3307
+
3033
3308
  // Setup keyboard input
3034
3309
  setupKeyboardInput();
3035
3310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.9.19",
3
+ "version": "1.10.0",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
package/src/cli/args.js CHANGED
@@ -20,6 +20,8 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
20
20
  * @property {number|null} visibleBranches - Visible branches override
21
21
  * @property {boolean} init - Run configuration wizard
22
22
  * @property {boolean} casino - Enable casino mode
23
+ * @property {boolean} web - Enable web dashboard mode
24
+ * @property {number|null} webPort - Web dashboard port override
23
25
  */
24
26
 
25
27
  /**
@@ -47,6 +49,9 @@ function parseArgs(argv, options = {}) {
47
49
  // UI settings
48
50
  sound: null,
49
51
  visibleBranches: null,
52
+ // Web dashboard
53
+ web: false,
54
+ webPort: null,
50
55
  // Actions
51
56
  init: false,
52
57
  casino: false,
@@ -108,6 +113,16 @@ function parseArgs(argv, options = {}) {
108
113
  } else if (args[i] === '--casino') {
109
114
  result.casino = true;
110
115
  }
116
+ // Web dashboard
117
+ else if (args[i] === '--web' || args[i] === '-w') {
118
+ result.web = true;
119
+ } else if (args[i] === '--web-port') {
120
+ const webPortValue = parseInt(args[i + 1], 10);
121
+ if (!isNaN(webPortValue) && webPortValue > 0 && webPortValue < 65536) {
122
+ result.webPort = webPortValue;
123
+ }
124
+ i++;
125
+ }
111
126
  // Actions and info
112
127
  else if (args[i] === '--init') {
113
128
  result.init = true;
@@ -175,6 +190,14 @@ function applyCliArgsToConfig(config, cliArgs) {
175
190
  merged.casinoMode = true;
176
191
  }
177
192
 
193
+ // Web dashboard
194
+ if (cliArgs.web) {
195
+ merged.web = { ...merged.web, enabled: true };
196
+ }
197
+ if (cliArgs.webPort !== null) {
198
+ merged.web = { ...merged.web, port: cliArgs.webPort };
199
+ }
200
+
178
201
  return merged;
179
202
  }
180
203
 
@@ -211,6 +234,10 @@ UI Options:
211
234
  --visible-branches <n> Number of branches to display (default: 7)
212
235
  --casino Enable casino mode
213
236
 
237
+ Web Dashboard:
238
+ -w, --web Launch web dashboard alongside TUI
239
+ --web-port <port> Web dashboard port (default: 4000)
240
+
214
241
  General:
215
242
  --init Run the configuration wizard
216
243
  -v, --version Show version number
@@ -232,6 +259,8 @@ Examples:
232
259
  git-watchtower --no-server # Branch monitoring only
233
260
  git-watchtower -p 8080 # Override port
234
261
  git-watchtower -m command -c "npm run dev" # Use custom dev server
262
+ git-watchtower --web # TUI + web dashboard on :4000
263
+ git-watchtower --web --web-port 8080 # Web dashboard on custom port
235
264
  git-watchtower --no-sound --poll-interval 10000
236
265
  `;
237
266
  }
@@ -147,6 +147,14 @@ function applyCliArgs(config, cliArgs) {
147
147
  result.server.restartOnSwitch = cliArgs.restartOnSwitch;
148
148
  }
149
149
 
150
+ // Web dashboard
151
+ if (cliArgs.web) {
152
+ result.web = { ...result.web, enabled: true };
153
+ }
154
+ if (cliArgs.webPort !== undefined && cliArgs.webPort !== null) {
155
+ result.web = { ...result.web, port: cliArgs.webPort };
156
+ }
157
+
150
158
  // Git settings
151
159
  if (cliArgs.remote !== undefined && cliArgs.remote !== null) {
152
160
  result.remoteName = cliArgs.remote;
@@ -19,9 +19,16 @@ const { ConfigError, ValidationError } = require('../utils/errors');
19
19
  * @property {boolean} restartOnSwitch - Restart on branch switch
20
20
  */
21
21
 
22
+ /**
23
+ * @typedef {Object} WebConfig
24
+ * @property {boolean} enabled - Web dashboard enabled
25
+ * @property {number} port - Web dashboard port
26
+ */
27
+
22
28
  /**
23
29
  * @typedef {Object} Config
24
30
  * @property {ServerConfig} server - Server configuration
31
+ * @property {WebConfig} web - Web dashboard configuration
25
32
  * @property {string} remoteName - Git remote name
26
33
  * @property {boolean} autoPull - Auto-pull enabled
27
34
  * @property {number} gitPollInterval - Polling interval in ms
@@ -47,6 +54,10 @@ const DEFAULTS = {
47
54
  port: 3000,
48
55
  restartOnSwitch: true,
49
56
  },
57
+ web: {
58
+ enabled: false,
59
+ port: 4000,
60
+ },
50
61
  remoteName: 'origin',
51
62
  autoPull: true,
52
63
  gitPollInterval: 5000,
@@ -71,6 +82,7 @@ const LIMITS = {
71
82
  function getDefaultConfig() {
72
83
  return {
73
84
  server: { ...DEFAULTS.server },
85
+ web: { ...DEFAULTS.web },
74
86
  remoteName: DEFAULTS.remoteName,
75
87
  autoPull: DEFAULTS.autoPull,
76
88
  gitPollInterval: DEFAULTS.gitPollInterval,
@@ -223,6 +235,19 @@ function validateConfig(config) {
223
235
  }
224
236
  }
225
237
 
238
+ // Validate web dashboard config
239
+ if (config.web) {
240
+ if (typeof config.web !== 'object') {
241
+ throw ConfigError.invalid('web must be an object');
242
+ }
243
+ if (config.web.enabled !== undefined) {
244
+ result.web.enabled = Boolean(config.web.enabled);
245
+ }
246
+ if (config.web.port !== undefined) {
247
+ result.web.port = validatePort(config.web.port);
248
+ }
249
+ }
250
+
226
251
  // Validate Git settings
227
252
  if (config.remoteName !== undefined) {
228
253
  if (typeof config.remoteName !== 'string' || !config.remoteName.trim()) {
package/src/index.js CHANGED
@@ -33,6 +33,9 @@ const configLoader = require('./config/loader');
33
33
 
34
34
  // Server management
35
35
  const serverProcess = require('./server/process');
36
+ const serverWeb = require('./server/web');
37
+ const serverWebUi = require('./server/web-ui');
38
+ const serverCoordinator = require('./server/coordinator');
36
39
 
37
40
  // Telemetry
38
41
  const telemetryModule = require('./telemetry');
@@ -156,6 +159,17 @@ module.exports = {
156
159
  ProcessManager: serverProcess.ProcessManager,
157
160
  parseCommand: serverProcess.parseCommand,
158
161
 
162
+ // Web dashboard server
163
+ WebDashboardServer: serverWeb.WebDashboardServer,
164
+ DEFAULT_WEB_PORT: serverWeb.DEFAULT_WEB_PORT,
165
+ getWebDashboardHtml: serverWebUi.getWebDashboardHtml,
166
+
167
+ // Multi-instance coordinator
168
+ Coordinator: serverCoordinator.Coordinator,
169
+ Worker: serverCoordinator.Worker,
170
+ generateProjectId: serverCoordinator.generateProjectId,
171
+ getActiveCoordinator: serverCoordinator.getActiveCoordinator,
172
+
159
173
  // CLI argument parsing
160
174
  parseArgs: cliArgs.parseArgs,
161
175
  applyCliArgsToConfig: cliArgs.applyCliArgsToConfig,