@akash-chowdhury-24/deployhub 2.0.26 → 2.0.28
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/README.md +38 -20
- package/package.json +1 -1
- package/src/adapters/index.js +1 -0
- package/src/artifact/engine.js +2 -2
- package/src/commands/doctor.js +8 -4
- package/src/commands/init.js +5 -0
- package/src/core/config.js +7 -0
- package/src/deployment/deployment-env.js +115 -41
- package/src/deployment/init-prompts.js +52 -7
- package/src/deployment/providers/azure-vm.js +6 -4
- package/src/deployment/providers/ec2.js +8 -3
- package/src/deployment/providers/gcp-vm.js +4 -4
- package/src/deployment/providers/ssh.js +2 -2
- package/src/detectors/backend.detector.js +107 -2
- package/src/detectors/index.js +3 -2
- package/src/utils/credential-inventory.js +292 -0
- package/src/utils/docker-image.js +6 -3
- package/src/utils/dockerfile-expose.js +1 -0
- package/src/utils/dockerfile.js +181 -28
- package/src/utils/github-actions.js +194 -17
|
@@ -207,6 +207,9 @@ export function isInterpretedBackendFramework(framework) {
|
|
|
207
207
|
'flask',
|
|
208
208
|
'laravel',
|
|
209
209
|
'symfony',
|
|
210
|
+
'php',
|
|
211
|
+
'python',
|
|
212
|
+
'ruby',
|
|
210
213
|
'rails',
|
|
211
214
|
].includes(framework || '');
|
|
212
215
|
}
|
|
@@ -224,21 +227,21 @@ export function describeInterpretedBackendGap(framework) {
|
|
|
224
227
|
installCmd: 'npm ci',
|
|
225
228
|
};
|
|
226
229
|
}
|
|
227
|
-
if (['fastapi', 'django', 'flask'].includes(framework)) {
|
|
230
|
+
if (['fastapi', 'django', 'flask', 'python'].includes(framework)) {
|
|
228
231
|
return {
|
|
229
232
|
ecosystem: 'Python',
|
|
230
233
|
missing: 'a pre-installed virtualenv / site-packages (artifacts only ship requirements.txt + source)',
|
|
231
234
|
installCmd: 'pip install',
|
|
232
235
|
};
|
|
233
236
|
}
|
|
234
|
-
if (['laravel', 'symfony'].includes(framework)) {
|
|
237
|
+
if (['laravel', 'symfony', 'php'].includes(framework)) {
|
|
235
238
|
return {
|
|
236
239
|
ecosystem: 'PHP',
|
|
237
240
|
missing: 'vendor/ (Composer dependencies are not packaged into the artifact)',
|
|
238
241
|
installCmd: 'composer install',
|
|
239
242
|
};
|
|
240
243
|
}
|
|
241
|
-
if (framework === 'rails') {
|
|
244
|
+
if (framework === 'rails' || framework === 'ruby') {
|
|
242
245
|
return {
|
|
243
246
|
ecosystem: 'Ruby',
|
|
244
247
|
missing: 'vendor/bundle / installed gems (artifacts only ship Gemfile + lock)',
|
|
@@ -71,6 +71,7 @@ export function resolveFallbackContainerPort(config) {
|
|
|
71
71
|
if (framework === 'go') return 8080;
|
|
72
72
|
if (framework === 'dotnet') return 5000;
|
|
73
73
|
if (framework === 'rails') return 3000;
|
|
74
|
+
if (framework === 'ruby') return 9292;
|
|
74
75
|
|
|
75
76
|
// nextjs, nestjs, express, and other Node backends
|
|
76
77
|
return 3000;
|
package/src/utils/dockerfile.js
CHANGED
|
@@ -24,26 +24,69 @@ export function resolveDockerSettings(config) {
|
|
|
24
24
|
const projectType = config.projectType || 'frontend';
|
|
25
25
|
|
|
26
26
|
if (projectType === 'both' && config.backend) {
|
|
27
|
+
const framework = config.backend.framework || 'express';
|
|
27
28
|
return {
|
|
28
|
-
framework
|
|
29
|
+
framework,
|
|
29
30
|
buildCommand: config.backend.buildCommand ?? null,
|
|
30
|
-
buildOutput: config.backend.buildOutput ||
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
buildOutput: config.backend.buildOutput || defaultBuildOutput(framework, projectType),
|
|
32
|
+
// null lets each generator supply a language-correct CMD (do not force npm start).
|
|
33
|
+
startCommand: config.backend.startCommand ?? null,
|
|
34
|
+
port: config.backend.port || defaultPort(framework, projectType),
|
|
33
35
|
projectType,
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
const framework =
|
|
40
|
+
config.framework || (projectType === 'frontend' ? 'react' : 'express');
|
|
37
41
|
return {
|
|
38
|
-
framework
|
|
42
|
+
framework,
|
|
39
43
|
buildCommand: config.buildCommand ?? null,
|
|
40
|
-
buildOutput: config.buildOutput ||
|
|
41
|
-
startCommand: config.startCommand
|
|
42
|
-
port: config.port ||
|
|
44
|
+
buildOutput: config.buildOutput || defaultBuildOutput(framework, projectType),
|
|
45
|
+
startCommand: config.startCommand ?? null,
|
|
46
|
+
port: config.port || defaultPort(framework, projectType),
|
|
43
47
|
projectType,
|
|
44
48
|
};
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} framework
|
|
53
|
+
* @param {string} projectType
|
|
54
|
+
*/
|
|
55
|
+
function defaultBuildOutput(framework, projectType) {
|
|
56
|
+
if (
|
|
57
|
+
projectType === 'frontend' ||
|
|
58
|
+
['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework)
|
|
59
|
+
) {
|
|
60
|
+
return 'dist';
|
|
61
|
+
}
|
|
62
|
+
if (framework === 'nestjs') return 'dist';
|
|
63
|
+
if (framework === 'dotnet') return 'publish';
|
|
64
|
+
if (framework === 'spring' || framework === 'java') return 'target';
|
|
65
|
+
if (framework === 'go') return 'bin';
|
|
66
|
+
return '.';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} framework
|
|
71
|
+
* @param {string} projectType
|
|
72
|
+
*/
|
|
73
|
+
function defaultPort(framework, projectType) {
|
|
74
|
+
if (
|
|
75
|
+
projectType === 'frontend' ||
|
|
76
|
+
['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework)
|
|
77
|
+
) {
|
|
78
|
+
return 80;
|
|
79
|
+
}
|
|
80
|
+
if (['laravel', 'symfony', 'php'].includes(framework)) return 80;
|
|
81
|
+
if (['fastapi', 'django', 'python'].includes(framework)) return 8000;
|
|
82
|
+
if (framework === 'flask') return 5000;
|
|
83
|
+
if (['spring', 'java', 'go'].includes(framework)) return 8080;
|
|
84
|
+
if (framework === 'dotnet') return 5000;
|
|
85
|
+
if (framework === 'rails') return 3000;
|
|
86
|
+
if (framework === 'ruby') return 9292;
|
|
87
|
+
return 3000;
|
|
88
|
+
}
|
|
89
|
+
|
|
47
90
|
/**
|
|
48
91
|
* Generate a .dockerignore matching the Dockerfile language/framework.
|
|
49
92
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
@@ -82,9 +125,10 @@ export function generateDockerignore(config) {
|
|
|
82
125
|
'express',
|
|
83
126
|
'fastify',
|
|
84
127
|
'koa',
|
|
128
|
+
'node',
|
|
85
129
|
];
|
|
86
|
-
const pythonFrameworks = ['fastapi', 'django', 'flask'];
|
|
87
|
-
const phpFrameworks = ['laravel', 'symfony'];
|
|
130
|
+
const pythonFrameworks = ['fastapi', 'django', 'flask', 'python'];
|
|
131
|
+
const phpFrameworks = ['laravel', 'symfony', 'php'];
|
|
88
132
|
|
|
89
133
|
if (nodeFrameworks.includes(framework) || !framework) {
|
|
90
134
|
lines.push('node_modules', 'npm-debug.log*', 'yarn-error.log*', '.npm', '');
|
|
@@ -119,7 +163,7 @@ export function generateDockerignore(config) {
|
|
|
119
163
|
lines.push('vendor', '');
|
|
120
164
|
}
|
|
121
165
|
|
|
122
|
-
if (framework === 'spring') {
|
|
166
|
+
if (framework === 'spring' || framework === 'java') {
|
|
123
167
|
lines.push('target', '.gradle', 'build', '');
|
|
124
168
|
}
|
|
125
169
|
|
|
@@ -131,7 +175,7 @@ export function generateDockerignore(config) {
|
|
|
131
175
|
lines.push('bin', 'obj', '');
|
|
132
176
|
}
|
|
133
177
|
|
|
134
|
-
if (framework === 'rails') {
|
|
178
|
+
if (framework === 'rails' || framework === 'ruby') {
|
|
135
179
|
lines.push('tmp', 'log', 'vendor/bundle', '');
|
|
136
180
|
}
|
|
137
181
|
|
|
@@ -159,6 +203,7 @@ export function generateDockerfile(config) {
|
|
|
159
203
|
case 'express':
|
|
160
204
|
case 'fastify':
|
|
161
205
|
case 'koa':
|
|
206
|
+
case 'node':
|
|
162
207
|
return generateNodeBackendDockerfile(settings);
|
|
163
208
|
case 'fastapi':
|
|
164
209
|
return generateFastapiDockerfile(settings);
|
|
@@ -166,11 +211,16 @@ export function generateDockerfile(config) {
|
|
|
166
211
|
return generateDjangoDockerfile(settings);
|
|
167
212
|
case 'flask':
|
|
168
213
|
return generateFlaskDockerfile(settings);
|
|
214
|
+
case 'python':
|
|
215
|
+
return generatePythonDockerfile(settings);
|
|
169
216
|
case 'laravel':
|
|
170
217
|
return generateLaravelDockerfile(settings);
|
|
171
218
|
case 'symfony':
|
|
172
219
|
return generateSymfonyDockerfile(settings);
|
|
220
|
+
case 'php':
|
|
221
|
+
return generatePhpDockerfile(settings);
|
|
173
222
|
case 'spring':
|
|
223
|
+
case 'java':
|
|
174
224
|
return generateSpringDockerfile(settings);
|
|
175
225
|
case 'go':
|
|
176
226
|
return generateGoDockerfile(settings);
|
|
@@ -178,6 +228,8 @@ export function generateDockerfile(config) {
|
|
|
178
228
|
return generateDotnetDockerfile(settings);
|
|
179
229
|
case 'rails':
|
|
180
230
|
return generateRailsDockerfile(settings);
|
|
231
|
+
case 'ruby':
|
|
232
|
+
return generateRubyDockerfile(settings);
|
|
181
233
|
default:
|
|
182
234
|
return generateNodeBackendDockerfile(settings);
|
|
183
235
|
}
|
|
@@ -383,25 +435,59 @@ CMD ${JSON.stringify(startParts)}
|
|
|
383
435
|
`;
|
|
384
436
|
}
|
|
385
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Plain Python (no FastAPI/Django/Flask): optional requirements + stdlib HTTP server.
|
|
440
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
441
|
+
*/
|
|
442
|
+
function generatePythonDockerfile(settings) {
|
|
443
|
+
const port = settings.port || 8000;
|
|
444
|
+
const startCmd =
|
|
445
|
+
settings.startCommand || `python -m http.server ${port} --bind 0.0.0.0`;
|
|
446
|
+
const startParts = parseCommand(startCmd);
|
|
447
|
+
|
|
448
|
+
return `${GENERATED_HEADER}
|
|
449
|
+
FROM python:3.11-slim
|
|
450
|
+
WORKDIR /app
|
|
451
|
+
COPY requirements.txt* ./
|
|
452
|
+
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
|
|
453
|
+
COPY . .
|
|
454
|
+
EXPOSE ${port}
|
|
455
|
+
CMD ${JSON.stringify(startParts)}
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
386
459
|
/**
|
|
387
460
|
* @param {{ port: number }} settings
|
|
388
461
|
*/
|
|
389
462
|
function generateLaravelDockerfile(settings) {
|
|
390
463
|
const port = settings.port || 80;
|
|
464
|
+
// Single-process HTTP for container/K8s fitness. SSH deploys still use host
|
|
465
|
+
// php-fpm + nginx; this image must listen on EXPOSE (FPM alone listens on 9000).
|
|
466
|
+
const cmd = JSON.stringify([
|
|
467
|
+
'php',
|
|
468
|
+
'artisan',
|
|
469
|
+
'serve',
|
|
470
|
+
'--host=0.0.0.0',
|
|
471
|
+
`--port=${port}`,
|
|
472
|
+
]);
|
|
391
473
|
|
|
392
474
|
return `${GENERATED_HEADER}
|
|
393
475
|
FROM composer:2 AS vendor
|
|
394
476
|
WORKDIR /app
|
|
395
|
-
COPY composer.json composer.lock ./
|
|
396
|
-
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
|
477
|
+
COPY composer.json composer.lock* ./
|
|
478
|
+
RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
|
|
397
479
|
|
|
398
|
-
FROM php:8.2-
|
|
480
|
+
FROM php:8.2-cli-alpine
|
|
399
481
|
WORKDIR /var/www/html
|
|
482
|
+
RUN apk add --no-cache icu-libs libzip \\
|
|
483
|
+
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev \\
|
|
484
|
+
&& docker-php-ext-install -j$(nproc) pdo_mysql mbstring zip opcache \\
|
|
485
|
+
&& apk del .build-deps
|
|
400
486
|
COPY --from=vendor /app/vendor ./vendor
|
|
401
487
|
COPY . .
|
|
402
488
|
RUN chown -R www-data:www-data storage bootstrap/cache || true
|
|
403
489
|
EXPOSE ${port}
|
|
404
|
-
CMD
|
|
490
|
+
CMD ${cmd}
|
|
405
491
|
`;
|
|
406
492
|
}
|
|
407
493
|
|
|
@@ -410,19 +496,54 @@ CMD ["php-fpm"]
|
|
|
410
496
|
*/
|
|
411
497
|
function generateSymfonyDockerfile(settings) {
|
|
412
498
|
const port = settings.port || 80;
|
|
499
|
+
// Built-in server binds HTTP to EXPOSE — suitable for a single-container pod.
|
|
500
|
+
// Prefer a production reverse-proxy image if you outgrow this starter template.
|
|
501
|
+
const cmd = JSON.stringify(['php', '-S', `0.0.0.0:${port}`, '-t', 'public']);
|
|
413
502
|
|
|
414
503
|
return `${GENERATED_HEADER}
|
|
415
504
|
FROM composer:2 AS vendor
|
|
416
505
|
WORKDIR /app
|
|
417
|
-
COPY composer.json composer.lock ./
|
|
418
|
-
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
|
506
|
+
COPY composer.json composer.lock* ./
|
|
507
|
+
RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
|
|
419
508
|
|
|
420
|
-
FROM php:8.2-
|
|
509
|
+
FROM php:8.2-cli-alpine
|
|
421
510
|
WORKDIR /var/www/html
|
|
511
|
+
RUN apk add --no-cache icu-libs libzip \\
|
|
512
|
+
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev \\
|
|
513
|
+
&& docker-php-ext-install -j$(nproc) pdo_mysql mbstring zip opcache \\
|
|
514
|
+
&& apk del .build-deps
|
|
422
515
|
COPY --from=vendor /app/vendor ./vendor
|
|
423
516
|
COPY . .
|
|
424
517
|
EXPOSE ${port}
|
|
425
|
-
CMD
|
|
518
|
+
CMD ${cmd}
|
|
519
|
+
`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Plain PHP (no Laravel/Symfony): single-process built-in server.
|
|
524
|
+
* Docroot prefers public/ at runtime when present; optional composer install.
|
|
525
|
+
* @param {{ port: number }} settings
|
|
526
|
+
*/
|
|
527
|
+
function generatePhpDockerfile(settings) {
|
|
528
|
+
const port = settings.port || 80;
|
|
529
|
+
const cmd = JSON.stringify([
|
|
530
|
+
'sh',
|
|
531
|
+
'-c',
|
|
532
|
+
`d=.; [ -d public ] && d=public; exec php -S 0.0.0.0:${port} -t "$d"`,
|
|
533
|
+
]);
|
|
534
|
+
|
|
535
|
+
return `${GENERATED_HEADER}
|
|
536
|
+
FROM php:8.2-cli-alpine
|
|
537
|
+
WORKDIR /var/www/html
|
|
538
|
+
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
539
|
+
RUN apk add --no-cache icu-libs libzip \\
|
|
540
|
+
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev \\
|
|
541
|
+
&& docker-php-ext-install -j$(nproc) pdo_mysql mbstring zip opcache \\
|
|
542
|
+
&& apk del .build-deps
|
|
543
|
+
COPY . .
|
|
544
|
+
RUN if [ -f composer.json ]; then composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs; fi
|
|
545
|
+
EXPOSE ${port}
|
|
546
|
+
CMD ${cmd}
|
|
426
547
|
`;
|
|
427
548
|
}
|
|
428
549
|
|
|
@@ -438,11 +559,13 @@ FROM eclipse-temurin:17-jdk-alpine AS build
|
|
|
438
559
|
WORKDIR /app
|
|
439
560
|
COPY pom.xml ./
|
|
440
561
|
COPY src ./src
|
|
441
|
-
RUN apk add --no-cache maven && ${buildCmd}
|
|
562
|
+
RUN apk add --no-cache maven && ${buildCmd} \\
|
|
563
|
+
&& JAR=$(ls target/*.jar 2>/dev/null | grep -v '\\-plain\\.jar$' | head -n1) \\
|
|
564
|
+
&& test -n "$JAR" && cp "$JAR" /app/app.jar
|
|
442
565
|
|
|
443
566
|
FROM eclipse-temurin:17-jre-alpine
|
|
444
567
|
WORKDIR /app
|
|
445
|
-
COPY --from=build /app/
|
|
568
|
+
COPY --from=build /app/app.jar app.jar
|
|
446
569
|
EXPOSE ${port}
|
|
447
570
|
CMD ["java", "-jar", "app.jar"]
|
|
448
571
|
`;
|
|
@@ -458,10 +581,10 @@ function generateGoDockerfile(settings) {
|
|
|
458
581
|
return `${GENERATED_HEADER}
|
|
459
582
|
FROM golang:1.22-alpine AS build
|
|
460
583
|
WORKDIR /app
|
|
461
|
-
COPY go.mod go.sum ./
|
|
584
|
+
COPY go.mod go.sum* ./
|
|
462
585
|
RUN go mod download
|
|
463
586
|
COPY . .
|
|
464
|
-
RUN ${buildCmd}
|
|
587
|
+
RUN mkdir -p /app/bin && ${buildCmd}
|
|
465
588
|
|
|
466
589
|
FROM alpine:3.19
|
|
467
590
|
WORKDIR /app
|
|
@@ -476,7 +599,8 @@ CMD ["./app"]
|
|
|
476
599
|
* @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
|
|
477
600
|
*/
|
|
478
601
|
function generateDotnetDockerfile(settings) {
|
|
479
|
-
|
|
602
|
+
// Publish into a path under WORKDIR so the runtime stage COPY path matches.
|
|
603
|
+
const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o publish';
|
|
480
604
|
const port = settings.port || 5000;
|
|
481
605
|
const output = settings.buildOutput || 'publish';
|
|
482
606
|
|
|
@@ -502,15 +626,17 @@ CMD ["dotnet", "App.dll"]
|
|
|
502
626
|
*/
|
|
503
627
|
function generateRailsDockerfile(settings) {
|
|
504
628
|
const port = settings.port || 3000;
|
|
505
|
-
|
|
629
|
+
// Bind explicitly — puma.rb / SSH assumptions are not the container PID 1 contract.
|
|
630
|
+
const startCmd =
|
|
631
|
+
settings.startCommand || `bundle exec puma -b tcp://0.0.0.0:${port}`;
|
|
506
632
|
const startParts = parseCommand(startCmd);
|
|
507
633
|
|
|
508
634
|
return `${GENERATED_HEADER}
|
|
509
635
|
FROM ruby:3.2-slim AS build
|
|
510
636
|
WORKDIR /app
|
|
511
637
|
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
|
|
512
|
-
COPY Gemfile Gemfile.lock ./
|
|
513
|
-
RUN bundle
|
|
638
|
+
COPY Gemfile Gemfile.lock* ./
|
|
639
|
+
RUN bundle config set --local without 'development test' && bundle install
|
|
514
640
|
COPY . .
|
|
515
641
|
RUN bundle exec rake assets:precompile || true
|
|
516
642
|
|
|
@@ -524,6 +650,28 @@ CMD ${JSON.stringify(startParts)}
|
|
|
524
650
|
`;
|
|
525
651
|
}
|
|
526
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Plain Ruby / Rack (no Rails): Puma as foreground HTTP process.
|
|
655
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
656
|
+
*/
|
|
657
|
+
function generateRubyDockerfile(settings) {
|
|
658
|
+
const port = settings.port || 9292;
|
|
659
|
+
const startCmd =
|
|
660
|
+
settings.startCommand || `bundle exec puma -b tcp://0.0.0.0:${port}`;
|
|
661
|
+
const startParts = parseCommand(startCmd);
|
|
662
|
+
|
|
663
|
+
return `${GENERATED_HEADER}
|
|
664
|
+
FROM ruby:3.2-slim
|
|
665
|
+
WORKDIR /app
|
|
666
|
+
RUN apt-get update -qq && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
|
667
|
+
COPY Gemfile Gemfile.lock* ./
|
|
668
|
+
RUN bundle config set --local without 'development test' && bundle install
|
|
669
|
+
COPY . .
|
|
670
|
+
EXPOSE ${port}
|
|
671
|
+
CMD ${JSON.stringify(startParts)}
|
|
672
|
+
`;
|
|
673
|
+
}
|
|
674
|
+
|
|
527
675
|
/**
|
|
528
676
|
* @param {string} command
|
|
529
677
|
* @returns {string[]}
|
|
@@ -567,6 +715,11 @@ export function getDockerfileFrameworkLabel(framework) {
|
|
|
567
715
|
flask: 'Flask',
|
|
568
716
|
laravel: 'Laravel',
|
|
569
717
|
symfony: 'Symfony',
|
|
718
|
+
php: 'PHP',
|
|
719
|
+
python: 'Python',
|
|
720
|
+
node: 'Node.js',
|
|
721
|
+
java: 'Java',
|
|
722
|
+
ruby: 'Ruby',
|
|
570
723
|
spring: 'Spring Boot',
|
|
571
724
|
go: 'Go',
|
|
572
725
|
dotnet: '.NET',
|