mlgym-deploy 3.3.5 → 3.3.13

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 (2) hide show
  1. package/index.js +183 -48
  2. package/package.json +5 -1
package/index.js CHANGED
@@ -18,7 +18,7 @@ import crypto from 'crypto';
18
18
  const execAsync = promisify(exec);
19
19
 
20
20
  // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
21
- const CURRENT_VERSION = '3.3.5'; // Fixed mlgym_deploy_logs project lookup (data.projects data) by importing fsSync for synchronous file operations
21
+ const CURRENT_VERSION = '3.3.13'; // Fix branch detection for git push (master vs main)
22
22
  const PACKAGE_NAME = 'mlgym-deploy';
23
23
 
24
24
  // Debug logging configuration - ENABLED BY DEFAULT
@@ -112,14 +112,15 @@ async function saveAuth(email, token) {
112
112
  );
113
113
  }
114
114
 
115
- // API client helper
116
- async function apiRequest(method, endpoint, data = null, useAuth = true) {
115
+ // API client helper with retry logic for 5xx errors
116
+ async function apiRequest(method, endpoint, data = null, useAuth = true, maxRetries = 3) {
117
117
  const config = {
118
118
  method,
119
119
  url: `${CONFIG.backend_url}${endpoint}`,
120
120
  headers: {
121
121
  'Content-Type': 'application/json'
122
- }
122
+ },
123
+ timeout: 120000 // 2 minute timeout for long operations
123
124
  };
124
125
 
125
126
  if (useAuth) {
@@ -133,13 +134,32 @@ async function apiRequest(method, endpoint, data = null, useAuth = true) {
133
134
  config.data = data;
134
135
  }
135
136
 
136
- try {
137
- const response = await axios(config);
138
- return { success: true, data: response.data };
139
- } catch (error) {
140
- const errorData = error.response?.data || { error: error.message };
141
- return { success: false, error: errorData.error || errorData.message || 'Request failed' };
137
+ let lastError = null;
138
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
139
+ try {
140
+ const response = await axios(config);
141
+ return { success: true, data: response.data };
142
+ } catch (error) {
143
+ lastError = error;
144
+ const statusCode = error.response?.status;
145
+
146
+ // Retry on 5xx errors (server errors) or network errors
147
+ if (statusCode >= 500 || !error.response) {
148
+ if (attempt < maxRetries) {
149
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); // Exponential backoff, max 10s
150
+ log.warning(`MCP >>> API request failed (attempt ${attempt}/${maxRetries}), status: ${statusCode || 'network error'}, retrying in ${delay}ms...`);
151
+ await new Promise(resolve => setTimeout(resolve, delay));
152
+ continue;
153
+ }
154
+ }
155
+
156
+ // Don't retry on 4xx errors (client errors)
157
+ break;
158
+ }
142
159
  }
160
+
161
+ const errorData = lastError?.response?.data || { error: lastError?.message };
162
+ return { success: false, error: errorData.error || errorData.message || 'Request failed' };
143
163
  }
144
164
 
145
165
  // Helper to generate random password
@@ -986,8 +1006,8 @@ async function detectDeploymentStrategy(projectPath) {
986
1006
 
987
1007
  // Case 2: Only docker-compose
988
1008
  if (!hasDockerfile && hasCompose) {
989
- log.info('MCP >>> [detectDeploymentStrategy] Strategy: docker-compose (compose only)');
990
- return { type: 'docker-compose', reason: 'docker-compose.yml only' };
1009
+ log.info('MCP >>> [detectDeploymentStrategy] Strategy: dockercompose (compose only)');
1010
+ return { type: 'dockercompose', reason: 'docker-compose.yml only' };
991
1011
  }
992
1012
 
993
1013
  // Case 3: Both exist - analyze docker-compose to decide
@@ -1008,11 +1028,11 @@ async function detectDeploymentStrategy(projectPath) {
1008
1028
 
1009
1029
  // COMPLEX: Multiple services (web + database, etc.)
1010
1030
  // → MUST use docker-compose for orchestration
1011
- log.info('MCP >>> [detectDeploymentStrategy] Strategy: docker-compose (multi-service)');
1012
- return { type: 'docker-compose', reason: 'Application requires multiple services (web + database/cache/etc)' };
1031
+ log.info('MCP >>> [detectDeploymentStrategy] Strategy: dockercompose (multi-service)');
1032
+ return { type: 'dockercompose', reason: 'Application requires multiple services (web + database/cache/etc)' };
1013
1033
  } catch (err) {
1014
- log.error('MCP >>> [detectDeploymentStrategy] Analysis failed, defaulting to docker-compose:', err.message);
1015
- return { type: 'docker-compose', reason: 'Multiple deployment files found' };
1034
+ log.error('MCP >>> [detectDeploymentStrategy] Analysis failed, defaulting to dockercompose:', err.message);
1035
+ return { type: 'dockercompose', reason: 'Multiple deployment files found' };
1016
1036
  }
1017
1037
  }
1018
1038
 
@@ -1100,6 +1120,35 @@ function validateAndFixDockerCompose(content) {
1100
1120
  };
1101
1121
  }
1102
1122
 
1123
+ // Randomize volume names to prevent collisions across deployments
1124
+ function randomizeVolumeNames(content, suffix) {
1125
+ // Common volume patterns to look for
1126
+ const volumePatterns = [
1127
+ 'mariadb-data', 'mysql-data', 'postgres-data', 'postgresql-data',
1128
+ 'mongo-data', 'mongodb-data', 'redis-data', 'cassandra-data',
1129
+ 'elasticsearch-data', 'rabbitmq-data', 'kafka-data', 'cockroach-data',
1130
+ 'db-data', 'database-data', 'app-data', 'web-data', 'cache-data'
1131
+ ];
1132
+
1133
+ let result = content;
1134
+ let randomized = false;
1135
+
1136
+ for (const volumeName of volumePatterns) {
1137
+ // Check if this volume is used
1138
+ if (result.includes(volumeName)) {
1139
+ // Replace in volume declarations (e.g., "mariadb-data:")
1140
+ result = result.split(volumeName + ':').join(volumeName + '-' + suffix + ':');
1141
+ // Replace in volume references (e.g., "mariadb-data:/var/lib/mysql")
1142
+ result = result.split(volumeName + ':/').join(volumeName + '-' + suffix + ':/');
1143
+ // Replace in top-level volume declarations (with newline)
1144
+ result = result.split(' ' + volumeName + '\n').join(' ' + volumeName + '-' + suffix + '\n');
1145
+ randomized = true;
1146
+ }
1147
+ }
1148
+
1149
+ return { content: result, randomized };
1150
+ }
1151
+
1103
1152
  // Validate Dockerfile for Coolify compliance
1104
1153
  function validateDockerfile(content) {
1105
1154
  const lines = content.split('\n');
@@ -1296,8 +1345,8 @@ async function initProject(args) {
1296
1345
  projectData.hostname = hostname;
1297
1346
  projectData.local_path = local_path;
1298
1347
 
1299
- // Read docker-compose content if using docker-compose strategy (v3.2.1+)
1300
- if (strategy.type === 'docker-compose') {
1348
+ // Read docker-compose content if using dockercompose strategy (v3.2.1+)
1349
+ if (strategy.type === 'dockercompose') {
1301
1350
  const composeFiles = ['docker-compose.yml', 'docker-compose.yaml'];
1302
1351
  let composeContent = null;
1303
1352
 
@@ -1476,12 +1525,23 @@ async function initProject(args) {
1476
1525
  log.info('MCP >>> [initProject] Pushing to GitLab...');
1477
1526
  log.debug('MCP >>> [initProject] Git remote URL:', gitUrl);
1478
1527
  gitSteps.push('Pushing to GitLab to trigger deployment');
1479
- console.error('Executing: git push -u mlgym main');
1528
+
1529
+ // Detect the current branch name (could be main or master)
1530
+ let branchName = 'main';
1531
+ try {
1532
+ const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: absolutePath });
1533
+ branchName = branchOutput.trim();
1534
+ log.info(`MCP >>> [initProject] Detected branch: ${branchName}`);
1535
+ } catch (branchError) {
1536
+ log.warning(`MCP >>> [initProject] Could not detect branch, using default: ${branchName}`);
1537
+ }
1538
+
1539
+ console.error(`Executing: git push -u mlgym ${branchName}`);
1480
1540
  const authInfo = await loadAuth();
1481
1541
  const sanitizedEmail = authInfo.email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1482
1542
  const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1483
1543
  const gitSshCmd = `ssh -i "${sshKeyPath}"`;
1484
- await execAsync('git push -u mlgym main', { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1544
+ await execAsync(`git push -u mlgym ${branchName}`, { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1485
1545
  gitSteps.push('✅ Successfully pushed to GitLab');
1486
1546
  pushSucceeded = true;
1487
1547
  log.success('MCP >>> [initProject] ✅ Git push completed successfully!');
@@ -1794,6 +1854,10 @@ class DeploymentWorkflow {
1794
1854
  const auth = await loadAuth();
1795
1855
  if (auth.token) {
1796
1856
  this.authToken = auth.token;
1857
+
1858
+ // Also ensure SSH key exists locally and in GitLab
1859
+ await this.ensureSSHKey(auth.email);
1860
+
1797
1861
  this.updateLastStep('completed', 'Using cached authentication');
1798
1862
  return { authenticated: true, cached: true };
1799
1863
  }
@@ -1820,6 +1884,43 @@ class DeploymentWorkflow {
1820
1884
  }
1821
1885
  }
1822
1886
 
1887
+ // Ensure SSH key exists locally and is registered with GitLab
1888
+ async ensureSSHKey(email) {
1889
+ if (!email) return;
1890
+
1891
+ const sanitizedEmail = email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1892
+ const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1893
+
1894
+ // Check if SSH key exists locally
1895
+ let keyExists = false;
1896
+ try {
1897
+ await fs.access(sshKeyPath);
1898
+ keyExists = true;
1899
+ log.info(`MCP >>> SSH key exists at ${sshKeyPath}`);
1900
+ } catch {
1901
+ log.info('MCP >>> SSH key does not exist locally, generating...');
1902
+ }
1903
+
1904
+ if (!keyExists) {
1905
+ // Generate SSH key pair
1906
+ const { publicKey, privateKeyPath } = await generateSSHKeyPair(email);
1907
+ log.success(`MCP >>> SSH key generated at ${privateKeyPath}`);
1908
+
1909
+ // Register key with GitLab via API
1910
+ const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`;
1911
+ const keyResult = await apiRequest('POST', '/api/v1/keys', {
1912
+ title: keyTitle,
1913
+ key: publicKey
1914
+ }, true);
1915
+
1916
+ if (keyResult.success) {
1917
+ log.success('MCP >>> SSH key registered with GitLab');
1918
+ } else {
1919
+ log.warning(`MCP >>> Failed to register SSH key: ${keyResult.error}`);
1920
+ }
1921
+ }
1922
+ }
1923
+
1823
1924
  async analyzeProject(local_path) {
1824
1925
  this.currentStep = 'analysis';
1825
1926
  this.addStep('project_analysis', 'running');
@@ -1956,8 +2057,8 @@ async function deployProject(args) {
1956
2057
  const {
1957
2058
  email,
1958
2059
  password,
1959
- project_name,
1960
- project_description,
2060
+ project_name: argProjectName,
2061
+ project_description: argProjectDescription,
1961
2062
  hostname,
1962
2063
  local_path = '.',
1963
2064
  project_type,
@@ -1965,8 +2066,17 @@ async function deployProject(args) {
1965
2066
  package_manager = 'npm'
1966
2067
  } = args;
1967
2068
 
2069
+ // Derive project name from local_path if not provided
2070
+ const resolvedPath = path.resolve(local_path);
2071
+ const derivedName = path.basename(resolvedPath);
2072
+ const project_name = argProjectName || derivedName;
2073
+ const project_description = argProjectDescription || `${project_name} - deployed via MLGym MCP`;
2074
+
1968
2075
  log.info('MCP >>> Starting deployment workflow');
1969
2076
  log.debug('MCP >>> Arguments:', { project_name, hostname, local_path, project_type, framework, package_manager });
2077
+ if (!argProjectName) {
2078
+ log.info(`MCP >>> Derived project name from path: ${project_name}`);
2079
+ }
1970
2080
 
1971
2081
  try {
1972
2082
  // Step 1: Ensure authenticated
@@ -2018,15 +2128,23 @@ async function deployProject(args) {
2018
2128
 
2019
2129
  // Step 4.5: Validate and fix deployment files for Coolify compliance
2020
2130
  log.info('MCP >>> STEP 4.5: Validating deployment configuration...');
2021
- const strategy = detectDeploymentStrategy(local_path);
2131
+ const strategy = await detectDeploymentStrategy(local_path);
2022
2132
 
2023
- if (strategy.type === 'docker-compose') {
2133
+ if (strategy.type === 'dockercompose') {
2024
2134
  const composePath = path.join(local_path, 'docker-compose.yml');
2025
2135
  const composePathYAML = path.join(local_path, 'docker-compose.yaml');
2026
2136
  const actualPath = fsSync.existsSync(composePath) ? composePath : composePathYAML;
2027
2137
 
2028
2138
  if (fsSync.existsSync(actualPath)) {
2029
- const content = fsSync.readFileSync(actualPath, 'utf8');
2139
+ let content = fsSync.readFileSync(actualPath, 'utf8');
2140
+ let fileModified = false;
2141
+
2142
+ // Create backup before any modifications
2143
+ const backupPath = actualPath + '.backup';
2144
+ fsSync.writeFileSync(backupPath, content);
2145
+ log.info(`MCP >>> Created backup at ${backupPath}`);
2146
+
2147
+ // Step 1: Validate and fix port mappings
2030
2148
  const validation = validateAndFixDockerCompose(content);
2031
2149
 
2032
2150
  if (!validation.isValid) {
@@ -2037,18 +2155,32 @@ async function deployProject(args) {
2037
2155
 
2038
2156
  // Auto-fix the issues
2039
2157
  log.info('MCP >>> Auto-fixing docker-compose.yml port mappings...');
2040
- fsSync.writeFileSync(actualPath, validation.fixedContent);
2041
-
2042
- // Create backup
2043
- const backupPath = actualPath + '.backup';
2044
- fsSync.writeFileSync(backupPath, content);
2045
- log.info(`MCP >>> Created backup at ${backupPath}`);
2158
+ content = validation.fixedContent;
2159
+ fileModified = true;
2046
2160
 
2047
2161
  log.success('MCP >>> Fixed docker-compose.yml:');
2048
2162
  validation.fixes.forEach(fix => log.success(` - ${fix}`));
2049
2163
  } else {
2050
2164
  log.success('MCP >>> Docker-compose.yml is Coolify compliant');
2051
2165
  }
2166
+
2167
+ // Step 2: Randomize volume names to prevent collisions across deployments
2168
+ // Generate unique suffix from timestamp + random chars
2169
+ const volumeSuffix = Date.now().toString(36) + Math.random().toString(36).substr(2, 4);
2170
+ const volumeResult = randomizeVolumeNames(content, volumeSuffix);
2171
+
2172
+ if (volumeResult.randomized) {
2173
+ log.info(`MCP >>> Randomizing volume names with suffix: ${volumeSuffix}`);
2174
+ content = volumeResult.content;
2175
+ fileModified = true;
2176
+ log.success('MCP >>> Volume names randomized to prevent collisions');
2177
+ }
2178
+
2179
+ // Write the modified content if anything changed
2180
+ if (fileModified) {
2181
+ fsSync.writeFileSync(actualPath, content);
2182
+ log.info('MCP >>> Saved modified docker-compose.yml');
2183
+ }
2052
2184
  }
2053
2185
  } else if (strategy.type === 'dockerfile') {
2054
2186
  const dockerfilePath = path.join(local_path, 'Dockerfile');
@@ -2232,27 +2364,30 @@ async function getDeploymentLogs(args) {
2232
2364
  }
2233
2365
 
2234
2366
  log.info(`MCP >>> [getDeploymentLogs] Detected project name: ${projectName}`);
2367
+ // Get Coolify app UUID directly from User Agent by name
2368
+ const userAgentURL = 'http://coolify.eu.ezb.net:9000';
2369
+ const appLookupEndpoint = `${userAgentURL}/applications/by-name/${projectName}`;
2370
+
2371
+ log.info(`MCP >>> [getDeploymentLogs] Looking up application by name`);
2235
2372
 
2236
- // Get project details from backend to get Coolify app UUID
2237
- const projectsResult = await apiRequest('GET', '/api/v1/projects', null, true);
2238
- if (!projectsResult.success) {
2239
- throw new Error(`Failed to get projects: ${projectsResult.error}`);
2240
- }
2241
-
2242
- const project = projectsResult.data?.find(p => p.name === projectName);
2243
- if (!project) {
2244
- throw new Error(`Project "${projectName}" not found in your account`);
2245
- }
2246
-
2247
- if (!project.coolify_app_uuid) {
2248
- throw new Error(`Project "${projectName}" does not have deployment enabled`);
2373
+ let appUUID;
2374
+ try {
2375
+ const appResponse = await axios.get(appLookupEndpoint, {
2376
+ headers: {
2377
+ 'Authorization': 'Bearer 1|Jkztb5qPptwRKgtocfbT2TLjp8WGJG1SkPE4DzLt4c5d600f',
2378
+ 'Content-Type': 'application/json'
2379
+ }
2380
+ });
2381
+ appUUID = appResponse.data.uuid;
2382
+ log.info(`MCP >>> [getDeploymentLogs] Found Coolify app UUID: ${appUUID}`);
2383
+ } catch (appError) {
2384
+ if (appError.response?.status === 404) {
2385
+ throw new Error(`Project "${projectName}" not found in Coolify. Make sure deployment is enabled.`);
2386
+ }
2387
+ throw new Error(`Failed to lookup application: ${appError.message}`);
2249
2388
  }
2250
2389
 
2251
- const appUUID = project.coolify_app_uuid;
2252
- log.info(`MCP >>> [getDeploymentLogs] Found Coolify app UUID: ${appUUID}`);
2253
-
2254
2390
  // Call User Agent API to get deployment logs
2255
- const userAgentURL = 'http://coolify.eu.ezb.net:9000';
2256
2391
  const endpoint = `${userAgentURL}/applications/${appUUID}/deployment-logs?depth=${depth}`;
2257
2392
 
2258
2393
  log.info(`MCP >>> [getDeploymentLogs] Calling User Agent: ${endpoint}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlgym-deploy",
3
- "version": "3.3.5",
3
+ "version": "3.3.13",
4
4
  "description": "MCP server for MLGym - Complete deployment management: deploy, configure, monitor, and rollback applications",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -52,6 +52,10 @@
52
52
  "name": "mlgym_set_env_vars",
53
53
  "description": "Set environment variables for an application (DATABASE_URL, API keys, etc.)"
54
54
  },
55
+ {
56
+ "name": "mlgym_deploy_logs",
57
+ "description": "View deployment history and logs for a project"
58
+ },
55
59
  {
56
60
  "name": "mlgym_set_health_check",
57
61
  "description": "Configure health checks to monitor application availability"