mlgym-deploy 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +529 -3
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -16,8 +16,8 @@ import crypto from 'crypto';
16
16
 
17
17
  const execAsync = promisify(exec);
18
18
 
19
- // Current version of this MCP server - INCREMENT FOR WORKFLOW COMPLIANCE
20
- const CURRENT_VERSION = '2.5.0';
19
+ // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
20
+ const CURRENT_VERSION = '2.7.0';
21
21
  const PACKAGE_NAME = 'mlgym-deploy';
22
22
 
23
23
  // Version check state
@@ -353,6 +353,140 @@ async function authenticate(args) {
353
353
  };
354
354
  }
355
355
 
356
+ // Analyze project to detect type, framework, and configuration
357
+ async function analyzeProject(local_path = '.') {
358
+ const absolutePath = path.resolve(local_path);
359
+ const dirName = path.basename(absolutePath);
360
+
361
+ const analysis = {
362
+ project_type: 'unknown',
363
+ detected_files: [],
364
+ suggested_name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
365
+ has_dockerfile: false,
366
+ has_git: false,
367
+ framework: null,
368
+ build_command: null,
369
+ start_command: null,
370
+ package_manager: null
371
+ };
372
+
373
+ try {
374
+ // Check for git
375
+ try {
376
+ await execAsync('git status', { cwd: absolutePath });
377
+ analysis.has_git = true;
378
+ } catch {}
379
+
380
+ // Check for Dockerfile
381
+ try {
382
+ await fs.access(path.join(absolutePath, 'Dockerfile'));
383
+ analysis.has_dockerfile = true;
384
+ analysis.detected_files.push('Dockerfile');
385
+ } catch {}
386
+
387
+ // Check for Node.js project
388
+ try {
389
+ const packageJsonPath = path.join(absolutePath, 'package.json');
390
+ await fs.access(packageJsonPath);
391
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
392
+
393
+ analysis.project_type = 'nodejs';
394
+ analysis.detected_files.push('package.json');
395
+ analysis.suggested_name = packageJson.name || analysis.suggested_name;
396
+
397
+ // Detect package manager
398
+ try {
399
+ await fs.access(path.join(absolutePath, 'package-lock.json'));
400
+ analysis.package_manager = 'npm';
401
+ analysis.detected_files.push('package-lock.json');
402
+ } catch {
403
+ try {
404
+ await fs.access(path.join(absolutePath, 'yarn.lock'));
405
+ analysis.package_manager = 'yarn';
406
+ analysis.detected_files.push('yarn.lock');
407
+ } catch {
408
+ analysis.package_manager = 'npm'; // default
409
+ }
410
+ }
411
+
412
+ // Detect framework
413
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
414
+ if (deps.next) {
415
+ analysis.framework = 'nextjs';
416
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
417
+ analysis.start_command = packageJson.scripts?.start || 'npm start';
418
+ } else if (deps.express) {
419
+ analysis.framework = 'express';
420
+ analysis.start_command = packageJson.scripts?.start || 'node index.js';
421
+ } else if (deps.react) {
422
+ analysis.framework = 'react';
423
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
424
+ } else if (deps.vue) {
425
+ analysis.framework = 'vue';
426
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
427
+ }
428
+
429
+ // Use package.json scripts as fallback
430
+ if (!analysis.build_command && packageJson.scripts?.build) {
431
+ analysis.build_command = 'npm run build';
432
+ }
433
+ if (!analysis.start_command && packageJson.scripts?.start) {
434
+ analysis.start_command = 'npm start';
435
+ }
436
+ } catch {}
437
+
438
+ // Check for Python project
439
+ if (analysis.project_type === 'unknown') {
440
+ try {
441
+ await fs.access(path.join(absolutePath, 'requirements.txt'));
442
+ analysis.project_type = 'python';
443
+ analysis.detected_files.push('requirements.txt');
444
+
445
+ // Check for specific Python files
446
+ try {
447
+ await fs.access(path.join(absolutePath, 'app.py'));
448
+ analysis.framework = 'flask';
449
+ analysis.start_command = 'python app.py';
450
+ analysis.detected_files.push('app.py');
451
+ } catch {
452
+ try {
453
+ await fs.access(path.join(absolutePath, 'main.py'));
454
+ analysis.framework = 'fastapi';
455
+ analysis.start_command = 'uvicorn main:app --host 0.0.0.0';
456
+ analysis.detected_files.push('main.py');
457
+ } catch {}
458
+ }
459
+ } catch {}
460
+ }
461
+
462
+ // Check for static HTML project
463
+ if (analysis.project_type === 'unknown') {
464
+ try {
465
+ await fs.access(path.join(absolutePath, 'index.html'));
466
+ analysis.project_type = 'static';
467
+ analysis.framework = 'html';
468
+ analysis.detected_files.push('index.html');
469
+ } catch {}
470
+ }
471
+
472
+ // Check for Go project
473
+ if (analysis.project_type === 'unknown') {
474
+ try {
475
+ await fs.access(path.join(absolutePath, 'go.mod'));
476
+ analysis.project_type = 'go';
477
+ analysis.detected_files.push('go.mod');
478
+ analysis.build_command = 'go build -o app';
479
+ analysis.start_command = './app';
480
+ } catch {}
481
+ }
482
+
483
+ } catch (error) {
484
+ console.error('Project analysis error:', error);
485
+ }
486
+
487
+ return analysis;
488
+ }
489
+
356
490
  // Check for existing MLGym project in current directory
357
491
  async function checkExistingProject(local_path = '.') {
358
492
  const absolutePath = path.resolve(local_path);
@@ -395,6 +529,203 @@ async function checkExistingProject(local_path = '.') {
395
529
  }
396
530
  }
397
531
 
532
+ // Generate appropriate Dockerfile based on project type
533
+ function generateDockerfile(projectType, framework, packageManager = 'npm') {
534
+ let dockerfile = '';
535
+
536
+ if (projectType === 'nodejs') {
537
+ if (framework === 'nextjs') {
538
+ dockerfile = `# Build stage
539
+ FROM node:18-alpine AS builder
540
+ WORKDIR /app
541
+ COPY package*.json ./
542
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
543
+ COPY . .
544
+ RUN ${packageManager} run build
545
+
546
+ # Production stage
547
+ FROM node:18-alpine
548
+ WORKDIR /app
549
+ COPY --from=builder /app/.next ./.next
550
+ COPY --from=builder /app/node_modules ./node_modules
551
+ COPY --from=builder /app/package.json ./
552
+ COPY --from=builder /app/public ./public
553
+ EXPOSE 3000
554
+ CMD ["${packageManager}", "start"]`;
555
+ } else if (framework === 'express') {
556
+ dockerfile = `FROM node:18-alpine
557
+ WORKDIR /app
558
+ COPY package*.json ./
559
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
560
+ COPY . .
561
+ EXPOSE 3000
562
+ CMD ["node", "index.js"]`;
563
+ } else if (framework === 'react' || framework === 'vue') {
564
+ dockerfile = `# Build stage
565
+ FROM node:18-alpine AS builder
566
+ WORKDIR /app
567
+ COPY package*.json ./
568
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
569
+ COPY . .
570
+ RUN ${packageManager} run build
571
+
572
+ # Production stage
573
+ FROM nginx:alpine
574
+ COPY --from=builder /app/${framework === 'react' ? 'build' : 'dist'} /usr/share/nginx/html
575
+ EXPOSE 80
576
+ CMD ["nginx", "-g", "daemon off;"]`;
577
+ } else {
578
+ // Generic Node.js
579
+ dockerfile = `FROM node:18-alpine
580
+ WORKDIR /app
581
+ COPY package*.json ./
582
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
583
+ COPY . .
584
+ EXPOSE 3000
585
+ CMD ["${packageManager}", "start"]`;
586
+ }
587
+ } else if (projectType === 'python') {
588
+ if (framework === 'flask') {
589
+ dockerfile = `FROM python:3.11-slim
590
+ WORKDIR /app
591
+ COPY requirements.txt .
592
+ RUN pip install --no-cache-dir -r requirements.txt
593
+ COPY . .
594
+ EXPOSE 5000
595
+ CMD ["python", "app.py"]`;
596
+ } else if (framework === 'fastapi') {
597
+ dockerfile = `FROM python:3.11-slim
598
+ WORKDIR /app
599
+ COPY requirements.txt .
600
+ RUN pip install --no-cache-dir -r requirements.txt
601
+ COPY . .
602
+ EXPOSE 8000
603
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`;
604
+ } else {
605
+ // Generic Python
606
+ dockerfile = `FROM python:3.11-slim
607
+ WORKDIR /app
608
+ COPY requirements.txt .
609
+ RUN pip install --no-cache-dir -r requirements.txt
610
+ COPY . .
611
+ EXPOSE 8000
612
+ CMD ["python", "main.py"]`;
613
+ }
614
+ } else if (projectType === 'static') {
615
+ dockerfile = `FROM nginx:alpine
616
+ COPY . /usr/share/nginx/html
617
+ EXPOSE 80
618
+ CMD ["nginx", "-g", "daemon off;"]`;
619
+ } else if (projectType === 'go') {
620
+ dockerfile = `# Build stage
621
+ FROM golang:1.21-alpine AS builder
622
+ WORKDIR /app
623
+ COPY go.mod go.sum ./
624
+ RUN go mod download
625
+ COPY . .
626
+ RUN go build -o app
627
+
628
+ # Production stage
629
+ FROM alpine:latest
630
+ RUN apk --no-cache add ca-certificates
631
+ WORKDIR /root/
632
+ COPY --from=builder /app/app .
633
+ EXPOSE 8080
634
+ CMD ["./app"]`;
635
+ } else {
636
+ // Unknown type - basic Alpine with shell
637
+ dockerfile = `FROM alpine:latest
638
+ WORKDIR /app
639
+ COPY . .
640
+ RUN echo "Unknown project type - please configure manually"
641
+ CMD ["/bin/sh"]`;
642
+ }
643
+
644
+ return dockerfile;
645
+ }
646
+
647
+ // Prepare project for deployment
648
+ async function prepareProject(args) {
649
+ const { local_path = '.', project_type, framework, package_manager } = args;
650
+ const absolutePath = path.resolve(local_path);
651
+
652
+ const actions = [];
653
+
654
+ try {
655
+ // Check if Dockerfile exists
656
+ const dockerfilePath = path.join(absolutePath, 'Dockerfile');
657
+ let dockerfileExists = false;
658
+
659
+ try {
660
+ await fs.access(dockerfilePath);
661
+ dockerfileExists = true;
662
+ actions.push('Dockerfile already exists - skipping generation');
663
+ } catch {}
664
+
665
+ // Generate Dockerfile if missing
666
+ if (!dockerfileExists && project_type !== 'unknown') {
667
+ const dockerfile = generateDockerfile(project_type, framework, package_manager);
668
+ await fs.writeFile(dockerfilePath, dockerfile);
669
+ actions.push(`Generated Dockerfile for ${project_type}/${framework || 'generic'}`);
670
+ }
671
+
672
+ // Check/create .gitignore
673
+ const gitignorePath = path.join(absolutePath, '.gitignore');
674
+ let gitignoreExists = false;
675
+
676
+ try {
677
+ await fs.access(gitignorePath);
678
+ gitignoreExists = true;
679
+ } catch {}
680
+
681
+ if (!gitignoreExists) {
682
+ let gitignoreContent = '';
683
+
684
+ if (project_type === 'nodejs') {
685
+ gitignoreContent = `node_modules/
686
+ .env
687
+ .env.local
688
+ dist/
689
+ build/
690
+ .next/
691
+ *.log`;
692
+ } else if (project_type === 'python') {
693
+ gitignoreContent = `__pycache__/
694
+ *.py[cod]
695
+ *$py.class
696
+ .env
697
+ venv/
698
+ env/
699
+ .venv/`;
700
+ } else {
701
+ gitignoreContent = `.env
702
+ *.log
703
+ .DS_Store`;
704
+ }
705
+
706
+ await fs.writeFile(gitignorePath, gitignoreContent);
707
+ actions.push('Created .gitignore file');
708
+ }
709
+
710
+ return {
711
+ status: 'success',
712
+ message: 'Project prepared for deployment',
713
+ actions: actions,
714
+ dockerfile_created: !dockerfileExists && project_type !== 'unknown',
715
+ project_type: project_type,
716
+ framework: framework
717
+ };
718
+
719
+ } catch (error) {
720
+ return {
721
+ status: 'error',
722
+ message: 'Failed to prepare project',
723
+ error: error.message,
724
+ actions: actions
725
+ };
726
+ }
727
+ }
728
+
398
729
  // Check authentication status
399
730
  async function checkAuthStatus() {
400
731
  const auth = await loadAuth();
@@ -446,6 +777,122 @@ async function checkAuthStatus() {
446
777
  };
447
778
  }
448
779
 
780
+ // Smart deployment initialization that follows the correct workflow
781
+ async function smartDeploy(args) {
782
+ const { local_path = '.' } = args;
783
+ const absolutePath = path.resolve(local_path);
784
+
785
+ const steps = [];
786
+
787
+ try {
788
+ // Step 1: Check authentication
789
+ steps.push({ step: 'auth_check', status: 'running' });
790
+ const auth = await loadAuth();
791
+ if (!auth.token) {
792
+ steps[steps.length - 1].status = 'failed';
793
+ return {
794
+ content: [{
795
+ type: 'text',
796
+ text: JSON.stringify({
797
+ status: 'error',
798
+ message: 'Not authenticated. Please use mlgym_authenticate first',
799
+ workflow_steps: steps
800
+ }, null, 2)
801
+ }]
802
+ };
803
+ }
804
+ steps[steps.length - 1].status = 'completed';
805
+
806
+ // Step 2: Analyze project
807
+ steps.push({ step: 'project_analysis', status: 'running' });
808
+ const analysis = await analyzeProject(local_path);
809
+ steps[steps.length - 1].status = 'completed';
810
+ steps[steps.length - 1].result = {
811
+ type: analysis.project_type,
812
+ framework: analysis.framework,
813
+ suggested_name: analysis.suggested_name
814
+ };
815
+
816
+ // Step 3: Check existing project
817
+ steps.push({ step: 'check_existing', status: 'running' });
818
+ const projectStatus = await checkExistingProject(local_path);
819
+ steps[steps.length - 1].status = 'completed';
820
+
821
+ if (projectStatus.configured) {
822
+ return {
823
+ content: [{
824
+ type: 'text',
825
+ text: JSON.stringify({
826
+ status: 'info',
827
+ message: projectStatus.message,
828
+ project_name: projectStatus.name,
829
+ git_remote: `git@git.mlgym.io:${projectStatus.namespace}/${projectStatus.name}.git`,
830
+ next_steps: [
831
+ 'Project already configured',
832
+ 'Run: git push mlgym main'
833
+ ],
834
+ workflow_steps: steps
835
+ }, null, 2)
836
+ }]
837
+ };
838
+ }
839
+
840
+ // Step 4: Prepare project (generate Dockerfile if needed)
841
+ steps.push({ step: 'prepare_project', status: 'running' });
842
+ if (!analysis.has_dockerfile && analysis.project_type !== 'unknown') {
843
+ const prepResult = await prepareProject({
844
+ local_path,
845
+ project_type: analysis.project_type,
846
+ framework: analysis.framework,
847
+ package_manager: analysis.package_manager
848
+ });
849
+ steps[steps.length - 1].status = 'completed';
850
+ steps[steps.length - 1].result = prepResult.actions;
851
+ } else {
852
+ steps[steps.length - 1].status = 'skipped';
853
+ steps[steps.length - 1].result = 'Dockerfile already exists or project type unknown';
854
+ }
855
+
856
+ // Return analysis and next steps
857
+ return {
858
+ content: [{
859
+ type: 'text',
860
+ text: JSON.stringify({
861
+ status: 'ready',
862
+ message: 'Project analyzed and prepared. Ready for MLGym initialization.',
863
+ analysis: {
864
+ project_type: analysis.project_type,
865
+ framework: analysis.framework,
866
+ suggested_name: analysis.suggested_name,
867
+ has_dockerfile: analysis.has_dockerfile || true
868
+ },
869
+ next_step: 'Use mlgym_project_init with project details to create MLGym project',
870
+ suggested_params: {
871
+ name: analysis.suggested_name,
872
+ description: `${analysis.framework || analysis.project_type} application`,
873
+ enable_deployment: true,
874
+ hostname: analysis.suggested_name
875
+ },
876
+ workflow_steps: steps
877
+ }, null, 2)
878
+ }]
879
+ };
880
+
881
+ } catch (error) {
882
+ return {
883
+ content: [{
884
+ type: 'text',
885
+ text: JSON.stringify({
886
+ status: 'error',
887
+ message: 'Smart deploy failed',
888
+ error: error.message,
889
+ workflow_steps: steps
890
+ }, null, 2)
891
+ }]
892
+ };
893
+ }
894
+ }
895
+
449
896
  // Initialize Project (requires authentication)
450
897
  async function initProject(args) {
451
898
  let { name, description, enable_deployment = true, hostname, local_path = '.' } = args;
@@ -631,9 +1078,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
631
1078
  required: ['email', 'password']
632
1079
  }
633
1080
  },
1081
+ {
1082
+ name: 'mlgym_project_analyze',
1083
+ description: 'PHASE 2: Analyze project to detect type, framework, and configuration. Call BEFORE creating project.',
1084
+ inputSchema: {
1085
+ type: 'object',
1086
+ properties: {
1087
+ local_path: {
1088
+ type: 'string',
1089
+ description: 'Local directory path (defaults to current directory)',
1090
+ default: '.'
1091
+ }
1092
+ }
1093
+ }
1094
+ },
634
1095
  {
635
1096
  name: 'mlgym_project_status',
636
- description: 'PHASE 2 START: Check if MLGym project exists in current directory. Call this AFTER authentication succeeds.',
1097
+ description: 'PHASE 2: Check if MLGym project exists in current directory.',
637
1098
  inputSchema: {
638
1099
  type: 'object',
639
1100
  properties: {
@@ -682,6 +1143,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
682
1143
  },
683
1144
  required: ['name', 'description']
684
1145
  }
1146
+ },
1147
+ {
1148
+ name: 'mlgym_project_prepare',
1149
+ description: 'PHASE 2: Prepare project for deployment by generating Dockerfile and config files.',
1150
+ inputSchema: {
1151
+ type: 'object',
1152
+ properties: {
1153
+ local_path: {
1154
+ type: 'string',
1155
+ description: 'Local directory path (defaults to current directory)',
1156
+ default: '.'
1157
+ },
1158
+ project_type: {
1159
+ type: 'string',
1160
+ description: 'Project type from analysis',
1161
+ enum: ['nodejs', 'python', 'static', 'go', 'unknown']
1162
+ },
1163
+ framework: {
1164
+ type: 'string',
1165
+ description: 'Framework from analysis',
1166
+ enum: ['nextjs', 'express', 'react', 'vue', 'flask', 'fastapi', 'html', null]
1167
+ },
1168
+ package_manager: {
1169
+ type: 'string',
1170
+ description: 'Package manager for Node.js projects',
1171
+ enum: ['npm', 'yarn'],
1172
+ default: 'npm'
1173
+ }
1174
+ }
1175
+ }
1176
+ },
1177
+ {
1178
+ name: 'mlgym_smart_deploy',
1179
+ description: 'RECOMMENDED: Smart deployment workflow that automatically analyzes, prepares, and guides you through the entire deployment process. Use this for new projects!',
1180
+ inputSchema: {
1181
+ type: 'object',
1182
+ properties: {
1183
+ local_path: {
1184
+ type: 'string',
1185
+ description: 'Local directory path (defaults to current directory)',
1186
+ default: '.'
1187
+ }
1188
+ }
1189
+ }
685
1190
  }
686
1191
  ]
687
1192
  };
@@ -701,6 +1206,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
701
1206
  case 'mlgym_authenticate':
702
1207
  return await authenticate(args);
703
1208
 
1209
+ case 'mlgym_project_analyze':
1210
+ const analysis = await analyzeProject(args.local_path);
1211
+ return {
1212
+ content: [{
1213
+ type: 'text',
1214
+ text: JSON.stringify(analysis, null, 2)
1215
+ }]
1216
+ };
1217
+
704
1218
  case 'mlgym_project_status':
705
1219
  const projectStatus = await checkExistingProject(args.local_path);
706
1220
  return {
@@ -713,6 +1227,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
713
1227
  case 'mlgym_project_init':
714
1228
  return await initProject(args);
715
1229
 
1230
+ case 'mlgym_project_prepare':
1231
+ const prepResult = await prepareProject(args);
1232
+ return {
1233
+ content: [{
1234
+ type: 'text',
1235
+ text: JSON.stringify(prepResult, null, 2)
1236
+ }]
1237
+ };
1238
+
1239
+ case 'mlgym_smart_deploy':
1240
+ return await smartDeploy(args);
1241
+
716
1242
  default:
717
1243
  throw new Error(`Unknown tool: ${name}`);
718
1244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlgym-deploy",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "MCP server for GitLab Backend - User creation and project deployment",
5
5
  "main": "index.js",
6
6
  "type": "module",