git-watchtower 2.2.1 → 2.2.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/README.md +2 -2
- package/bin/git-watchtower.js +1 -1
- package/package.json +1 -1
- package/src/cli/args.js +17 -4
- package/src/config/schema.js +5 -5
- 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/static.js +13 -5
- package/src/utils/errors.js +0 -27
- package/src/utils/sound.js +39 -12
package/README.md
CHANGED
|
@@ -95,9 +95,9 @@ Real-time branch monitoring, PR workflows, CI status, and session statistics —
|
|
|
95
95
|
|
|
96
96
|
| Mode | Flag | Description |
|
|
97
97
|
|------|------|-------------|
|
|
98
|
-
| **
|
|
98
|
+
| **No Server** | `--no-server` | Branch monitoring only (default) |
|
|
99
|
+
| **Static Site** | `--mode static` | Built-in server with live reload for HTML/CSS/JS |
|
|
99
100
|
| **Custom Command** | `--mode command -c "npm run dev"` | Run your own dev server (Next.js, Vite, Nuxt, etc.) |
|
|
100
|
-
| **No Server** | `--no-server` | Branch monitoring only |
|
|
101
101
|
|
|
102
102
|
## 🎰 Casino Mode
|
|
103
103
|
|
package/bin/git-watchtower.js
CHANGED
|
@@ -862,7 +862,7 @@ let pollIntervalId = null;
|
|
|
862
862
|
const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi, padRight, padLeft, getMaxBranchesForScreen: calcMaxBranches, drawBox: renderBox, clearArea: renderClearArea } = require('../src/ui/ansi');
|
|
863
863
|
|
|
864
864
|
// Error detection utilities imported from src/utils/errors.js
|
|
865
|
-
const {
|
|
865
|
+
const { isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
|
|
866
866
|
const { Mutex, sleep } = require('../src/utils/async');
|
|
867
867
|
|
|
868
868
|
// Keyboard handling utilities imported from src/ui/keybindings.js
|
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
|
|
|
@@ -255,9 +268,9 @@ Usage:
|
|
|
255
268
|
git-watchtower [options]
|
|
256
269
|
|
|
257
270
|
Server Options:
|
|
258
|
-
-m, --mode <mode> Server mode: static, command, or none
|
|
271
|
+
-m, --mode <mode> Server mode: static, command, or none (default: none)
|
|
259
272
|
-p, --port <port> Server port (default: 3000)
|
|
260
|
-
-n, --no-server Shorthand for --mode none
|
|
273
|
+
-n, --no-server Shorthand for --mode none (default)
|
|
261
274
|
--static-dir <dir> Directory for static file serving (default: public)
|
|
262
275
|
-c, --command <cmd> Command to run in command mode (e.g., "npm run dev")
|
|
263
276
|
--restart-on-switch Restart server on branch switch (default)
|
|
@@ -287,9 +300,9 @@ General:
|
|
|
287
300
|
-h, --help Show this help message
|
|
288
301
|
|
|
289
302
|
Server Modes:
|
|
290
|
-
|
|
303
|
+
none Branch monitoring only (default)
|
|
304
|
+
static Serve static files with live reload
|
|
291
305
|
command Run your own dev server (Next.js, Vite, Nuxt, etc.)
|
|
292
|
-
none Branch monitoring only
|
|
293
306
|
|
|
294
307
|
Configuration:
|
|
295
308
|
On first run, Git Watchtower will prompt you to configure settings.
|
package/src/config/schema.js
CHANGED
|
@@ -48,7 +48,7 @@ const SERVER_MODES = ['static', 'command', 'none'];
|
|
|
48
48
|
*/
|
|
49
49
|
const DEFAULTS = {
|
|
50
50
|
server: {
|
|
51
|
-
mode: /** @type {ServerMode} */ ('
|
|
51
|
+
mode: /** @type {ServerMode} */ ('none'),
|
|
52
52
|
staticDir: 'public',
|
|
53
53
|
command: '',
|
|
54
54
|
port: 3000,
|
|
@@ -291,12 +291,12 @@ function migrateConfig(config) {
|
|
|
291
291
|
return validateConfig(config);
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
// Convert old format to new
|
|
294
|
+
// Convert old format to new. Legacy configs predate the 'none' default,
|
|
295
|
+
// so preserve the old behavior: noServer maps to 'none', anything else
|
|
296
|
+
// implies the user wanted the static server that used to be the default.
|
|
295
297
|
const newConfig = getDefaultConfig();
|
|
296
298
|
|
|
297
|
-
|
|
298
|
-
newConfig.server.mode = 'none';
|
|
299
|
-
}
|
|
299
|
+
newConfig.server.mode = config.noServer ? 'none' : 'static';
|
|
300
300
|
if (config.port !== undefined) {
|
|
301
301
|
newConfig.server.port = validatePort(config.port);
|
|
302
302
|
}
|
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
|
};
|
package/src/server/static.js
CHANGED
|
@@ -56,14 +56,22 @@ const LIVE_RELOAD_SCRIPT = `
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Inject live reload script into HTML content.
|
|
59
|
+
*
|
|
60
|
+
* Splits on the LAST `</body>` rather than the first. String.prototype.replace
|
|
61
|
+
* with a string argument only replaces the first match, which mis-injects
|
|
62
|
+
* the script when the page has a literal `</body>` earlier in the document —
|
|
63
|
+
* e.g. inside a `<pre>` or `<code>` block on a docs site rendering HTML
|
|
64
|
+
* samples. The browser only honours the last `</body>` as the actual close,
|
|
65
|
+
* so that's where the script needs to land.
|
|
66
|
+
*
|
|
59
67
|
* @param {string} html - HTML content
|
|
60
|
-
* @returns {string} HTML with live reload script injected before </body>
|
|
68
|
+
* @returns {string} HTML with live reload script injected before the closing </body>
|
|
61
69
|
*/
|
|
62
70
|
function injectLiveReload(html) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return html;
|
|
71
|
+
const closeTag = '</body>';
|
|
72
|
+
const idx = html.lastIndexOf(closeTag);
|
|
73
|
+
if (idx === -1) return html;
|
|
74
|
+
return html.slice(0, idx) + LIVE_RELOAD_SCRIPT + html.slice(idx + closeTag.length);
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
/**
|
package/src/utils/errors.js
CHANGED
|
@@ -252,33 +252,6 @@ class ServerError extends AppError {
|
|
|
252
252
|
this.name = 'ServerError';
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
/**
|
|
256
|
-
* Create ServerError for port in use
|
|
257
|
-
* @param {number} port - The port that's in use
|
|
258
|
-
* @returns {ServerError}
|
|
259
|
-
*/
|
|
260
|
-
static portInUse(port) {
|
|
261
|
-
return new ServerError(
|
|
262
|
-
`Port ${port} is already in use`,
|
|
263
|
-
'PORT_IN_USE',
|
|
264
|
-
{ port }
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Create ServerError for process crash
|
|
270
|
-
* @param {string} command - The command that crashed
|
|
271
|
-
* @param {number} exitCode - Exit code
|
|
272
|
-
* @returns {ServerError}
|
|
273
|
-
*/
|
|
274
|
-
static processCrashed(command, exitCode) {
|
|
275
|
-
return new ServerError(
|
|
276
|
-
`Server process crashed with exit code ${exitCode}`,
|
|
277
|
-
'PROCESS_CRASHED',
|
|
278
|
-
{ command, exitCode }
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
255
|
/**
|
|
283
256
|
* Create ServerError for start failure
|
|
284
257
|
* @param {string} command - The command that failed to start
|
package/src/utils/sound.js
CHANGED
|
@@ -3,30 +3,57 @@
|
|
|
3
3
|
* @module utils/sound
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { execFile } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
const playBell = () => {
|
|
11
|
+
process.stdout.write('\x07');
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Linux audio-tool cascade. Each entry is [command, args]; we try them in
|
|
15
|
+
// order and fall through on non-zero exit, ending in a terminal bell.
|
|
16
|
+
// Mirrors the previous shell `||` chain without spawning a shell.
|
|
17
|
+
const LINUX_ATTEMPTS = [
|
|
18
|
+
['paplay', ['/usr/share/sounds/freedesktop/stereo/message-new-instant.oga']],
|
|
19
|
+
['paplay', ['/usr/share/sounds/freedesktop/stereo/complete.oga']],
|
|
20
|
+
['aplay', ['-q', '/usr/share/sounds/sound-icons/prompt.wav']],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function cascade(attempts, onAllFailed, options) {
|
|
24
|
+
if (attempts.length === 0) {
|
|
25
|
+
onAllFailed();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const [cmd, args] = attempts[0];
|
|
29
|
+
execFile(cmd, args, options, (err) => {
|
|
30
|
+
if (err) cascade(attempts.slice(1), onAllFailed, options);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
7
33
|
|
|
8
34
|
/**
|
|
9
35
|
* Play a system notification sound (non-blocking).
|
|
10
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* Cross-platform: macOS (afplay), Linux (paplay/aplay cascade), Windows
|
|
38
|
+
* (terminal bell). Uses execFile (no shell) to match the pattern in
|
|
39
|
+
* casino/sounds.js — the previous exec calls passed fixed strings so
|
|
40
|
+
* there was no injection surface either way, but dropping the shell
|
|
41
|
+
* removes the per-call /bin/sh fork and makes the two sound modules
|
|
42
|
+
* consistent.
|
|
43
|
+
*
|
|
11
44
|
* @param {object} [options]
|
|
12
|
-
* @param {string} [options.cwd] - Working directory
|
|
45
|
+
* @param {string} [options.cwd] - Working directory
|
|
13
46
|
*/
|
|
14
47
|
function playSound(options = {}) {
|
|
15
48
|
const { platform } = process;
|
|
16
49
|
const cwd = options.cwd || process.cwd();
|
|
17
50
|
|
|
18
51
|
if (platform === 'darwin') {
|
|
19
|
-
|
|
52
|
+
execFile('afplay', ['/System/Library/Sounds/Pop.aiff'], { cwd }, noop);
|
|
20
53
|
} else if (platform === 'linux') {
|
|
21
|
-
|
|
22
|
-
'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
|
|
23
|
-
'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
|
|
24
|
-
'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
|
|
25
|
-
'printf "\\a"',
|
|
26
|
-
{ cwd }
|
|
27
|
-
);
|
|
54
|
+
cascade(LINUX_ATTEMPTS, playBell, { cwd });
|
|
28
55
|
} else {
|
|
29
|
-
|
|
56
|
+
playBell();
|
|
30
57
|
}
|
|
31
58
|
}
|
|
32
59
|
|