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.
- package/bin/git-watchtower.js +95 -51
- package/package.json +1 -1
- package/src/cli/args.js +9 -1
- package/src/server/coordinator.js +83 -9
package/bin/git-watchtower.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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 =
|
|
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(
|
|
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
|
-
//
|
|
3093
|
-
|
|
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 (
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
worker.
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3154
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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))
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|