servherd 1.0.0 → 1.1.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.
Files changed (52) hide show
  1. package/README.md +75 -1
  2. package/dist/cli/commands/config.d.ts +10 -0
  3. package/dist/cli/commands/config.js +106 -1
  4. package/dist/cli/commands/info.js +2 -1
  5. package/dist/cli/commands/logs.js +2 -2
  6. package/dist/cli/commands/refresh.d.ts +6 -0
  7. package/dist/cli/commands/refresh.js +58 -16
  8. package/dist/cli/commands/remove.js +6 -0
  9. package/dist/cli/commands/restart.js +21 -7
  10. package/dist/cli/commands/start.d.ts +10 -1
  11. package/dist/cli/commands/start.js +229 -42
  12. package/dist/cli/index.js +24 -2
  13. package/dist/cli/output/formatters.d.ts +16 -1
  14. package/dist/cli/output/formatters.js +59 -31
  15. package/dist/mcp/index.js +2 -1
  16. package/dist/mcp/tools/config.d.ts +28 -1
  17. package/dist/mcp/tools/config.js +52 -2
  18. package/dist/mcp/tools/info.js +14 -18
  19. package/dist/mcp/tools/list.d.ts +3 -0
  20. package/dist/mcp/tools/list.js +2 -0
  21. package/dist/mcp/tools/remove.d.ts +3 -0
  22. package/dist/mcp/tools/remove.js +1 -0
  23. package/dist/mcp/tools/start.d.ts +11 -1
  24. package/dist/mcp/tools/start.js +18 -4
  25. package/dist/mcp/tools/stop.d.ts +3 -0
  26. package/dist/mcp/tools/stop.js +2 -0
  27. package/dist/services/config.service.d.ts +1 -0
  28. package/dist/services/config.service.js +8 -1
  29. package/dist/services/port.service.d.ts +15 -0
  30. package/dist/services/port.service.js +58 -0
  31. package/dist/services/process.service.d.ts +9 -3
  32. package/dist/services/process.service.js +53 -21
  33. package/dist/services/registry.service.d.ts +7 -1
  34. package/dist/services/registry.service.js +30 -3
  35. package/dist/types/config.d.ts +3 -0
  36. package/dist/types/config.js +2 -0
  37. package/dist/types/registry.d.ts +60 -0
  38. package/dist/types/registry.js +7 -0
  39. package/dist/utils/ci-detector.js +6 -0
  40. package/dist/utils/config-drift.d.ts +14 -2
  41. package/dist/utils/config-drift.js +81 -3
  42. package/dist/utils/env-compare.d.ts +36 -0
  43. package/dist/utils/env-compare.js +57 -0
  44. package/dist/utils/format.d.ts +15 -0
  45. package/dist/utils/format.js +41 -0
  46. package/dist/utils/names.d.ts +17 -0
  47. package/dist/utils/names.js +40 -0
  48. package/dist/utils/template.d.ts +42 -2
  49. package/dist/utils/template.js +125 -16
  50. package/dist/utils/version.d.ts +5 -0
  51. package/dist/utils/version.js +17 -0
  52. package/package.json +5 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <p align="center">
12
12
  <a href="https://www.npmjs.com/package/servherd"><img src="https://img.shields.io/npm/v/servherd.svg" alt="npm version"></a>
13
13
  <a href="https://github.com/apowers313/servherd/actions/workflows/ci.yml"><img src="https://github.com/apowers313/servherd/actions/workflows/ci.yml/badge.svg" alt="CI Status"></a>
14
- <a href="https://codecov.io/gh/apowers313/servherd"><img src="https://codecov.io/gh/apowers313/servherd/branch/main/graph/badge.svg" alt="Coverage"></a>
14
+ <a href="https://coveralls.io/github/apowers313/servherd?branch=master"><img src="https://coveralls.io/repos/github/apowers313/servherd/badge.svg?branch=master" alt="Coverage"></a>
15
15
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
16
16
  </p>
17
17
 
@@ -200,6 +200,79 @@ servherd start --name my-flask -e FLASK_RUN_PORT={{port}} -- flask run
200
200
  servherd start --name my-fastapi -- uvicorn main:app --port {{port}}
201
201
  ```
202
202
 
203
+ ## Cross-Server Communication
204
+
205
+ When running multiple servers (like a frontend and backend), you often need one server to know the port of another. The `{{$ ...}}` helper looks up properties from other running servers.
206
+
207
+ ### Basic Syntax
208
+
209
+ ```bash
210
+ # Positional arguments: {{$ "server-name" "property"}}
211
+ {{$ "backend" "port"}}
212
+
213
+ # Named arguments (more explicit)
214
+ {{$ service="backend" prop="port"}}
215
+
216
+ # With aliases
217
+ {{$ svc="backend" property="port"}}
218
+
219
+ # Explicit working directory (for cross-project lookups)
220
+ {{$ service="backend" prop="port" cwd="/path/to/project"}}
221
+ ```
222
+
223
+ ### Available Properties
224
+
225
+ | Property | Description | Example |
226
+ |----------|-------------|---------|
227
+ | `port` | Server's assigned port | `9042` |
228
+ | `url` | Full URL | `http://localhost:9042` |
229
+ | `name` | Server name | `backend` |
230
+ | `hostname` | Configured hostname | `localhost` |
231
+ | `protocol` | Protocol (http/https) | `http` |
232
+
233
+ ### Frontend + Backend Example
234
+
235
+ Start a backend API server, then a frontend that connects to it:
236
+
237
+ ```bash
238
+ # Start backend first
239
+ servherd start -n backend -e 'PORT={{port}}' -- node server.js
240
+
241
+ # Start frontend with reference to backend's port
242
+ servherd start -n frontend \
243
+ -e 'PORT={{port}}' \
244
+ -e 'API_URL=http://localhost:{{$ "backend" "port"}}' \
245
+ -- npm run dev
246
+ ```
247
+
248
+ The frontend's `API_URL` will be set to the backend's actual port (e.g., `http://localhost:9042`).
249
+
250
+ ### npm Scripts Example
251
+
252
+ In your `package.json`, use single quotes inside double quotes to avoid escaping issues:
253
+
254
+ ```json
255
+ {
256
+ "scripts": {
257
+ "start:backend": "servherd start -n backend -e 'PORT={{port}}' -- node server.js",
258
+ "start:frontend": "servherd start -n frontend -e 'PORT={{port}}' -e 'API_URL=http://localhost:{{$ \"backend\" \"port\"}}' -- npm run dev",
259
+ "stop": "servherd stop --all"
260
+ }
261
+ }
262
+ ```
263
+
264
+ **Quoting tips for npm scripts:**
265
+ - Wrap `-e` values in single quotes: `-e 'VAR={{value}}'`
266
+ - Use escaped double quotes inside for `$` helper arguments: `{{$ \"name\" \"prop\"}}`
267
+ - Both `{{$ "x" "y"}}` and `{{$ 'x' 'y'}}` work in Handlebars, but single quotes are easier in JSON
268
+
269
+ ### Important Notes
270
+
271
+ 1. **Start order matters** - The referenced server must be running before you start the dependent server
272
+ 2. **Same working directory** - By default, lookups are scoped to servers in the same working directory (same git worktree)
273
+ 3. **Error on missing** - If the referenced server doesn't exist, the start command will fail with a clear error message
274
+ 4. **Cross-project lookups** - Use the `cwd` parameter to reference servers in other directories
275
+
203
276
  ## CLI Reference
204
277
 
205
278
  ### Global Options
@@ -238,6 +311,7 @@ servherd start [options] -- <command>
238
311
  | `{{url}}` | Full URL | `http://localhost:8080` |
239
312
  | `{{https-cert}}` | HTTPS certificate path | `/path/to/cert.pem` |
240
313
  | `{{https-key}}` | HTTPS key path | `/path/to/key.pem` |
314
+ | `{{$ "name" "prop"}}` | Look up property from another server | `{{$ "backend" "port"}}` |
241
315
 
242
316
  **Examples:**
243
317
  ```bash
@@ -10,6 +10,9 @@ export interface ConfigCommandOptions {
10
10
  refreshAll?: boolean;
11
11
  tag?: string;
12
12
  dryRun?: boolean;
13
+ add?: string;
14
+ remove?: string;
15
+ listVars?: boolean;
13
16
  }
14
17
  /**
15
18
  * Execute the config command
@@ -32,4 +35,11 @@ export declare function configAction(options: {
32
35
  reset?: boolean;
33
36
  force?: boolean;
34
37
  json?: boolean;
38
+ add?: string;
39
+ remove?: string;
40
+ listVars?: boolean;
41
+ refresh?: string;
42
+ refreshAll?: boolean;
43
+ tag?: string;
44
+ dryRun?: boolean;
35
45
  }): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { confirm, input, select } from "@inquirer/prompts";
2
+ import { pathExists } from "fs-extra/esm";
2
3
  import { ConfigService } from "../../services/config.service.js";
3
4
  import { RegistryService } from "../../services/registry.service.js";
4
5
  import { formatConfigResult } from "../output/formatters.js";
@@ -9,10 +10,14 @@ import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
9
10
  import { findServersUsingConfigKey } from "../../utils/config-drift.js";
10
11
  import { executeRefresh } from "./refresh.js";
11
12
  // Valid top-level config keys
12
- const VALID_TOP_LEVEL_KEYS = ["version", "hostname", "protocol", "portRange", "tempDir", "pm2", "httpsCert", "httpsKey", "refreshOnChange"];
13
+ const VALID_TOP_LEVEL_KEYS = ["version", "hostname", "protocol", "portRange", "tempDir", "pm2", "httpsCert", "httpsKey", "refreshOnChange", "variables"];
13
14
  const VALID_NESTED_KEYS = ["portRange.min", "portRange.max", "pm2.logDir", "pm2.pidDir"];
14
15
  // Config keys that can affect server commands (used for drift detection)
15
16
  const SERVER_AFFECTING_KEYS = ["hostname", "httpsCert", "httpsKey"];
17
+ // Reserved variable names that cannot be used for custom variables
18
+ const RESERVED_VARIABLE_NAMES = ["port", "hostname", "url", "https-cert", "https-key"];
19
+ // Regex pattern for valid variable names (must match template regex)
20
+ const VARIABLE_NAME_PATTERN = /^[\w-]+$/;
16
21
  function isValidKey(key) {
17
22
  return VALID_TOP_LEVEL_KEYS.includes(key) || VALID_NESTED_KEYS.includes(key);
18
23
  }
@@ -126,6 +131,73 @@ export async function executeConfig(options) {
126
131
  dryRun: options.dryRun,
127
132
  };
128
133
  }
134
+ // Handle --list-vars
135
+ if (options.listVars) {
136
+ const config = await configService.load();
137
+ return {
138
+ variables: config.variables ?? {},
139
+ };
140
+ }
141
+ // Handle --add
142
+ if (options.add) {
143
+ const name = options.add;
144
+ if (options.value === undefined) {
145
+ return {
146
+ addedVar: false,
147
+ error: "--value is required when using --add",
148
+ };
149
+ }
150
+ // Validate variable name format
151
+ if (!VARIABLE_NAME_PATTERN.test(name)) {
152
+ return {
153
+ addedVar: false,
154
+ error: `Invalid variable name "${name}". Variable names can only contain letters, numbers, underscores, and hyphens.`,
155
+ };
156
+ }
157
+ // Check for reserved names
158
+ if (RESERVED_VARIABLE_NAMES.includes(name)) {
159
+ return {
160
+ addedVar: false,
161
+ error: `"${name}" is a reserved variable name. Reserved names: ${RESERVED_VARIABLE_NAMES.join(", ")}`,
162
+ };
163
+ }
164
+ const config = await configService.load();
165
+ const updatedConfig = {
166
+ ...config,
167
+ variables: {
168
+ ...(config.variables ?? {}),
169
+ [name]: options.value,
170
+ },
171
+ };
172
+ await configService.save(updatedConfig);
173
+ return {
174
+ addedVar: true,
175
+ varName: name,
176
+ varValue: options.value,
177
+ };
178
+ }
179
+ // Handle --remove
180
+ if (options.remove) {
181
+ const name = options.remove;
182
+ const config = await configService.load();
183
+ if (!config.variables || !(name in config.variables)) {
184
+ return {
185
+ removedVar: false,
186
+ error: `Variable "${name}" does not exist`,
187
+ };
188
+ }
189
+ const updatedVariables = { ...config.variables };
190
+ delete updatedVariables[name];
191
+ const updatedConfig = {
192
+ ...config,
193
+ variables: updatedVariables,
194
+ };
195
+ await configService.save(updatedConfig);
196
+ return {
197
+ removedVar: true,
198
+ varName: name,
199
+ };
200
+ }
129
201
  // Handle --get
130
202
  if (options.get) {
131
203
  const key = options.get;
@@ -191,6 +263,16 @@ export async function executeConfig(options) {
191
263
  }
192
264
  parsedValue = num;
193
265
  }
266
+ // Validate HTTPS certificate/key paths exist
267
+ if ((key === "httpsCert" || key === "httpsKey") && options.value && options.value.length > 0) {
268
+ const fileExists = await pathExists(options.value);
269
+ if (!fileExists) {
270
+ return {
271
+ updated: false,
272
+ error: `File not found: ${options.value}`,
273
+ };
274
+ }
275
+ }
194
276
  // Handle nested keys vs top-level keys
195
277
  let updatedConfig;
196
278
  if (key.includes(".")) {
@@ -311,6 +393,29 @@ export async function runConfigWizard() {
311
393
  */
312
394
  export async function configAction(options) {
313
395
  try {
396
+ // Check if any explicit option was provided
397
+ const hasOptions = options.show || options.get || options.set ||
398
+ options.reset || options.refresh || options.refreshAll ||
399
+ options.add || options.remove || options.listVars;
400
+ // If no options provided, run wizard in interactive mode, or show config in CI mode
401
+ if (!hasOptions) {
402
+ if (CIDetector.isCI()) {
403
+ // In CI, default to showing config (no interactive wizard)
404
+ const result = await executeConfig({ show: true });
405
+ if (options.json) {
406
+ console.log(formatAsJson(result));
407
+ }
408
+ else {
409
+ console.log(formatConfigResult(result));
410
+ }
411
+ return;
412
+ }
413
+ else {
414
+ // In interactive mode, run the wizard
415
+ await runConfigWizard();
416
+ return;
417
+ }
418
+ }
314
419
  const result = await executeConfig(options);
315
420
  if (options.json) {
316
421
  console.log(formatAsJson(result));
@@ -3,6 +3,7 @@ import { ProcessService } from "../../services/process.service.js";
3
3
  import { formatServerInfo } from "../output/formatters.js";
4
4
  import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
5
5
  import { logger } from "../../utils/logger.js";
6
+ import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
6
7
  /**
7
8
  * Execute the info command
8
9
  */
@@ -15,7 +16,7 @@ export async function executeInfo(options) {
15
16
  // Find server by name
16
17
  const server = registryService.findByName(options.name);
17
18
  if (!server) {
18
- throw new Error(`Server "${options.name}" not found`);
19
+ throw new ServherdError(ServherdErrorCode.SERVER_NOT_FOUND, `Server "${options.name}" not found`);
19
20
  }
20
21
  // Connect to PM2 to get process details
21
22
  await processService.connect();
@@ -37,11 +37,11 @@ export async function executeFlush(options) {
37
37
  try {
38
38
  await processService.connect();
39
39
  if (options.all) {
40
- await processService.flush();
40
+ await processService.flushAll();
41
41
  return {
42
42
  flushed: true,
43
43
  all: true,
44
- message: "Logs flushed for all servers",
44
+ message: "Logs flushed for all servherd-managed servers",
45
45
  };
46
46
  }
47
47
  if (!options.name) {
@@ -12,6 +12,12 @@ export interface RefreshResult {
12
12
  message?: string;
13
13
  driftDetails?: string;
14
14
  skipped?: boolean;
15
+ /** Whether port was reassigned due to being out of range */
16
+ portReassigned?: boolean;
17
+ /** Original port before reassignment */
18
+ originalPort?: number;
19
+ /** New port after reassignment */
20
+ newPort?: number;
15
21
  }
16
22
  /**
17
23
  * Execute the refresh command
@@ -1,32 +1,50 @@
1
1
  import { RegistryService } from "../../services/registry.service.js";
2
2
  import { ProcessService } from "../../services/process.service.js";
3
3
  import { ConfigService } from "../../services/config.service.js";
4
+ import { PortService } from "../../services/port.service.js";
4
5
  import { renderTemplate, renderEnvTemplates, getTemplateVariables } from "../../utils/template.js";
5
6
  import { extractUsedConfigKeys, createConfigSnapshot, findServersWithDrift, formatDrift, } from "../../utils/config-drift.js";
7
+ /**
8
+ * Build template context for server lookups
9
+ */
10
+ function buildTemplateContext(registryService, cwd) {
11
+ return {
12
+ cwd,
13
+ lookupServer: (name, lookupCwd) => {
14
+ return registryService.findByCwdAndName(lookupCwd ?? cwd, name);
15
+ },
16
+ };
17
+ }
6
18
  /**
7
19
  * Re-resolve a server's command template with current config values
8
20
  * Updates the registry with new resolved command and config snapshot
21
+ * @param newPort - Optional new port if port was reassigned
9
22
  */
10
- async function refreshServerConfig(server, config, registryService) {
23
+ async function refreshServerConfig(server, config, registryService, templateContext, newPort) {
24
+ const port = newPort ?? server.port;
11
25
  // Get template variables with current config
12
- const templateVars = getTemplateVariables(config, server.port);
26
+ const templateVars = getTemplateVariables(config, port);
13
27
  // Re-resolve the command template
14
- const resolvedCommand = renderTemplate(server.command, templateVars);
28
+ const resolvedCommand = renderTemplate(server.command, templateVars, templateContext);
15
29
  // Re-resolve environment variables if any
16
30
  const resolvedEnv = server.env
17
- ? renderEnvTemplates(server.env, templateVars)
31
+ ? renderEnvTemplates(server.env, templateVars, templateContext)
18
32
  : {};
19
33
  // Extract new used config keys and create new snapshot
20
34
  const usedConfigKeys = extractUsedConfigKeys(server.command);
21
- const configSnapshot = createConfigSnapshot(config, usedConfigKeys);
22
- // Update the registry
23
- await registryService.updateServer(server.id, {
35
+ const configSnapshot = createConfigSnapshot(config, usedConfigKeys, server.command);
36
+ // Update the registry (include port if it changed)
37
+ const updates = {
24
38
  resolvedCommand,
25
39
  env: resolvedEnv,
26
40
  usedConfigKeys,
27
41
  configSnapshot,
28
- });
29
- return { resolvedCommand };
42
+ };
43
+ if (newPort !== undefined) {
44
+ updates.port = newPort;
45
+ }
46
+ await registryService.updateServer(server.id, updates);
47
+ return { resolvedCommand, port };
30
48
  }
31
49
  /**
32
50
  * Execute the refresh command
@@ -73,22 +91,40 @@ export async function executeRefresh(options) {
73
91
  }];
74
92
  }
75
93
  const results = [];
94
+ const portService = new PortService(config);
76
95
  for (const { server, drift } of serversWithDrift) {
77
96
  const driftDetails = formatDrift(drift);
97
+ // Build template context for this server's cwd
98
+ const templateContext = buildTemplateContext(registryService, server.cwd);
99
+ // Check if port needs reassignment
100
+ let newPort;
101
+ let portReassigned = false;
102
+ if (drift.portOutOfRange) {
103
+ const { port } = await portService.assignPort(server.cwd, server.command, undefined);
104
+ newPort = port;
105
+ portReassigned = true;
106
+ }
78
107
  // Dry run mode - just report what would happen
79
108
  if (options.dryRun) {
80
- results.push({
109
+ const dryRunResult = {
81
110
  name: server.name,
82
111
  success: true,
83
112
  skipped: true,
84
113
  message: "Would refresh (dry-run mode)",
85
114
  driftDetails,
86
- });
115
+ };
116
+ if (portReassigned) {
117
+ dryRunResult.portReassigned = true;
118
+ dryRunResult.originalPort = server.port;
119
+ dryRunResult.newPort = newPort;
120
+ dryRunResult.message = `Would refresh and reassign port ${server.port} → ${newPort} (dry-run mode)`;
121
+ }
122
+ results.push(dryRunResult);
87
123
  continue;
88
124
  }
89
125
  try {
90
- // Re-resolve command with new config values
91
- const { resolvedCommand } = await refreshServerConfig(server, config, registryService);
126
+ // Re-resolve command with new config values (and new port if reassigned)
127
+ const { resolvedCommand, port } = await refreshServerConfig(server, config, registryService, templateContext, newPort);
92
128
  // Delete old process and start with new command
93
129
  try {
94
130
  await processService.delete(server.pm2Name);
@@ -110,16 +146,22 @@ export async function executeRefresh(options) {
110
146
  cwd: server.cwd,
111
147
  env: {
112
148
  ...env,
113
- PORT: String(server.port),
149
+ PORT: String(port),
114
150
  },
115
151
  });
116
152
  const status = await processService.getStatus(server.pm2Name);
117
- results.push({
153
+ const result = {
118
154
  name: server.name,
119
155
  success: true,
120
156
  status,
121
157
  driftDetails,
122
- });
158
+ };
159
+ if (portReassigned) {
160
+ result.portReassigned = true;
161
+ result.originalPort = server.port;
162
+ result.newPort = newPort;
163
+ }
164
+ results.push(result);
123
165
  }
124
166
  catch (error) {
125
167
  const message = error instanceof Error ? error.message : String(error);
@@ -4,6 +4,8 @@ import { ProcessService } from "../../services/process.service.js";
4
4
  import { formatRemoveResult } from "../output/formatters.js";
5
5
  import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
6
6
  import { logger } from "../../utils/logger.js";
7
+ import { CIDetector } from "../../utils/ci-detector.js";
8
+ import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
7
9
  /**
8
10
  * Execute the remove command
9
11
  */
@@ -39,6 +41,10 @@ export async function executeRemove(options) {
39
41
  }
40
42
  // Ask for confirmation unless --force is specified
41
43
  if (!options.force) {
44
+ // In CI mode, require --force to prevent hanging on confirmation prompt
45
+ if (CIDetector.isCI()) {
46
+ throw new ServherdError(ServherdErrorCode.INTERACTIVE_NOT_AVAILABLE, "Remove requires --force flag in CI mode to prevent hanging on confirmation prompt");
47
+ }
42
48
  const serverNames = serversToRemove.map((s) => s.name).join(", ");
43
49
  const message = serversToRemove.length === 1
44
50
  ? `Are you sure you want to remove server "${serversToRemove[0].name}"?`
@@ -6,22 +6,34 @@ import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
6
6
  import { logger } from "../../utils/logger.js";
7
7
  import { renderTemplate, renderEnvTemplates, getTemplateVariables } from "../../utils/template.js";
8
8
  import { extractUsedConfigKeys, createConfigSnapshot, detectDrift, } from "../../utils/config-drift.js";
9
+ import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
10
+ /**
11
+ * Build template context for server lookups
12
+ */
13
+ function buildTemplateContext(registryService, cwd) {
14
+ return {
15
+ cwd,
16
+ lookupServer: (name, lookupCwd) => {
17
+ return registryService.findByCwdAndName(lookupCwd ?? cwd, name);
18
+ },
19
+ };
20
+ }
9
21
  /**
10
22
  * Re-resolve a server's command template with current config values
11
23
  * Updates the registry with new resolved command and config snapshot
12
24
  */
13
- async function refreshServerConfig(server, config, registryService) {
25
+ async function refreshServerConfig(server, config, registryService, templateContext) {
14
26
  // Get template variables with current config
15
27
  const templateVars = getTemplateVariables(config, server.port);
16
28
  // Re-resolve the command template
17
- const resolvedCommand = renderTemplate(server.command, templateVars);
29
+ const resolvedCommand = renderTemplate(server.command, templateVars, templateContext);
18
30
  // Re-resolve environment variables if any
19
31
  const resolvedEnv = server.env
20
- ? renderEnvTemplates(server.env, templateVars)
32
+ ? renderEnvTemplates(server.env, templateVars, templateContext)
21
33
  : {};
22
34
  // Extract new used config keys and create new snapshot
23
35
  const usedConfigKeys = extractUsedConfigKeys(server.command);
24
- const configSnapshot = createConfigSnapshot(config, usedConfigKeys);
36
+ const configSnapshot = createConfigSnapshot(config, usedConfigKeys, server.command);
25
37
  // Update the registry
26
38
  await registryService.updateServer(server.id, {
27
39
  resolvedCommand,
@@ -55,24 +67,26 @@ export async function executeRestart(options) {
55
67
  else if (options.name) {
56
68
  const server = registryService.findByName(options.name);
57
69
  if (!server) {
58
- throw new Error(`Server "${options.name}" not found`);
70
+ throw new ServherdError(ServherdErrorCode.SERVER_NOT_FOUND, `Server "${options.name}" not found`);
59
71
  }
60
72
  servers = [server];
61
73
  }
62
74
  else {
63
- throw new Error("Either --name, --all, or --tag must be specified");
75
+ throw new ServherdError(ServherdErrorCode.COMMAND_MISSING_ARGUMENT, "Either --name, --all, or --tag must be specified");
64
76
  }
65
77
  // Restart all matched servers
66
78
  const results = [];
67
79
  for (const server of servers) {
68
80
  try {
69
81
  let configRefreshed = false;
82
+ // Build template context for this server's cwd
83
+ const templateContext = buildTemplateContext(registryService, server.cwd);
70
84
  // Check if we should refresh config on restart (on-start mode)
71
85
  if (config.refreshOnChange === "on-start") {
72
86
  const drift = detectDrift(server, config);
73
87
  if (drift.hasDrift) {
74
88
  // Re-resolve command with new config values
75
- const { resolvedCommand } = await refreshServerConfig(server, config, registryService);
89
+ const { resolvedCommand } = await refreshServerConfig(server, config, registryService, templateContext);
76
90
  configRefreshed = true;
77
91
  // Delete old process and start with new command
78
92
  try {
@@ -10,12 +10,21 @@ export interface StartCommandOptions {
10
10
  env?: Record<string, string>;
11
11
  }
12
12
  export interface StartCommandResult {
13
- action: "started" | "existing" | "restarted" | "renamed";
13
+ action: "started" | "existing" | "restarted" | "renamed" | "refreshed";
14
14
  server: ServerEntry;
15
15
  status: ServerStatus;
16
16
  portReassigned?: boolean;
17
17
  originalPort?: number;
18
18
  previousName?: string;
19
+ envChanged?: boolean;
20
+ /** Whether the command was changed (with explicit -n) */
21
+ commandChanged?: boolean;
22
+ /** Whether config drift was detected and applied */
23
+ configDrift?: boolean;
24
+ /** Details of config drift that was applied */
25
+ driftDetails?: string[];
26
+ /** User declined refresh when prompted */
27
+ userDeclinedRefresh?: boolean;
19
28
  }
20
29
  /**
21
30
  * Execute the start command