git-watchtower 1.6.1 → 1.7.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/bin/git-watchtower.js +49 -2
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration file loading and saving
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { ConfigError } = require('../utils/errors');
|
|
8
|
+
const { getDefaultConfig, validateConfig, migrateConfig } = require('./schema');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default configuration file name
|
|
12
|
+
*/
|
|
13
|
+
const CONFIG_FILE_NAME = '.watchtowerrc.json';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the configuration file path
|
|
17
|
+
* @param {string} [projectRoot] - Project root directory
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function getConfigPath(projectRoot = process.cwd()) {
|
|
21
|
+
return path.join(projectRoot, CONFIG_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if configuration file exists
|
|
26
|
+
* @param {string} [projectRoot] - Project root directory
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function configExists(projectRoot) {
|
|
30
|
+
return fs.existsSync(getConfigPath(projectRoot));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load configuration from file
|
|
35
|
+
* @param {string} [projectRoot] - Project root directory
|
|
36
|
+
* @returns {Object|null} - Raw config object or null if not found
|
|
37
|
+
* @throws {ConfigError} - If file exists but cannot be parsed
|
|
38
|
+
*/
|
|
39
|
+
function loadConfigRaw(projectRoot) {
|
|
40
|
+
const configPath = getConfigPath(projectRoot);
|
|
41
|
+
|
|
42
|
+
if (!fs.existsSync(configPath)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
48
|
+
return JSON.parse(content);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof SyntaxError) {
|
|
51
|
+
throw ConfigError.parseError(error);
|
|
52
|
+
}
|
|
53
|
+
throw new ConfigError(
|
|
54
|
+
`Failed to read configuration: ${error.message}`,
|
|
55
|
+
'CONFIG_READ_ERROR',
|
|
56
|
+
{ path: configPath }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load and validate configuration from file
|
|
63
|
+
* @param {string} [projectRoot] - Project root directory
|
|
64
|
+
* @returns {Object|null} - Validated config or null if not found
|
|
65
|
+
* @throws {ConfigError} - If config is invalid
|
|
66
|
+
*/
|
|
67
|
+
function loadConfig(projectRoot) {
|
|
68
|
+
const raw = loadConfigRaw(projectRoot);
|
|
69
|
+
|
|
70
|
+
if (!raw) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Migrate old format if needed
|
|
75
|
+
return migrateConfig(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Save configuration to file
|
|
80
|
+
* @param {Object} config - Configuration to save
|
|
81
|
+
* @param {string} [projectRoot] - Project root directory
|
|
82
|
+
* @throws {ConfigError} - If save fails
|
|
83
|
+
*/
|
|
84
|
+
function saveConfig(config, projectRoot) {
|
|
85
|
+
const configPath = getConfigPath(projectRoot);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Validate before saving
|
|
89
|
+
const validated = validateConfig(config);
|
|
90
|
+
const content = JSON.stringify(validated, null, 2) + '\n';
|
|
91
|
+
fs.writeFileSync(configPath, content, 'utf8');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof ConfigError) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
throw new ConfigError(
|
|
97
|
+
`Failed to save configuration: ${error.message}`,
|
|
98
|
+
'CONFIG_WRITE_ERROR',
|
|
99
|
+
{ path: configPath }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delete configuration file
|
|
106
|
+
* @param {string} [projectRoot] - Project root directory
|
|
107
|
+
* @returns {boolean} - True if deleted, false if didn't exist
|
|
108
|
+
*/
|
|
109
|
+
function deleteConfig(projectRoot) {
|
|
110
|
+
const configPath = getConfigPath(projectRoot);
|
|
111
|
+
|
|
112
|
+
if (!fs.existsSync(configPath)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fs.unlinkSync(configPath);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply CLI arguments to configuration
|
|
122
|
+
* CLI args take precedence over config file values
|
|
123
|
+
* @param {Object} config - Base configuration
|
|
124
|
+
* @param {Object} cliArgs - CLI arguments
|
|
125
|
+
* @returns {Object} - Merged configuration
|
|
126
|
+
*/
|
|
127
|
+
function applyCliArgs(config, cliArgs) {
|
|
128
|
+
const result = JSON.parse(JSON.stringify(config)); // Deep clone
|
|
129
|
+
|
|
130
|
+
// Server settings
|
|
131
|
+
if (cliArgs.mode !== undefined && cliArgs.mode !== null) {
|
|
132
|
+
result.server.mode = cliArgs.mode;
|
|
133
|
+
}
|
|
134
|
+
if (cliArgs.noServer) {
|
|
135
|
+
result.server.mode = 'none';
|
|
136
|
+
}
|
|
137
|
+
if (cliArgs.port !== undefined && cliArgs.port !== null) {
|
|
138
|
+
result.server.port = cliArgs.port;
|
|
139
|
+
}
|
|
140
|
+
if (cliArgs.staticDir !== undefined && cliArgs.staticDir !== null) {
|
|
141
|
+
result.server.staticDir = cliArgs.staticDir;
|
|
142
|
+
}
|
|
143
|
+
if (cliArgs.command !== undefined && cliArgs.command !== null) {
|
|
144
|
+
result.server.command = cliArgs.command;
|
|
145
|
+
}
|
|
146
|
+
if (cliArgs.restartOnSwitch !== undefined && cliArgs.restartOnSwitch !== null) {
|
|
147
|
+
result.server.restartOnSwitch = cliArgs.restartOnSwitch;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Git settings
|
|
151
|
+
if (cliArgs.remote !== undefined && cliArgs.remote !== null) {
|
|
152
|
+
result.remoteName = cliArgs.remote;
|
|
153
|
+
}
|
|
154
|
+
if (cliArgs.autoPull !== undefined && cliArgs.autoPull !== null) {
|
|
155
|
+
result.autoPull = cliArgs.autoPull;
|
|
156
|
+
}
|
|
157
|
+
if (cliArgs.pollInterval !== undefined && cliArgs.pollInterval !== null) {
|
|
158
|
+
result.gitPollInterval = cliArgs.pollInterval;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// UI settings
|
|
162
|
+
if (cliArgs.sound !== undefined && cliArgs.sound !== null) {
|
|
163
|
+
result.soundEnabled = cliArgs.sound;
|
|
164
|
+
}
|
|
165
|
+
if (cliArgs.visibleBranches !== undefined && cliArgs.visibleBranches !== null) {
|
|
166
|
+
result.visibleBranches = cliArgs.visibleBranches;
|
|
167
|
+
}
|
|
168
|
+
if (cliArgs.casino !== undefined && cliArgs.casino !== null) {
|
|
169
|
+
result.casinoMode = cliArgs.casino;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse CLI arguments
|
|
177
|
+
* @param {string[]} [argv] - Command line arguments (defaults to process.argv.slice(2))
|
|
178
|
+
* @returns {Object} - Parsed arguments
|
|
179
|
+
*/
|
|
180
|
+
function parseCliArgs(argv = process.argv.slice(2)) {
|
|
181
|
+
const result = {
|
|
182
|
+
// Server settings
|
|
183
|
+
mode: null,
|
|
184
|
+
noServer: false,
|
|
185
|
+
port: null,
|
|
186
|
+
staticDir: null,
|
|
187
|
+
command: null,
|
|
188
|
+
restartOnSwitch: null,
|
|
189
|
+
// Git settings
|
|
190
|
+
remote: null,
|
|
191
|
+
autoPull: null,
|
|
192
|
+
pollInterval: null,
|
|
193
|
+
// UI settings
|
|
194
|
+
sound: null,
|
|
195
|
+
visibleBranches: null,
|
|
196
|
+
casino: null,
|
|
197
|
+
// Special flags
|
|
198
|
+
init: false,
|
|
199
|
+
help: false,
|
|
200
|
+
version: false,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < argv.length; i++) {
|
|
204
|
+
const arg = argv[i];
|
|
205
|
+
|
|
206
|
+
switch (arg) {
|
|
207
|
+
// Server settings
|
|
208
|
+
case '--mode':
|
|
209
|
+
result.mode = argv[++i];
|
|
210
|
+
break;
|
|
211
|
+
case '--no-server':
|
|
212
|
+
result.noServer = true;
|
|
213
|
+
break;
|
|
214
|
+
case '--port':
|
|
215
|
+
case '-p':
|
|
216
|
+
result.port = parseInt(argv[++i], 10);
|
|
217
|
+
break;
|
|
218
|
+
case '--static-dir':
|
|
219
|
+
result.staticDir = argv[++i];
|
|
220
|
+
break;
|
|
221
|
+
case '--command':
|
|
222
|
+
case '-c':
|
|
223
|
+
result.command = argv[++i];
|
|
224
|
+
break;
|
|
225
|
+
case '--restart-on-switch':
|
|
226
|
+
result.restartOnSwitch = true;
|
|
227
|
+
break;
|
|
228
|
+
case '--no-restart-on-switch':
|
|
229
|
+
result.restartOnSwitch = false;
|
|
230
|
+
break;
|
|
231
|
+
|
|
232
|
+
// Git settings
|
|
233
|
+
case '--remote':
|
|
234
|
+
case '-r':
|
|
235
|
+
result.remote = argv[++i];
|
|
236
|
+
break;
|
|
237
|
+
case '--auto-pull':
|
|
238
|
+
result.autoPull = true;
|
|
239
|
+
break;
|
|
240
|
+
case '--no-auto-pull':
|
|
241
|
+
result.autoPull = false;
|
|
242
|
+
break;
|
|
243
|
+
case '--poll-interval':
|
|
244
|
+
result.pollInterval = parseInt(argv[++i], 10) * 1000; // Convert seconds to ms
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
// UI settings
|
|
248
|
+
case '--sound':
|
|
249
|
+
result.sound = true;
|
|
250
|
+
break;
|
|
251
|
+
case '--no-sound':
|
|
252
|
+
result.sound = false;
|
|
253
|
+
break;
|
|
254
|
+
case '--visible-branches':
|
|
255
|
+
result.visibleBranches = parseInt(argv[++i], 10);
|
|
256
|
+
break;
|
|
257
|
+
case '--casino':
|
|
258
|
+
result.casino = true;
|
|
259
|
+
break;
|
|
260
|
+
case '--no-casino':
|
|
261
|
+
result.casino = false;
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
// Special flags
|
|
265
|
+
case '--init':
|
|
266
|
+
result.init = true;
|
|
267
|
+
break;
|
|
268
|
+
case '--help':
|
|
269
|
+
case '-h':
|
|
270
|
+
result.help = true;
|
|
271
|
+
break;
|
|
272
|
+
case '--version':
|
|
273
|
+
case '-v':
|
|
274
|
+
result.version = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Ensure configuration exists
|
|
284
|
+
* Loads from file, runs wizard if needed, and applies CLI args
|
|
285
|
+
* @param {Object} cliArgs - CLI arguments
|
|
286
|
+
* @param {Object} [options] - Options
|
|
287
|
+
* @param {string} [options.projectRoot] - Project root directory
|
|
288
|
+
* @param {boolean} [options.interactive=true] - Allow interactive prompts
|
|
289
|
+
* @param {Function} [options.runWizard] - Wizard function to run if needed
|
|
290
|
+
* @returns {Promise<Object>} - Final configuration
|
|
291
|
+
*/
|
|
292
|
+
async function ensureConfig(cliArgs, options = {}) {
|
|
293
|
+
const { projectRoot, interactive = true, runWizard } = options;
|
|
294
|
+
|
|
295
|
+
// Check if --init flag was passed (force reconfiguration)
|
|
296
|
+
if (cliArgs.init && runWizard) {
|
|
297
|
+
const config = await runWizard();
|
|
298
|
+
return applyCliArgs(config, cliArgs);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Load existing config
|
|
302
|
+
let config = loadConfig(projectRoot);
|
|
303
|
+
|
|
304
|
+
// If no config exists
|
|
305
|
+
if (!config) {
|
|
306
|
+
if (runWizard && interactive && process.stdin.isTTY) {
|
|
307
|
+
config = await runWizard();
|
|
308
|
+
} else {
|
|
309
|
+
// Use defaults
|
|
310
|
+
config = getDefaultConfig();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Apply CLI args over config
|
|
315
|
+
return applyCliArgs(config, cliArgs);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
CONFIG_FILE_NAME,
|
|
320
|
+
getConfigPath,
|
|
321
|
+
configExists,
|
|
322
|
+
loadConfigRaw,
|
|
323
|
+
loadConfig,
|
|
324
|
+
saveConfig,
|
|
325
|
+
deleteConfig,
|
|
326
|
+
applyCliArgs,
|
|
327
|
+
parseCliArgs,
|
|
328
|
+
ensureConfig,
|
|
329
|
+
};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration schema and defaults
|
|
3
|
+
* Defines the structure and validation for Git Watchtower configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { ConfigError, ValidationError } = require('../utils/errors');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {'static' | 'command' | 'none'} ServerMode
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ServerConfig
|
|
15
|
+
* @property {ServerMode} mode - Server mode
|
|
16
|
+
* @property {string} staticDir - Directory for static files
|
|
17
|
+
* @property {string} command - Command for command mode
|
|
18
|
+
* @property {number} port - Server port
|
|
19
|
+
* @property {boolean} restartOnSwitch - Restart on branch switch
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} Config
|
|
24
|
+
* @property {ServerConfig} server - Server configuration
|
|
25
|
+
* @property {string} remoteName - Git remote name
|
|
26
|
+
* @property {boolean} autoPull - Auto-pull enabled
|
|
27
|
+
* @property {number} gitPollInterval - Polling interval in ms
|
|
28
|
+
* @property {boolean} soundEnabled - Sound notifications enabled
|
|
29
|
+
* @property {number} visibleBranches - Number of visible branches
|
|
30
|
+
* @property {boolean} casinoMode - Casino mode enabled
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Valid server modes
|
|
35
|
+
*/
|
|
36
|
+
const SERVER_MODES = ['static', 'command', 'none'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Configuration defaults
|
|
40
|
+
* @type {Config}
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULTS = {
|
|
43
|
+
server: {
|
|
44
|
+
mode: /** @type {ServerMode} */ ('static'),
|
|
45
|
+
staticDir: 'public',
|
|
46
|
+
command: '',
|
|
47
|
+
port: 3000,
|
|
48
|
+
restartOnSwitch: true,
|
|
49
|
+
},
|
|
50
|
+
remoteName: 'origin',
|
|
51
|
+
autoPull: true,
|
|
52
|
+
gitPollInterval: 5000,
|
|
53
|
+
soundEnabled: true,
|
|
54
|
+
visibleBranches: 7,
|
|
55
|
+
casinoMode: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Configuration limits
|
|
60
|
+
*/
|
|
61
|
+
const LIMITS = {
|
|
62
|
+
port: { min: 1, max: 65535 },
|
|
63
|
+
gitPollInterval: { min: 1000, max: 300000 }, // 1s to 5min
|
|
64
|
+
visibleBranches: { min: 1, max: 50 },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get default configuration
|
|
69
|
+
* @returns {Config}
|
|
70
|
+
*/
|
|
71
|
+
function getDefaultConfig() {
|
|
72
|
+
return {
|
|
73
|
+
server: { ...DEFAULTS.server },
|
|
74
|
+
remoteName: DEFAULTS.remoteName,
|
|
75
|
+
autoPull: DEFAULTS.autoPull,
|
|
76
|
+
gitPollInterval: DEFAULTS.gitPollInterval,
|
|
77
|
+
soundEnabled: DEFAULTS.soundEnabled,
|
|
78
|
+
visibleBranches: DEFAULTS.visibleBranches,
|
|
79
|
+
casinoMode: DEFAULTS.casinoMode,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a port number
|
|
85
|
+
* @param {*} port - Port to validate
|
|
86
|
+
* @returns {number}
|
|
87
|
+
* @throws {ValidationError}
|
|
88
|
+
*/
|
|
89
|
+
function validatePort(port) {
|
|
90
|
+
const num = Number(port);
|
|
91
|
+
if (isNaN(num) || !Number.isInteger(num)) {
|
|
92
|
+
throw ValidationError.invalidPort(port);
|
|
93
|
+
}
|
|
94
|
+
if (num < LIMITS.port.min || num > LIMITS.port.max) {
|
|
95
|
+
throw ValidationError.invalidPort(port);
|
|
96
|
+
}
|
|
97
|
+
return num;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate server mode
|
|
102
|
+
* @param {*} mode - Mode to validate
|
|
103
|
+
* @returns {ServerMode}
|
|
104
|
+
* @throws {ConfigError}
|
|
105
|
+
*/
|
|
106
|
+
function validateServerMode(mode) {
|
|
107
|
+
if (!SERVER_MODES.includes(mode)) {
|
|
108
|
+
throw ConfigError.invalid(
|
|
109
|
+
`Invalid server mode: "${mode}". Must be one of: ${SERVER_MODES.join(', ')}`,
|
|
110
|
+
{ field: 'server.mode', value: mode, valid: SERVER_MODES }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return mode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate poll interval
|
|
118
|
+
* @param {*} interval - Interval to validate
|
|
119
|
+
* @returns {number}
|
|
120
|
+
* @throws {ConfigError}
|
|
121
|
+
*/
|
|
122
|
+
function validatePollInterval(interval) {
|
|
123
|
+
const num = Number(interval);
|
|
124
|
+
if (isNaN(num) || num < LIMITS.gitPollInterval.min || num > LIMITS.gitPollInterval.max) {
|
|
125
|
+
throw ConfigError.invalid(
|
|
126
|
+
`Invalid poll interval: ${interval}. Must be between ${LIMITS.gitPollInterval.min}ms and ${LIMITS.gitPollInterval.max}ms`,
|
|
127
|
+
{ field: 'gitPollInterval', value: interval }
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return Math.round(num);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate visible branches count
|
|
135
|
+
* @param {*} count - Count to validate
|
|
136
|
+
* @returns {number}
|
|
137
|
+
* @throws {ConfigError}
|
|
138
|
+
*/
|
|
139
|
+
function validateVisibleBranches(count) {
|
|
140
|
+
const num = Number(count);
|
|
141
|
+
if (isNaN(num) || !Number.isInteger(num)) {
|
|
142
|
+
throw ConfigError.invalid(
|
|
143
|
+
`Invalid visible branches: ${count}. Must be an integer`,
|
|
144
|
+
{ field: 'visibleBranches', value: count }
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (num < LIMITS.visibleBranches.min || num > LIMITS.visibleBranches.max) {
|
|
148
|
+
throw ConfigError.invalid(
|
|
149
|
+
`Invalid visible branches: ${count}. Must be between ${LIMITS.visibleBranches.min} and ${LIMITS.visibleBranches.max}`,
|
|
150
|
+
{ field: 'visibleBranches', value: count }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return num;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validate and normalize a full configuration object
|
|
158
|
+
* @param {Object} config - Configuration to validate
|
|
159
|
+
* @returns {Config}
|
|
160
|
+
* @throws {ConfigError}
|
|
161
|
+
*/
|
|
162
|
+
function validateConfig(config) {
|
|
163
|
+
if (!config || typeof config !== 'object') {
|
|
164
|
+
throw ConfigError.invalid('Configuration must be an object');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = getDefaultConfig();
|
|
168
|
+
|
|
169
|
+
// Validate server config
|
|
170
|
+
if (config.server) {
|
|
171
|
+
if (typeof config.server !== 'object') {
|
|
172
|
+
throw ConfigError.invalid('server must be an object');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (config.server.mode !== undefined) {
|
|
176
|
+
result.server.mode = validateServerMode(config.server.mode);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (config.server.port !== undefined) {
|
|
180
|
+
result.server.port = validatePort(config.server.port);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (config.server.staticDir !== undefined) {
|
|
184
|
+
if (typeof config.server.staticDir !== 'string') {
|
|
185
|
+
throw ConfigError.invalid('server.staticDir must be a string');
|
|
186
|
+
}
|
|
187
|
+
// Reject absolute paths and path traversal attempts
|
|
188
|
+
if (path.isAbsolute(config.server.staticDir)) {
|
|
189
|
+
throw ConfigError.invalid(
|
|
190
|
+
'server.staticDir must be a relative path within the project',
|
|
191
|
+
{ field: 'server.staticDir', value: config.server.staticDir }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const resolved = path.resolve(config.server.staticDir);
|
|
195
|
+
const cwd = process.cwd();
|
|
196
|
+
if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
|
|
197
|
+
throw ConfigError.invalid(
|
|
198
|
+
'server.staticDir must not escape the project directory',
|
|
199
|
+
{ field: 'server.staticDir', value: config.server.staticDir }
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
result.server.staticDir = config.server.staticDir;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (config.server.command !== undefined) {
|
|
206
|
+
if (typeof config.server.command !== 'string') {
|
|
207
|
+
throw ConfigError.invalid('server.command must be a string');
|
|
208
|
+
}
|
|
209
|
+
// Reject commands containing shell injection patterns
|
|
210
|
+
const dangerousPatterns = /[|;&`$(){}]|>\s*\/|<\s*\//;
|
|
211
|
+
if (config.server.command && dangerousPatterns.test(config.server.command)) {
|
|
212
|
+
throw ConfigError.invalid(
|
|
213
|
+
'server.command contains potentially dangerous shell characters (|;&`$(){}). ' +
|
|
214
|
+
'Only simple commands like "npm run dev" are allowed.',
|
|
215
|
+
{ field: 'server.command', value: config.server.command }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
result.server.command = config.server.command;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (config.server.restartOnSwitch !== undefined) {
|
|
222
|
+
result.server.restartOnSwitch = Boolean(config.server.restartOnSwitch);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Validate Git settings
|
|
227
|
+
if (config.remoteName !== undefined) {
|
|
228
|
+
if (typeof config.remoteName !== 'string' || !config.remoteName.trim()) {
|
|
229
|
+
throw ConfigError.invalid('remoteName must be a non-empty string');
|
|
230
|
+
}
|
|
231
|
+
result.remoteName = config.remoteName.trim();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (config.autoPull !== undefined) {
|
|
235
|
+
result.autoPull = Boolean(config.autoPull);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (config.gitPollInterval !== undefined) {
|
|
239
|
+
result.gitPollInterval = validatePollInterval(config.gitPollInterval);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Validate UI settings
|
|
243
|
+
if (config.soundEnabled !== undefined) {
|
|
244
|
+
result.soundEnabled = Boolean(config.soundEnabled);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (config.visibleBranches !== undefined) {
|
|
248
|
+
result.visibleBranches = validateVisibleBranches(config.visibleBranches);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (config.casinoMode !== undefined) {
|
|
252
|
+
result.casinoMode = Boolean(config.casinoMode);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Migrate old config format to new format
|
|
260
|
+
* @param {Object} config - Old config
|
|
261
|
+
* @returns {Config}
|
|
262
|
+
*/
|
|
263
|
+
function migrateConfig(config) {
|
|
264
|
+
// Already in new format
|
|
265
|
+
if (config.server) {
|
|
266
|
+
return validateConfig(config);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Convert old format to new
|
|
270
|
+
const newConfig = getDefaultConfig();
|
|
271
|
+
|
|
272
|
+
if (config.noServer) {
|
|
273
|
+
newConfig.server.mode = 'none';
|
|
274
|
+
}
|
|
275
|
+
if (config.port !== undefined) {
|
|
276
|
+
newConfig.server.port = validatePort(config.port);
|
|
277
|
+
}
|
|
278
|
+
if (config.staticDir !== undefined) {
|
|
279
|
+
newConfig.server.staticDir = config.staticDir;
|
|
280
|
+
}
|
|
281
|
+
if (config.gitPollInterval !== undefined) {
|
|
282
|
+
newConfig.gitPollInterval = validatePollInterval(config.gitPollInterval);
|
|
283
|
+
}
|
|
284
|
+
if (typeof config.soundEnabled === 'boolean') {
|
|
285
|
+
newConfig.soundEnabled = config.soundEnabled;
|
|
286
|
+
}
|
|
287
|
+
if (config.visibleBranches !== undefined) {
|
|
288
|
+
newConfig.visibleBranches = validateVisibleBranches(config.visibleBranches);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return newConfig;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
SERVER_MODES,
|
|
296
|
+
DEFAULTS,
|
|
297
|
+
LIMITS,
|
|
298
|
+
getDefaultConfig,
|
|
299
|
+
validatePort,
|
|
300
|
+
validateServerMode,
|
|
301
|
+
validatePollInterval,
|
|
302
|
+
validateVisibleBranches,
|
|
303
|
+
validateConfig,
|
|
304
|
+
migrateConfig,
|
|
305
|
+
};
|