servherd 0.0.1 → 1.0.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/CONTRIBUTING.md +250 -0
- package/LICENSE +21 -0
- package/README.md +653 -29
- package/dist/cli/commands/config.d.ts +35 -0
- package/dist/cli/commands/config.js +336 -0
- package/dist/cli/commands/info.d.ts +37 -0
- package/dist/cli/commands/info.js +98 -0
- package/dist/cli/commands/list.d.ts +26 -0
- package/dist/cli/commands/list.js +86 -0
- package/dist/cli/commands/logs.d.ts +46 -0
- package/dist/cli/commands/logs.js +292 -0
- package/dist/cli/commands/mcp.d.ts +5 -0
- package/dist/cli/commands/mcp.js +17 -0
- package/dist/cli/commands/refresh.d.ts +20 -0
- package/dist/cli/commands/refresh.js +139 -0
- package/dist/cli/commands/remove.d.ts +20 -0
- package/dist/cli/commands/remove.js +144 -0
- package/dist/cli/commands/restart.d.ts +25 -0
- package/dist/cli/commands/restart.js +177 -0
- package/dist/cli/commands/start.d.ts +37 -0
- package/dist/cli/commands/start.js +293 -0
- package/dist/cli/commands/stop.d.ts +20 -0
- package/dist/cli/commands/stop.js +108 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.js +160 -0
- package/dist/cli/output/formatters.d.ts +117 -0
- package/dist/cli/output/formatters.js +454 -0
- package/dist/cli/output/json-formatter.d.ts +22 -0
- package/dist/cli/output/json-formatter.js +40 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +25 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +352 -0
- package/dist/mcp/resources/servers.d.ts +14 -0
- package/dist/mcp/resources/servers.js +128 -0
- package/dist/mcp/tools/config.d.ts +33 -0
- package/dist/mcp/tools/config.js +88 -0
- package/dist/mcp/tools/info.d.ts +36 -0
- package/dist/mcp/tools/info.js +65 -0
- package/dist/mcp/tools/list.d.ts +36 -0
- package/dist/mcp/tools/list.js +49 -0
- package/dist/mcp/tools/logs.d.ts +44 -0
- package/dist/mcp/tools/logs.js +55 -0
- package/dist/mcp/tools/refresh.d.ts +33 -0
- package/dist/mcp/tools/refresh.js +54 -0
- package/dist/mcp/tools/remove.d.ts +23 -0
- package/dist/mcp/tools/remove.js +43 -0
- package/dist/mcp/tools/restart.d.ts +23 -0
- package/dist/mcp/tools/restart.js +42 -0
- package/dist/mcp/tools/start.d.ts +38 -0
- package/dist/mcp/tools/start.js +73 -0
- package/dist/mcp/tools/stop.d.ts +23 -0
- package/dist/mcp/tools/stop.js +40 -0
- package/dist/services/config.service.d.ts +80 -0
- package/dist/services/config.service.js +227 -0
- package/dist/services/port.service.d.ts +82 -0
- package/dist/services/port.service.js +151 -0
- package/dist/services/process.service.d.ts +61 -0
- package/dist/services/process.service.js +220 -0
- package/dist/services/registry.service.d.ts +50 -0
- package/dist/services/registry.service.js +157 -0
- package/dist/types/config.d.ts +107 -0
- package/dist/types/config.js +44 -0
- package/dist/types/errors.d.ts +102 -0
- package/dist/types/errors.js +197 -0
- package/dist/types/pm2.d.ts +50 -0
- package/dist/types/pm2.js +4 -0
- package/dist/types/registry.d.ts +230 -0
- package/dist/types/registry.js +33 -0
- package/dist/utils/ci-detector.d.ts +31 -0
- package/dist/utils/ci-detector.js +68 -0
- package/dist/utils/config-drift.d.ts +71 -0
- package/dist/utils/config-drift.js +128 -0
- package/dist/utils/error-handler.d.ts +21 -0
- package/dist/utils/error-handler.js +38 -0
- package/dist/utils/log-follower.d.ts +10 -0
- package/dist/utils/log-follower.js +98 -0
- package/dist/utils/logger.d.ts +11 -0
- package/dist/utils/logger.js +24 -0
- package/dist/utils/names.d.ts +7 -0
- package/dist/utils/names.js +20 -0
- package/dist/utils/template.d.ts +88 -0
- package/dist/utils/template.js +180 -0
- package/dist/utils/time-parser.d.ts +19 -0
- package/dist/utils/time-parser.js +54 -0
- package/docs/ci-cd.md +408 -0
- package/docs/configuration.md +325 -0
- package/docs/mcp-integration.md +411 -0
- package/examples/basic-usage/README.md +187 -0
- package/examples/ci-github-actions/workflow.yml +195 -0
- package/examples/mcp-claude-code/README.md +213 -0
- package/examples/multi-server/README.md +270 -0
- package/examples/storybook/README.md +187 -0
- package/examples/vite-project/README.md +251 -0
- package/package.json +123 -6
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { pathExists } from "fs-extra/esm";
|
|
3
|
+
import { RegistryService } from "../../services/registry.service.js";
|
|
4
|
+
import { ProcessService } from "../../services/process.service.js";
|
|
5
|
+
import { formatLogs, formatError, formatWarning, formatSuccess } from "../output/formatters.js";
|
|
6
|
+
import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
import { followLog } from "../../utils/log-follower.js";
|
|
9
|
+
import { parseTimeFilter, filterLogsByTime } from "../../utils/time-parser.js";
|
|
10
|
+
import { ServherdError, ServherdErrorCode } from "../../types/errors.js";
|
|
11
|
+
const DEFAULT_LINES = 50;
|
|
12
|
+
/**
|
|
13
|
+
* Parse timestamp from PM2-style log lines.
|
|
14
|
+
* PM2 outputs timestamps in ISO 8601 format: 2026-01-11T09:51:49.598-08:00: message
|
|
15
|
+
* Returns null if no timestamp is found.
|
|
16
|
+
*/
|
|
17
|
+
function parseLogTimestamp(line) {
|
|
18
|
+
// PM2 format: "2026-01-11T09:51:49.598-08:00: message"
|
|
19
|
+
// Extract everything before the first ": " as potential timestamp
|
|
20
|
+
const colonIndex = line.indexOf(": ");
|
|
21
|
+
if (colonIndex === -1) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const potentialTimestamp = line.substring(0, colonIndex);
|
|
25
|
+
const date = new Date(potentialTimestamp);
|
|
26
|
+
if (!isNaN(date.getTime())) {
|
|
27
|
+
return date;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Execute flush command to clear logs
|
|
33
|
+
*/
|
|
34
|
+
export async function executeFlush(options) {
|
|
35
|
+
const registryService = new RegistryService();
|
|
36
|
+
const processService = new ProcessService();
|
|
37
|
+
try {
|
|
38
|
+
await processService.connect();
|
|
39
|
+
if (options.all) {
|
|
40
|
+
await processService.flush();
|
|
41
|
+
return {
|
|
42
|
+
flushed: true,
|
|
43
|
+
all: true,
|
|
44
|
+
message: "Logs flushed for all servers",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!options.name) {
|
|
48
|
+
throw new ServherdError(ServherdErrorCode.COMMAND_MISSING_ARGUMENT, "Server name is required (or use --all to flush all logs)");
|
|
49
|
+
}
|
|
50
|
+
// Load registry to verify server exists
|
|
51
|
+
await registryService.load();
|
|
52
|
+
const server = registryService.findByName(options.name);
|
|
53
|
+
if (!server) {
|
|
54
|
+
throw new ServherdError(ServherdErrorCode.SERVER_NOT_FOUND, `Server "${options.name}" not found`);
|
|
55
|
+
}
|
|
56
|
+
await processService.flush(server.pm2Name);
|
|
57
|
+
return {
|
|
58
|
+
flushed: true,
|
|
59
|
+
name: options.name,
|
|
60
|
+
message: `Logs flushed for server "${options.name}"`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
processService.disconnect();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Execute the logs command
|
|
69
|
+
*/
|
|
70
|
+
export async function executeLogs(options) {
|
|
71
|
+
const registryService = new RegistryService();
|
|
72
|
+
const processService = new ProcessService();
|
|
73
|
+
if (!options.name) {
|
|
74
|
+
throw new ServherdError(ServherdErrorCode.COMMAND_MISSING_ARGUMENT, "Server name is required");
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
// Load registry
|
|
78
|
+
await registryService.load();
|
|
79
|
+
// Find server by name
|
|
80
|
+
const server = registryService.findByName(options.name);
|
|
81
|
+
if (!server) {
|
|
82
|
+
throw new ServherdError(ServherdErrorCode.SERVER_NOT_FOUND, `Server "${options.name}" not found`);
|
|
83
|
+
}
|
|
84
|
+
// Connect to PM2 to get process details
|
|
85
|
+
await processService.connect();
|
|
86
|
+
// Get process info from PM2
|
|
87
|
+
const procDesc = await processService.describe(server.pm2Name);
|
|
88
|
+
const lines = options.lines ?? DEFAULT_LINES;
|
|
89
|
+
let outLogPath;
|
|
90
|
+
let errLogPath;
|
|
91
|
+
let status = "unknown";
|
|
92
|
+
let logs = "";
|
|
93
|
+
if (procDesc) {
|
|
94
|
+
const pm2Env = procDesc.pm2_env;
|
|
95
|
+
outLogPath = pm2Env.pm_out_log_path;
|
|
96
|
+
errLogPath = pm2Env.pm_err_log_path;
|
|
97
|
+
status = pm2Env.status === "online" ? "online"
|
|
98
|
+
: pm2Env.status === "stopped" || pm2Env.status === "stopping" ? "stopped"
|
|
99
|
+
: pm2Env.status === "errored" ? "errored"
|
|
100
|
+
: "unknown";
|
|
101
|
+
// Read the appropriate log file
|
|
102
|
+
const logPath = options.error ? errLogPath : outLogPath;
|
|
103
|
+
if (logPath) {
|
|
104
|
+
try {
|
|
105
|
+
const exists = await pathExists(logPath);
|
|
106
|
+
if (exists) {
|
|
107
|
+
const content = await readFile(logPath, "utf-8");
|
|
108
|
+
// Filter out empty strings caused by trailing newlines
|
|
109
|
+
let allLines = content.split("\n").filter((line) => line.length > 0);
|
|
110
|
+
// Apply --since filter if specified
|
|
111
|
+
if (options.since) {
|
|
112
|
+
const sinceDate = parseTimeFilter(options.since);
|
|
113
|
+
allLines = filterLogsByTime(allLines, sinceDate, parseLogTimestamp);
|
|
114
|
+
}
|
|
115
|
+
// Apply --head or --lines
|
|
116
|
+
if (options.head !== undefined) {
|
|
117
|
+
// --head shows first N lines
|
|
118
|
+
const headLines = allLines.slice(0, options.head);
|
|
119
|
+
logs = headLines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Default: --lines shows last N lines
|
|
123
|
+
const lastLines = allLines.slice(-lines);
|
|
124
|
+
logs = lastLines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
logs = "(log file does not exist)";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
logger.debug({ error, logPath }, "Failed to read log file");
|
|
133
|
+
logs = "(failed to read log file)";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
logs = "(no log path available)";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
logs = "(process not found in PM2)";
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
name: options.name,
|
|
145
|
+
status,
|
|
146
|
+
logs,
|
|
147
|
+
lines: options.head ?? lines,
|
|
148
|
+
outLogPath,
|
|
149
|
+
errLogPath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
processService.disconnect();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* CLI action handler for logs command
|
|
158
|
+
*/
|
|
159
|
+
export async function logsAction(name, options) {
|
|
160
|
+
try {
|
|
161
|
+
// Handle --flush option
|
|
162
|
+
if (options.flush) {
|
|
163
|
+
const result = await executeFlush({ name, all: options.all });
|
|
164
|
+
if (options.json) {
|
|
165
|
+
console.log(formatAsJson(result));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
console.log(formatSuccess(result.message));
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Validate that name is provided for non-flush operations
|
|
173
|
+
if (!name) {
|
|
174
|
+
const error = new ServherdError(ServherdErrorCode.COMMAND_MISSING_ARGUMENT, "Server name is required");
|
|
175
|
+
if (options.json) {
|
|
176
|
+
console.log(formatErrorAsJson(error));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.error(formatError(error.message));
|
|
180
|
+
}
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Handle --follow option
|
|
185
|
+
if (options.follow && !options.json) {
|
|
186
|
+
await handleFollowMode(name, options);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (options.follow && options.json) {
|
|
190
|
+
// Follow mode doesn't work well with JSON output
|
|
191
|
+
console.log(formatWarning("The --follow flag is not supported with --json output. Showing static logs."));
|
|
192
|
+
}
|
|
193
|
+
const result = await executeLogs({
|
|
194
|
+
name,
|
|
195
|
+
lines: options.lines,
|
|
196
|
+
error: options.error,
|
|
197
|
+
follow: options.follow,
|
|
198
|
+
since: options.since,
|
|
199
|
+
head: options.head,
|
|
200
|
+
});
|
|
201
|
+
if (options.json) {
|
|
202
|
+
console.log(formatAsJson(result));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
console.log(formatLogs(result));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (options.json) {
|
|
210
|
+
console.log(formatErrorAsJson(error));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
console.error(formatError(message));
|
|
215
|
+
}
|
|
216
|
+
logger.error({ error }, "Logs command failed");
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Handle follow mode for logs
|
|
222
|
+
*/
|
|
223
|
+
async function handleFollowMode(name, options) {
|
|
224
|
+
const registryService = new RegistryService();
|
|
225
|
+
const processService = new ProcessService();
|
|
226
|
+
try {
|
|
227
|
+
await registryService.load();
|
|
228
|
+
const server = registryService.findByName(name);
|
|
229
|
+
if (!server) {
|
|
230
|
+
console.error(formatError(`Server "${name}" not found`));
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await processService.connect();
|
|
235
|
+
const procDesc = await processService.describe(server.pm2Name);
|
|
236
|
+
if (!procDesc) {
|
|
237
|
+
console.error(formatError(`Process not found in PM2 for "${name}"`));
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const logPath = options.error
|
|
242
|
+
? procDesc.pm2_env.pm_err_log_path
|
|
243
|
+
: procDesc.pm2_env.pm_out_log_path;
|
|
244
|
+
if (!logPath) {
|
|
245
|
+
console.error(formatError("No log path available"));
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const exists = await pathExists(logPath);
|
|
250
|
+
if (!exists) {
|
|
251
|
+
console.error(formatError(`Log file does not exist: ${logPath}`));
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
console.log(formatWarning(`Following logs for "${name}" (Ctrl+C to stop)...`));
|
|
256
|
+
console.log("");
|
|
257
|
+
// Set up abort controller for graceful shutdown
|
|
258
|
+
const controller = new AbortController();
|
|
259
|
+
// Handle SIGINT (Ctrl+C)
|
|
260
|
+
const handleSignal = () => {
|
|
261
|
+
controller.abort();
|
|
262
|
+
};
|
|
263
|
+
process.on("SIGINT", handleSignal);
|
|
264
|
+
process.on("SIGTERM", handleSignal);
|
|
265
|
+
// Optional: parse --since for filtering live logs
|
|
266
|
+
let sinceDate;
|
|
267
|
+
if (options.since) {
|
|
268
|
+
sinceDate = parseTimeFilter(options.since);
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await followLog(logPath, controller.signal, (line) => {
|
|
272
|
+
// Filter by time if --since is specified
|
|
273
|
+
if (sinceDate) {
|
|
274
|
+
const timestamp = parseLogTimestamp(line);
|
|
275
|
+
if (timestamp && timestamp < sinceDate) {
|
|
276
|
+
return; // Skip old lines
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
console.log(line);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
process.off("SIGINT", handleSignal);
|
|
284
|
+
process.off("SIGTERM", handleSignal);
|
|
285
|
+
}
|
|
286
|
+
console.log("");
|
|
287
|
+
console.log(formatSuccess("Stopped following logs."));
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
processService.disconnect();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { startStdioServer } from "../../mcp/index.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
/**
|
|
4
|
+
* CLI action handler for mcp command
|
|
5
|
+
* Starts the MCP server in stdio mode for use with Claude Code or other MCP clients
|
|
6
|
+
*/
|
|
7
|
+
export async function mcpAction() {
|
|
8
|
+
try {
|
|
9
|
+
await startStdioServer();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13
|
+
// Don't use console.error as it would interfere with stdio transport
|
|
14
|
+
logger.error({ error }, "MCP server failed: " + message);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ServerStatus } from "../../types/registry.js";
|
|
2
|
+
export interface RefreshCommandOptions {
|
|
3
|
+
name?: string;
|
|
4
|
+
all?: boolean;
|
|
5
|
+
tag?: string;
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface RefreshResult {
|
|
9
|
+
name: string;
|
|
10
|
+
success: boolean;
|
|
11
|
+
status?: ServerStatus;
|
|
12
|
+
message?: string;
|
|
13
|
+
driftDetails?: string;
|
|
14
|
+
skipped?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Execute the refresh command
|
|
18
|
+
* Finds servers with config drift and restarts them with updated config
|
|
19
|
+
*/
|
|
20
|
+
export declare function executeRefresh(options: RefreshCommandOptions): Promise<RefreshResult[]>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { RegistryService } from "../../services/registry.service.js";
|
|
2
|
+
import { ProcessService } from "../../services/process.service.js";
|
|
3
|
+
import { ConfigService } from "../../services/config.service.js";
|
|
4
|
+
import { renderTemplate, renderEnvTemplates, getTemplateVariables } from "../../utils/template.js";
|
|
5
|
+
import { extractUsedConfigKeys, createConfigSnapshot, findServersWithDrift, formatDrift, } from "../../utils/config-drift.js";
|
|
6
|
+
/**
|
|
7
|
+
* Re-resolve a server's command template with current config values
|
|
8
|
+
* Updates the registry with new resolved command and config snapshot
|
|
9
|
+
*/
|
|
10
|
+
async function refreshServerConfig(server, config, registryService) {
|
|
11
|
+
// Get template variables with current config
|
|
12
|
+
const templateVars = getTemplateVariables(config, server.port);
|
|
13
|
+
// Re-resolve the command template
|
|
14
|
+
const resolvedCommand = renderTemplate(server.command, templateVars);
|
|
15
|
+
// Re-resolve environment variables if any
|
|
16
|
+
const resolvedEnv = server.env
|
|
17
|
+
? renderEnvTemplates(server.env, templateVars)
|
|
18
|
+
: {};
|
|
19
|
+
// Extract new used config keys and create new snapshot
|
|
20
|
+
const usedConfigKeys = extractUsedConfigKeys(server.command);
|
|
21
|
+
const configSnapshot = createConfigSnapshot(config, usedConfigKeys);
|
|
22
|
+
// Update the registry
|
|
23
|
+
await registryService.updateServer(server.id, {
|
|
24
|
+
resolvedCommand,
|
|
25
|
+
env: resolvedEnv,
|
|
26
|
+
usedConfigKeys,
|
|
27
|
+
configSnapshot,
|
|
28
|
+
});
|
|
29
|
+
return { resolvedCommand };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Execute the refresh command
|
|
33
|
+
* Finds servers with config drift and restarts them with updated config
|
|
34
|
+
*/
|
|
35
|
+
export async function executeRefresh(options) {
|
|
36
|
+
const registryService = new RegistryService();
|
|
37
|
+
const processService = new ProcessService();
|
|
38
|
+
const configService = new ConfigService();
|
|
39
|
+
try {
|
|
40
|
+
// Load registry and config
|
|
41
|
+
await registryService.load();
|
|
42
|
+
const config = await configService.load();
|
|
43
|
+
// Connect to PM2
|
|
44
|
+
await processService.connect();
|
|
45
|
+
// Determine which servers to check
|
|
46
|
+
let servers = [];
|
|
47
|
+
if (options.all) {
|
|
48
|
+
servers = registryService.listServers();
|
|
49
|
+
}
|
|
50
|
+
else if (options.tag) {
|
|
51
|
+
servers = registryService.listServers({ tag: options.tag });
|
|
52
|
+
}
|
|
53
|
+
else if (options.name) {
|
|
54
|
+
const server = registryService.findByName(options.name);
|
|
55
|
+
if (!server) {
|
|
56
|
+
throw new Error(`Server "${options.name}" not found`);
|
|
57
|
+
}
|
|
58
|
+
servers = [server];
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Default: find all servers with drift
|
|
62
|
+
servers = registryService.listServers();
|
|
63
|
+
}
|
|
64
|
+
// Find servers with drift
|
|
65
|
+
const serversWithDrift = findServersWithDrift(servers, config);
|
|
66
|
+
// If no drift, return early
|
|
67
|
+
if (serversWithDrift.length === 0) {
|
|
68
|
+
return [{
|
|
69
|
+
name: "",
|
|
70
|
+
success: true,
|
|
71
|
+
skipped: true,
|
|
72
|
+
message: "No servers have config drift",
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
const results = [];
|
|
76
|
+
for (const { server, drift } of serversWithDrift) {
|
|
77
|
+
const driftDetails = formatDrift(drift);
|
|
78
|
+
// Dry run mode - just report what would happen
|
|
79
|
+
if (options.dryRun) {
|
|
80
|
+
results.push({
|
|
81
|
+
name: server.name,
|
|
82
|
+
success: true,
|
|
83
|
+
skipped: true,
|
|
84
|
+
message: "Would refresh (dry-run mode)",
|
|
85
|
+
driftDetails,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// Re-resolve command with new config values
|
|
91
|
+
const { resolvedCommand } = await refreshServerConfig(server, config, registryService);
|
|
92
|
+
// Delete old process and start with new command
|
|
93
|
+
try {
|
|
94
|
+
await processService.delete(server.pm2Name);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Process might not exist
|
|
98
|
+
}
|
|
99
|
+
// Parse the resolved command to extract script and args
|
|
100
|
+
const parts = resolvedCommand.trim().split(/\s+/);
|
|
101
|
+
const script = parts[0] || "node";
|
|
102
|
+
const args = parts.slice(1);
|
|
103
|
+
// Get the updated server entry for current env
|
|
104
|
+
const updatedServer = registryService.findById(server.id);
|
|
105
|
+
const env = updatedServer?.env ?? server.env;
|
|
106
|
+
await processService.start({
|
|
107
|
+
name: server.pm2Name,
|
|
108
|
+
script,
|
|
109
|
+
args,
|
|
110
|
+
cwd: server.cwd,
|
|
111
|
+
env: {
|
|
112
|
+
...env,
|
|
113
|
+
PORT: String(server.port),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const status = await processService.getStatus(server.pm2Name);
|
|
117
|
+
results.push({
|
|
118
|
+
name: server.name,
|
|
119
|
+
success: true,
|
|
120
|
+
status,
|
|
121
|
+
driftDetails,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
results.push({
|
|
127
|
+
name: server.name,
|
|
128
|
+
success: false,
|
|
129
|
+
message,
|
|
130
|
+
driftDetails,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
processService.disconnect();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type RemoveResult } from "../output/formatters.js";
|
|
2
|
+
export interface RemoveCommandOptions {
|
|
3
|
+
name?: string;
|
|
4
|
+
all?: boolean;
|
|
5
|
+
tag?: string;
|
|
6
|
+
force?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Execute the remove command
|
|
10
|
+
*/
|
|
11
|
+
export declare function executeRemove(options: RemoveCommandOptions): Promise<RemoveResult[]>;
|
|
12
|
+
/**
|
|
13
|
+
* CLI action handler for remove command
|
|
14
|
+
*/
|
|
15
|
+
export declare function removeAction(name: string | undefined, options: {
|
|
16
|
+
all?: boolean;
|
|
17
|
+
tag?: string;
|
|
18
|
+
force?: boolean;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { confirm } from "@inquirer/prompts";
|
|
2
|
+
import { RegistryService } from "../../services/registry.service.js";
|
|
3
|
+
import { ProcessService } from "../../services/process.service.js";
|
|
4
|
+
import { formatRemoveResult } from "../output/formatters.js";
|
|
5
|
+
import { formatAsJson, formatErrorAsJson } from "../output/json-formatter.js";
|
|
6
|
+
import { logger } from "../../utils/logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Execute the remove command
|
|
9
|
+
*/
|
|
10
|
+
export async function executeRemove(options) {
|
|
11
|
+
const registryService = new RegistryService();
|
|
12
|
+
const processService = new ProcessService();
|
|
13
|
+
const results = [];
|
|
14
|
+
try {
|
|
15
|
+
await registryService.load();
|
|
16
|
+
await processService.connect();
|
|
17
|
+
let serversToRemove = [];
|
|
18
|
+
if (options.all) {
|
|
19
|
+
serversToRemove = registryService.listServers();
|
|
20
|
+
}
|
|
21
|
+
else if (options.tag) {
|
|
22
|
+
serversToRemove = registryService.listServers({ tag: options.tag });
|
|
23
|
+
}
|
|
24
|
+
else if (options.name) {
|
|
25
|
+
const server = registryService.findByName(options.name);
|
|
26
|
+
if (server) {
|
|
27
|
+
serversToRemove = [server];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
return [{
|
|
31
|
+
name: options.name,
|
|
32
|
+
success: false,
|
|
33
|
+
message: `Server "${options.name}" not found in registry`,
|
|
34
|
+
}];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (serversToRemove.length === 0) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
// Ask for confirmation unless --force is specified
|
|
41
|
+
if (!options.force) {
|
|
42
|
+
const serverNames = serversToRemove.map((s) => s.name).join(", ");
|
|
43
|
+
const message = serversToRemove.length === 1
|
|
44
|
+
? `Are you sure you want to remove server "${serversToRemove[0].name}"?`
|
|
45
|
+
: `Are you sure you want to remove ${serversToRemove.length} servers (${serverNames})?`;
|
|
46
|
+
const confirmed = await confirm({ message });
|
|
47
|
+
if (!confirmed) {
|
|
48
|
+
return serversToRemove.map((server) => ({
|
|
49
|
+
name: server.name,
|
|
50
|
+
success: false,
|
|
51
|
+
cancelled: true,
|
|
52
|
+
message: "Cancelled by user",
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const server of serversToRemove) {
|
|
57
|
+
try {
|
|
58
|
+
// First try to delete from PM2
|
|
59
|
+
let pm2DeleteFailed = false;
|
|
60
|
+
try {
|
|
61
|
+
await processService.delete(server.pm2Name);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// If PM2 delete fails due to process not found, we still want to remove from registry
|
|
65
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
+
if (!message.includes("not found") && !message.includes("process name not found")) {
|
|
67
|
+
pm2DeleteFailed = true;
|
|
68
|
+
results.push({
|
|
69
|
+
name: server.name,
|
|
70
|
+
success: false,
|
|
71
|
+
message,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Process not found in PM2 is okay - we'll still remove from registry
|
|
76
|
+
}
|
|
77
|
+
// Then remove from registry
|
|
78
|
+
if (!pm2DeleteFailed) {
|
|
79
|
+
await registryService.removeServer(server.id);
|
|
80
|
+
results.push({
|
|
81
|
+
name: server.name,
|
|
82
|
+
success: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
results.push({
|
|
89
|
+
name: server.name,
|
|
90
|
+
success: false,
|
|
91
|
+
message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
processService.disconnect();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* CLI action handler for remove command
|
|
103
|
+
*/
|
|
104
|
+
export async function removeAction(name, options) {
|
|
105
|
+
try {
|
|
106
|
+
if (!name && !options.all && !options.tag) {
|
|
107
|
+
if (options.json) {
|
|
108
|
+
console.log(formatErrorAsJson(new Error("Provide a server name, --all, or --tag")));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.error("Error: Provide a server name, --all, or --tag");
|
|
112
|
+
}
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const results = await executeRemove({
|
|
117
|
+
name,
|
|
118
|
+
all: options.all,
|
|
119
|
+
tag: options.tag,
|
|
120
|
+
force: options.force,
|
|
121
|
+
});
|
|
122
|
+
if (options.json) {
|
|
123
|
+
console.log(formatAsJson({ results }));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(formatRemoveResult(results));
|
|
127
|
+
}
|
|
128
|
+
// Set exit code if any failures (excluding cancellations)
|
|
129
|
+
if (results.some((r) => !r.success && !r.cancelled)) {
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (options.json) {
|
|
135
|
+
console.log(formatErrorAsJson(error));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
console.error(`Error: ${message}`);
|
|
140
|
+
}
|
|
141
|
+
logger.error({ error }, "Remove command failed");
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ServerStatus } from "../../types/registry.js";
|
|
2
|
+
export interface RestartCommandOptions {
|
|
3
|
+
name?: string;
|
|
4
|
+
all?: boolean;
|
|
5
|
+
tag?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface RestartResult {
|
|
8
|
+
name: string;
|
|
9
|
+
success: boolean;
|
|
10
|
+
status?: ServerStatus;
|
|
11
|
+
message?: string;
|
|
12
|
+
configRefreshed?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Execute the restart command for a single server
|
|
16
|
+
*/
|
|
17
|
+
export declare function executeRestart(options: RestartCommandOptions): Promise<RestartResult | RestartResult[]>;
|
|
18
|
+
/**
|
|
19
|
+
* CLI action handler for restart command
|
|
20
|
+
*/
|
|
21
|
+
export declare function restartAction(name: string | undefined, options: {
|
|
22
|
+
all?: boolean;
|
|
23
|
+
tag?: string;
|
|
24
|
+
json?: boolean;
|
|
25
|
+
}): Promise<void>;
|