mlgym-deploy 3.3.19 → 3.3.24
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/index.js +258 -29
- package/package.json +1 -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.
|
|
21
|
+
const CURRENT_VERSION = '3.3.23'; // Scala: flexible src structure, copy all .scala files
|
|
22
22
|
const PACKAGE_NAME = 'mlgym-deploy';
|
|
23
23
|
|
|
24
24
|
// Debug logging configuration - ENABLED BY DEFAULT
|
|
@@ -222,7 +222,14 @@ Host git.mlgym.io
|
|
|
222
222
|
|
|
223
223
|
try {
|
|
224
224
|
const existingConfig = await fs.readFile(configPath, 'utf8');
|
|
225
|
-
if (
|
|
225
|
+
if (existingConfig.includes('Host git.mlgym.io')) {
|
|
226
|
+
// Replace existing MLGym SSH config block with new key
|
|
227
|
+
const updatedConfig = existingConfig.replace(
|
|
228
|
+
/# MLGym GitLab.*?Host git\.mlgym\.io.*?(?=\n#|\n\nHost|\n?$)/gs,
|
|
229
|
+
''
|
|
230
|
+
).trim();
|
|
231
|
+
await fs.writeFile(configPath, updatedConfig + configEntry, { mode: 0o600 });
|
|
232
|
+
} else {
|
|
226
233
|
await fs.appendFile(configPath, configEntry);
|
|
227
234
|
}
|
|
228
235
|
} catch {
|
|
@@ -564,6 +571,27 @@ async function analyzeProject(local_path = '.') {
|
|
|
564
571
|
} catch {}
|
|
565
572
|
}
|
|
566
573
|
|
|
574
|
+
// Check for Scala/sbt project
|
|
575
|
+
if (analysis.project_type === 'unknown') {
|
|
576
|
+
try {
|
|
577
|
+
await fs.access(path.join(absolutePath, 'build.sbt'));
|
|
578
|
+
analysis.project_type = 'scala';
|
|
579
|
+
analysis.detected_files.push('build.sbt');
|
|
580
|
+
analysis.framework = 'sbt';
|
|
581
|
+
analysis.build_command = 'sbt assembly';
|
|
582
|
+
analysis.start_command = 'java -jar app.jar';
|
|
583
|
+
|
|
584
|
+
// Check if sbt-assembly plugin exists
|
|
585
|
+
try {
|
|
586
|
+
await fs.access(path.join(absolutePath, 'project', 'plugins.sbt'));
|
|
587
|
+
analysis.detected_files.push('project/plugins.sbt');
|
|
588
|
+
analysis.has_sbt_assembly = true;
|
|
589
|
+
} catch {
|
|
590
|
+
analysis.has_sbt_assembly = false;
|
|
591
|
+
}
|
|
592
|
+
} catch {}
|
|
593
|
+
}
|
|
594
|
+
|
|
567
595
|
} catch (error) {
|
|
568
596
|
console.error('Project analysis error:', error);
|
|
569
597
|
}
|
|
@@ -716,6 +744,21 @@ WORKDIR /root/
|
|
|
716
744
|
COPY --from=builder /app/app .
|
|
717
745
|
EXPOSE 8080
|
|
718
746
|
CMD ["./app"]`;
|
|
747
|
+
} else if (projectType === 'scala') {
|
|
748
|
+
dockerfile = `# Build stage
|
|
749
|
+
FROM sbtscala/scala-sbt:eclipse-temurin-jammy-17.0.10_7_1.10.2_2.13.15 AS builder
|
|
750
|
+
WORKDIR /app
|
|
751
|
+
COPY project ./project
|
|
752
|
+
COPY build.sbt .
|
|
753
|
+
COPY src ./src
|
|
754
|
+
RUN sbt assembly
|
|
755
|
+
|
|
756
|
+
# Production stage
|
|
757
|
+
FROM eclipse-temurin:17-jre-jammy
|
|
758
|
+
WORKDIR /app
|
|
759
|
+
COPY --from=builder /app/target/scala-2.13/app.jar ./app.jar
|
|
760
|
+
EXPOSE 8080
|
|
761
|
+
CMD ["java", "-jar", "app.jar"]`;
|
|
719
762
|
} else {
|
|
720
763
|
// Unknown type - basic Alpine with shell
|
|
721
764
|
dockerfile = `FROM alpine:latest
|
|
@@ -766,6 +809,90 @@ async function prepareProject(args) {
|
|
|
766
809
|
log.warning('MCP >>> [prepareProject-func] Project type is unknown, skipping Dockerfile generation');
|
|
767
810
|
}
|
|
768
811
|
|
|
812
|
+
// Scala/sbt: Check for sbt-assembly plugin (required for fat JAR)
|
|
813
|
+
if (project_type === 'scala') {
|
|
814
|
+
const projectDir = path.join(absolutePath, 'project');
|
|
815
|
+
const pluginsSbtPath = path.join(projectDir, 'plugins.sbt');
|
|
816
|
+
let hasAssemblyPlugin = false;
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const pluginsContent = await fs.readFile(pluginsSbtPath, 'utf8');
|
|
820
|
+
hasAssemblyPlugin = pluginsContent.includes('sbt-assembly');
|
|
821
|
+
} catch {
|
|
822
|
+
// plugins.sbt doesn't exist
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (!hasAssemblyPlugin) {
|
|
826
|
+
log.info('MCP >>> [prepareProject-func] Scala project missing sbt-assembly plugin, adding...');
|
|
827
|
+
|
|
828
|
+
// Create project directory if it doesn't exist
|
|
829
|
+
try {
|
|
830
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
831
|
+
} catch {}
|
|
832
|
+
|
|
833
|
+
// Create plugins.sbt with sbt-assembly
|
|
834
|
+
const pluginsContent = 'addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.5")\n';
|
|
835
|
+
await fs.writeFile(pluginsSbtPath, pluginsContent);
|
|
836
|
+
actions.push('Created project/plugins.sbt with sbt-assembly plugin');
|
|
837
|
+
log.success('MCP >>> [prepareProject-func] ✅ Added sbt-assembly plugin');
|
|
838
|
+
|
|
839
|
+
// Also update build.sbt to add assembly merge strategy if not present
|
|
840
|
+
const buildSbtPath = path.join(absolutePath, 'build.sbt');
|
|
841
|
+
try {
|
|
842
|
+
let buildSbtContent = await fs.readFile(buildSbtPath, 'utf8');
|
|
843
|
+
if (!buildSbtContent.includes('assemblyMergeStrategy')) {
|
|
844
|
+
const assemblyConfig = `
|
|
845
|
+
|
|
846
|
+
assembly / assemblyMergeStrategy := {
|
|
847
|
+
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
|
|
848
|
+
case "reference.conf" => MergeStrategy.concat
|
|
849
|
+
case x => MergeStrategy.first
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
assembly / assemblyJarName := "app.jar"
|
|
853
|
+
`;
|
|
854
|
+
buildSbtContent += assemblyConfig;
|
|
855
|
+
await fs.writeFile(buildSbtPath, buildSbtContent);
|
|
856
|
+
actions.push('Added assembly merge strategy to build.sbt');
|
|
857
|
+
log.success('MCP >>> [prepareProject-func] ✅ Added assembly config to build.sbt');
|
|
858
|
+
}
|
|
859
|
+
} catch (err) {
|
|
860
|
+
log.warning('MCP >>> [prepareProject-func] Could not update build.sbt:', err.message);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Ensure proper Scala project structure - move root .scala files to src/main/scala/
|
|
865
|
+
try {
|
|
866
|
+
const srcDir = path.join(absolutePath, 'src', 'main', 'scala');
|
|
867
|
+
const rootFiles = await fs.readdir(absolutePath);
|
|
868
|
+
const scalaFilesInRoot = rootFiles.filter(f => f.endsWith('.scala'));
|
|
869
|
+
|
|
870
|
+
if (scalaFilesInRoot.length > 0) {
|
|
871
|
+
// Check if src/main/scala exists
|
|
872
|
+
let srcExists = false;
|
|
873
|
+
try {
|
|
874
|
+
await fs.access(srcDir);
|
|
875
|
+
srcExists = true;
|
|
876
|
+
} catch {}
|
|
877
|
+
|
|
878
|
+
if (!srcExists) {
|
|
879
|
+
// Create src/main/scala and move .scala files there
|
|
880
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
881
|
+
for (const scalaFile of scalaFilesInRoot) {
|
|
882
|
+
const srcPath = path.join(absolutePath, scalaFile);
|
|
883
|
+
const destPath = path.join(srcDir, scalaFile);
|
|
884
|
+
await fs.rename(srcPath, destPath);
|
|
885
|
+
log.info(`MCP >>> [prepareProject-func] Moved ${scalaFile} to src/main/scala/`);
|
|
886
|
+
}
|
|
887
|
+
actions.push(`Moved ${scalaFilesInRoot.length} .scala files to src/main/scala/`);
|
|
888
|
+
log.success('MCP >>> [prepareProject-func] ✅ Created proper Scala project structure');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} catch (err) {
|
|
892
|
+
log.warning('MCP >>> [prepareProject-func] Could not reorganize Scala files:', err.message);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
769
896
|
// Check/create .gitignore
|
|
770
897
|
const gitignorePath = path.join(absolutePath, '.gitignore');
|
|
771
898
|
let gitignoreExists = false;
|
|
@@ -794,6 +921,14 @@ build/
|
|
|
794
921
|
venv/
|
|
795
922
|
env/
|
|
796
923
|
.venv/`;
|
|
924
|
+
} else if (project_type === 'scala') {
|
|
925
|
+
gitignoreContent = `target/
|
|
926
|
+
project/target/
|
|
927
|
+
.bsp/
|
|
928
|
+
.idea/
|
|
929
|
+
*.class
|
|
930
|
+
*.log
|
|
931
|
+
.env`;
|
|
797
932
|
} else {
|
|
798
933
|
gitignoreContent = `.env
|
|
799
934
|
*.log
|
|
@@ -1199,11 +1334,28 @@ function validateDockerfile(content) {
|
|
|
1199
1334
|
const issues = [];
|
|
1200
1335
|
let hasExpose = false;
|
|
1201
1336
|
let exposedPort = null;
|
|
1337
|
+
let baseImage = null;
|
|
1338
|
+
let usesCpanm = false;
|
|
1339
|
+
let installsCpanm = false;
|
|
1340
|
+
|
|
1341
|
+
// Elixir multi-stage detection
|
|
1342
|
+
let isElixirMultiStage = false;
|
|
1343
|
+
let hasMixLockCopy = false;
|
|
1344
|
+
let builderStageHasMixDepsGet = false;
|
|
1202
1345
|
|
|
1203
1346
|
for (let i = 0; i < lines.length; i++) {
|
|
1204
1347
|
const trimmed = lines[i].trim();
|
|
1205
1348
|
const upper = trimmed.toUpperCase();
|
|
1206
1349
|
|
|
1350
|
+
// Track base image
|
|
1351
|
+
if (upper.startsWith('FROM ')) {
|
|
1352
|
+
baseImage = trimmed.substring(5).split(' ')[0].toLowerCase();
|
|
1353
|
+
// Check for Elixir multi-stage build
|
|
1354
|
+
if (baseImage.includes('elixir') && upper.includes(' AS ')) {
|
|
1355
|
+
isElixirMultiStage = true;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1207
1359
|
if (upper.startsWith('EXPOSE')) {
|
|
1208
1360
|
hasExpose = true;
|
|
1209
1361
|
// Extract the port number
|
|
@@ -1212,22 +1364,118 @@ function validateDockerfile(content) {
|
|
|
1212
1364
|
exposedPort = portMatch[1];
|
|
1213
1365
|
}
|
|
1214
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
// Check for cpanm usage and installation
|
|
1369
|
+
if (trimmed.includes('cpanm ') && !trimmed.includes('App::cpanminus')) {
|
|
1370
|
+
usesCpanm = true;
|
|
1371
|
+
}
|
|
1372
|
+
if (trimmed.includes('App::cpanminus')) {
|
|
1373
|
+
installsCpanm = true;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Elixir-specific checks
|
|
1377
|
+
if (trimmed.includes('mix deps.get')) {
|
|
1378
|
+
builderStageHasMixDepsGet = true;
|
|
1379
|
+
}
|
|
1380
|
+
if (upper.includes('COPY') && trimmed.includes('mix.lock')) {
|
|
1381
|
+
hasMixLockCopy = true;
|
|
1382
|
+
}
|
|
1215
1383
|
}
|
|
1216
1384
|
|
|
1217
1385
|
if (!hasExpose) {
|
|
1218
1386
|
issues.push({
|
|
1219
1387
|
issue: 'Dockerfile does not have an EXPOSE directive',
|
|
1220
|
-
fix: 'Add "EXPOSE <port>" to your Dockerfile (e.g., EXPOSE 80 for web servers, EXPOSE 3000 for Node.js)'
|
|
1388
|
+
fix: 'Add "EXPOSE <port>" to your Dockerfile (e.g., EXPOSE 80 for web servers, EXPOSE 3000 for Node.js)',
|
|
1389
|
+
autofix: 'add_expose'
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Perl-specific: cpanm needs to be installed on slim images
|
|
1394
|
+
if (baseImage && baseImage.includes('perl') && baseImage.includes('slim') && usesCpanm && !installsCpanm) {
|
|
1395
|
+
issues.push({
|
|
1396
|
+
issue: 'Dockerfile uses cpanm but perl:*-slim does not have it pre-installed',
|
|
1397
|
+
fix: 'Add "RUN cpan -T App::cpanminus" before using cpanm, and install build deps: "RUN apt-get update && apt-get install -y --no-install-recommends make gcc && rm -rf /var/lib/apt/lists/*"',
|
|
1398
|
+
autofix: 'perl_cpanm'
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Elixir-specific: multi-stage builds must copy mix.lock from builder
|
|
1403
|
+
if (isElixirMultiStage && builderStageHasMixDepsGet && !hasMixLockCopy) {
|
|
1404
|
+
issues.push({
|
|
1405
|
+
issue: 'Elixir multi-stage Dockerfile missing mix.lock copy from builder stage',
|
|
1406
|
+
fix: 'Add "COPY --from=builder /app/mix.lock /app/mix.lock" to copy the generated mix.lock file',
|
|
1407
|
+
autofix: 'elixir_mix_lock'
|
|
1221
1408
|
});
|
|
1222
1409
|
}
|
|
1223
1410
|
|
|
1224
1411
|
return {
|
|
1225
1412
|
isValid: issues.length === 0,
|
|
1226
1413
|
issues,
|
|
1227
|
-
exposedPort
|
|
1414
|
+
exposedPort,
|
|
1415
|
+
baseImage
|
|
1228
1416
|
};
|
|
1229
1417
|
}
|
|
1230
1418
|
|
|
1419
|
+
// Auto-fix known Dockerfile issues
|
|
1420
|
+
function autoFixDockerfile(content, issues) {
|
|
1421
|
+
let lines = content.split('\n');
|
|
1422
|
+
let modified = false;
|
|
1423
|
+
|
|
1424
|
+
for (const issue of issues) {
|
|
1425
|
+
if (issue.autofix === 'perl_cpanm') {
|
|
1426
|
+
// Find the line that uses cpanm and insert the fix before it
|
|
1427
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1428
|
+
const trimmed = lines[i].trim().toUpperCase();
|
|
1429
|
+
if (trimmed.startsWith('RUN') && lines[i].includes('cpanm ') && !lines[i].includes('App::cpanminus')) {
|
|
1430
|
+
// Insert build dependencies and cpanm installation before this line
|
|
1431
|
+
const indent = lines[i].match(/^(\s*)/)[1];
|
|
1432
|
+
lines.splice(i, 0,
|
|
1433
|
+
`${indent}RUN apt-get update && apt-get install -y --no-install-recommends make gcc && rm -rf /var/lib/apt/lists/*`,
|
|
1434
|
+
`${indent}RUN cpan -T App::cpanminus`
|
|
1435
|
+
);
|
|
1436
|
+
modified = true;
|
|
1437
|
+
log.success('MCP >>> Auto-fixed Perl Dockerfile: added cpanm installation');
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (issue.autofix === 'add_expose') {
|
|
1444
|
+
// Find the last FROM or WORKDIR line to add EXPOSE after it
|
|
1445
|
+
const hasAnyExpose = /^\s*EXPOSE\s+\d+/mi.test(content);
|
|
1446
|
+
if (!hasAnyExpose) {
|
|
1447
|
+
let insertIndex = lines.length - 1;
|
|
1448
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1449
|
+
if (lines[i].trim().startsWith('WORKDIR') || lines[i].trim().startsWith('FROM')) {
|
|
1450
|
+
insertIndex = i + 1;
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
lines.splice(insertIndex, 0, '', 'EXPOSE 80');
|
|
1455
|
+
modified = true;
|
|
1456
|
+
log.success('MCP >>> Auto-fixed Dockerfile: added EXPOSE 80');
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (issue.autofix === 'elixir_mix_lock') {
|
|
1461
|
+
// Find the last COPY --from=builder line and add mix.lock copy after it
|
|
1462
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1463
|
+
const trimmed = lines[i].trim();
|
|
1464
|
+
if (trimmed.includes('COPY --from=builder') && trimmed.includes('mix.exs')) {
|
|
1465
|
+
// Insert mix.lock copy right after mix.exs copy
|
|
1466
|
+
const indent = lines[i].match(/^(\s*)/)[1];
|
|
1467
|
+
lines.splice(i + 1, 0, `${indent}COPY --from=builder /app/mix.lock /app/mix.lock`);
|
|
1468
|
+
modified = true;
|
|
1469
|
+
log.success('MCP >>> Auto-fixed Elixir Dockerfile: added mix.lock copy from builder');
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return { content: lines.join('\n'), modified };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1231
1479
|
// Analyze docker-compose.yml to determine deployment strategy
|
|
1232
1480
|
async function analyzeComposeFile(composePath) {
|
|
1233
1481
|
const content = await fs.readFile(composePath, 'utf-8');
|
|
@@ -2251,32 +2499,13 @@ async function deployProject(args) {
|
|
|
2251
2499
|
log.info(` Fix: ${issue.fix}`);
|
|
2252
2500
|
});
|
|
2253
2501
|
|
|
2254
|
-
//
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
log.info('MCP >>> Auto-fixing Dockerfile: adding EXPOSE 80 (no existing EXPOSE found)...');
|
|
2259
|
-
const lines = content.split('\n');
|
|
2260
|
-
|
|
2261
|
-
// Find the last FROM or WORKDIR line to add EXPOSE after it
|
|
2262
|
-
let insertIndex = lines.length - 1;
|
|
2263
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2264
|
-
if (lines[i].trim().startsWith('WORKDIR') || lines[i].trim().startsWith('FROM')) {
|
|
2265
|
-
insertIndex = i + 1;
|
|
2266
|
-
break;
|
|
2267
|
-
}
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
lines.splice(insertIndex, 0, '', 'EXPOSE 80');
|
|
2271
|
-
const fixedContent = lines.join('\n');
|
|
2272
|
-
|
|
2273
|
-
// Create backup
|
|
2502
|
+
// Use autoFixDockerfile to apply all fixes
|
|
2503
|
+
const fixResult = autoFixDockerfile(content, validation.issues);
|
|
2504
|
+
if (fixResult.modified) {
|
|
2505
|
+
// Create backup and save fixed content
|
|
2274
2506
|
fsSync.writeFileSync(dockerfilePath + '.backup', content);
|
|
2275
|
-
fsSync.writeFileSync(dockerfilePath,
|
|
2276
|
-
|
|
2277
|
-
log.success('MCP >>> Fixed Dockerfile: added EXPOSE 80');
|
|
2278
|
-
} else {
|
|
2279
|
-
log.info('MCP >>> Dockerfile already has EXPOSE directive, skipping auto-fix');
|
|
2507
|
+
fsSync.writeFileSync(dockerfilePath, fixResult.content);
|
|
2508
|
+
log.success('MCP >>> Dockerfile auto-fixes applied');
|
|
2280
2509
|
}
|
|
2281
2510
|
} else {
|
|
2282
2511
|
log.success('MCP >>> Dockerfile is Coolify compliant');
|
package/package.json
CHANGED