git-watchtower 2.2.2 → 2.3.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.
- package/package.json +1 -1
- package/src/cli/args.js +13 -0
- package/src/git/branch.js +0 -77
- package/src/index.js +0 -3
- package/src/polling/engine.js +7 -153
- package/src/server/process.js +5 -356
- package/src/server/web-ui/css.js +207 -199
- package/src/server/web-ui/html.js +28 -18
- package/src/server/web-ui/js.js +79 -87
- package/src/utils/sound.js +39 -12
package/package.json
CHANGED
package/src/cli/args.js
CHANGED
|
@@ -169,6 +169,19 @@ function parseArgs(argv, options = {}) {
|
|
|
169
169
|
result.errors.push(`Unknown option: ${args[i]}`);
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
// Cross-validation: if both --port and --web-port are explicit on the
|
|
174
|
+
// CLI, they must differ. The web dashboard's EADDRINUSE-retry loop
|
|
175
|
+
// would silently bump the web port to the next free slot, hiding the
|
|
176
|
+
// misconfiguration — and the user thinks they're hitting :4000 when
|
|
177
|
+
// it's actually :4001. Surface the conflict at parse time instead.
|
|
178
|
+
if (result.port !== null && result.webPort !== null && result.port === result.webPort) {
|
|
179
|
+
result.errors.push(
|
|
180
|
+
`--port and --web-port cannot share the same value (${result.port}). ` +
|
|
181
|
+
`Pick a different value for one of them.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
return result;
|
|
173
186
|
}
|
|
174
187
|
|
package/src/git/branch.js
CHANGED
|
@@ -198,81 +198,6 @@ async function getAllBranches(options = {}) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/**
|
|
202
|
-
* Detect changes between two branch lists
|
|
203
|
-
* @param {Branch[]} oldBranches - Previous branch list
|
|
204
|
-
* @param {Branch[]} newBranches - Current branch list
|
|
205
|
-
* @returns {{added: Branch[], removed: Branch[], updated: Branch[]}}
|
|
206
|
-
*/
|
|
207
|
-
function detectBranchChanges(oldBranches, newBranches) {
|
|
208
|
-
const oldNames = new Map(oldBranches.map((b) => [b.name, b]));
|
|
209
|
-
const newNames = new Map(newBranches.map((b) => [b.name, b]));
|
|
210
|
-
|
|
211
|
-
const added = newBranches.filter((b) => !oldNames.has(b.name));
|
|
212
|
-
const removed = oldBranches.filter((b) => !newNames.has(b.name));
|
|
213
|
-
const updated = newBranches.filter((b) => {
|
|
214
|
-
const old = oldNames.get(b.name);
|
|
215
|
-
return old && old.commit !== b.commit;
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
return { added, removed, updated };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Check out a branch
|
|
223
|
-
* @param {string} branchName - Branch to check out
|
|
224
|
-
* @param {Object} [options] - Options
|
|
225
|
-
* @param {string} [options.remoteName='origin'] - Remote name
|
|
226
|
-
* @param {boolean} [options.force=false] - Force checkout (discard changes)
|
|
227
|
-
* @param {string} [options.cwd] - Working directory
|
|
228
|
-
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
229
|
-
*/
|
|
230
|
-
async function checkout(branchName, options = {}) {
|
|
231
|
-
const { remoteName = 'origin', force = false, cwd } = options;
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
// Validate branch name
|
|
235
|
-
const safeName = sanitizeBranchName(branchName);
|
|
236
|
-
|
|
237
|
-
// Check for uncommitted changes (unless force)
|
|
238
|
-
if (!force && (await hasUncommittedChanges(cwd))) {
|
|
239
|
-
return {
|
|
240
|
-
success: false,
|
|
241
|
-
error: new GitError(
|
|
242
|
-
'Cannot switch: uncommitted changes in working directory',
|
|
243
|
-
'GIT_DIRTY_WORKDIR'
|
|
244
|
-
),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Check if local branch exists
|
|
249
|
-
const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd });
|
|
250
|
-
const hasLocal = localBranches
|
|
251
|
-
.split('\n')
|
|
252
|
-
.some((b) => b.trim().replace(/^\* /, '') === safeName);
|
|
253
|
-
|
|
254
|
-
if (hasLocal) {
|
|
255
|
-
// Local branch exists - just check out
|
|
256
|
-
if (force) {
|
|
257
|
-
await execGit(['checkout', '--', '.'], { cwd });
|
|
258
|
-
await execGit(['checkout', safeName], { cwd });
|
|
259
|
-
} else {
|
|
260
|
-
await execGit(['checkout', safeName], { cwd });
|
|
261
|
-
}
|
|
262
|
-
} else {
|
|
263
|
-
// Create local branch from remote
|
|
264
|
-
await execGit(['checkout', '-b', safeName, `${remoteName}/${safeName}`], { cwd });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return { success: true };
|
|
268
|
-
} catch (error) {
|
|
269
|
-
return {
|
|
270
|
-
success: false,
|
|
271
|
-
error: error instanceof GitError ? error : GitError.fromExecError(error, 'checkout'),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
201
|
/**
|
|
277
202
|
* Get preview data for a branch
|
|
278
203
|
* @param {string} branchName - Branch name
|
|
@@ -449,8 +374,6 @@ module.exports = {
|
|
|
449
374
|
sanitizeBranchName,
|
|
450
375
|
getCurrentBranch,
|
|
451
376
|
getAllBranches,
|
|
452
|
-
detectBranchChanges,
|
|
453
|
-
checkout,
|
|
454
377
|
getPreviewData,
|
|
455
378
|
generateSparkline,
|
|
456
379
|
getLocalBranches,
|
package/src/index.js
CHANGED
|
@@ -123,8 +123,6 @@ module.exports = {
|
|
|
123
123
|
sanitizeBranchName: gitBranch.sanitizeBranchName,
|
|
124
124
|
getCurrentBranch: gitBranch.getCurrentBranch,
|
|
125
125
|
getAllBranches: gitBranch.getAllBranches,
|
|
126
|
-
detectBranchChanges: gitBranch.detectBranchChanges,
|
|
127
|
-
checkout: gitBranch.checkout,
|
|
128
126
|
getPreviewData: gitBranch.getPreviewData,
|
|
129
127
|
generateSparkline: gitBranch.generateSparkline,
|
|
130
128
|
getLocalBranches: gitBranch.getLocalBranches,
|
|
@@ -153,7 +151,6 @@ module.exports = {
|
|
|
153
151
|
deleteConfig: configLoader.deleteConfig,
|
|
154
152
|
|
|
155
153
|
// Server process management
|
|
156
|
-
ProcessManager: serverProcess.ProcessManager,
|
|
157
154
|
parseCommand: serverProcess.parseCommand,
|
|
158
155
|
|
|
159
156
|
// Web dashboard server
|
package/src/polling/engine.js
CHANGED
|
@@ -1,154 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Polling engine — pure logic for branch
|
|
2
|
+
* Polling engine — pure logic for branch tracking-set maintenance.
|
|
3
|
+
*
|
|
4
|
+
* Note: this module previously also exported helpers like detectNewBranches,
|
|
5
|
+
* detectDeletedBranches, sortBranches, calculateAdaptiveInterval, etc., but
|
|
6
|
+
* the bin reimplements those flows inline. Only pruneStaleEntries is wired
|
|
7
|
+
* up in production, so that's all that lives here now.
|
|
8
|
+
*
|
|
3
9
|
* @module polling/engine
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
|
-
const { isBaseBranch } = require('../git/pr');
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Detect new branches not previously known.
|
|
10
|
-
* @param {Array<{name: string, isNew?: boolean, newAt?: number}>} allBranches - All currently fetched branches
|
|
11
|
-
* @param {Set<string>} knownBranchNames - Previously known branch names
|
|
12
|
-
* @returns {Array<{name: string, isNew?: boolean, newAt?: number}>} Branches with isNew flag
|
|
13
|
-
*/
|
|
14
|
-
function detectNewBranches(allBranches, knownBranchNames) {
|
|
15
|
-
const now = Date.now();
|
|
16
|
-
const newBranches = [];
|
|
17
|
-
for (const branch of allBranches) {
|
|
18
|
-
if (!knownBranchNames.has(branch.name)) {
|
|
19
|
-
branch.isNew = true;
|
|
20
|
-
branch.newAt = now;
|
|
21
|
-
newBranches.push(branch);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return newBranches;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Detect deleted branches (were known but no longer exist in fetched set).
|
|
29
|
-
* @param {Set<string>} knownBranchNames - Previously known branch names
|
|
30
|
-
* @param {Set<string>} fetchedBranchNames - Currently fetched branch names
|
|
31
|
-
* @param {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} existingBranches - Previous branch list
|
|
32
|
-
* @returns {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} Deleted branches
|
|
33
|
-
*/
|
|
34
|
-
function detectDeletedBranches(knownBranchNames, fetchedBranchNames, existingBranches) {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
const deleted = [];
|
|
37
|
-
for (const knownName of knownBranchNames) {
|
|
38
|
-
if (!fetchedBranchNames.has(knownName)) {
|
|
39
|
-
const existing = existingBranches.find(b => b.name === knownName);
|
|
40
|
-
if (existing && !existing.isDeleted) {
|
|
41
|
-
existing.isDeleted = true;
|
|
42
|
-
existing.deletedAt = now;
|
|
43
|
-
deleted.push(existing);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return deleted;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Detect branches that have been updated (commit changed) since last poll.
|
|
52
|
-
* @param {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} branches - Current branch list
|
|
53
|
-
* @param {Map<string, string>} previousStates - Map of branch name -> previous commit hash
|
|
54
|
-
* @param {string} currentBranch - Name of current branch (excluded from updates)
|
|
55
|
-
* @returns {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} Updated branches
|
|
56
|
-
*/
|
|
57
|
-
function detectUpdatedBranches(branches, previousStates, currentBranch) {
|
|
58
|
-
const updated = [];
|
|
59
|
-
for (const branch of branches) {
|
|
60
|
-
// Clear previous cycle's flag so only freshly-updated branches are highlighted
|
|
61
|
-
branch.justUpdated = false;
|
|
62
|
-
if (branch.isDeleted) continue;
|
|
63
|
-
const prevCommit = previousStates.get(branch.name);
|
|
64
|
-
if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
|
|
65
|
-
branch.justUpdated = true;
|
|
66
|
-
updated.push(branch);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return updated;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Sort branches: new first, then by date, merged near bottom, deleted at bottom.
|
|
74
|
-
* @param {Array} branches - Branch list to sort
|
|
75
|
-
* @param {Map} prStatusMap - Map of branch name -> PR status
|
|
76
|
-
* @returns {Array} Sorted branches (mutates and returns input)
|
|
77
|
-
*/
|
|
78
|
-
function sortBranches(branches, prStatusMap) {
|
|
79
|
-
return branches.sort((a, b) => {
|
|
80
|
-
const aIsBase = isBaseBranch(a.name);
|
|
81
|
-
const bIsBase = isBaseBranch(b.name);
|
|
82
|
-
const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
|
|
83
|
-
const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
|
|
84
|
-
if (a.isDeleted && !b.isDeleted) return 1;
|
|
85
|
-
if (!a.isDeleted && b.isDeleted) return -1;
|
|
86
|
-
if (aMerged && !bMerged && !b.isDeleted) return 1;
|
|
87
|
-
if (!aMerged && bMerged && !a.isDeleted) return -1;
|
|
88
|
-
if (a.isNew && !b.isNew) return -1;
|
|
89
|
-
if (!a.isNew && b.isNew) return 1;
|
|
90
|
-
return (b.date || 0) - (a.date || 0);
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Calculate adaptive polling interval based on fetch duration.
|
|
96
|
-
* @param {number} fetchDuration - How long the fetch took (ms)
|
|
97
|
-
* @param {number} currentInterval - Current polling interval (ms)
|
|
98
|
-
* @param {number} baseInterval - Base/default polling interval (ms)
|
|
99
|
-
* @returns {{ interval: number, warning: string|null }}
|
|
100
|
-
*/
|
|
101
|
-
function calculateAdaptiveInterval(fetchDuration, currentInterval, baseInterval) {
|
|
102
|
-
if (fetchDuration > 30000) {
|
|
103
|
-
return {
|
|
104
|
-
interval: Math.min(currentInterval * 2, 60000),
|
|
105
|
-
warning: 'very_slow',
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
if (fetchDuration > 15000) {
|
|
109
|
-
return {
|
|
110
|
-
interval: currentInterval,
|
|
111
|
-
warning: 'slow',
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
if (fetchDuration < 5000 && currentInterval > baseInterval) {
|
|
115
|
-
return {
|
|
116
|
-
interval: baseInterval,
|
|
117
|
-
warning: 'restored',
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
interval: currentInterval,
|
|
122
|
-
warning: null,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Restore selection index after branch list reorder.
|
|
128
|
-
* @param {Array<{name: string}>} branches - New branch list
|
|
129
|
-
* @param {string|null} previousName - Previously selected branch name
|
|
130
|
-
* @param {number} previousIndex - Previously selected index
|
|
131
|
-
* @returns {{ selectedIndex: number, selectedBranchName: string|null }}
|
|
132
|
-
*/
|
|
133
|
-
function restoreSelection(branches, previousName, previousIndex) {
|
|
134
|
-
if (previousName) {
|
|
135
|
-
const newIndex = branches.findIndex(b => b.name === previousName);
|
|
136
|
-
if (newIndex >= 0) {
|
|
137
|
-
return { selectedIndex: newIndex, selectedBranchName: previousName };
|
|
138
|
-
}
|
|
139
|
-
const clampedIndex = Math.min(previousIndex, Math.max(0, branches.length - 1));
|
|
140
|
-
return {
|
|
141
|
-
selectedIndex: clampedIndex,
|
|
142
|
-
selectedBranchName: branches[clampedIndex] ? branches[clampedIndex].name : null,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
if (previousIndex >= branches.length) {
|
|
146
|
-
const idx = Math.max(0, branches.length - 1);
|
|
147
|
-
return { selectedIndex: idx, selectedBranchName: branches[idx] ? branches[idx].name : null };
|
|
148
|
-
}
|
|
149
|
-
return { selectedIndex: previousIndex, selectedBranchName: previousName };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
12
|
/**
|
|
153
13
|
* Prune stale entries from tracking sets and caches for branches
|
|
154
14
|
* that no longer exist in git.
|
|
@@ -182,11 +42,5 @@ function pruneStaleEntries({ knownBranchNames, fetchedBranchNames, allBranches,
|
|
|
182
42
|
}
|
|
183
43
|
|
|
184
44
|
module.exports = {
|
|
185
|
-
detectNewBranches,
|
|
186
|
-
detectDeletedBranches,
|
|
187
|
-
detectUpdatedBranches,
|
|
188
|
-
sortBranches,
|
|
189
|
-
calculateAdaptiveInterval,
|
|
190
|
-
restoreSelection,
|
|
191
45
|
pruneStaleEntries,
|
|
192
46
|
};
|
package/src/server/process.js
CHANGED
|
@@ -1,34 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server process management for command mode
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const { ServerError } = require('../utils/errors');
|
|
8
|
-
const { Mutex } = require('../utils/async');
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* @typedef {Object} ServerProcessState
|
|
12
|
-
* @property {import('child_process').ChildProcess|null} process - The server process
|
|
13
|
-
* @property {boolean} running - Is the server running
|
|
14
|
-
* @property {boolean} crashed - Did the server crash
|
|
15
|
-
* @property {Array<{timestamp: string, line: string, isError: boolean}>} logs - Server logs
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Maximum log lines to keep in buffer
|
|
20
|
-
*/
|
|
21
|
-
const MAX_LOG_LINES = 500;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Grace period before force kill (ms)
|
|
25
|
-
*/
|
|
26
|
-
const KILL_GRACE_PERIOD = 3000;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Restart delay after stop (ms)
|
|
2
|
+
* Server process management — command parsing for command mode
|
|
3
|
+
*
|
|
4
|
+
* Note: the bin's `startServerProcess` / `stopServerProcess` /
|
|
5
|
+
* `restartServerProcess` implement the actual lifecycle inline; this
|
|
6
|
+
* file only exports the parseCommand helper they use.
|
|
30
7
|
*/
|
|
31
|
-
const RESTART_DELAY = 500;
|
|
32
8
|
|
|
33
9
|
/**
|
|
34
10
|
* Parse a command string into command and arguments.
|
|
@@ -96,333 +72,6 @@ function parseCommand(commandString) {
|
|
|
96
72
|
};
|
|
97
73
|
}
|
|
98
74
|
|
|
99
|
-
/**
|
|
100
|
-
* Server process manager
|
|
101
|
-
*/
|
|
102
|
-
class ProcessManager {
|
|
103
|
-
/**
|
|
104
|
-
* @param {Object} [options]
|
|
105
|
-
* @param {string} [options.cwd] - Working directory
|
|
106
|
-
* @param {Function} [options.onLog] - Log callback (line, isError)
|
|
107
|
-
* @param {Function} [options.onStateChange] - State change callback (state)
|
|
108
|
-
*/
|
|
109
|
-
constructor(options = {}) {
|
|
110
|
-
this.cwd = options.cwd || process.cwd();
|
|
111
|
-
this.onLog = options.onLog || (() => {});
|
|
112
|
-
this.onStateChange = options.onStateChange || (() => {});
|
|
113
|
-
|
|
114
|
-
this.process = null;
|
|
115
|
-
this.running = false;
|
|
116
|
-
this.crashed = false;
|
|
117
|
-
this.logs = [];
|
|
118
|
-
this.command = '';
|
|
119
|
-
this._restartMutex = new Mutex();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Get current state
|
|
124
|
-
* @returns {ServerProcessState}
|
|
125
|
-
*/
|
|
126
|
-
getState() {
|
|
127
|
-
return {
|
|
128
|
-
process: this.process,
|
|
129
|
-
running: this.running,
|
|
130
|
-
crashed: this.crashed,
|
|
131
|
-
logs: [...this.logs],
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Add a log entry
|
|
137
|
-
* @param {string} line - Log line
|
|
138
|
-
* @param {boolean} [isError=false] - Is error output
|
|
139
|
-
*/
|
|
140
|
-
addLog(line, isError = false) {
|
|
141
|
-
const entry = {
|
|
142
|
-
timestamp: new Date().toLocaleTimeString(),
|
|
143
|
-
line,
|
|
144
|
-
isError,
|
|
145
|
-
};
|
|
146
|
-
this.logs.push(entry);
|
|
147
|
-
if (this.logs.length > MAX_LOG_LINES) {
|
|
148
|
-
this.logs.shift();
|
|
149
|
-
}
|
|
150
|
-
this.onLog(line, isError);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Clear log buffer
|
|
155
|
-
*/
|
|
156
|
-
clearLogs() {
|
|
157
|
-
this.logs = [];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Start the server process
|
|
162
|
-
* @param {string} commandString - Command to run
|
|
163
|
-
* @returns {{success: boolean, error?: Error, pid?: number}}
|
|
164
|
-
*/
|
|
165
|
-
start(commandString) {
|
|
166
|
-
if (!commandString) {
|
|
167
|
-
return {
|
|
168
|
-
success: false,
|
|
169
|
-
error: ServerError.startFailed(commandString, 'No command specified'),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Stop existing process first
|
|
174
|
-
if (this.process) {
|
|
175
|
-
this.stop();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
this.clearLogs();
|
|
179
|
-
this.crashed = false;
|
|
180
|
-
this.running = false;
|
|
181
|
-
this.command = commandString;
|
|
182
|
-
|
|
183
|
-
this.addLog(`$ ${commandString}`);
|
|
184
|
-
|
|
185
|
-
// Parse command
|
|
186
|
-
const { command, args } = parseCommand(commandString);
|
|
187
|
-
|
|
188
|
-
if (!command) {
|
|
189
|
-
const error = ServerError.startFailed(commandString, 'Invalid command');
|
|
190
|
-
this.crashed = true;
|
|
191
|
-
this.addLog(`Failed to start: Invalid command`, true);
|
|
192
|
-
this.notifyStateChange();
|
|
193
|
-
return { success: false, error };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Spawn options
|
|
197
|
-
const isWindows = process.platform === 'win32';
|
|
198
|
-
/** @type {import('child_process').SpawnOptions} */
|
|
199
|
-
const spawnOptions = {
|
|
200
|
-
cwd: this.cwd,
|
|
201
|
-
env: { ...process.env, FORCE_COLOR: '1' },
|
|
202
|
-
shell: isWindows,
|
|
203
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
204
|
-
// On Unix, create a new process group so we can kill the entire tree
|
|
205
|
-
// (e.g. npm -> node -> next). On Windows, taskkill /t handles this.
|
|
206
|
-
detached: !isWindows,
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
this.process = spawn(command, args, spawnOptions);
|
|
211
|
-
this.running = true;
|
|
212
|
-
|
|
213
|
-
// Handle stdout
|
|
214
|
-
this.process.stdout?.on('data', (data) => {
|
|
215
|
-
const lines = data.toString().split('\n').filter(Boolean);
|
|
216
|
-
lines.forEach((line) => this.addLog(line));
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Handle stderr
|
|
220
|
-
this.process.stderr?.on('data', (data) => {
|
|
221
|
-
const lines = data.toString().split('\n').filter(Boolean);
|
|
222
|
-
lines.forEach((line) => this.addLog(line, true));
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// Handle error
|
|
226
|
-
this.process.on('error', (err) => {
|
|
227
|
-
this.running = false;
|
|
228
|
-
this.crashed = true;
|
|
229
|
-
this.addLog(`Error: ${err.message}`, true);
|
|
230
|
-
this.notifyStateChange();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
// Handle close
|
|
234
|
-
this.process.on('close', (code) => {
|
|
235
|
-
this.running = false;
|
|
236
|
-
if (code !== 0 && code !== null) {
|
|
237
|
-
this.crashed = true;
|
|
238
|
-
this.addLog(`Process exited with code ${code}`, true);
|
|
239
|
-
} else {
|
|
240
|
-
this.addLog('Process stopped');
|
|
241
|
-
}
|
|
242
|
-
this.process = null;
|
|
243
|
-
this.notifyStateChange();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
this.notifyStateChange();
|
|
247
|
-
return { success: true, pid: this.process.pid };
|
|
248
|
-
} catch (err) {
|
|
249
|
-
this.crashed = true;
|
|
250
|
-
this.addLog(`Failed to start: ${err.message}`, true);
|
|
251
|
-
this.notifyStateChange();
|
|
252
|
-
return {
|
|
253
|
-
success: false,
|
|
254
|
-
error: ServerError.startFailed(commandString, err.message),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Stop the server process
|
|
261
|
-
* @returns {boolean} - True if a process was stopped
|
|
262
|
-
*/
|
|
263
|
-
stop() {
|
|
264
|
-
if (!this.process) {
|
|
265
|
-
return false;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Capture reference before nulling — needed for deferred force-kill
|
|
269
|
-
const proc = this.process;
|
|
270
|
-
|
|
271
|
-
if (process.platform === 'win32') {
|
|
272
|
-
this._stopWindows(proc);
|
|
273
|
-
} else {
|
|
274
|
-
this._stopUnix(proc);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
this.process = null;
|
|
278
|
-
this.running = false;
|
|
279
|
-
this.notifyStateChange();
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Unix stop: SIGTERM the process group, then SIGKILL after a grace period.
|
|
285
|
-
* The grace timer is unref'd so it doesn't keep the event loop alive when
|
|
286
|
-
* the main process wants to exit.
|
|
287
|
-
* @param {import('child_process').ChildProcess} proc
|
|
288
|
-
* @private
|
|
289
|
-
*/
|
|
290
|
-
_stopUnix(proc) {
|
|
291
|
-
// If the process has already exited, there's nothing to signal.
|
|
292
|
-
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
293
|
-
|
|
294
|
-
try {
|
|
295
|
-
process.kill(-proc.pid, 'SIGTERM');
|
|
296
|
-
} catch (e) {
|
|
297
|
-
// Process group may already be dead
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const forceKillTimeout = setTimeout(() => {
|
|
302
|
-
// Re-check: process may have exited during the grace period.
|
|
303
|
-
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
304
|
-
try {
|
|
305
|
-
process.kill(-proc.pid, 'SIGKILL');
|
|
306
|
-
} catch (e) {
|
|
307
|
-
// Process group may already be dead
|
|
308
|
-
}
|
|
309
|
-
}, KILL_GRACE_PERIOD);
|
|
310
|
-
|
|
311
|
-
// Don't let this timer keep the event loop alive on shutdown.
|
|
312
|
-
forceKillTimeout.unref();
|
|
313
|
-
|
|
314
|
-
// Clear early if the process exits before the grace period.
|
|
315
|
-
proc.once('close', () => {
|
|
316
|
-
clearTimeout(forceKillTimeout);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Windows stop: taskkill /t (tree kill). If the process doesn't exit
|
|
322
|
-
* within the grace period, retry with /f (force).
|
|
323
|
-
* @param {import('child_process').ChildProcess} proc
|
|
324
|
-
* @private
|
|
325
|
-
*/
|
|
326
|
-
_stopWindows(proc) {
|
|
327
|
-
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
spawn('taskkill', ['/pid', proc.pid.toString(), '/t']);
|
|
331
|
-
} catch (e) {
|
|
332
|
-
// Ignore spawn errors (PID already gone, etc.)
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Fallback: force-kill if the process is still alive after the
|
|
337
|
-
// grace period. This mirrors the Unix SIGTERM → SIGKILL pattern.
|
|
338
|
-
const forceKillTimeout = setTimeout(() => {
|
|
339
|
-
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
340
|
-
try {
|
|
341
|
-
spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
|
|
342
|
-
} catch (e) {
|
|
343
|
-
// Ignore — process may already be dead
|
|
344
|
-
}
|
|
345
|
-
}, KILL_GRACE_PERIOD);
|
|
346
|
-
|
|
347
|
-
forceKillTimeout.unref();
|
|
348
|
-
|
|
349
|
-
proc.once('close', () => {
|
|
350
|
-
clearTimeout(forceKillTimeout);
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Restart the server process.
|
|
356
|
-
*
|
|
357
|
-
* Waits for the old process to fully exit (so it releases its port)
|
|
358
|
-
* rather than sleeping a static RESTART_DELAY that is shorter than
|
|
359
|
-
* the SIGKILL grace period. Bounded by KILL_GRACE_PERIOD + a small
|
|
360
|
-
* margin so we never hang indefinitely if 'close' doesn't fire.
|
|
361
|
-
*
|
|
362
|
-
* @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
|
|
363
|
-
*/
|
|
364
|
-
async restart() {
|
|
365
|
-
return this._restartMutex.withLock(async () => {
|
|
366
|
-
const command = this.command;
|
|
367
|
-
// Capture before stop() nulls this.process.
|
|
368
|
-
const oldProc = this.process;
|
|
369
|
-
|
|
370
|
-
this.stop();
|
|
371
|
-
|
|
372
|
-
if (oldProc && oldProc.exitCode === null && oldProc.signalCode === null) {
|
|
373
|
-
// Old process hasn't exited yet — wait for 'close' with a bounded
|
|
374
|
-
// timeout so we don't hang if the process ignores all signals.
|
|
375
|
-
await new Promise((resolve) => {
|
|
376
|
-
const timeout = setTimeout(resolve, KILL_GRACE_PERIOD + RESTART_DELAY);
|
|
377
|
-
timeout.unref();
|
|
378
|
-
oldProc.once('close', () => {
|
|
379
|
-
clearTimeout(timeout);
|
|
380
|
-
resolve();
|
|
381
|
-
});
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return this.start(command);
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Check if server is running
|
|
391
|
-
* @returns {boolean}
|
|
392
|
-
*/
|
|
393
|
-
isRunning() {
|
|
394
|
-
return this.running;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Check if server crashed
|
|
399
|
-
* @returns {boolean}
|
|
400
|
-
*/
|
|
401
|
-
hasCrashed() {
|
|
402
|
-
return this.crashed;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Get server PID
|
|
407
|
-
* @returns {number|null}
|
|
408
|
-
*/
|
|
409
|
-
getPid() {
|
|
410
|
-
return this.process ? this.process.pid : null;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Notify state change
|
|
415
|
-
* @private
|
|
416
|
-
*/
|
|
417
|
-
notifyStateChange() {
|
|
418
|
-
this.onStateChange(this.getState());
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
75
|
module.exports = {
|
|
423
|
-
ProcessManager,
|
|
424
76
|
parseCommand,
|
|
425
|
-
MAX_LOG_LINES,
|
|
426
|
-
KILL_GRACE_PERIOD,
|
|
427
|
-
RESTART_DELAY,
|
|
428
77
|
};
|