• 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

93.26
/src/services/config.service.ts
1
import { cosmiconfig, type CosmiconfigResult } from "cosmiconfig";
1✔
2
import { ensureDir, writeJson } from "fs-extra/esm";
3
import { chmod } from "fs/promises";
4
import * as path from "path";
5
import * as os from "os";
6
import { DEFAULT_CONFIG, GlobalConfigSchema, type GlobalConfig } from "../types/config.js";
7
import { logger } from "../utils/logger.js";
8

9
const MODULE_NAME = "servherd";
1✔
10

11
/**
12
 * Configuration search locations (in order of precedence):
13
 * 1. Project-local configs (searched from cwd upward):
14
 *    - package.json "servherd" key
15
 *    - .servherdrc (JSON or YAML)
16
 *    - .servherdrc.json
17
 *    - .servherdrc.yaml / .servherdrc.yml
18
 *    - .servherdrc.js / .servherdrc.cjs
19
 *    - servherd.config.js / servherd.config.cjs
20
 * 2. Global config: ~/.servherd/config.json
21
 * 3. Environment variables (highest priority)
22
 */
23

24
/**
25
 * Service for managing global configuration using cosmiconfig
26
 */
27
export class ConfigService {
1✔
28
  private config: GlobalConfig;
50✔
29
  private globalConfigDir: string;
50✔
30
  private globalConfigPath: string;
50✔
31
  private loadedFilepath: string | null = null;
50✔
32
  private explorer = cosmiconfig(MODULE_NAME, {
50✔
33
    searchPlaces: [
50✔
34
      "package.json",
50✔
35
      `.${MODULE_NAME}rc`,
50✔
36
      `.${MODULE_NAME}rc.json`,
50✔
37
      `.${MODULE_NAME}rc.yaml`,
50✔
38
      `.${MODULE_NAME}rc.yml`,
50✔
39
      `.${MODULE_NAME}rc.js`,
50✔
40
      `.${MODULE_NAME}rc.cjs`,
50✔
41
      `${MODULE_NAME}.config.js`,
50✔
42
      `${MODULE_NAME}.config.cjs`,
50✔
43
    ],
50✔
44
  });
50✔
45

46
  constructor() {
50✔
47
    this.config = { ...DEFAULT_CONFIG };
50✔
48
    const homeDir = process.env.SERVHERD_HOME || os.homedir();
50!
49
    this.globalConfigDir = path.join(homeDir, ".servherd");
50✔
50
    this.globalConfigPath = path.join(this.globalConfigDir, "config.json");
50✔
51
  }
50✔
52

53
  /**
54
   * Load configuration from multiple sources with the following priority:
55
   * 1. Environment variables (highest)
56
   * 2. Project-local config file (if found)
57
   * 3. Global config file (~/.servherd/config.json)
58
   * 4. Default values (lowest)
59
   */
60
  async load(searchFrom?: string): Promise<GlobalConfig> {
50✔
61
    let baseConfig = { ...DEFAULT_CONFIG };
43✔
62

63
    // First, try to load global config
64
    const globalResult = await this.loadGlobalConfig();
43✔
65
    if (globalResult) {
43✔
66
      baseConfig = this.mergeConfigs(baseConfig, globalResult);
12✔
67
    }
12✔
68

69
    // Then, search for project-local config (overrides global)
70
    const projectResult = await this.searchProjectConfig(searchFrom);
43✔
71
    if (projectResult?.config && typeof projectResult.config === "object") {
43✔
72
      // Accept partial configs without strict validation - merge will handle defaults
73
      baseConfig = this.mergeConfigs(baseConfig, projectResult.config);
6✔
74
      this.loadedFilepath = projectResult.filepath;
6✔
75
      logger.debug({ filepath: projectResult.filepath }, "Loaded project config");
6✔
76
    }
6✔
77

78
    // Finally, apply environment variable overrides (highest priority)
79
    this.config = this.applyEnvironmentOverrides(baseConfig);
43✔
80

81
    return this.config;
43✔
82
  }
43✔
83

84
  /**
85
   * Load the global config file from ~/.servherd/config.json
86
   */
87
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
  private async loadGlobalConfig(): Promise<Record<string, any> | null> {
50✔
89
    try {
43✔
90
      const result = await this.explorer.load(this.globalConfigPath);
43✔
91
      if (result?.config) {
43✔
92
        const parsed = GlobalConfigSchema.deepPartial().safeParse(result.config);
12✔
93
        if (parsed.success) {
12✔
94
          logger.debug({ filepath: this.globalConfigPath }, "Loaded global config");
12✔
95
          return parsed.data;
12✔
96
        }
12!
97
        logger.warn({ error: parsed.error }, "Invalid global config file, ignoring");
×
98
      }
×
99
    } catch {
43✔
100
      // Global config doesn't exist or is unreadable, that's fine
101
      logger.debug("No global config found");
31✔
102
    }
31✔
103
    return null;
31✔
104
  }
43✔
105

106
  /**
107
   * Search for project-local config starting from the given directory
108
   */
109
  private async searchProjectConfig(searchFrom?: string): Promise<CosmiconfigResult | null> {
50✔
110
    try {
43✔
111
      const result = await this.explorer.search(searchFrom);
43✔
112
      // Don't return if it's the global config (we handle that separately)
113
      if (result?.filepath === this.globalConfigPath) {
43!
114
        return null;
×
115
      }
×
116
      return result;
43✔
117
    } catch {
43!
118
      return null;
×
119
    }
×
120
  }
43✔
121

122
  /**
123
   * Merge partial config into base config (supports deeply partial configs)
124
   */
125
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
  private mergeConfigs(base: GlobalConfig, partial: Record<string, any>): GlobalConfig {
50✔
127
    return {
18✔
128
      ...base,
18✔
129
      ...partial,
18✔
130
      portRange: {
18✔
131
        ...base.portRange,
18✔
132
        ...(partial.portRange || {}),
18✔
133
      },
18✔
134
      pm2: {
18✔
135
        ...base.pm2,
18✔
136
        ...(partial.pm2 || {}),
18✔
137
      },
18✔
138
    };
18✔
139
  }
18✔
140

141
  /**
142
   * Save configuration to the global config file
143
   * Sets file permissions to 600 (owner read/write only) to protect sensitive data
144
   */
145
  async save(config: GlobalConfig): Promise<void> {
50✔
146
    await ensureDir(this.globalConfigDir);
4✔
147
    // Set directory permissions to 700 (owner only)
148
    await chmod(this.globalConfigDir, 0o700);
4✔
149
    await writeJson(this.globalConfigPath, config, { spaces: 2 });
4✔
150
    // Set file permissions to 600 (owner read/write only) to protect secrets
151
    await chmod(this.globalConfigPath, 0o600);
4✔
152
    this.config = config;
4✔
153
  }
4✔
154

155
  /**
156
   * Get a configuration value
157
   */
158
  get<K extends keyof GlobalConfig>(key: K): GlobalConfig[K] {
50✔
159
    return this.config[key];
5✔
160
  }
5✔
161

162
  /**
163
   * Set a configuration value and persist to the global config file
164
   */
165
  async set<K extends keyof GlobalConfig>(key: K, value: GlobalConfig[K]): Promise<void> {
50✔
166
    this.config[key] = value;
2✔
167
    await this.save(this.config);
2✔
168
  }
2✔
169

170
  /**
171
   * Get default configuration
172
   */
173
  getDefaults(): GlobalConfig {
50✔
174
    return { ...DEFAULT_CONFIG };
1✔
175
  }
1✔
176

177
  /**
178
   * Get the global config file path
179
   */
180
  getConfigPath(): string {
50✔
181
    return this.globalConfigPath;
1✔
182
  }
1✔
183

184
  /**
185
   * Get the path of the currently loaded config file (if any)
186
   */
187
  getLoadedConfigPath(): string | null {
50✔
188
    return this.loadedFilepath;
2✔
189
  }
2✔
190

191
  /**
192
   * Get all supported config file names for documentation
193
   */
194
  getSupportedConfigFiles(): string[] {
50✔
195
    return [
1✔
196
      "package.json (\"servherd\" key)",
1✔
197
      ".servherdrc",
1✔
198
      ".servherdrc.json",
1✔
199
      ".servherdrc.yaml",
1✔
200
      ".servherdrc.yml",
1✔
201
      ".servherdrc.js",
1✔
202
      ".servherdrc.cjs",
1✔
203
      "servherd.config.js",
1✔
204
      "servherd.config.cjs",
1✔
205
      "~/.servherd/config.json (global)",
1✔
206
    ];
1✔
207
  }
1✔
208

209
  /**
210
   * Apply environment variable overrides to configuration
211
   */
212
  applyEnvironmentOverrides(config: GlobalConfig): GlobalConfig {
50✔
213
    const result = { ...config };
43✔
214

215
    if (process.env.SERVHERD_HOSTNAME) {
43✔
216
      result.hostname = process.env.SERVHERD_HOSTNAME;
1✔
217
    }
1✔
218

219
    if (process.env.SERVHERD_PROTOCOL) {
43✔
220
      const protocol = process.env.SERVHERD_PROTOCOL;
1✔
221
      if (protocol === "http" || protocol === "https") {
1✔
222
        result.protocol = protocol;
1✔
223
      }
1✔
224
    }
1✔
225

226
    if (process.env.SERVHERD_PORT_MIN) {
43✔
227
      const min = parseInt(process.env.SERVHERD_PORT_MIN, 10);
1✔
228
      if (!isNaN(min)) {
1✔
229
        result.portRange = { ...result.portRange, min };
1✔
230
      }
1✔
231
    }
1✔
232

233
    if (process.env.SERVHERD_PORT_MAX) {
43✔
234
      const max = parseInt(process.env.SERVHERD_PORT_MAX, 10);
1✔
235
      if (!isNaN(max)) {
1✔
236
        result.portRange = { ...result.portRange, max };
1✔
237
      }
1✔
238
    }
1✔
239

240
    if (process.env.SERVHERD_TEMP_DIR) {
43!
241
      result.tempDir = process.env.SERVHERD_TEMP_DIR;
×
242
    }
×
243

244
    if (process.env.SERVHERD_HTTPS_CERT) {
43✔
245
      result.httpsCert = process.env.SERVHERD_HTTPS_CERT;
1✔
246
    }
1✔
247

248
    if (process.env.SERVHERD_HTTPS_KEY) {
43✔
249
      result.httpsKey = process.env.SERVHERD_HTTPS_KEY;
1✔
250
    }
1✔
251

252
    return result;
43✔
253
  }
43✔
254

255
  /**
256
   * Clear the cosmiconfig cache (useful for testing or after config changes)
257
   */
258
  clearCache(): void {
50✔
259
    this.explorer.clearCaches();
1✔
260
  }
1✔
261
}
50✔
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