• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / servherd / 20961701317

13 Jan 2026 03:07PM UTC coverage: 82.727% (+1.2%) from 81.563%
20961701317

push

github

apowers313
test: improved test coverage to 81.56%

901 of 1027 branches covered (87.73%)

Branch coverage included in aggregate %.

3601 of 4415 relevant lines covered (81.56%)

13.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.21
/src/cli/commands/config.ts
1
import { confirm, input, select } from "@inquirer/prompts";
1✔
2
import { pathExists } from "fs-extra/esm";
3
import { ConfigService } from "../../services/config.service.js";
4
import { RegistryService } from "../../services/registry.service.js";
5
import type { GlobalConfig } from "../../types/config.js";
6
import { formatConfigResult, type ConfigResult } from "../output/formatters.js";
7
import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
8
import { logger } from "../../utils/logger.js";
9
import { CIDetector } from "../../utils/ci-detector.js";
10
import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
11
import { findServersUsingConfigKey } from "../../utils/config-drift.js";
12
import { executeRefresh } from "./refresh.js";
13

14
export interface ConfigCommandOptions {
15
  show?: boolean;
16
  get?: string;
17
  set?: string;
18
  value?: string;
19
  reset?: boolean;
20
  force?: boolean;
21
  refresh?: string;
22
  refreshAll?: boolean;
23
  tag?: string;
24
  dryRun?: boolean;
25
  add?: string;
26
  remove?: string;
27
  listVars?: boolean;
28
}
29

30
// Valid top-level config keys
31
const VALID_TOP_LEVEL_KEYS = ["version", "hostname", "protocol", "portRange", "tempDir", "pm2", "httpsCert", "httpsKey", "refreshOnChange", "variables"];
1✔
32
const VALID_NESTED_KEYS = ["portRange.min", "portRange.max", "pm2.logDir", "pm2.pidDir"];
1✔
33

34
// Config keys that can affect server commands (used for drift detection)
35
const SERVER_AFFECTING_KEYS = ["hostname", "httpsCert", "httpsKey"];
1✔
36

37
// Reserved variable names that cannot be used for custom variables
38
const RESERVED_VARIABLE_NAMES = ["port", "hostname", "url", "https-cert", "https-key"];
1✔
39

40
// Regex pattern for valid variable names (must match template regex)
41
const VARIABLE_NAME_PATTERN = /^[\w-]+$/;
1✔
42

43
function isValidKey(key: string): boolean {
31✔
44
  return VALID_TOP_LEVEL_KEYS.includes(key) || VALID_NESTED_KEYS.includes(key);
31✔
45
}
31✔
46

47
function getNestedValue(config: GlobalConfig, key: string): unknown {
7✔
48
  const parts = key.split(".");
7✔
49
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
  let current: any = config;
7✔
51
  for (const part of parts) {
7✔
52
    if (current && typeof current === "object" && part in current) {
9✔
53
      current = current[part];
9✔
54
    } else {
9!
55
      return undefined;
×
56
    }
×
57
  }
9✔
58
  return current;
7✔
59
}
7✔
60

61
function setNestedValue(config: GlobalConfig, key: string, value: unknown): GlobalConfig {
2✔
62
  const parts = key.split(".");
2✔
63
  const result = { ...config };
2✔
64

65
  if (parts.length === 1) {
2!
66
    // Top-level key
67
    (result as Record<string, unknown>)[key] = value;
×
68
  } else if (parts.length === 2) {
2✔
69
    // Nested key (e.g., portRange.min)
70
    const [parentKey, childKey] = parts;
2✔
71
    if (parentKey === "portRange") {
2✔
72
      result.portRange = { ...result.portRange, [childKey]: value };
2✔
73
    } else if (parentKey === "pm2") {
2!
74
      result.pm2 = { ...result.pm2, [childKey]: value };
×
75
    }
×
76
  }
2✔
77

78
  return result;
2✔
79
}
2✔
80

81
/**
82
 * Handle server refresh after a config change based on refreshOnChange setting
83
 */
84
async function handleConfigChangeRefresh(
16✔
85
  config: GlobalConfig,
16✔
86
  changedKey: string,
16✔
87
): Promise<{ refreshed: boolean; message?: string }> {
16✔
88
  // Only handle server-affecting keys
89
  if (!SERVER_AFFECTING_KEYS.includes(changedKey)) {
16✔
90
    return { refreshed: false };
7✔
91
  }
7✔
92

93
  const refreshOnChange = config.refreshOnChange ?? "on-start";
16✔
94

95
  // manual and on-start modes don't auto-refresh
96
  if (refreshOnChange === "manual" || refreshOnChange === "on-start") {
16✔
97
    return { refreshed: false };
9✔
98
  }
9!
99

100
  // Find servers that use this config key
101
  const registryService = new RegistryService();
×
102
  await registryService.load();
×
103
  const allServers = registryService.listServers();
×
104
  const affectedServers = findServersUsingConfigKey(allServers, changedKey);
×
105

106
  if (affectedServers.length === 0) {
×
107
    return { refreshed: false };
×
108
  }
×
109

110
  const serverNames = affectedServers.map(s => s.name).join(", ");
×
111

112
  if (refreshOnChange === "prompt") {
×
113
    // Don't prompt in CI mode
114
    if (CIDetector.isCI()) {
×
115
      return {
×
116
        refreshed: false,
×
117
        message: `${affectedServers.length} server(s) use this config value. Run "servherd refresh" to apply changes.`,
×
118
      };
×
119
    }
×
120

121
    // Prompt user
122
    const shouldRefresh = await confirm({
×
123
      message: `${affectedServers.length} server(s) use this config value (${serverNames}). Restart them now?`,
×
124
    });
×
125

126
    if (!shouldRefresh) {
×
127
      return {
×
128
        refreshed: false,
×
129
        message: "Run \"servherd refresh\" later to apply changes to affected servers.",
×
130
      };
×
131
    }
×
132
  }
×
133

134
  // Execute refresh for all servers (will filter to those with drift)
135
  try {
×
136
    await executeRefresh({ all: true });
×
137
    return {
×
138
      refreshed: true,
×
139
      message: `Refreshed ${affectedServers.length} server(s): ${serverNames}`,
×
140
    };
×
141
  } catch (error) {
×
142
    const errorMsg = error instanceof Error ? error.message : String(error);
×
143
    return {
×
144
      refreshed: false,
×
145
      message: `Failed to refresh servers: ${errorMsg}`,
×
146
    };
×
147
  }
×
148
}
16✔
149

150
/**
151
 * Execute the config command
152
 */
153
export async function executeConfig(options: ConfigCommandOptions): Promise<ConfigResult> {
78✔
154
  const configService = new ConfigService();
78✔
155

156
  // Load current config
157
  await configService.load();
78✔
158

159
  // Handle --refresh or --refresh-all
160
  if (options.refresh || options.refreshAll) {
78✔
161
    const refreshResults = await executeRefresh({
8✔
162
      name: options.refresh,
8✔
163
      all: options.refreshAll,
8✔
164
      tag: options.tag,
8✔
165
      dryRun: options.dryRun,
8✔
166
    });
8✔
167

168
    return {
8✔
169
      refreshResults,
8✔
170
      dryRun: options.dryRun,
8✔
171
    };
8✔
172
  }
8✔
173

174
  // Handle --list-vars
175
  if (options.listVars) {
78✔
176
    const config = await configService.load();
5✔
177
    return {
5✔
178
      variables: config.variables ?? {},
5✔
179
    };
5✔
180
  }
5✔
181

182
  // Handle --add
183
  if (options.add) {
78✔
184
    const name = options.add;
15✔
185

186
    if (options.value === undefined) {
15✔
187
      return {
2✔
188
        addedVar: false,
2✔
189
        error: "--value is required when using --add",
2✔
190
      };
2✔
191
    }
2✔
192

193
    // Validate variable name format
194
    if (!VARIABLE_NAME_PATTERN.test(name)) {
15✔
195
      return {
1✔
196
        addedVar: false,
1✔
197
        error: `Invalid variable name "${name}". Variable names can only contain letters, numbers, underscores, and hyphens.`,
1✔
198
      };
1✔
199
    }
1✔
200

201
    // Check for reserved names
202
    if (RESERVED_VARIABLE_NAMES.includes(name)) {
15✔
203
      return {
6✔
204
        addedVar: false,
6✔
205
        error: `"${name}" is a reserved variable name. Reserved names: ${RESERVED_VARIABLE_NAMES.join(", ")}`,
6✔
206
      };
6✔
207
    }
6✔
208

209
    const config = await configService.load();
6✔
210
    const updatedConfig: GlobalConfig = {
6✔
211
      ...config,
6✔
212
      variables: {
6✔
213
        ...(config.variables ?? {}),
15!
214
        [name]: options.value,
15✔
215
      },
15✔
216
    };
15✔
217
    await configService.save(updatedConfig);
15✔
218

219
    return {
6✔
220
      addedVar: true,
6✔
221
      varName: name,
6✔
222
      varValue: options.value,
6✔
223
    };
6✔
224
  }
6✔
225

226
  // Handle --remove
227
  if (options.remove) {
78✔
228
    const name = options.remove;
5✔
229
    const config = await configService.load();
5✔
230

231
    if (!config.variables || !(name in config.variables)) {
5✔
232
      return {
3✔
233
        removedVar: false,
3✔
234
        error: `Variable "${name}" does not exist`,
3✔
235
      };
3✔
236
    }
3✔
237

238
    const updatedVariables = { ...config.variables };
2✔
239
    delete updatedVariables[name];
2✔
240
    const updatedConfig: GlobalConfig = {
2✔
241
      ...config,
2✔
242
      variables: updatedVariables,
2✔
243
    };
2✔
244
    await configService.save(updatedConfig);
2✔
245

246
    return {
2✔
247
      removedVar: true,
2✔
248
      varName: name,
2✔
249
    };
2✔
250
  }
2✔
251

252
  // Handle --get
253
  if (options.get) {
78✔
254
    const key = options.get;
9✔
255

256
    if (!isValidKey(key)) {
9✔
257
      return {
2✔
258
        error: `Unknown configuration key: "${key}"`,
2✔
259
      };
2✔
260
    }
2✔
261

262
    const config = await configService.load();
7✔
263
    const value = getNestedValue(config, key);
7✔
264

265
    return {
7✔
266
      key,
7✔
267
      value,
7✔
268
    };
7✔
269
  }
7✔
270

271
  // Handle --set
272
  if (options.set) {
78✔
273
    const key = options.set;
24✔
274

275
    if (options.value === undefined) {
24✔
276
      return {
2✔
277
        updated: false,
2✔
278
        error: "--value is required when using --set",
2✔
279
      };
2✔
280
    }
2✔
281

282
    if (!isValidKey(key)) {
24✔
283
      return {
1✔
284
        updated: false,
1✔
285
        error: `Unknown configuration key: "${key}"`,
1✔
286
      };
1✔
287
    }
1✔
288

289
    // Validate and convert value based on key
290
    let parsedValue: unknown = options.value;
21✔
291

292
    if (key === "protocol") {
24✔
293
      if (options.value !== "http" && options.value !== "https") {
2✔
294
        return {
1✔
295
          updated: false,
1✔
296
          error: "Invalid protocol value. Must be \"http\" or \"https\"",
1✔
297
        };
1✔
298
      }
1✔
299
    } else if (key === "refreshOnChange") {
24✔
300
      const validModes = ["manual", "prompt", "auto", "on-start"];
5✔
301
      if (!validModes.includes(options.value)) {
5✔
302
        return {
1✔
303
          updated: false,
1✔
304
          error: `Invalid refreshOnChange value. Must be one of: ${validModes.join(", ")}`,
1✔
305
        };
1✔
306
      }
1✔
307
    } else if (key === "portRange.min" || key === "portRange.max") {
19✔
308
      const num = parseInt(options.value, 10);
3✔
309
      if (isNaN(num)) {
3✔
310
        return {
1✔
311
          updated: false,
1✔
312
          error: "Invalid port value. Must be a number",
1✔
313
        };
1✔
314
      }
1✔
315
      if (num < 1 || num > 65535) {
3!
316
        return {
×
317
          updated: false,
×
318
          error: "Invalid port value. Must be between 1 and 65535",
×
319
        };
×
320
      }
✔
321
      parsedValue = num;
2✔
322
    }
2✔
323

324
    // Validate HTTPS certificate/key paths exist
325
    if ((key === "httpsCert" || key === "httpsKey") && options.value && options.value.length > 0) {
24✔
326
      const fileExists = await pathExists(options.value);
6✔
327
      if (!fileExists) {
6✔
328
        return {
2✔
329
          updated: false,
2✔
330
          error: `File not found: ${options.value}`,
2✔
331
        };
2✔
332
      }
2✔
333
    }
6✔
334

335
    // Handle nested keys vs top-level keys
336
    let updatedConfig: GlobalConfig;
16✔
337
    if (key.includes(".")) {
24✔
338
      const config = await configService.load();
2✔
339
      updatedConfig = setNestedValue(config, key, parsedValue);
2✔
340
      await configService.save(updatedConfig);
2✔
341
    } else {
24✔
342
      await configService.set(key as keyof GlobalConfig, parsedValue as GlobalConfig[keyof GlobalConfig]);
14✔
343
      updatedConfig = await configService.load();
14✔
344
    }
14✔
345

346
    // Handle refresh behavior after config change
347
    const refreshResult = await handleConfigChangeRefresh(updatedConfig, key);
16✔
348

349
    return {
16✔
350
      updated: true,
16✔
351
      key,
16✔
352
      value: parsedValue,
16✔
353
      refreshMessage: refreshResult.message,
16✔
354
    };
16✔
355
  }
16✔
356

357
  // Handle --reset
358
  if (options.reset) {
78✔
359
    if (!options.force) {
5✔
360
      const confirmed = await confirm({
3✔
361
        message: "Are you sure you want to reset configuration to defaults?",
3✔
362
      });
3✔
363

364
      if (!confirmed) {
3✔
365
        return {
2✔
366
          reset: false,
2✔
367
          cancelled: true,
2✔
368
        };
2✔
369
      }
2✔
370
    }
3✔
371

372
    const defaults = configService.getDefaults();
3✔
373
    await configService.save(defaults);
3✔
374

375
    return {
3✔
376
      reset: true,
3✔
377
      config: defaults,
3✔
378
    };
3✔
379
  }
3✔
380

381
  // Handle --show (default)
382
  const config = await configService.load();
7✔
383
  const configPath = configService.getLoadedConfigPath();
7✔
384
  const globalConfigPath = configService.getConfigPath();
7✔
385

386
  return {
7✔
387
    config,
7✔
388
    configPath,
7✔
389
    globalConfigPath,
7✔
390
  };
7✔
391
}
7✔
392

393
/**
394
 * Run the interactive configuration wizard
395
 * Prompts the user for all configuration values interactively
396
 * @throws ServherdError if running in CI mode
397
 */
398
export async function runConfigWizard(): Promise<void> {
10✔
399
  if (CIDetector.isCI()) {
10✔
400
    throw new ServherdError(
4✔
401
      ServherdErrorCode.INTERACTIVE_NOT_AVAILABLE,
4✔
402
      "Interactive config not available in CI mode. Use \"servherd config --set <key> --value <value>\"",
4✔
403
    );
4✔
404
  }
4✔
405

406
  const configService = new ConfigService();
6✔
407
  await configService.load();
6✔
408
  const currentConfig = await configService.load();
6✔
409

410
  // Prompt for hostname
411
  const hostname = await input({
6✔
412
    message: "Default hostname:",
6✔
413
    default: currentConfig.hostname,
6✔
414
  });
6✔
415

416
  // Prompt for protocol
417
  const protocol = await select({
6✔
418
    message: "Default protocol:",
6✔
419
    choices: [
6✔
420
      { name: "HTTP", value: "http" },
6✔
421
      { name: "HTTPS", value: "https" },
6✔
422
    ],
6✔
423
    default: currentConfig.protocol,
6✔
424
  }) as "http" | "https";
6✔
425

426
  // If HTTPS, prompt for cert and key paths
427
  let httpsCert: string | undefined;
6✔
428
  let httpsKey: string | undefined;
6✔
429

430
  if (protocol === "https") {
10✔
431
    httpsCert = await input({
1✔
432
      message: "Path to HTTPS certificate:",
1✔
433
      default: currentConfig.httpsCert,
1✔
434
    });
1✔
435
    httpsKey = await input({
1✔
436
      message: "Path to HTTPS key:",
1✔
437
      default: currentConfig.httpsKey,
1✔
438
    });
1✔
439
  }
1✔
440

441
  // Prompt for port range
442
  const portMinStr = await input({
6✔
443
    message: "Minimum port:",
6✔
444
    default: String(currentConfig.portRange.min),
6✔
445
    validate: (v) => !isNaN(parseInt(v)) || "Must be a number",
6✔
446
  });
6✔
447

448
  const portMaxStr = await input({
6✔
449
    message: "Maximum port:",
6✔
450
    default: String(currentConfig.portRange.max),
6✔
451
    validate: (v) => !isNaN(parseInt(v)) || "Must be a number",
6✔
452
  });
6✔
453

454
  // Save the configuration
455
  const newConfig: GlobalConfig = {
6✔
456
    ...currentConfig,
6✔
457
    hostname,
6✔
458
    protocol,
6✔
459
    httpsCert,
6✔
460
    httpsKey,
6✔
461
    portRange: {
6✔
462
      min: parseInt(portMinStr, 10),
6✔
463
      max: parseInt(portMaxStr, 10),
6✔
464
    },
6✔
465
  };
6✔
466

467
  await configService.save(newConfig);
6✔
468
  console.log("✓ Configuration saved");
6✔
469
}
6✔
470

471
/**
472
 * CLI action handler for config command
473
 */
474
export async function configAction(options: {
1✔
475
  show?: boolean;
476
  get?: string;
477
  set?: string;
478
  value?: string;
479
  reset?: boolean;
480
  force?: boolean;
481
  json?: boolean;
482
  add?: string;
483
  remove?: string;
484
  listVars?: boolean;
485
  refresh?: string;
486
  refreshAll?: boolean;
487
  tag?: string;
488
  dryRun?: boolean;
489
}): Promise<void> {
1✔
490
  try {
1✔
491
    // Check if any explicit option was provided
492
    const hasOptions = options.show || options.get || options.set ||
1✔
493
                       options.reset || options.refresh || options.refreshAll ||
1✔
494
                       options.add || options.remove || options.listVars;
1✔
495

496
    // If no options provided, run wizard in interactive mode, or show config in CI mode
497
    if (!hasOptions) {
1✔
498
      if (CIDetector.isCI()) {
1!
499
        // In CI, default to showing config (no interactive wizard)
500
        const result = await executeConfig({ show: true });
×
501
        if (options.json) {
×
502
          console.log(formatAsJson(result));
×
503
        } else {
×
504
          console.log(formatConfigResult(result));
×
505
        }
×
506
        return;
×
507
      } else {
1✔
508
        // In interactive mode, run the wizard
509
        await runConfigWizard();
1✔
510
        return;
1✔
511
      }
1✔
512
    }
1!
513

514
    const result = await executeConfig(options);
×
515

516
    if (options.json) {
×
517
      console.log(formatAsJson(result));
×
518
    } else {
×
519
      console.log(formatConfigResult(result));
×
520
    }
×
521

522
    if (result.error) {
×
523
      process.exitCode = 1;
×
524
    }
×
525
  } catch (error) {
1!
526
    if (options.json) {
×
527
      console.log(formatErrorAsJson(error));
×
528
    } else {
×
529
      const message = error instanceof Error ? error.message : String(error);
×
530
      console.error(`Error: ${message}`);
×
531
    }
×
532
    logger.error({ error }, "Config command failed");
×
533
    process.exitCode = 1;
×
534
  }
×
535
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc