• 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

50.99
/src/cli/commands/start.ts
1
import { input, confirm } from "@inquirer/prompts";
1✔
2
import { ConfigService } from "../../services/config.service.js";
3
import { RegistryService } from "../../services/registry.service.js";
4
import { PortService } from "../../services/port.service.js";
5
import { ProcessService } from "../../services/process.service.js";
6
import {
7
  renderTemplate,
8
  parseEnvStrings,
9
  renderEnvTemplates,
10
  findMissingVariables,
11
  getTemplateVariables,
12
  formatMissingVariablesError,
13
  type MissingVariable,
14
  type TemplateVariables,
15
  type TemplateContext,
16
} from "../../utils/template.js";
17
import {
18
  extractUsedConfigKeys,
19
  createConfigSnapshot,
20
  detectDrift,
21
  formatDrift,
22
  type DriftResult,
23
} from "../../utils/config-drift.js";
24
import { hasEnvChanged } from "../../utils/env-compare.js";
25
import { generateDeterministicName } from "../../utils/names.js";
26
import type { GlobalConfig } from "../../types/config.js";
27
import type { ServerEntry, ServerStatus } from "../../types/registry.js";
28
import { formatStartResult } from "../output/formatters.js";
29
import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
30
import { logger } from "../../utils/logger.js";
31
import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
32
import { CIDetector, type CIModeOptions } from "../../utils/ci-detector.js";
33

34
export interface StartCommandOptions {
35
  command: string;
36
  cwd?: string;
37
  name?: string;
38
  port?: number;
39
  protocol?: "http" | "https";
40
  tags?: string[];
41
  description?: string;
42
  env?: Record<string, string>;
43
}
44

45
export interface StartCommandResult {
46
  action: "started" | "existing" | "restarted" | "renamed" | "refreshed";
47
  server: ServerEntry;
48
  status: ServerStatus;
49
  portReassigned?: boolean;
50
  originalPort?: number;
51
  previousName?: string;
52
  envChanged?: boolean;
53
  /** Whether the command was changed (with explicit -n) */
54
  commandChanged?: boolean;
55
  /** Whether config drift was detected and applied */
56
  configDrift?: boolean;
57
  /** Details of config drift that was applied */
58
  driftDetails?: string[];
59
  /** User declined refresh when prompted */
60
  userDeclinedRefresh?: boolean;
61
}
62

63
/**
64
 * Execute the start command
65
 */
66
export async function executeStart(options: StartCommandOptions): Promise<StartCommandResult> {
39✔
67
  const configService = new ConfigService();
39✔
68
  const registryService = new RegistryService();
39✔
69
  const processService = new ProcessService();
39✔
70

71
  try {
39✔
72
    // Load config and registry
73
    const config = await configService.load();
39✔
74
    await registryService.load();
39✔
75

76
    // Connect to PM2
77
    await processService.connect();
39✔
78

79
    const cwd = options.cwd || process.cwd();
39!
80

81
    // Build template context for server lookups
82
    const templateContext = buildTemplateContext(registryService, cwd);
39✔
83

84
    // Determine server name for identity lookup
85
    // New identity model: server identity = cwd + name
86
    let serverName: string;
39✔
87
    let isExplicitName = false;
39✔
88

89
    if (options.name) {
39✔
90
      // User provided explicit name - identity is cwd + provided name
91
      serverName = options.name;
3✔
92
      isExplicitName = true;
3✔
93
    } else {
39✔
94
      // Generate deterministic name from command + env (UNRESOLVED values)
95
      serverName = generateDeterministicName(options.command, options.env);
36✔
96
    }
36✔
97

98
    // Primary lookup: cwd + name (new identity model)
99
    let existingServer = registryService.findByCwdAndName(cwd, serverName);
39✔
100

101
    // Fallback for backward compatibility with legacy servers
102
    // Legacy servers may have been created with random names, matched by command hash
103
    if (!existingServer && !isExplicitName) {
39✔
104
      existingServer = registryService.findByCommandHash(cwd, options.command);
36✔
105
    }
36✔
106

107
    if (existingServer) {
39✔
108
      // Check if command has changed (only relevant with explicit -n)
109
      const commandChanged = isExplicitName && existingServer.command !== options.command;
24✔
110

111
      // Check for config drift before checking status
112
      const drift = detectDrift(existingServer, config);
24✔
113

114
      if (drift.hasDrift) {
24!
115
        const refreshOnChange = config.refreshOnChange ?? "on-start";
×
116

117
        if (refreshOnChange === "prompt") {
×
118
          // Ask user
119
          console.log("\n" + formatDrift(drift));
×
120
          const shouldRefresh = await confirm({
×
121
            message: "Configuration has changed. Refresh server with new settings?",
×
122
            default: true,
×
123
          });
×
124

125
          if (shouldRefresh) {
×
126
            return await handleDriftRefresh(
×
127
              existingServer, config, drift, processService, registryService, options, templateContext, commandChanged,
×
128
            );
×
129
          }
×
130
          // User declined - continue with normal flow but mark it
131
        } else if (refreshOnChange === "on-start" || refreshOnChange === "auto") {
×
132
          // Auto-refresh
133
          return await handleDriftRefresh(
×
134
            existingServer, config, drift, processService, registryService, options, templateContext, commandChanged,
×
135
          );
×
136
        }
×
137
        // refreshOnChange === "manual" - don't auto-refresh, continue with normal flow
138
      }
×
139

140
      // Server exists - check its status
141
      const status = await processService.getStatus(existingServer.pm2Name);
24✔
142

143
      // Check if environment variables have changed
144
      const templateVars = buildTemplateVars(config, existingServer.port, existingServer.hostname, existingServer.protocol);
24✔
145
      const resolvedEnv = options.env
24✔
146
        ? renderEnvTemplates(options.env, templateVars, templateContext)
17✔
147
        : undefined;
7✔
148
      const envChanged = hasEnvChanged(existingServer.env, resolvedEnv);
24✔
149

150
      if (status === "online" && !envChanged && !commandChanged) {
24✔
151
        // Already running with same config
152
        return {
9✔
153
          action: "existing",
9✔
154
          server: existingServer,
9✔
155
          status: "online",
9✔
156
          userDeclinedRefresh: drift.hasDrift, // If we got here with drift, user declined
9✔
157
        };
9✔
158
      }
9✔
159

160
      // Command changed, env changed, or server stopped/errored - need to restart
161
      if (commandChanged || envChanged) {
24✔
162
        // Build template vars for re-resolving command
163
        const newTemplateVars = {
14✔
164
          ...(config.variables ?? {}),
14✔
165
          port: existingServer.port,
14✔
166
          hostname: existingServer.hostname,
14✔
167
          url: `${existingServer.protocol}://${existingServer.hostname}:${existingServer.port}`,
14✔
168
          "https-cert": config.httpsCert ?? "",
14✔
169
          "https-key": config.httpsKey ?? "",
14✔
170
        };
14✔
171

172
        // Re-resolve command if it changed
173
        const newCommand = commandChanged ? options.command : existingServer.command;
14!
174
        const newResolvedCommand = renderTemplate(newCommand, newTemplateVars, templateContext);
14✔
175

176
        // Re-extract used config keys and create snapshot
177
        const usedConfigKeys = extractUsedConfigKeys(newCommand);
14✔
178
        const configSnapshot = createConfigSnapshot(config, usedConfigKeys, newCommand);
14✔
179

180
        // Update the registry (undefined env means "clear env", use empty object)
181
        await registryService.updateServer(existingServer.id, {
14✔
182
          command: newCommand,
14✔
183
          resolvedCommand: newResolvedCommand,
14✔
184
          env: resolvedEnv ?? {},
14✔
185
          usedConfigKeys,
14✔
186
          configSnapshot,
14✔
187
        });
14✔
188

189
        // Delete the old PM2 process to ensure fresh start
190
        try {
14✔
191
          await processService.delete(existingServer.pm2Name);
14✔
192
        } catch {
14!
193
          // Process might not exist in PM2, that's okay
194
        }
×
195

196
        const updatedServer: ServerEntry = {
14✔
197
          ...existingServer,
14✔
198
          command: newCommand,
14✔
199
          resolvedCommand: newResolvedCommand,
14✔
200
          env: resolvedEnv ?? {},
14✔
201
          usedConfigKeys,
14✔
202
          configSnapshot,
14✔
203
        };
14✔
204

205
        // Start with new config
206
        await startProcess(processService, updatedServer);
14✔
207

208
        if (commandChanged) {
14!
209
          logger.info({ serverName: existingServer.name }, "Server restarted due to command change");
×
210
        } else {
14✔
211
          logger.info({ serverName: existingServer.name }, "Server restarted due to environment change");
14✔
212
        }
14✔
213

214
        return {
14✔
215
          action: "restarted",
14✔
216
          server: updatedServer,
14✔
217
          status: "online",
14✔
218
          envChanged,
14✔
219
          commandChanged,
14✔
220
        };
14✔
221
      }
14✔
222

223
      // Stopped or errored - restart it
224
      try {
1✔
225
        await processService.restart(existingServer.pm2Name);
1✔
226
      } catch {
7!
227
        // Process might not exist in PM2, start it fresh
228
        await startProcess(processService, existingServer);
×
229
      }
✔
230

231
      return {
1✔
232
        action: "restarted",
1✔
233
        server: existingServer,
1✔
234
        status: "online",
1✔
235
      };
1✔
236
    }
1✔
237

238
    // New server - register and start
239
    const portService = new PortService(config);
15✔
240

241
    // Assign port with availability checking
242
    const { port, reassigned: portReassigned } = await portService.assignPort(
15✔
243
      cwd,
15✔
244
      options.command,
15✔
245
      options.port,
15✔
246
    );
15✔
247

248
    // Track original port for reporting if reassigned
249
    const originalPort = portReassigned
13!
250
      ? (options.port ?? portService.generatePort(cwd, options.command))
✔
251
      : undefined;
13✔
252

253
    const hostname = config.hostname;
39✔
254
    const protocol = options.protocol ?? config.protocol;
39✔
255
    const url = `${protocol}://${hostname}:${port}`;
39✔
256

257
    // Template variables for substitution (includes HTTPS cert/key paths and custom vars)
258
    const templateVars = {
39✔
259
      ...(config.variables ?? {}), // Custom variables first
39✔
260
      port,
39✔
261
      hostname,
39✔
262
      url,
39✔
263
      "https-cert": config.httpsCert ?? "",
39✔
264
      "https-key": config.httpsKey ?? "",
39✔
265
    };
39✔
266

267
    // Resolve template variables in command
268
    const resolvedCommand = renderTemplate(options.command, templateVars, templateContext);
39✔
269

270
    // Resolve template variables in environment values
271
    const resolvedEnv = options.env
39✔
272
      ? renderEnvTemplates(options.env, templateVars, templateContext)
2✔
273
      : undefined;
11✔
274

275
    // Extract used config keys and create snapshot for drift detection
276
    const usedConfigKeys = extractUsedConfigKeys(options.command);
39✔
277
    const configSnapshot = createConfigSnapshot(config, usedConfigKeys, options.command);
39✔
278

279
    // Register server with the determined name (explicit or deterministic)
280
    const server = await registryService.addServer({
39✔
281
      command: options.command,
39✔
282
      cwd,
39✔
283
      port,
39✔
284
      name: serverName,
39✔
285
      protocol,
39✔
286
      hostname,
39✔
287
      tags: options.tags,
39✔
288
      description: options.description,
39✔
289
      env: resolvedEnv,
39✔
290
      usedConfigKeys,
39✔
291
      configSnapshot,
39✔
292
    });
39✔
293

294
    // Update with resolved command
295
    await registryService.updateServer(server.id, {
13✔
296
      resolvedCommand,
13✔
297
    });
13✔
298

299
    // Start the process
300
    await startProcess(processService, {
13✔
301
      ...server,
13✔
302
      resolvedCommand,
13✔
303
    });
13✔
304

305
    return {
13✔
306
      action: "started",
13✔
307
      server: {
13✔
308
        ...server,
13✔
309
        resolvedCommand,
13✔
310
      },
13✔
311
      status: "online",
13✔
312
      portReassigned,
13✔
313
      originalPort,
13✔
314
    };
13✔
315
  } finally {
39✔
316
    processService.disconnect();
39✔
317
  }
39✔
318
}
39✔
319

320
/**
321
 * Start a process using PM2
322
 */
323
async function startProcess(processService: ProcessService, server: ServerEntry): Promise<void> {
27✔
324
  // Parse the resolved command to extract script and args
325
  const parts = parseCommand(server.resolvedCommand);
27✔
326

327
  await processService.start({
27✔
328
    name: server.pm2Name,
27✔
329
    script: parts.script,
27✔
330
    args: parts.args,
27✔
331
    cwd: server.cwd,
27✔
332
    env: {
27✔
333
      ...server.env,
27✔
334
      PORT: String(server.port),
27✔
335
    },
27✔
336
  });
27✔
337
}
27✔
338

339
/**
340
 * Parse a command string into script and args
341
 */
342
function parseCommand(command: string): { script: string; args: string[] } {
27✔
343
  const parts = command.trim().split(/\s+/);
27✔
344
  const script = parts[0] || "node";
27!
345
  const args = parts.slice(1);
27✔
346
  return { script, args };
27✔
347
}
27✔
348

349
/**
350
 * Handle refreshing a server due to config drift
351
 */
352
async function handleDriftRefresh(
×
353
  server: ServerEntry,
×
354
  config: GlobalConfig,
×
355
  drift: DriftResult,
×
356
  processService: ProcessService,
×
357
  registryService: RegistryService,
×
358
  options: StartCommandOptions,
×
359
  templateContext: TemplateContext,
×
360
  commandChanged = false,
×
361
): Promise<StartCommandResult> {
×
362
  const portService = new PortService(config);
×
363

364
  // Use new command if it changed (with explicit -n), otherwise keep existing
365
  const newCommand = commandChanged ? options.command : server.command;
×
366

367
  let newPort = server.port;
×
368
  let portReassigned = false;
×
369
  let originalPort: number | undefined;
×
370

371
  // Handle port out of range - use deterministic port assignment
372
  if (drift.portOutOfRange) {
×
373
    originalPort = server.port;
×
374
    const { port, reassigned } = await portService.assignPort(
×
375
      server.cwd,
×
376
      newCommand,
×
377
      undefined, // No explicit port - use deterministic logic
×
378
    );
×
379
    newPort = port;
×
380
    portReassigned = reassigned || (port !== server.port);
×
381
  }
×
382

383
  // Get new config values
384
  const hostname = config.hostname;
×
385
  const protocol = options.protocol ?? config.protocol;
×
386
  const url = `${protocol}://${hostname}:${newPort}`;
×
387

388
  // Build template variables with new config (including custom variables)
389
  const templateVars = {
×
390
    ...(config.variables ?? {}), // Custom variables first
×
391
    port: newPort,
×
392
    hostname,
×
393
    url,
×
394
    "https-cert": config.httpsCert ?? "",
×
395
    "https-key": config.httpsKey ?? "",
×
396
  };
×
397

398
  // Re-render command with new values
399
  const resolvedCommand = renderTemplate(newCommand, templateVars, templateContext);
×
400

401
  // Re-resolve environment variables
402
  const resolvedEnv = options.env
×
403
    ? renderEnvTemplates(options.env, templateVars, templateContext)
×
404
    : server.env;
×
405

406
  // Re-extract used config keys and create new snapshot
407
  const usedConfigKeys = extractUsedConfigKeys(newCommand);
×
408
  const configSnapshot = createConfigSnapshot(config, usedConfigKeys, newCommand);
×
409

410
  // Update registry
411
  await registryService.updateServer(server.id, {
×
412
    command: newCommand,
×
413
    port: newPort,
×
414
    protocol,
×
415
    hostname,
×
416
    resolvedCommand,
×
417
    env: resolvedEnv,
×
418
    usedConfigKeys,
×
419
    configSnapshot,
×
420
  });
×
421

422
  // Delete old PM2 process and start fresh
423
  try {
×
424
    await processService.delete(server.pm2Name);
×
425
  } catch {
×
426
    // Process might not exist
427
  }
×
428

429
  const updatedServer: ServerEntry = {
×
430
    ...server,
×
431
    command: newCommand,
×
432
    port: newPort,
×
433
    protocol,
×
434
    hostname,
×
435
    resolvedCommand,
×
436
    env: resolvedEnv,
×
437
    usedConfigKeys,
×
438
    configSnapshot,
×
439
  };
×
440

441
  await startProcess(processService, updatedServer);
×
442

443
  logger.info(
×
444
    { serverName: server.name, driftedKeys: drift.driftedValues.map(d => d.configKey) },
×
445
    "Server refreshed due to config drift",
×
446
  );
×
447

448
  return {
×
449
    action: "refreshed",
×
450
    server: updatedServer,
×
451
    status: "online",
×
452
    configDrift: true,
×
453
    commandChanged,
×
454
    driftDetails: drift.driftedValues.map(d => {
×
455
      const from = d.startedWith ?? "(not set)";
×
456
      const to = d.currentValue ?? "(not set)";
×
457
      return `${d.configKey}: "${from}" → "${to}"`;
×
458
    }),
×
459
    portReassigned,
×
460
    originalPort,
×
461
  };
×
462
}
×
463

464
/**
465
 * Build template variables from config and server details
466
 */
467
function buildTemplateVars(
24✔
468
  config: GlobalConfig,
24✔
469
  port: number,
24✔
470
  hostname: string,
24✔
471
  protocol: string,
24✔
472
): TemplateVariables {
24✔
473
  const url = `${protocol}://${hostname}:${port}`;
24✔
474
  return {
24✔
475
    port,
24✔
476
    hostname,
24✔
477
    url,
24✔
478
    "https-cert": config.httpsCert ?? "",
24✔
479
    "https-key": config.httpsKey ?? "",
24✔
480
  };
24✔
481
}
24✔
482

483
/**
484
 * Build template context for server lookups
485
 * @param registryService - Registry service instance for looking up servers
486
 * @param cwd - Current working directory for scoping server lookups
487
 */
488
function buildTemplateContext(
39✔
489
  registryService: RegistryService,
39✔
490
  cwd: string,
39✔
491
): TemplateContext {
39✔
492
  return {
39✔
493
    cwd,
39✔
494
    lookupServer: (name: string, lookupCwd?: string) => {
39✔
495
      return registryService.findByCwdAndName(lookupCwd ?? cwd, name);
×
496
    },
×
497
  };
39✔
498
}
39✔
499

500
/**
501
 * Prompt user for missing template variables and update config
502
 * @param missing - Array of missing variables
503
 * @param configService - Config service instance
504
 * @param config - Current config
505
 * @returns Updated config with new values
506
 */
507
async function promptForMissingVariables(
×
508
  missing: MissingVariable[],
×
509
  configService: ConfigService,
×
510
  config: GlobalConfig,
×
511
): Promise<GlobalConfig> {
×
512
  const configurableMissing = missing.filter(v => v.configurable);
×
513

514
  if (configurableMissing.length === 0) {
×
515
    return config;
×
516
  }
×
517

518
  console.log("\nThe following template variables need to be configured:\n");
×
519

520
  const updatedConfig = { ...config };
×
521

522
  for (const v of configurableMissing) {
×
523
    const value = await input({
×
524
      message: v.prompt,
×
525
    });
×
526

527
    // Update config with the new value
528
    if (v.configKey) {
×
529
      // Handle nested config keys if needed
530
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
531
      (updatedConfig as any)[v.configKey] = value;
×
532
    }
×
533
  }
×
534

535
  // Save the updated config for future use
536
  await configService.save(updatedConfig);
×
537
  console.log("\n✓ Configuration saved for future use\n");
×
538

539
  return updatedConfig;
×
540
}
×
541

542
/**
543
 * CLI action handler for start command
544
 */
545
export async function startAction(
×
546
  commandArgs: string[],
×
547
  options: {
×
548
    name?: string;
549
    port?: number;
550
    protocol?: "http" | "https";
551
    tag?: string[];
552
    description?: string;
553
    env?: string[];
554
    json?: boolean;
555
    ci?: boolean;
556
    noCi?: boolean;
557
  },
558
): Promise<void> {
×
559
  try {
×
560
    const command = commandArgs.join(" ");
×
561

562
    if (!command) {
×
563
      const error = new ServherdError(
×
564
        ServherdErrorCode.COMMAND_MISSING_ARGUMENT,
×
565
        "Command is required",
×
566
      );
×
567
      if (options.json) {
×
568
        console.log(formatErrorAsJson(error));
×
569
      } else {
×
570
        console.error("Error: Command is required");
×
571
      }
×
572
      process.exitCode = 1;
×
573
      return;
×
574
    }
×
575

576
    // Check for missing template variables before starting
577
    const configService = new ConfigService();
×
578
    let config = await configService.load();
×
579

580
    // Use placeholder port to check config-based variables
581
    const templateVars = getTemplateVariables(config, 0);
×
582
    const missingVars = findMissingVariables(command, templateVars);
×
583
    const configurableMissing = missingVars.filter(v => v.configurable);
×
584

585
    // Check CI mode options
586
    const ciModeOptions: CIModeOptions = {
×
587
      ci: options.ci,
×
588
      noCi: options.noCi,
×
589
    };
×
590
    const isCI = CIDetector.isCI(ciModeOptions);
×
591

592
    if (configurableMissing.length > 0) {
×
593
      if (isCI) {
×
594
        // In CI mode, show error and exit
595
        const errorMessage = formatMissingVariablesError(configurableMissing);
×
596
        if (options.json) {
×
597
          console.log(formatErrorAsJson(new ServherdError(
×
598
            ServherdErrorCode.CONFIG_VALIDATION_FAILED,
×
599
            errorMessage,
×
600
          )));
×
601
        } else {
×
602
          console.error(`Error: ${errorMessage}`);
×
603
        }
×
604
        process.exitCode = 1;
×
605
        return;
×
606
      }
×
607

608
      // In interactive mode, prompt for missing values
609
      config = await promptForMissingVariables(configurableMissing, configService, config);
×
610
    }
×
611

612
    // Parse environment variables from KEY=VALUE format
613
    let env: Record<string, string> | undefined;
×
614
    if (options.env && options.env.length > 0) {
×
615
      env = parseEnvStrings(options.env);
×
616
    }
×
617

618
    const result = await executeStart({
×
619
      command,
×
620
      cwd: process.cwd(),
×
621
      name: options.name,
×
622
      port: options.port,
×
623
      protocol: options.protocol,
×
624
      tags: options.tag,
×
625
      description: options.description,
×
626
      env,
×
627
    });
×
628

629
    // Warn about port reassignment (unless in JSON mode)
630
    if (result.portReassigned && !options.json) {
×
631
      console.warn(
×
632
        `\x1b[33m⚠ Port ${result.originalPort} unavailable, reassigned to ${result.server.port}\x1b[0m`,
×
633
      );
×
634
    }
×
635

636
    if (options.json) {
×
637
      console.log(formatAsJson(result));
×
638
    } else {
×
639
      console.log(formatStartResult(result));
×
640
    }
×
641
  } catch (error) {
×
642
    if (options.json) {
×
643
      console.log(formatErrorAsJson(error));
×
644
    } else {
×
645
      const message = error instanceof Error ? error.message : String(error);
×
646
      console.error(`Error: ${message}`);
×
647
    }
×
648
    logger.error({ error }, "Start command failed");
×
649
    process.exitCode = 1;
×
650
  }
×
651
}
×
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