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.
- package/README.md +75 -1
- package/dist/cli/commands/config.d.ts +10 -0
- package/dist/cli/commands/config.js +106 -1
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/refresh.d.ts +6 -0
- package/dist/cli/commands/refresh.js +58 -16
- package/dist/cli/commands/remove.js +6 -0
- package/dist/cli/commands/restart.js +21 -7
- package/dist/cli/commands/start.d.ts +10 -1
- package/dist/cli/commands/start.js +229 -42
- package/dist/cli/index.js +24 -2
- package/dist/cli/output/formatters.d.ts +16 -1
- package/dist/cli/output/formatters.js +59 -31
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/tools/config.d.ts +28 -1
- package/dist/mcp/tools/config.js +52 -2
- package/dist/mcp/tools/info.js +14 -18
- package/dist/mcp/tools/list.d.ts +3 -0
- package/dist/mcp/tools/list.js +2 -0
- package/dist/mcp/tools/remove.d.ts +3 -0
- package/dist/mcp/tools/remove.js +1 -0
- package/dist/mcp/tools/start.d.ts +11 -1
- package/dist/mcp/tools/start.js +18 -4
- package/dist/mcp/tools/stop.d.ts +3 -0
- package/dist/mcp/tools/stop.js +2 -0
- package/dist/services/config.service.d.ts +1 -0
- package/dist/services/config.service.js +8 -1
- package/dist/services/port.service.d.ts +15 -0
- package/dist/services/port.service.js +58 -0
- package/dist/services/process.service.d.ts +9 -3
- package/dist/services/process.service.js +53 -21
- package/dist/services/registry.service.d.ts +7 -1
- package/dist/services/registry.service.js +30 -3
- package/dist/types/config.d.ts +3 -0
- package/dist/types/config.js +2 -0
- package/dist/types/registry.d.ts +60 -0
- package/dist/types/registry.js +7 -0
- package/dist/utils/ci-detector.js +6 -0
- package/dist/utils/config-drift.d.ts +14 -2
- package/dist/utils/config-drift.js +81 -3
- package/dist/utils/env-compare.d.ts +36 -0
- package/dist/utils/env-compare.js +57 -0
- package/dist/utils/format.d.ts +15 -0
- package/dist/utils/format.js +41 -0
- package/dist/utils/names.d.ts +17 -0
- package/dist/utils/names.js +40 -0
- package/dist/utils/template.d.ts +42 -2
- package/dist/utils/template.js +125 -16
- package/dist/utils/version.d.ts +5 -0
- package/dist/utils/version.js +17 -0
- 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://
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
149
|
+
PORT: String(port),
|
|
114
150
|
},
|
|
115
151
|
});
|
|
116
152
|
const status = await processService.getStatus(server.pm2Name);
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|