cyrus-config-updater 0.2.0-rc

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 (51) hide show
  1. package/LICENSE +674 -0
  2. package/dist/ConfigUpdater.d.ts +48 -0
  3. package/dist/ConfigUpdater.d.ts.map +1 -0
  4. package/dist/ConfigUpdater.js +116 -0
  5. package/dist/ConfigUpdater.js.map +1 -0
  6. package/dist/handlers/checkGh.d.ts +10 -0
  7. package/dist/handlers/checkGh.d.ts.map +1 -0
  8. package/dist/handlers/checkGh.js +54 -0
  9. package/dist/handlers/checkGh.js.map +1 -0
  10. package/dist/handlers/configureMcp.d.ts +7 -0
  11. package/dist/handlers/configureMcp.d.ts.map +1 -0
  12. package/dist/handlers/configureMcp.js +104 -0
  13. package/dist/handlers/configureMcp.js.map +1 -0
  14. package/dist/handlers/cyrusConfig.d.ts +11 -0
  15. package/dist/handlers/cyrusConfig.d.ts.map +1 -0
  16. package/dist/handlers/cyrusConfig.js +161 -0
  17. package/dist/handlers/cyrusConfig.js.map +1 -0
  18. package/dist/handlers/cyrusEnv.d.ts +7 -0
  19. package/dist/handlers/cyrusEnv.d.ts.map +1 -0
  20. package/dist/handlers/cyrusEnv.js +113 -0
  21. package/dist/handlers/cyrusEnv.js.map +1 -0
  22. package/dist/handlers/repository.d.ts +9 -0
  23. package/dist/handlers/repository.d.ts.map +1 -0
  24. package/dist/handlers/repository.js +123 -0
  25. package/dist/handlers/repository.js.map +1 -0
  26. package/dist/handlers/testMcp.d.ts +10 -0
  27. package/dist/handlers/testMcp.d.ts.map +1 -0
  28. package/dist/handlers/testMcp.js +74 -0
  29. package/dist/handlers/testMcp.js.map +1 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +3 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/types.d.ts +111 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +37 -0
  39. package/src/ConfigUpdater.ts +156 -0
  40. package/src/handlers/checkGh.ts +59 -0
  41. package/src/handlers/configureMcp.ts +127 -0
  42. package/src/handlers/cyrusConfig.ts +185 -0
  43. package/src/handlers/cyrusEnv.ts +132 -0
  44. package/src/handlers/repository.ts +137 -0
  45. package/src/handlers/testMcp.ts +82 -0
  46. package/src/index.ts +2 -0
  47. package/src/types.ts +111 -0
  48. package/test/handlers/checkGh.test.ts +144 -0
  49. package/test-scripts/test-check-gh.js +56 -0
  50. package/tsconfig.json +12 -0
  51. package/vitest.config.ts +8 -0
@@ -0,0 +1,156 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import { handleCheckGh } from "./handlers/checkGh.js";
3
+ import { handleConfigureMcp } from "./handlers/configureMcp.js";
4
+ import { handleCyrusConfig } from "./handlers/cyrusConfig.js";
5
+ import { handleCyrusEnv } from "./handlers/cyrusEnv.js";
6
+ import { handleRepository } from "./handlers/repository.js";
7
+ import { handleTestMcp } from "./handlers/testMcp.js";
8
+ import type {
9
+ ApiResponse,
10
+ CheckGhPayload,
11
+ ConfigureMcpPayload,
12
+ CyrusConfigPayload,
13
+ CyrusEnvPayload,
14
+ RepositoryPayload,
15
+ TestMcpPayload,
16
+ } from "./types.js";
17
+
18
+ /**
19
+ * ConfigUpdater registers configuration update routes with a Fastify server
20
+ * Handles: cyrus-config, cyrus-env, repository, test-mcp, configure-mcp, check-gh endpoints
21
+ */
22
+ export class ConfigUpdater {
23
+ private fastify: FastifyInstance;
24
+ private cyrusHome: string;
25
+ private apiKey: string;
26
+
27
+ constructor(fastify: FastifyInstance, cyrusHome: string, apiKey: string) {
28
+ this.fastify = fastify;
29
+ this.cyrusHome = cyrusHome;
30
+ this.apiKey = apiKey;
31
+ }
32
+
33
+ /**
34
+ * Register all configuration update routes with the Fastify instance
35
+ */
36
+ register(): void {
37
+ // Register all routes with authentication
38
+ this.registerRoute("/api/update/cyrus-config", this.handleCyrusConfigRoute);
39
+ this.registerRoute("/api/update/cyrus-env", this.handleCyrusEnvRoute);
40
+ this.registerRoute("/api/update/repository", this.handleRepositoryRoute);
41
+ this.registerRoute("/api/test-mcp", this.handleTestMcpRoute);
42
+ this.registerRoute("/api/configure-mcp", this.handleConfigureMcpRoute);
43
+ this.registerRoute("/api/check-gh", this.handleCheckGhRoute);
44
+ }
45
+
46
+ /**
47
+ * Register a route with authentication
48
+ */
49
+ private registerRoute(
50
+ path: string,
51
+ handler: (payload: any) => Promise<ApiResponse>,
52
+ ): void {
53
+ this.fastify.post(path, async (request, reply) => {
54
+ // Verify authentication
55
+ const authHeader = request.headers.authorization;
56
+ if (!this.verifyAuth(authHeader)) {
57
+ return reply.status(401).send({
58
+ success: false,
59
+ error: "Unauthorized",
60
+ });
61
+ }
62
+
63
+ try {
64
+ const response = await handler.call(this, request.body);
65
+ const statusCode = response.success ? 200 : 400;
66
+ return reply.status(statusCode).send(response);
67
+ } catch (error) {
68
+ return reply.status(500).send({
69
+ success: false,
70
+ error: "Internal server error",
71
+ details: error instanceof Error ? error.message : String(error),
72
+ });
73
+ }
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Verify Bearer token authentication
79
+ */
80
+ private verifyAuth(authHeader: string | undefined): boolean {
81
+ if (!authHeader || !this.apiKey) {
82
+ return false;
83
+ }
84
+
85
+ const expectedAuth = `Bearer ${this.apiKey}`;
86
+ return authHeader === expectedAuth;
87
+ }
88
+
89
+ /**
90
+ * Handle cyrus-config update
91
+ */
92
+ private async handleCyrusConfigRoute(
93
+ payload: CyrusConfigPayload,
94
+ ): Promise<ApiResponse> {
95
+ const response = await handleCyrusConfig(payload, this.cyrusHome);
96
+
97
+ // Emit restart event if requested
98
+ if (response.success && response.data?.restartCyrus) {
99
+ this.fastify.log.info("Config update requested Cyrus restart");
100
+ }
101
+
102
+ return response;
103
+ }
104
+
105
+ /**
106
+ * Handle cyrus-env update
107
+ */
108
+ private async handleCyrusEnvRoute(
109
+ payload: CyrusEnvPayload,
110
+ ): Promise<ApiResponse> {
111
+ const response = await handleCyrusEnv(payload, this.cyrusHome);
112
+
113
+ // Emit restart event if requested
114
+ if (response.success && response.data?.restartCyrus) {
115
+ this.fastify.log.info("Env update requested Cyrus restart");
116
+ }
117
+
118
+ return response;
119
+ }
120
+
121
+ /**
122
+ * Handle repository clone/verify
123
+ */
124
+ private async handleRepositoryRoute(
125
+ payload: RepositoryPayload,
126
+ ): Promise<ApiResponse> {
127
+ return handleRepository(payload, this.cyrusHome);
128
+ }
129
+
130
+ /**
131
+ * Handle MCP connection test
132
+ */
133
+ private async handleTestMcpRoute(
134
+ payload: TestMcpPayload,
135
+ ): Promise<ApiResponse> {
136
+ return handleTestMcp(payload);
137
+ }
138
+
139
+ /**
140
+ * Handle MCP server configuration
141
+ */
142
+ private async handleConfigureMcpRoute(
143
+ payload: ConfigureMcpPayload,
144
+ ): Promise<ApiResponse> {
145
+ return handleConfigureMcp(payload, this.cyrusHome);
146
+ }
147
+
148
+ /**
149
+ * Handle GitHub CLI check
150
+ */
151
+ private async handleCheckGhRoute(
152
+ payload: CheckGhPayload,
153
+ ): Promise<ApiResponse> {
154
+ return handleCheckGh(payload, this.cyrusHome);
155
+ }
156
+ }
@@ -0,0 +1,59 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { ApiResponse, CheckGhData, CheckGhPayload } from "../types.js";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ /**
8
+ * Check if GitHub CLI (gh) is installed and authenticated
9
+ *
10
+ * @param _payload - Empty payload (no parameters needed)
11
+ * @param _cyrusHome - Cyrus home directory (not used)
12
+ * @returns ApiResponse with installation and authentication status
13
+ */
14
+ export async function handleCheckGh(
15
+ _payload: CheckGhPayload,
16
+ _cyrusHome: string,
17
+ ): Promise<ApiResponse> {
18
+ try {
19
+ // Check if gh is installed
20
+ let isInstalled = false;
21
+ try {
22
+ await execAsync("gh --version");
23
+ isInstalled = true;
24
+ } catch {
25
+ // gh command not found
26
+ isInstalled = false;
27
+ }
28
+
29
+ // Check if gh is authenticated (only if installed)
30
+ let isAuthenticated = false;
31
+ if (isInstalled) {
32
+ try {
33
+ // Run 'gh auth status' and check exit code
34
+ await execAsync("gh auth status");
35
+ isAuthenticated = true;
36
+ } catch {
37
+ // gh auth status failed (not authenticated)
38
+ isAuthenticated = false;
39
+ }
40
+ }
41
+
42
+ const data: CheckGhData = {
43
+ isInstalled,
44
+ isAuthenticated,
45
+ };
46
+
47
+ return {
48
+ success: true,
49
+ message: "GitHub CLI check completed",
50
+ data,
51
+ };
52
+ } catch (error) {
53
+ return {
54
+ success: false,
55
+ error: "Failed to check GitHub CLI status",
56
+ details: error instanceof Error ? error.message : String(error),
57
+ };
58
+ }
59
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ApiResponse, ConfigureMcpPayload } from "../types.js";
4
+
5
+ /**
6
+ * Handle MCP server configuration
7
+ * Writes individual MCP config files to ~/.cyrus/mcp-{slug}.json
8
+ */
9
+ export async function handleConfigureMcp(
10
+ payload: ConfigureMcpPayload,
11
+ cyrusHome: string,
12
+ ): Promise<ApiResponse> {
13
+ try {
14
+ // Validate payload
15
+ if (!payload.mcpServers || typeof payload.mcpServers !== "object") {
16
+ return {
17
+ success: false,
18
+ error: "MCP configuration requires server definitions",
19
+ details:
20
+ "The mcpServers field must be an object containing server configurations.",
21
+ };
22
+ }
23
+
24
+ const serverSlugs = Object.keys(payload.mcpServers);
25
+ if (serverSlugs.length === 0) {
26
+ return {
27
+ success: false,
28
+ error: "No MCP servers to configure",
29
+ details: "At least one MCP server configuration must be provided.",
30
+ };
31
+ }
32
+
33
+ // Ensure the .cyrus directory exists
34
+ if (!existsSync(cyrusHome)) {
35
+ mkdirSync(cyrusHome, { recursive: true });
36
+ }
37
+
38
+ const mcpFilesWritten: string[] = [];
39
+
40
+ // Write each MCP server configuration to its own file
41
+ for (const slug of serverSlugs) {
42
+ const serverConfig = payload.mcpServers[slug];
43
+ const mcpFilePath = join(cyrusHome, `mcp-${slug}.json`);
44
+
45
+ // Perform environment variable substitution
46
+ const processedConfig = performEnvSubstitution(serverConfig);
47
+
48
+ // Write the config file
49
+ try {
50
+ const configData = {
51
+ mcpServers: {
52
+ [slug]: processedConfig,
53
+ },
54
+ };
55
+
56
+ writeFileSync(
57
+ mcpFilePath,
58
+ JSON.stringify(configData, null, 2),
59
+ "utf-8",
60
+ );
61
+
62
+ mcpFilesWritten.push(mcpFilePath);
63
+ } catch (error) {
64
+ return {
65
+ success: false,
66
+ error: `Failed to save MCP server configuration for "${slug}"`,
67
+ details: `Could not write to ${mcpFilePath}: ${error instanceof Error ? error.message : String(error)}`,
68
+ };
69
+ }
70
+ }
71
+
72
+ return {
73
+ success: true,
74
+ message: "MCP configuration files written successfully",
75
+ data: {
76
+ mcpFilesWritten,
77
+ serversConfigured: serverSlugs,
78
+ },
79
+ };
80
+ } catch (error) {
81
+ return {
82
+ success: false,
83
+ error: "MCP server configuration failed",
84
+ details: error instanceof Error ? error.message : String(error),
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Perform environment variable substitution on a config object
91
+ * Replaces ${VAR_NAME} placeholders with values from the env map
92
+ */
93
+ function performEnvSubstitution(config: any): any {
94
+ if (!config) return config;
95
+
96
+ // Get environment variables from the config
97
+ const env = config.env || {};
98
+
99
+ // Deep clone the config to avoid mutations
100
+ const processed = JSON.parse(JSON.stringify(config));
101
+
102
+ // Recursively process all string values
103
+ function processValue(value: any): any {
104
+ if (typeof value === "string") {
105
+ // Replace ${VAR_NAME} with the actual value from env
106
+ return value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
107
+ return env[varName] || match;
108
+ });
109
+ }
110
+
111
+ if (Array.isArray(value)) {
112
+ return value.map(processValue);
113
+ }
114
+
115
+ if (typeof value === "object" && value !== null) {
116
+ const result: any = {};
117
+ for (const key of Object.keys(value)) {
118
+ result[key] = processValue(value[key]);
119
+ }
120
+ return result;
121
+ }
122
+
123
+ return value;
124
+ }
125
+
126
+ return processValue(processed);
127
+ }
@@ -0,0 +1,185 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { ApiResponse, CyrusConfigPayload } from "../types.js";
4
+
5
+ /**
6
+ * Handle Cyrus configuration update
7
+ * Updates the ~/.cyrus/config.json file with the provided configuration
8
+ */
9
+ export async function handleCyrusConfig(
10
+ payload: CyrusConfigPayload,
11
+ cyrusHome: string,
12
+ ): Promise<ApiResponse> {
13
+ try {
14
+ // Validate payload
15
+ if (!payload.repositories || !Array.isArray(payload.repositories)) {
16
+ return {
17
+ success: false,
18
+ error: "Configuration update requires repositories array",
19
+ details:
20
+ "The repositories field must be provided as an array, even if empty.",
21
+ };
22
+ }
23
+
24
+ // Validate each repository has required fields
25
+ for (const repo of payload.repositories) {
26
+ if (!repo.id || !repo.name || !repo.repositoryPath || !repo.baseBranch) {
27
+ const missingFields: string[] = [];
28
+ if (!repo.id) missingFields.push("id");
29
+ if (!repo.name) missingFields.push("name");
30
+ if (!repo.repositoryPath) missingFields.push("repositoryPath");
31
+ if (!repo.baseBranch) missingFields.push("baseBranch");
32
+
33
+ return {
34
+ success: false,
35
+ error: "Repository configuration is incomplete",
36
+ details: `Repository "${repo.name || "unknown"}" is missing required fields: ${missingFields.join(", ")}`,
37
+ };
38
+ }
39
+ }
40
+
41
+ const configPath = join(cyrusHome, "config.json");
42
+
43
+ // Ensure the .cyrus directory exists
44
+ const configDir = dirname(configPath);
45
+ if (!existsSync(configDir)) {
46
+ mkdirSync(configDir, { recursive: true });
47
+ }
48
+
49
+ // Build the config object with repositories and optional settings
50
+ const repositories = payload.repositories.map((repo) => {
51
+ const repoConfig: any = {
52
+ id: repo.id,
53
+ name: repo.name,
54
+ repositoryPath: repo.repositoryPath,
55
+ baseBranch: repo.baseBranch,
56
+ };
57
+
58
+ // Add optional Linear fields
59
+ if (repo.linearWorkspaceId) {
60
+ repoConfig.linearWorkspaceId = repo.linearWorkspaceId;
61
+ }
62
+ if (repo.linearToken) {
63
+ repoConfig.linearToken = repo.linearToken;
64
+ }
65
+
66
+ // Set workspaceBaseDir (use provided or default to ~/.cyrus/workspaces)
67
+ repoConfig.workspaceBaseDir =
68
+ repo.workspaceBaseDir || join(cyrusHome, "workspaces");
69
+
70
+ // Set isActive (defaults to true)
71
+ repoConfig.isActive = repo.isActive !== false;
72
+
73
+ // Optional arrays and objects
74
+ if (repo.allowedTools && repo.allowedTools.length > 0) {
75
+ repoConfig.allowedTools = repo.allowedTools;
76
+ }
77
+
78
+ if (repo.mcpConfigPath && repo.mcpConfigPath.length > 0) {
79
+ repoConfig.mcpConfigPath = repo.mcpConfigPath;
80
+ }
81
+
82
+ if (repo.teamKeys) {
83
+ repoConfig.teamKeys = repo.teamKeys;
84
+ } else {
85
+ repoConfig.teamKeys = [];
86
+ }
87
+
88
+ if (repo.labelPrompts && Object.keys(repo.labelPrompts).length > 0) {
89
+ repoConfig.labelPrompts = repo.labelPrompts;
90
+ }
91
+
92
+ return repoConfig;
93
+ });
94
+
95
+ // Build complete config
96
+ const config: any = {
97
+ repositories,
98
+ };
99
+
100
+ // Add optional global settings
101
+ if (payload.disallowedTools && payload.disallowedTools.length > 0) {
102
+ config.disallowedTools = payload.disallowedTools;
103
+ }
104
+
105
+ if (payload.ngrokAuthToken) {
106
+ config.ngrokAuthToken = payload.ngrokAuthToken;
107
+ }
108
+
109
+ if (payload.stripeCustomerId) {
110
+ config.stripeCustomerId = payload.stripeCustomerId;
111
+ }
112
+
113
+ if (payload.defaultModel) {
114
+ config.defaultModel = payload.defaultModel;
115
+ }
116
+
117
+ if (payload.defaultFallbackModel) {
118
+ config.defaultFallbackModel = payload.defaultFallbackModel;
119
+ }
120
+
121
+ if (payload.global_setup_script) {
122
+ config.global_setup_script = payload.global_setup_script;
123
+ }
124
+
125
+ // Backup existing config if requested
126
+ if (payload.backupConfig && existsSync(configPath)) {
127
+ try {
128
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
129
+ const backupPath = join(cyrusHome, `config.backup-${timestamp}.json`);
130
+ const existingConfig = readFileSync(configPath, "utf-8");
131
+ writeFileSync(backupPath, existingConfig, "utf-8");
132
+ } catch (backupError) {
133
+ // Log but don't fail - backup is not critical
134
+ console.warn(
135
+ `Failed to backup config: ${backupError instanceof Error ? backupError.message : String(backupError)}`,
136
+ );
137
+ }
138
+ }
139
+
140
+ // Write config file
141
+ try {
142
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
143
+
144
+ return {
145
+ success: true,
146
+ message: "Cyrus configuration updated successfully",
147
+ data: {
148
+ configPath,
149
+ repositoriesCount: repositories.length,
150
+ restartCyrus: payload.restartCyrus || false,
151
+ },
152
+ };
153
+ } catch (error) {
154
+ return {
155
+ success: false,
156
+ error: "Failed to save configuration file",
157
+ details: `Could not write configuration to ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
158
+ };
159
+ }
160
+ } catch (error) {
161
+ return {
162
+ success: false,
163
+ error: "Configuration update failed",
164
+ details: error instanceof Error ? error.message : String(error),
165
+ };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Read current Cyrus configuration
171
+ */
172
+ export function readCyrusConfig(cyrusHome: string): any {
173
+ const configPath = join(cyrusHome, "config.json");
174
+
175
+ if (!existsSync(configPath)) {
176
+ return { repositories: [] };
177
+ }
178
+
179
+ try {
180
+ const data = readFileSync(configPath, "utf-8");
181
+ return JSON.parse(data);
182
+ } catch {
183
+ return { repositories: [] };
184
+ }
185
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { ApiResponse, CyrusEnvPayload } from "../types.js";
4
+
5
+ /**
6
+ * Handle Cyrus environment variables update
7
+ * Primarily used to update/provide the Claude API token
8
+ */
9
+ export async function handleCyrusEnv(
10
+ payload: CyrusEnvPayload,
11
+ cyrusHome: string,
12
+ ): Promise<ApiResponse> {
13
+ try {
14
+ // Validate payload
15
+ if (!payload || typeof payload !== "object") {
16
+ return {
17
+ success: false,
18
+ error: "Environment variables update requires valid data",
19
+ details:
20
+ "Payload must be an object containing environment variable key-value pairs.",
21
+ };
22
+ }
23
+
24
+ // Extract environment variables from payload
25
+ // The payload may have a 'variables' key containing the env vars,
26
+ // or the env vars may be directly in the payload
27
+ const envVarsSource = payload.variables || payload;
28
+ const envVars = Object.entries(envVarsSource).filter(
29
+ ([key, value]) =>
30
+ value !== undefined &&
31
+ typeof value === "string" &&
32
+ !["variables", "restartCyrus", "backupEnv"].includes(key),
33
+ ) as [string, string][];
34
+
35
+ if (envVars.length === 0) {
36
+ return {
37
+ success: false,
38
+ error: "No environment variables to update",
39
+ details:
40
+ "At least one environment variable must be provided in the request.",
41
+ };
42
+ }
43
+
44
+ const envPath = join(cyrusHome, ".env");
45
+
46
+ // Ensure the .cyrus directory exists
47
+ const envDir = dirname(envPath);
48
+ if (!existsSync(envDir)) {
49
+ mkdirSync(envDir, { recursive: true });
50
+ }
51
+
52
+ // Read existing env file if it exists
53
+ const existingEnv: Record<string, string> = {};
54
+ if (existsSync(envPath)) {
55
+ try {
56
+ const content = readFileSync(envPath, "utf-8");
57
+ const lines = content.split("\n");
58
+
59
+ for (const line of lines) {
60
+ const trimmed = line.trim();
61
+ // Skip empty lines and comments
62
+ if (!trimmed || trimmed.startsWith("#")) {
63
+ continue;
64
+ }
65
+
66
+ const equalIndex = trimmed.indexOf("=");
67
+ if (equalIndex > 0) {
68
+ const key = trimmed.substring(0, equalIndex);
69
+ const value = trimmed.substring(equalIndex + 1);
70
+ existingEnv[key] = value;
71
+ }
72
+ }
73
+ } catch {
74
+ // Ignore errors reading existing file - we'll create a new one
75
+ }
76
+ }
77
+
78
+ // Merge new variables (new values override existing ones)
79
+ for (const [key, value] of envVars) {
80
+ if (value !== undefined) {
81
+ existingEnv[key] = value;
82
+ }
83
+ }
84
+
85
+ // Build new env file content
86
+ const envContent = Object.entries(existingEnv)
87
+ .map(([key, value]) => `${key}=${value}`)
88
+ .join("\n");
89
+
90
+ // Backup existing env file if requested
91
+ if (payload.backupEnv && existsSync(envPath)) {
92
+ try {
93
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
94
+ const backupPath = join(cyrusHome, `.env.backup-${timestamp}`);
95
+ const existingEnvFile = readFileSync(envPath, "utf-8");
96
+ writeFileSync(backupPath, existingEnvFile, "utf-8");
97
+ } catch (backupError) {
98
+ // Log but don't fail - backup is not critical
99
+ console.warn(
100
+ `Failed to backup env: ${backupError instanceof Error ? backupError.message : String(backupError)}`,
101
+ );
102
+ }
103
+ }
104
+
105
+ // Write env file
106
+ try {
107
+ writeFileSync(envPath, `${envContent}\n`, "utf-8");
108
+
109
+ return {
110
+ success: true,
111
+ message: "Environment variables updated successfully",
112
+ data: {
113
+ envPath,
114
+ variablesUpdated: envVars.map(([key]) => key),
115
+ restartCyrus: payload.restartCyrus || false,
116
+ },
117
+ };
118
+ } catch (error) {
119
+ return {
120
+ success: false,
121
+ error: "Failed to save environment variables",
122
+ details: `Could not write to ${envPath}: ${error instanceof Error ? error.message : String(error)}`,
123
+ };
124
+ }
125
+ } catch (error) {
126
+ return {
127
+ success: false,
128
+ error: "Environment variables update failed",
129
+ details: error instanceof Error ? error.message : String(error),
130
+ };
131
+ }
132
+ }