git-watchtower 1.12.1 → 1.12.3

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.
@@ -101,7 +101,7 @@ const store = new Store();
101
101
 
102
102
  // Web dashboard server
103
103
  const { WebDashboardServer } = require('../src/server/web');
104
- const { Coordinator, Worker, generateProjectId, getActiveCoordinator, writeLock, removeLock } = require('../src/server/coordinator');
104
+ const { Coordinator, Worker, generateProjectId, getActiveCoordinator, tryAcquireLock, finalizeLock, removeLock, removeSocket } = require('../src/server/coordinator');
105
105
 
106
106
  const PROJECT_ROOT = process.cwd();
107
107
 
@@ -419,6 +419,10 @@ let worker = null;
419
419
  let projectId = null;
420
420
  let webStateInterval = null;
421
421
 
422
+ // Periodic update check controller — hoisted to module scope so the exit
423
+ // handler can clean it up regardless of where in start() we are.
424
+ let periodicUpdateCheck = null;
425
+
422
426
  function applyConfig(config) {
423
427
  // Server settings
424
428
  SERVER_MODE = config.server?.mode || 'static';
@@ -481,6 +485,17 @@ function openInBrowser(url) {
481
485
  });
482
486
  }
483
487
 
488
+ /**
489
+ * Build a localhost URL for the given port.
490
+ * Centralizes the `http://localhost:${port}` pattern so it's easy to adjust
491
+ * (e.g. switch protocol or host) in one place.
492
+ * @param {number} port
493
+ * @returns {string}
494
+ */
495
+ function localhostUrl(port) {
496
+ return `http://localhost:${port}`;
497
+ }
498
+
484
499
  // parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl
485
500
  // imported from src/git/remote.js
486
501
 
@@ -2123,7 +2138,7 @@ function createStaticServer() {
2123
2138
  return;
2124
2139
  }
2125
2140
 
2126
- const url = new URL(req.url, `http://localhost:${PORT}`);
2141
+ const url = new URL(req.url, localhostUrl(PORT));
2127
2142
  let pathname = url.pathname;
2128
2143
  const logPath = pathname; // Keep original for logging
2129
2144
 
@@ -2773,7 +2788,7 @@ function setupKeyboardInput() {
2773
2788
 
2774
2789
  case 'o': // Open live server in browser
2775
2790
  if (!NO_SERVER) {
2776
- const serverUrl = `http://localhost:${PORT}`;
2791
+ const serverUrl = localhostUrl(PORT);
2777
2792
  addLog(`Opening ${serverUrl} in browser...`, 'info');
2778
2793
  openInBrowser(serverUrl);
2779
2794
  render();
@@ -3016,7 +3031,7 @@ async function handleWebAction(action, payload) {
3016
3031
  break;
3017
3032
  case 'openBrowser':
3018
3033
  if (!NO_SERVER) {
3019
- openInBrowser(`http://localhost:${PORT}`);
3034
+ openInBrowser(localhostUrl(PORT));
3020
3035
  sendResult(true, 'Opened in browser');
3021
3036
  }
3022
3037
  break;
@@ -3089,44 +3104,64 @@ async function startWebDashboard(openBrowser) {
3089
3104
  if (url) webDashboard.setRepoWebUrl(url);
3090
3105
  }).catch(() => {});
3091
3106
 
3092
- // Check if a coordinator is already running
3093
- const existing = getActiveCoordinator();
3107
+ // Atomically try to claim the coordinator role. If another live instance
3108
+ // already owns the lock, connect as a worker instead. This prevents a
3109
+ // TOCTOU race where two instances both pass a "no coordinator" check and
3110
+ // then clobber each other's socket in Coordinator.start().
3111
+ const lockResult = tryAcquireLock(process.pid);
3094
3112
 
3095
- if (existing) {
3096
- // Connect as a worker to the existing coordinator
3097
- try {
3098
- worker = new Worker({
3099
- id: projectId,
3100
- projectPath: PROJECT_ROOT,
3101
- projectName: path.basename(PROJECT_ROOT),
3102
- socketPath: existing.socketPath,
3103
- });
3104
- worker.onCommand = (action, payload) => handleWebAction(action, payload);
3105
- await worker.connect();
3106
- addLog(`Joined web dashboard at http://localhost:${existing.port} (tab)`, 'success');
3107
-
3108
- // Push state periodically
3109
- webStateInterval = setInterval(() => {
3110
- if (worker && worker.isConnected()) {
3111
- worker.pushState(webDashboard.getSerializableState());
3112
- } else {
3113
- clearInterval(webStateInterval);
3114
- webStateInterval = null;
3115
- }
3116
- }, 500);
3113
+ if (!lockResult.acquired) {
3114
+ const existing = lockResult.existing || getActiveCoordinator();
3115
+ if (existing) {
3116
+ try {
3117
+ worker = new Worker({
3118
+ id: projectId,
3119
+ projectPath: PROJECT_ROOT,
3120
+ projectName: path.basename(PROJECT_ROOT),
3121
+ socketPath: existing.socketPath,
3122
+ });
3123
+ worker.onCommand = (action, payload) => handleWebAction(action, payload);
3124
+ await worker.connect();
3125
+ addLog(`Joined web dashboard at ${localhostUrl(existing.port)} (tab)`, 'success');
3126
+
3127
+ // Push state periodically
3128
+ webStateInterval = setInterval(() => {
3129
+ if (worker && worker.isConnected()) {
3130
+ worker.pushState(webDashboard.getSerializableState());
3131
+ } else {
3132
+ clearInterval(webStateInterval);
3133
+ webStateInterval = null;
3134
+ }
3135
+ }, 500);
3117
3136
 
3118
- // Don't start our own server — piggyback on the coordinator's.
3119
- // Don't open browser either — the existing tab will show this project automatically.
3120
- WEB_PORT = existing.port;
3121
- render();
3122
- return;
3123
- } catch (err) {
3124
- // Couldn't connect become coordinator instead
3125
- worker = null;
3137
+ // Don't start our own server — piggyback on the coordinator's.
3138
+ // Don't open browser either — the existing tab will show this project automatically.
3139
+ WEB_PORT = existing.port;
3140
+ render();
3141
+ return;
3142
+ } catch (err) {
3143
+ // Another coordinator owns the lock but we can't talk to it. Do NOT
3144
+ // take over (that would unlink the live coordinator's socket). Run
3145
+ // without a web dashboard for this instance.
3146
+ worker = null;
3147
+ addLog(`Could not join web dashboard at ${localhostUrl(existing.port)}: ${err.message}`, 'error');
3148
+ webDashboard = null;
3149
+ render();
3150
+ return;
3151
+ }
3126
3152
  }
3153
+ // Lock exists but we couldn't claim it and couldn't read the owner.
3154
+ // Bail out rather than race a concurrent startup.
3155
+ addLog('Web dashboard unavailable: could not acquire coordinator lock', 'error');
3156
+ webDashboard = null;
3157
+ render();
3158
+ return;
3127
3159
  }
3128
3160
 
3129
- // We are the coordinator
3161
+ // We hold the lock — it is now safe to remove any leftover socket and
3162
+ // start listening. The lock file contains a placeholder pid-only entry
3163
+ // until finalizeLock() writes the real port/socketPath after a successful
3164
+ // bind.
3130
3165
  try {
3131
3166
  coordinator = new Coordinator();
3132
3167
  coordinator.onProjectsChanged = (projects) => {
@@ -3140,7 +3175,12 @@ async function startWebDashboard(openBrowser) {
3140
3175
  await coordinator.start();
3141
3176
  coordinator.registerLocal(projectId, PROJECT_ROOT, path.basename(PROJECT_ROOT), webDashboard.getSerializableState());
3142
3177
 
3143
- // Update coordinator with our latest state periodically
3178
+ const { port } = await webDashboard.start();
3179
+ WEB_PORT = port;
3180
+ finalizeLock(process.pid, port, coordinator.socketPath);
3181
+
3182
+ // Update coordinator with our latest state periodically. Started only
3183
+ // after a successful bind so a failed start doesn't leak an interval.
3144
3184
  webStateInterval = setInterval(() => {
3145
3185
  if (coordinator && webDashboard) {
3146
3186
  coordinator.updateLocal(projectId, webDashboard.getSerializableState());
@@ -3150,15 +3190,16 @@ async function startWebDashboard(openBrowser) {
3150
3190
  }
3151
3191
  }, 500);
3152
3192
 
3153
- const { port } = await webDashboard.start();
3154
- WEB_PORT = port;
3155
- writeLock(process.pid, port, coordinator.socketPath);
3156
-
3157
- addLog(`Web dashboard: http://localhost:${port}`, 'success');
3158
- if (openBrowser) openInBrowser(`http://localhost:${port}`);
3193
+ addLog(`Web dashboard: ${localhostUrl(port)}`, 'success');
3194
+ if (openBrowser) openInBrowser(localhostUrl(port));
3159
3195
  render();
3160
3196
  } catch (err) {
3161
3197
  addLog(`Web dashboard failed: ${err.message}`, 'error');
3198
+ if (coordinator) {
3199
+ try { coordinator.stop(); } catch (_) { /* ignore */ }
3200
+ }
3201
+ removeLock();
3202
+ removeSocket();
3162
3203
  webDashboard = null;
3163
3204
  coordinator = null;
3164
3205
  render();
@@ -3285,6 +3326,11 @@ async function shutdown() {
3285
3326
 
3286
3327
  process.on('SIGINT', shutdown);
3287
3328
  process.on('SIGTERM', shutdown);
3329
+ // Clean up long-lived timers on exit, regardless of which code path got us
3330
+ // here (normal exit, uncaught exception, early failure in start()).
3331
+ process.on('exit', () => {
3332
+ if (periodicUpdateCheck) periodicUpdateCheck.stop();
3333
+ });
3288
3334
  process.on('uncaughtException', async (err) => {
3289
3335
  telemetry.captureError(err);
3290
3336
  write(ansi.showCursor);
@@ -3413,11 +3459,11 @@ async function start() {
3413
3459
  // Static mode
3414
3460
  server = createStaticServer();
3415
3461
  server.listen(PORT, '127.0.0.1', () => {
3416
- addLog(`Server started on http://localhost:${PORT}`, 'success');
3462
+ addLog(`Server started on ${localhostUrl(PORT)}`, 'success');
3417
3463
  addLog(`Serving ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`, 'info');
3418
3464
  addLog(`Current branch: ${store.get('currentBranch')}`, 'info');
3419
3465
  // Add server log entries for static server
3420
- addServerLog(`Static server started on http://localhost:${PORT}`);
3466
+ addServerLog(`Static server started on ${localhostUrl(PORT)}`);
3421
3467
  addServerLog(`Serving files from: ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`);
3422
3468
  addServerLog(`Live reload enabled - waiting for browser connections...`);
3423
3469
  render();
@@ -3468,8 +3514,9 @@ async function start() {
3468
3514
  }
3469
3515
  }).catch(() => {});
3470
3516
 
3471
- // Re-check for updates periodically (every 4 hours) while running
3472
- const periodicCheck = startPeriodicUpdateCheck((latestVersion) => {
3517
+ // Re-check for updates periodically (every 4 hours) while running.
3518
+ // Assigned to module scope so the top-level exit handler can stop it.
3519
+ periodicUpdateCheck = startPeriodicUpdateCheck((latestVersion) => {
3473
3520
  const alreadyKnown = store.get('updateAvailable');
3474
3521
  store.setState({ updateAvailable: latestVersion });
3475
3522
  if (!alreadyKnown) {
@@ -3479,9 +3526,6 @@ async function start() {
3479
3526
  }
3480
3527
  render();
3481
3528
  });
3482
-
3483
- // Clean up periodic check on exit
3484
- process.on('exit', () => periodicCheck.stop());
3485
3529
  }
3486
3530
 
3487
3531
  start().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.12.1",
3
+ "version": "1.12.3",
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
@@ -175,7 +175,15 @@ function parseArgs(argv, options = {}) {
175
175
  * @returns {object} Merged config
176
176
  */
177
177
  function applyCliArgsToConfig(config, cliArgs) {
178
- const merged = JSON.parse(JSON.stringify(config)); // deep clone
178
+ // Shallow clone with nested object spreading — the config is at most two
179
+ // levels deep and all values are primitives, so this is equivalent to a deep
180
+ // clone but avoids the JSON round-trip (which silently drops `undefined`,
181
+ // functions, and throws on circular refs).
182
+ const merged = {
183
+ ...config,
184
+ server: { ...config.server },
185
+ web: { ...config.web },
186
+ };
179
187
 
180
188
  // Server settings
181
189
  if (cliArgs.mode !== null) {
@@ -68,13 +68,18 @@ function isProcessAlive(pid) {
68
68
 
69
69
  /**
70
70
  * Read the lock file.
71
- * @returns {{ pid: number, port: number, socketPath: string } | null}
71
+ *
72
+ * A lock may be a placeholder (pid only, no port/socketPath) while a new
73
+ * coordinator is still binding its socket. Callers that need a connectable
74
+ * coordinator should use getActiveCoordinator(), which rejects placeholders.
75
+ *
76
+ * @returns {{ pid: number, port?: number, socketPath?: string, pending?: boolean } | null}
72
77
  */
73
78
  function readLock() {
74
79
  try {
75
80
  if (!fs.existsSync(LOCK_FILE)) return null;
76
81
  const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
77
- if (!data || !data.pid || !data.port) return null;
82
+ if (!data || !data.pid) return null;
78
83
  return data;
79
84
  } catch (e) {
80
85
  return null;
@@ -92,6 +97,66 @@ function writeLock(pid, port, socketPath) {
92
97
  fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid, port, socketPath }, null, 2) + '\n', 'utf8');
93
98
  }
94
99
 
100
+ /**
101
+ * Atomically reserve the coordinator lock.
102
+ *
103
+ * Uses `fs.openSync(..., 'wx')` to create the lock file exclusively, so two
104
+ * instances racing to become coordinator cannot both succeed. A placeholder
105
+ * entry ({ pid, pending: true }) is written immediately so that any process
106
+ * reading the lock while we bind our socket still sees a valid owning PID.
107
+ *
108
+ * If the lock already exists but the owning process is dead, the stale lock
109
+ * (and socket) are cleaned up and the acquisition is retried once.
110
+ *
111
+ * @param {number} pid - PID of the acquiring process
112
+ * @returns {{acquired: true} | {acquired: false, existing: {pid: number, port?: number, socketPath?: string, pending?: boolean} | null}}
113
+ */
114
+ function tryAcquireLock(pid) {
115
+ ensureDir();
116
+
117
+ // One retry after stale-lock cleanup; avoids looping if another process
118
+ // keeps recreating the lock faster than we can clean it up.
119
+ for (let attempt = 0; attempt < 2; attempt++) {
120
+ try {
121
+ const fd = fs.openSync(LOCK_FILE, 'wx');
122
+ try {
123
+ fs.writeSync(fd, JSON.stringify({ pid, pending: true }) + '\n');
124
+ } finally {
125
+ fs.closeSync(fd);
126
+ }
127
+ return { acquired: true };
128
+ } catch (err) {
129
+ if (err.code !== 'EEXIST') throw err;
130
+
131
+ // Lock file exists — check if the owner is alive.
132
+ const existing = readLock();
133
+ if (existing && isProcessAlive(existing.pid)) {
134
+ return { acquired: false, existing };
135
+ }
136
+ // Stale or unreadable — clean up and retry the exclusive create.
137
+ removeLock();
138
+ removeSocket();
139
+ }
140
+ }
141
+
142
+ // Another process raced us to re-create the lock. Treat it as active.
143
+ const existing = readLock();
144
+ return { acquired: false, existing: existing || null };
145
+ }
146
+
147
+ /**
148
+ * Replace the placeholder lock with the final port/socket details after the
149
+ * coordinator has successfully bound its IPC socket and the web server has
150
+ * started listening. Caller must already own the lock via tryAcquireLock().
151
+ *
152
+ * @param {number} pid
153
+ * @param {number} port
154
+ * @param {string} socketPath
155
+ */
156
+ function finalizeLock(pid, port, socketPath) {
157
+ writeLock(pid, port, socketPath);
158
+ }
159
+
95
160
  /**
96
161
  * Remove the lock file.
97
162
  */
@@ -107,18 +172,25 @@ function removeSocket() {
107
172
  }
108
173
 
109
174
  /**
110
- * Check if a coordinator is already running.
111
- * Cleans up stale lock if the process is dead.
175
+ * Check if a coordinator is already running and reachable.
176
+ *
177
+ * Returns null for stale locks (cleans them up) and for placeholder locks
178
+ * that haven't finished binding yet — callers shouldn't try to connect to
179
+ * a coordinator that isn't listening.
180
+ *
112
181
  * @returns {{ pid: number, port: number, socketPath: string } | null}
113
182
  */
114
183
  function getActiveCoordinator() {
115
184
  const lock = readLock();
116
185
  if (!lock) return null;
117
- if (isProcessAlive(lock.pid)) return lock;
118
- // Stale lock — clean up
119
- removeLock();
120
- removeSocket();
121
- return null;
186
+ if (!isProcessAlive(lock.pid)) {
187
+ removeLock();
188
+ removeSocket();
189
+ return null;
190
+ }
191
+ // Placeholder (pending) — coordinator is still binding.
192
+ if (!lock.port || !lock.socketPath) return null;
193
+ return /** @type {{pid:number,port:number,socketPath:string}} */ (lock);
122
194
  }
123
195
 
124
196
  // ─── Coordinator (first instance) ────────────────────────────────
@@ -533,6 +605,8 @@ module.exports = {
533
605
  getActiveCoordinator,
534
606
  readLock,
535
607
  writeLock,
608
+ tryAcquireLock,
609
+ finalizeLock,
536
610
  removeLock,
537
611
  removeSocket,
538
612
  isProcessAlive,