@dockerforge/core 0.1.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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +88 -0
- package/package.json +44 -0
- package/src/_engine/backend/src/modules/analysis/analyser.js +1128 -0
- package/src/_engine/backend/src/modules/engine.js +219 -0
- package/src/_engine/backend/src/modules/explanation/explainer.js +78 -0
- package/src/_engine/backend/src/modules/generation/composeGenerator.js +318 -0
- package/src/_engine/backend/src/modules/generation/generator.js +1355 -0
- package/src/_engine/backend/src/modules/ingestion/ingestion.js +525 -0
- package/src/_engine/backend/src/modules/optimisation/optimiser.js +38 -0
- package/src/_engine/backend/src/modules/security/security.js +91 -0
- package/src/_engine/shared/constants.js +98 -0
- package/src/errors.js +36 -0
- package/src/index.js +70 -0
- package/src/lint/index.js +92 -0
- package/src/lint/parse.js +112 -0
- package/src/lint/rules.js +148 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ingestGitRepo, ingestZip, ingestTree } = require('./ingestion/ingestion');
|
|
4
|
+
const { analyseProject } = require('./analysis/analyser');
|
|
5
|
+
const { generateDockerfile, addPowerDockerfileHeader } = require('./generation/generator');
|
|
6
|
+
const { optimise } = require('./optimisation/optimiser');
|
|
7
|
+
const { securityPass } = require('./security/security');
|
|
8
|
+
const { buildExplanation } = require('./explanation/explainer');
|
|
9
|
+
const { generateCompose } = require('./generation/composeGenerator');
|
|
10
|
+
|
|
11
|
+
function clampScore(score) {
|
|
12
|
+
return Math.max(0, Math.min(1, Number(score.toFixed(2))));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function confidenceLabel(score) {
|
|
16
|
+
if (score >= 0.9) return 'High';
|
|
17
|
+
if (score >= 0.7) return 'Medium';
|
|
18
|
+
if (score >= 0.4) return 'Low';
|
|
19
|
+
return 'Unsupported/Risky';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectAssumptions(analysisResult) {
|
|
23
|
+
return analysisResult.services.flatMap(service =>
|
|
24
|
+
(service.assumptions || []).map(item => `${service.serviceDir}: ${item}`)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildWarnings({ analysisResult, result, securityNotes }) {
|
|
29
|
+
const warnings = [];
|
|
30
|
+
for (const note of securityNotes) {
|
|
31
|
+
if (/security|warning|avoid|possible|root|latest/i.test(note)) warnings.push(note);
|
|
32
|
+
}
|
|
33
|
+
for (const note of result.improvements || []) {
|
|
34
|
+
if (/verify|must|warning|blocked|assumption|source|healthcheck/i.test(note)) warnings.push(note);
|
|
35
|
+
}
|
|
36
|
+
for (const service of analysisResult.services) {
|
|
37
|
+
if (service.stack === 'python') {
|
|
38
|
+
warnings.push(`${service.serviceDir}: Python source copy confidence needs review; verify all package, template, static, and migration directories are copied.`);
|
|
39
|
+
}
|
|
40
|
+
if (service.stack === 'node' && !service.lockFile) {
|
|
41
|
+
warnings.push(`${service.serviceDir}: no Node lockfile detected; dependency installs may not be reproducible.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(warnings)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scoreConfidence({ analysisResult, warnings }) {
|
|
48
|
+
let score = 1;
|
|
49
|
+
const reasons = [];
|
|
50
|
+
const assumptions = collectAssumptions(analysisResult);
|
|
51
|
+
|
|
52
|
+
if (analysisResult.services.length > 1) {
|
|
53
|
+
score -= 0.04;
|
|
54
|
+
reasons.push(`${analysisResult.services.length} services detected`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const service of analysisResult.services) {
|
|
58
|
+
if (!service.version) {
|
|
59
|
+
score -= 0.15;
|
|
60
|
+
reasons.push(`${service.serviceDir}: runtime version missing`);
|
|
61
|
+
}
|
|
62
|
+
if (service.stack === 'node') {
|
|
63
|
+
if (!service.lockFile) {
|
|
64
|
+
score -= 0.18;
|
|
65
|
+
reasons.push(`${service.serviceDir}: lockfile missing`);
|
|
66
|
+
}
|
|
67
|
+
if (!service.startCmd || service.startCmd.length === 0) {
|
|
68
|
+
score -= 0.2;
|
|
69
|
+
reasons.push(`${service.serviceDir}: start command uncertain`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (service.stack === 'python') {
|
|
73
|
+
if (!service.entryPoint && !service.appTarget) {
|
|
74
|
+
score -= 0.2;
|
|
75
|
+
reasons.push(`${service.serviceDir}: Python entrypoint uncertain`);
|
|
76
|
+
}
|
|
77
|
+
if (!service.sourceCopyConfidence || service.sourceCopyConfidence !== 'high') {
|
|
78
|
+
score -= 0.12;
|
|
79
|
+
reasons.push(`${service.serviceDir}: Python source copy needs review`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if ((service.assumptions || []).length > 0) {
|
|
83
|
+
score -= Math.min(0.18, service.assumptions.length * 0.06);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const majorWarnings = warnings.filter(w => /security|must|not detected|permission denied|invalid|unsafe/i.test(w));
|
|
88
|
+
if (majorWarnings.length > 0) {
|
|
89
|
+
score -= Math.min(0.18, majorWarnings.length * 0.04);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const finalScore = clampScore(score);
|
|
93
|
+
const label = confidenceLabel(finalScore);
|
|
94
|
+
let reason;
|
|
95
|
+
if (reasons.length > 0) {
|
|
96
|
+
reason = reasons.slice(0, 2).join('; ');
|
|
97
|
+
} else if (assumptions.length > 0) {
|
|
98
|
+
reason = `${assumptions.length} assumption${assumptions.length === 1 ? '' : 's'} detected.`;
|
|
99
|
+
} else if (warnings.length > 0) {
|
|
100
|
+
reason = `${warnings.length} warning${warnings.length === 1 ? '' : 's'} to review.`;
|
|
101
|
+
} else {
|
|
102
|
+
reason = 'Stack, runtime, dependencies, and start command were detected with no major warnings.';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { confidence: finalScore, confidenceLabel: label, confidenceReason: reason };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyValidationEvidence(confidenceResult, validation) {
|
|
109
|
+
if (!validation || validation.status === 'skipped') return confidenceResult;
|
|
110
|
+
|
|
111
|
+
if (validation.status === 'passed') {
|
|
112
|
+
const delta = validation.evidence?.runtimePassed ? 0.12 : 0.08;
|
|
113
|
+
const confidence = clampScore(confidenceResult.confidence + delta);
|
|
114
|
+
return {
|
|
115
|
+
confidence,
|
|
116
|
+
confidenceLabel: confidenceLabel(confidence),
|
|
117
|
+
confidenceReason: `${confidenceResult.confidenceReason} Build/runtime validated.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (validation.status === 'failed') {
|
|
122
|
+
const confidence = clampScore(confidenceResult.confidence - 0.2);
|
|
123
|
+
return {
|
|
124
|
+
confidence,
|
|
125
|
+
confidenceLabel: confidenceLabel(confidence),
|
|
126
|
+
confidenceReason: `${confidenceResult.confidenceReason} Validation failed during ${validation.stage}.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return confidenceResult;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Strip BuildKit-specific features from a Dockerfile to produce the simple default output.
|
|
135
|
+
* The full version (with mounts, CRA flags, etc.) becomes powerDockerfile.
|
|
136
|
+
*/
|
|
137
|
+
function toSimpleDockerfile(dockerfile) {
|
|
138
|
+
return dockerfile.split('\n').map(line => {
|
|
139
|
+
const isRun = /^RUN\s/.test(line);
|
|
140
|
+
if (!isRun) return line;
|
|
141
|
+
// Strip --mount=type=cache,... and --mount=type=secret,...
|
|
142
|
+
line = line.replace(/--mount=type=\w+,\S+\s+/g, '');
|
|
143
|
+
// Strip CRA build environment prefix (CI=false GENERATE_SOURCEMAP=false ...)
|
|
144
|
+
line = line.replace(
|
|
145
|
+
/^(RUN\s+)CI=false GENERATE_SOURCEMAP=false NODE_OPTIONS=--max-old-space-size=\d+ DISABLE_ESLINT_PLUGIN=true\s+/,
|
|
146
|
+
'$1'
|
|
147
|
+
);
|
|
148
|
+
return line;
|
|
149
|
+
}).join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async function resolveProjectPath(input) {
|
|
154
|
+
if (input.projectPath) return input.projectPath;
|
|
155
|
+
if (input.gitUrl) return ingestGitRepo(input.gitUrl, input.workDir, input.pat);
|
|
156
|
+
if (input.zipPath) return ingestZip(input.zipPath, input.workDir);
|
|
157
|
+
if (input.fileTree) return ingestTree(input.fileTree, input.workDir);
|
|
158
|
+
throw new Error('Provide projectPath, gitUrl, zipPath, or fileTree');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function runDockerfileEngine(input = {}) {
|
|
162
|
+
const projectPath = await resolveProjectPath(input);
|
|
163
|
+
const analysisResult = await analyseProject(projectPath, input.hints || {});
|
|
164
|
+
const primaryService = analysisResult.services[0];
|
|
165
|
+
|
|
166
|
+
let result = generateDockerfile(analysisResult);
|
|
167
|
+
const composeResult = generateCompose(analysisResult);
|
|
168
|
+
|
|
169
|
+
if (input.optimise !== false) {
|
|
170
|
+
result = optimise(result, primaryService);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const securityNotes = input.security === false ? [] : securityPass(result, primaryService);
|
|
174
|
+
const explanation = buildExplanation(primaryService, result, securityNotes);
|
|
175
|
+
const improvements = [...securityNotes, ...(result.improvements || []), ...(composeResult.improvements || [])];
|
|
176
|
+
const assumptions = collectAssumptions(analysisResult);
|
|
177
|
+
const warnings = buildWarnings({ analysisResult, result, securityNotes });
|
|
178
|
+
const confidence = applyValidationEvidence(
|
|
179
|
+
scoreConfidence({ analysisResult, warnings }),
|
|
180
|
+
input.validation
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const fullDockerfile = result.dockerfile;
|
|
184
|
+
const powerDockerfile = addPowerDockerfileHeader(fullDockerfile);
|
|
185
|
+
const simpleDockerfile = toSimpleDockerfile(fullDockerfile);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
dockerfile: simpleDockerfile,
|
|
189
|
+
powerDockerfile,
|
|
190
|
+
validationDockerfile: result.validationDockerfile || null,
|
|
191
|
+
validationDrift: result.validationDrift || null,
|
|
192
|
+
dockerignore: result.dockerignore,
|
|
193
|
+
nginxConf: result.nginxConf || null,
|
|
194
|
+
compose: composeResult.compose || null,
|
|
195
|
+
explanation,
|
|
196
|
+
improvements,
|
|
197
|
+
warnings,
|
|
198
|
+
assumptions,
|
|
199
|
+
validation: input.validation || null,
|
|
200
|
+
analysis: {
|
|
201
|
+
services: analysisResult.services.map(service => ({
|
|
202
|
+
stack: service.stack,
|
|
203
|
+
role: service.role,
|
|
204
|
+
serviceDir: service.serviceDir,
|
|
205
|
+
version: service.version,
|
|
206
|
+
framework: service.framework || null,
|
|
207
|
+
port: service.port,
|
|
208
|
+
})),
|
|
209
|
+
},
|
|
210
|
+
...confidence,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
applyValidationEvidence,
|
|
216
|
+
runDockerfileEngine,
|
|
217
|
+
scoreConfidence,
|
|
218
|
+
buildWarnings,
|
|
219
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// backend/src/modules/explanation/explainer.js
|
|
2
|
+
// Turns analysis + result into a human-readable explanation
|
|
3
|
+
|
|
4
|
+
const { STACKS } = require('../../../../shared/constants');
|
|
5
|
+
|
|
6
|
+
function buildExplanation(analysis, result, securityNotes) {
|
|
7
|
+
const stackNames = {
|
|
8
|
+
[STACKS.NODE]: 'Node.js',
|
|
9
|
+
[STACKS.PYTHON]: 'Python',
|
|
10
|
+
[STACKS.DOTNET]: '.NET',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const lines = [];
|
|
14
|
+
|
|
15
|
+
lines.push(`**Stack detected:** ${stackNames[analysis.stack]}`);
|
|
16
|
+
lines.push(`**Runtime version:** ${analysis.version}`);
|
|
17
|
+
|
|
18
|
+
// Base image reasoning
|
|
19
|
+
if (analysis.stack === STACKS.NODE) {
|
|
20
|
+
lines.push(
|
|
21
|
+
`**Base image:** \`node:${analysis.version}-alpine\` (Alpine Linux keeps the image small, ~50MB vs ~900MB for the full Debian image).`
|
|
22
|
+
);
|
|
23
|
+
lines.push(`**Package manager:** ${analysis.packageManager} (detected from lockfile)`);
|
|
24
|
+
if (analysis.hasBuild) {
|
|
25
|
+
lines.push(
|
|
26
|
+
`**Build strategy:** Multi-stage build. A build container compiles the app, then only the output is copied to a clean runtime container. Dev dependencies never reach production.`
|
|
27
|
+
);
|
|
28
|
+
} else {
|
|
29
|
+
lines.push(
|
|
30
|
+
`**Build strategy:** Single stage. No build script detected, so all dependencies are installed directly in the runtime container with \`--omit=dev\` to exclude dev packages.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (analysis.stack === STACKS.PYTHON) {
|
|
36
|
+
lines.push(
|
|
37
|
+
`**Base image:** \`python:${analysis.version}-slim\` (Debian slim removes most docs and locales, saving ~100MB vs the full image).`
|
|
38
|
+
);
|
|
39
|
+
if (analysis.framework) {
|
|
40
|
+
lines.push(`**Framework detected:** ${analysis.framework}, start command tuned accordingly.`);
|
|
41
|
+
}
|
|
42
|
+
lines.push(
|
|
43
|
+
`**PYTHONDONTWRITEBYTECODE + PYTHONUNBUFFERED set:** prevents .pyc clutter and ensures logs appear immediately in Docker.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (analysis.stack === STACKS.DOTNET) {
|
|
48
|
+
lines.push(
|
|
49
|
+
`**Base images:** SDK image (\`mcr.microsoft.com/dotnet/sdk:${analysis.version}\`) used to build, then the much smaller ASP.NET runtime image (\`mcr.microsoft.com/dotnet/aspnet:${analysis.version}\`) for production. SDK is ~750MB, runtime is ~220MB.`
|
|
50
|
+
);
|
|
51
|
+
lines.push(`**Multi-stage build:** Always used for .NET. The SDK is never shipped to production.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Port
|
|
55
|
+
lines.push(`**Port:** \`${analysis.port}\` exposed`);
|
|
56
|
+
|
|
57
|
+
// Security
|
|
58
|
+
lines.push(`**Security:** Non-root user added. Container does not run as root.`);
|
|
59
|
+
|
|
60
|
+
// Cache optimisation
|
|
61
|
+
lines.push(
|
|
62
|
+
`**Layer caching:** Lockfile and package manifest are copied before the rest of the app. This means \`docker build\` only re-runs the install step if your dependencies actually changed.`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
summary: lines.join('\n'),
|
|
67
|
+
stack: stackNames[analysis.stack],
|
|
68
|
+
version: analysis.version,
|
|
69
|
+
port: analysis.port,
|
|
70
|
+
multiStage: analysis.hasBuild,
|
|
71
|
+
packageManager: analysis.packageManager || null,
|
|
72
|
+
framework: analysis.framework || null,
|
|
73
|
+
assumptions: analysis.assumptions || [],
|
|
74
|
+
securityChecks: securityNotes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { buildExplanation };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// backend/src/modules/generation/composeGenerator.js
|
|
2
|
+
// Takes the same analysisResult shape as generator.js.
|
|
3
|
+
// Returns { compose: string, improvements: string[] }
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
// ── Infra detection patterns ──────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const INFRA_PATTERNS = {
|
|
9
|
+
postgres: [
|
|
10
|
+
'DATABASE_URL', 'POSTGRES_URL', 'POSTGRES_HOST', 'DB_HOST',
|
|
11
|
+
'DATABASE_HOST', 'POSTGRESQL_URL', 'PG_HOST', 'PGHOST',
|
|
12
|
+
],
|
|
13
|
+
redis: [
|
|
14
|
+
'REDIS_URL', 'REDIS_HOST', 'REDIS_URI', 'REDIS_TLS_URL',
|
|
15
|
+
],
|
|
16
|
+
mongo: [
|
|
17
|
+
'MONGO_URI', 'MONGODB_URI', 'MONGODB_URL', 'MONGO_URL', 'MONGO_HOST',
|
|
18
|
+
],
|
|
19
|
+
rabbitmq: [
|
|
20
|
+
'RABBITMQ_URL', 'AMQP_URL', 'RABBITMQ_HOST',
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const INFRA_SERVICES = {
|
|
25
|
+
postgres: {
|
|
26
|
+
name: 'db',
|
|
27
|
+
image: 'postgres:16-alpine',
|
|
28
|
+
ports: ['5432:5432'],
|
|
29
|
+
environment: [
|
|
30
|
+
'POSTGRES_USER=${POSTGRES_USER:?POSTGRES_USER must be set}',
|
|
31
|
+
'POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}',
|
|
32
|
+
'POSTGRES_DB=${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
33
|
+
],
|
|
34
|
+
volume: 'postgres_data:/var/lib/postgresql/data',
|
|
35
|
+
volumeName: 'postgres_data',
|
|
36
|
+
healthcheck: {
|
|
37
|
+
test: '["CMD-SHELL", "pg_isready -U postgres"]',
|
|
38
|
+
interval: '5s',
|
|
39
|
+
timeout: '5s',
|
|
40
|
+
retries: 5,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
redis: {
|
|
44
|
+
name: 'redis',
|
|
45
|
+
image: 'redis:7-alpine',
|
|
46
|
+
ports: ['6379:6379'],
|
|
47
|
+
environment: [],
|
|
48
|
+
volume: 'redis_data:/data',
|
|
49
|
+
volumeName: 'redis_data',
|
|
50
|
+
healthcheck: {
|
|
51
|
+
test: '["CMD", "redis-cli", "ping"]',
|
|
52
|
+
interval: '5s',
|
|
53
|
+
timeout: '5s',
|
|
54
|
+
retries: 5,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
mongo: {
|
|
58
|
+
name: 'mongo',
|
|
59
|
+
image: 'mongo:7',
|
|
60
|
+
ports: ['27017:27017'],
|
|
61
|
+
environment: [
|
|
62
|
+
'MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}',
|
|
63
|
+
'MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}',
|
|
64
|
+
],
|
|
65
|
+
volume: 'mongo_data:/data/db',
|
|
66
|
+
volumeName: 'mongo_data',
|
|
67
|
+
healthcheck: {
|
|
68
|
+
test: '["CMD", "mongosh", "--eval", "db.adminCommand(\'ping\')"]',
|
|
69
|
+
interval: '10s',
|
|
70
|
+
timeout: '5s',
|
|
71
|
+
retries: 5,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
rabbitmq: {
|
|
75
|
+
name: 'rabbitmq',
|
|
76
|
+
image: 'rabbitmq:3-management-alpine',
|
|
77
|
+
ports: ['5672:5672', '15672:15672'],
|
|
78
|
+
environment: [
|
|
79
|
+
'RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER:?RABBITMQ_DEFAULT_USER must be set}',
|
|
80
|
+
'RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS:?RABBITMQ_DEFAULT_PASS must be set}',
|
|
81
|
+
],
|
|
82
|
+
volume: 'rabbitmq_data:/var/lib/rabbitmq',
|
|
83
|
+
volumeName: 'rabbitmq_data',
|
|
84
|
+
healthcheck: {
|
|
85
|
+
test: '["CMD", "rabbitmq-diagnostics", "ping"]',
|
|
86
|
+
interval: '10s',
|
|
87
|
+
timeout: '5s',
|
|
88
|
+
retries: 5,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ── Env var rewriting ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const INFRA_REWRITES = {
|
|
96
|
+
postgres: {
|
|
97
|
+
DATABASE_URL: 'postgresql://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@db:5432/${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
98
|
+
POSTGRES_URL: 'postgresql://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@db:5432/${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
99
|
+
POSTGRES_HOST: 'db',
|
|
100
|
+
DB_HOST: 'db',
|
|
101
|
+
DATABASE_HOST: 'db',
|
|
102
|
+
POSTGRESQL_URL: 'postgresql://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@db:5432/${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
103
|
+
PG_HOST: 'db',
|
|
104
|
+
PGHOST: 'db',
|
|
105
|
+
PGUSER: '${POSTGRES_USER:?POSTGRES_USER must be set}',
|
|
106
|
+
PGPASSWORD: '${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}',
|
|
107
|
+
PGDATABASE: '${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
108
|
+
POSTGRES_USER: '${POSTGRES_USER:?POSTGRES_USER must be set}',
|
|
109
|
+
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}',
|
|
110
|
+
POSTGRES_DB: '${POSTGRES_DB:?POSTGRES_DB must be set}',
|
|
111
|
+
},
|
|
112
|
+
redis: {
|
|
113
|
+
REDIS_URL: 'redis://redis:6379',
|
|
114
|
+
REDIS_HOST: 'redis',
|
|
115
|
+
REDIS_URI: 'redis://redis:6379',
|
|
116
|
+
REDIS_TLS_URL: 'redis://redis:6379',
|
|
117
|
+
REDIS_PORT: '6379',
|
|
118
|
+
},
|
|
119
|
+
mongo: {
|
|
120
|
+
MONGO_URI: 'mongodb://${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}:${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}@mongo:27017/appdb',
|
|
121
|
+
MONGODB_URI: 'mongodb://${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}:${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}@mongo:27017/appdb',
|
|
122
|
+
MONGODB_URL: 'mongodb://${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}:${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}@mongo:27017/appdb',
|
|
123
|
+
MONGO_URL: 'mongodb://${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}:${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}@mongo:27017/appdb',
|
|
124
|
+
MONGO_HOST: 'mongo',
|
|
125
|
+
MONGO_INITDB_ROOT_USERNAME: '${MONGO_INITDB_ROOT_USERNAME:?MONGO_INITDB_ROOT_USERNAME must be set}',
|
|
126
|
+
MONGO_INITDB_ROOT_PASSWORD: '${MONGO_INITDB_ROOT_PASSWORD:?MONGO_INITDB_ROOT_PASSWORD must be set}',
|
|
127
|
+
},
|
|
128
|
+
rabbitmq: {
|
|
129
|
+
RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER:?RABBITMQ_DEFAULT_USER must be set}:${RABBITMQ_DEFAULT_PASS:?RABBITMQ_DEFAULT_PASS must be set}@rabbitmq:5672',
|
|
130
|
+
AMQP_URL: 'amqp://guest:guest@rabbitmq:5672',
|
|
131
|
+
RABBITMQ_HOST: 'rabbitmq',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── Infra detection ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {Array<{key: string}>} envVars
|
|
139
|
+
* @returns {Set<string>}
|
|
140
|
+
*/
|
|
141
|
+
function detectInfra(envVars) {
|
|
142
|
+
const found = new Set();
|
|
143
|
+
const keys = new Set(envVars.map(v => v.key));
|
|
144
|
+
for (const [type, patterns] of Object.entries(INFRA_PATTERNS)) {
|
|
145
|
+
if (patterns.some(p => keys.has(p))) {
|
|
146
|
+
found.add(type);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── YAML helpers ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function indent(str, spaces) {
|
|
155
|
+
const pad = ' '.repeat(spaces);
|
|
156
|
+
return str.split('\n').map(l => (l.trim() === '' ? '' : pad + l)).join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function healthcheckBlock(hc) {
|
|
160
|
+
return [
|
|
161
|
+
`healthcheck:`,
|
|
162
|
+
` test: ${hc.test}`,
|
|
163
|
+
` interval: ${hc.interval}`,
|
|
164
|
+
` timeout: ${hc.timeout}`,
|
|
165
|
+
` retries: ${hc.retries}`,
|
|
166
|
+
].join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Rewrite env vars for a service ───────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
function buildEnvLines(envVars, detectedInfra) {
|
|
172
|
+
const rewrites = {};
|
|
173
|
+
for (const type of detectedInfra) {
|
|
174
|
+
Object.assign(rewrites, INFRA_REWRITES[type]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const lines = [];
|
|
178
|
+
lines.push('NODE_ENV=production');
|
|
179
|
+
|
|
180
|
+
for (const { key, value } of envVars) {
|
|
181
|
+
if (key === 'NODE_ENV') continue;
|
|
182
|
+
if (rewrites[key]) {
|
|
183
|
+
lines.push(`${key}=${rewrites[key]}`);
|
|
184
|
+
} else {
|
|
185
|
+
lines.push(`${key}=${value || ''}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Service name helpers ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function serviceNameFromDir(serviceDir) {
|
|
195
|
+
if (!serviceDir || serviceDir === '.') return 'app';
|
|
196
|
+
return serviceDir.replace(/\//g, '-').replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Main entry ────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {object} analysisResult — same shape returned by analyseProject
|
|
203
|
+
* @returns {{ compose: string, improvements: string[] }}
|
|
204
|
+
*/
|
|
205
|
+
function generateCompose({ services, envVars = [], sharedDirs = [] }) {
|
|
206
|
+
const improvements = [];
|
|
207
|
+
const detectedInfra = detectInfra(envVars);
|
|
208
|
+
|
|
209
|
+
const appServiceBlocks = [];
|
|
210
|
+
const infraDependencies = [...detectedInfra].map(t => INFRA_SERVICES[t].name);
|
|
211
|
+
|
|
212
|
+
for (const svc of services) {
|
|
213
|
+
const name = serviceNameFromDir(svc.serviceDir);
|
|
214
|
+
|
|
215
|
+
const envLines = buildEnvLines(envVars, detectedInfra);
|
|
216
|
+
|
|
217
|
+
const lines = [
|
|
218
|
+
`${name}:`,
|
|
219
|
+
` build:`,
|
|
220
|
+
` context: .`,
|
|
221
|
+
` dockerfile: Dockerfile`,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
lines.push(` ports:`);
|
|
225
|
+
lines.push(` - "${svc.port}:${svc.port}"`);
|
|
226
|
+
|
|
227
|
+
if (envLines.length > 0) {
|
|
228
|
+
lines.push(` environment:`);
|
|
229
|
+
for (const l of envLines) {
|
|
230
|
+
lines.push(` - ${l}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (infraDependencies.length > 0) {
|
|
235
|
+
lines.push(` depends_on:`);
|
|
236
|
+
for (const dep of infraDependencies) {
|
|
237
|
+
lines.push(` ${dep}:`);
|
|
238
|
+
lines.push(` condition: service_healthy`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
lines.push(` networks:`);
|
|
243
|
+
lines.push(` - app-network`);
|
|
244
|
+
lines.push(` restart: unless-stopped`);
|
|
245
|
+
|
|
246
|
+
appServiceBlocks.push(lines.join('\n'));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const infraServiceBlocks = [];
|
|
250
|
+
const volumeNames = [];
|
|
251
|
+
|
|
252
|
+
for (const type of detectedInfra) {
|
|
253
|
+
const cfg = INFRA_SERVICES[type];
|
|
254
|
+
const lines = [
|
|
255
|
+
`${cfg.name}:`,
|
|
256
|
+
` image: ${cfg.image}`,
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
if (cfg.ports.length > 0) {
|
|
260
|
+
lines.push(` ports:`);
|
|
261
|
+
for (const p of cfg.ports) {
|
|
262
|
+
lines.push(` - "${p}"`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (cfg.environment.length > 0) {
|
|
267
|
+
lines.push(` environment:`);
|
|
268
|
+
for (const e of cfg.environment) {
|
|
269
|
+
lines.push(` - ${e}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
lines.push(` volumes:`);
|
|
274
|
+
lines.push(` - ${cfg.volume}`);
|
|
275
|
+
lines.push(indent(healthcheckBlock(cfg.healthcheck), 2));
|
|
276
|
+
lines.push(` networks:`);
|
|
277
|
+
lines.push(` - app-network`);
|
|
278
|
+
lines.push(` restart: unless-stopped`);
|
|
279
|
+
|
|
280
|
+
infraServiceBlocks.push(lines.join('\n'));
|
|
281
|
+
volumeNames.push(cfg.volumeName);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const allBlocks = [...appServiceBlocks, ...infraServiceBlocks];
|
|
285
|
+
|
|
286
|
+
let yaml = `# Generated by Dockerforge — https://containerise.dev\n`;
|
|
287
|
+
yaml += `# Run: docker compose up --build\n\n`;
|
|
288
|
+
yaml += `services:\n`;
|
|
289
|
+
for (const block of allBlocks) {
|
|
290
|
+
yaml += indent(block, 2) + '\n\n';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
yaml += `networks:\n`;
|
|
294
|
+
yaml += ` app-network:\n`;
|
|
295
|
+
yaml += ` driver: bridge\n`;
|
|
296
|
+
|
|
297
|
+
if (volumeNames.length > 0) {
|
|
298
|
+
yaml += `\nvolumes:\n`;
|
|
299
|
+
for (const v of volumeNames) {
|
|
300
|
+
yaml += ` ${v}:\n`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (detectedInfra.size > 0) {
|
|
305
|
+
improvements.push(`Detected infra from env vars: ${[...detectedInfra].join(', ')} — added as Compose services with healthchecks`);
|
|
306
|
+
}
|
|
307
|
+
if (envVars.some(v => !v.hasDefault)) {
|
|
308
|
+
improvements.push('Some env vars have no default value — fill them in docker-compose.yml before running');
|
|
309
|
+
}
|
|
310
|
+
improvements.push('For production, replace build: with image: tags pointing at your registry');
|
|
311
|
+
if (services.some(s => s.role === 'frontend')) {
|
|
312
|
+
improvements.push('Frontend service: consider adding a reverse proxy (nginx) to route traffic in production');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { compose: yaml.trim(), improvements };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { generateCompose, detectInfra };
|