@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.
@@ -0,0 +1,1355 @@
1
+ // backend/src/modules/generation/generator.js
2
+ // Accepts { services, sharedDirs } from the analyser.
3
+ // Produces one Dockerfile (multi-stage if needed) + .dockerignore.
4
+
5
+ const path = require('path');
6
+ const { STACKS, BASE_IMAGES } = require('../../../../shared/constants');
7
+ const { isSecretLikeEnvKey, isSecretLikeEnvValue } = require('../security/security');
8
+
9
+ const STATIC_RUNTIME_IMAGE = 'nginx:1.27-alpine';
10
+ const PNPM_VERSION = '9';
11
+
12
+ function installPnpmGlobalCmd() {
13
+ return `npm install -g pnpm@${PNPM_VERSION}`;
14
+ }
15
+
16
+ function dockerCmdArray(cmd, fallback = []) {
17
+ if (Array.isArray(cmd)) return cmd;
18
+ if (typeof cmd === 'string' && cmd.trim()) return cmd.trim().split(/\s+/);
19
+ return fallback;
20
+ }
21
+
22
+ function runtimeStartCmd(a) {
23
+ const cmd = dockerCmdArray(a.startCmd, ['node', a.entryPoint]);
24
+ if (!a.hasBuild || !Array.isArray(cmd) || cmd[0] !== 'node' || !cmd[1]) return cmd;
25
+
26
+ const buildOut = a.buildOutputDir || 'dist';
27
+ const entry = cmd[1].replace(/\\/g, '/');
28
+ if (entry.startsWith(`${buildOut}/`) || entry.startsWith('./' + buildOut + '/')) {
29
+ return cmd;
30
+ }
31
+
32
+ return ['node', `${buildOut}/${entry.replace(/^\.\//, '')}`];
33
+ }
34
+
35
+ function nginxStaticServerBlock(port) {
36
+ return 'COPY nginx.conf /etc/nginx/conf.d/default.conf';
37
+ }
38
+
39
+ function nginxConf(port) {
40
+ return `server {
41
+ listen ${port};
42
+ root /usr/share/nginx/html;
43
+ index index.html;
44
+
45
+ add_header X-Frame-Options "SAMEORIGIN";
46
+ add_header X-Content-Type-Options "nosniff";
47
+ add_header Referrer-Policy "strict-origin-when-cross-origin";
48
+
49
+ location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
50
+ expires 1y;
51
+ add_header Cache-Control "public, immutable";
52
+ }
53
+
54
+ location /health { return 200 "ok"; add_header Content-Type text/plain; }
55
+ location / { try_files $uri $uri/ /index.html; }
56
+
57
+ gzip on;
58
+ gzip_types text/plain text/css application/json application/javascript
59
+ text/xml application/xml image/svg+xml font/woff2 application/manifest+json;
60
+ }`.trim();
61
+ }
62
+
63
+ function nginxNonRootPrepBlock() {
64
+ return [
65
+ 'RUN mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp /var/cache/nginx/scgi_temp',
66
+ 'RUN touch /var/run/nginx.pid && chown -R nginx:nginx /var/cache/nginx /var/run/nginx.pid /var/log/nginx /etc/nginx/conf.d /usr/share/nginx/html',
67
+ ].join('\n');
68
+ }
69
+
70
+ function simpleHttpHealthcheck(port) {
71
+ return `HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD wget -qO- http://localhost:${port}/health || exit 1`;
72
+ }
73
+
74
+ function addDockerfileHeader(dockerfile) {
75
+ const text = normalizeDockerfileText(dockerfile).trim();
76
+ if (text.startsWith('# syntax=docker/dockerfile:1')) return text;
77
+ // Prepend the BuildKit syntax directive — required for --mount=type=cache to work.
78
+ return ['# syntax=docker/dockerfile:1', text].join('\n');
79
+ }
80
+
81
+ /**
82
+ * Power-mode header: adds CI/CD stamping args and OCI image labels.
83
+ * Injected into powerDockerfile only — not the default output.
84
+ */
85
+ function addPowerDockerfileHeader(dockerfile) {
86
+ const text = normalizeDockerfileText(dockerfile).trim();
87
+ if (text.startsWith('# syntax=docker/dockerfile:1')) {
88
+ // Already has a simple header; replace it with the full power header.
89
+ const withoutSyntax = text.replace(/^# syntax=docker\/dockerfile:1\n/, '');
90
+ return buildPowerHeader(withoutSyntax);
91
+ }
92
+ return buildPowerHeader(text);
93
+ }
94
+
95
+ function buildPowerHeader(text) {
96
+ const lines = text.split('\n');
97
+ const isMultiPlatform = text.includes('--platform=$BUILDPLATFORM');
98
+
99
+ const header = [
100
+ '# syntax=docker/dockerfile:1',
101
+ '# Enables layer caching when using a registry cache (--cache-from in CI).',
102
+ 'ARG BUILDKIT_INLINE_CACHE=1',
103
+ '# Pass --build-arg GIT_SHA=$(git rev-parse HEAD) in CI to stamp the image.',
104
+ 'ARG GIT_SHA=unknown',
105
+ '# Pass --build-arg VERSION=$TAG in CI to record the release version.',
106
+ 'ARG VERSION=unknown',
107
+ ];
108
+ if (isMultiPlatform) header.splice(1, 0, 'ARG TARGETPLATFORM', 'ARG BUILDPLATFORM');
109
+
110
+ const firstFromIndex = lines.findIndex(line => line.trim().startsWith('FROM '));
111
+ if (firstFromIndex === -1) return [...header, text].join('\n');
112
+
113
+ const lastFromIndex = lines.reduce((last, line, index) => (
114
+ line.trim().startsWith('FROM ') ? index : last
115
+ ), firstFromIndex);
116
+
117
+ // Re-declare ARGs after the final FROM so they're in scope for LABEL.
118
+ // ARGs before FROM are only visible to FROM itself in Docker's build model.
119
+ const stageMetadata = [
120
+ 'ARG GIT_SHA',
121
+ 'ARG VERSION',
122
+ '# Image stamped at build time by CI via --build-arg.',
123
+ 'LABEL org.opencontainers.image.revision="${GIT_SHA}" \\',
124
+ ' org.opencontainers.image.version="${VERSION}"',
125
+ ];
126
+ if (isMultiPlatform) stageMetadata.unshift('ARG TARGETPLATFORM', 'ARG BUILDPLATFORM');
127
+
128
+ return [
129
+ ...header,
130
+ ...lines.slice(0, lastFromIndex + 1),
131
+ ...stageMetadata,
132
+ ...lines.slice(lastFromIndex + 1),
133
+ ].join('\n');
134
+ }
135
+
136
+ function builderFrom(image, stageName) {
137
+ return `FROM --platform=$BUILDPLATFORM ${image} AS ${stageName}`;
138
+ }
139
+
140
+ function dotnetRuntimeRid(runtimeImage) {
141
+ return runtimeImage.includes('alpine') ? 'linux-musl-x64' : 'linux-x64';
142
+ }
143
+
144
+ function dotnetPublishLine(projectPath, runtimeImage) {
145
+ return `RUN dotnet publish "${projectPath}" -c Release -o /app/publish --no-restore --runtime ${dotnetRuntimeRid(runtimeImage)} --self-contained false`;
146
+ }
147
+
148
+ function nodeInstallLine(packageManager, prodOnly = false, options = {}) {
149
+ const prefix = options.cwd ? `cd ${options.cwd} && ` : '';
150
+ const omitDev = prodOnly;
151
+ const secretMount = options.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
152
+ if (packageManager === 'pnpm') {
153
+ const filter = options.filter ? ` --filter ${options.filter}` : '';
154
+ return `RUN --mount=type=cache,target=/root/.local/share/pnpm/store ${secretMount}${prefix}${installPnpmGlobalCmd()} && ${prefix}pnpm install --frozen-lockfile${omitDev ? ' --prod' : ''}${filter}`;
155
+ }
156
+ if (packageManager === 'yarn') {
157
+ return `RUN --mount=type=cache,target=/usr/local/share/.cache/yarn ${secretMount}${prefix}yarn install --frozen-lockfile${omitDev ? ' --production' : ''}`;
158
+ }
159
+ return `RUN --mount=type=cache,target=/root/.npm ${secretMount}${prefix}npm ci${omitDev ? ' --omit=dev' : ''}`;
160
+ }
161
+
162
+ function nodePruneLine(packageManager, cwd = '') {
163
+ const prefix = cwd ? `cd ${cwd} && ` : '';
164
+ if (packageManager === 'pnpm') return `RUN ${prefix}pnpm prune --prod`;
165
+ if (packageManager === 'yarn') return `RUN ${prefix}yarn install --frozen-lockfile --production --ignore-scripts && yarn cache clean`;
166
+ return `RUN ${prefix}npm prune --omit=dev`;
167
+ }
168
+
169
+ function nodeSourceCopyBlock(a, configCopyLine = '', frontendAssetCopyLine = '') {
170
+ const blocks = [];
171
+ if (configCopyLine) blocks.push(configCopyLine.trimEnd());
172
+ if (a.framework === 'nextjs') {
173
+ blocks.push('COPY app/ ./app/');
174
+ blocks.push('COPY pages/ ./pages/');
175
+ blocks.push('COPY components/ ./components/');
176
+ blocks.push('COPY public/ ./public/');
177
+ } else if (a.framework === 'remix') {
178
+ blocks.push('COPY app/ ./app/');
179
+ blocks.push('COPY public/ ./public/');
180
+ } else if (['astro', 'vite', 'cra', 'sveltekit'].includes(a.framework)) {
181
+ if (frontendAssetCopyLine) blocks.push(frontendAssetCopyLine.trimEnd());
182
+ blocks.push('COPY src/ ./src/');
183
+ } else if (a.framework === 'nestjs') {
184
+ blocks.push('COPY src/ ./src/');
185
+ } else {
186
+ blocks.push('# Update these COPY lines if your source is not under src/.');
187
+ blocks.push('COPY src/ ./src/');
188
+ }
189
+ return blocks.join('\n');
190
+ }
191
+
192
+ function pythonBuilderImage(a) {
193
+ return a.hasNativeDeps ? `python:${a.version}` : BASE_IMAGES[STACKS.PYTHON](a.version);
194
+ }
195
+
196
+ function nodeSecretMount(a) {
197
+ return a.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
198
+ }
199
+
200
+ function nodeStopSignalBlock() {
201
+ return 'STOPSIGNAL SIGTERM';
202
+ }
203
+
204
+ function runtimeUserBlock(alpine = true) {
205
+ return alpine
206
+ ? 'RUN addgroup -S appgroup && adduser -S appuser -G appgroup && chown -R appuser:appgroup /app\nUSER appuser'
207
+ : 'RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser && chown -R appuser:appgroup /app\nUSER appuser';
208
+ }
209
+
210
+ function pythonInstallBlock(a, prefix = '') {
211
+ const hashFlag = a.requirementsHasHashes ? ' --require-hashes' : '';
212
+ const installCmd = a.packageManager === 'pip'
213
+ ? `pip install --no-cache-dir${hashFlag} -r ${a.requirementsFile}`
214
+ : a.installCmd;
215
+ const lines = [`RUN --mount=type=cache,target=/root/.cache/pip ${prefix}${installCmd}`];
216
+ if (a.needsGunicornInstall) {
217
+ lines.push(`RUN ${prefix}pip install --no-cache-dir gunicorn`);
218
+ }
219
+ return lines.join('\n');
220
+ }
221
+
222
+ function pythonWheelInstallBlock(a, prefix = '') {
223
+ const lines = [`RUN ${prefix}pip install --no-cache-dir --no-index --find-links=/wheels -r ${a.requirementsFile}`];
224
+ if (a.needsGunicornInstall) {
225
+ lines.push(`RUN ${prefix}pip install --no-cache-dir --no-index --find-links=/wheels gunicorn`);
226
+ }
227
+ return lines.join('\n');
228
+ }
229
+
230
+ function pythonWheelBuildBlock(a, prefix = '') {
231
+ const lines = [`RUN ${prefix}pip wheel --no-cache-dir --wheel-dir /wheels -r ${a.requirementsFile}`];
232
+ if (a.needsGunicornInstall) {
233
+ lines.push(`RUN ${prefix}pip wheel --no-cache-dir --wheel-dir /wheels gunicorn`);
234
+ }
235
+ return lines.join('\n');
236
+ }
237
+
238
+ // ── Main Entry ───────────────────────────────────────────────────────────────
239
+
240
+ function generateDockerfile({ services, sharedDirs = [], staticAssets = [], rootConfigFiles = [], envVars = [], dockerignoreBlockedDirs = [], workspacePackageDirs = [], workspaceSharedConfigs = [] }) {
241
+ // Single service at project root — classic path
242
+ if (services.length === 1 && services[0].serviceDir === '.') {
243
+ return finalizeResult(generateSingleRoot(services[0], envVars, rootConfigFiles));
244
+ }
245
+
246
+ // Single service in a subdirectory
247
+ if (services.length === 1) {
248
+ return finalizeResult(generateSingleSub(services[0], sharedDirs, staticAssets, rootConfigFiles, envVars, workspacePackageDirs, workspaceSharedConfigs));
249
+ }
250
+
251
+ // Multiple services — build a multi-stage Dockerfile
252
+ return finalizeResult(generateMultiService(services, sharedDirs, rootConfigFiles, envVars, dockerignoreBlockedDirs, workspacePackageDirs, workspaceSharedConfigs));
253
+ }
254
+
255
+ // Builds a safe runtime ENV block from detected non-secret defaults.
256
+ function buildEnvBlock(envVars) {
257
+ const safeDefaults = (envVars || []).filter(v =>
258
+ v.hasDefault &&
259
+ !isSecretLikeEnvKey(v.key) &&
260
+ !isSecretLikeEnvValue(v.value)
261
+ );
262
+ if (safeDefaults.length === 0) return 'ENV NODE_ENV=production';
263
+ const envPairs = [
264
+ 'NODE_ENV=production',
265
+ ...safeDefaults
266
+ .filter(v => v.key !== 'NODE_ENV')
267
+ .map(v => ` ${v.key}=${v.value}`),
268
+ ];
269
+ const envLine = envPairs.length === 1
270
+ ? `ENV NODE_ENV=production`
271
+ : `ENV ${envPairs.join(' \\\n')}`;
272
+ return envLine;
273
+ }
274
+
275
+ function buildCommandPrefix(service) {
276
+ // Fire for CRA regardless of how detection landed — framework='cra', role='frontend',
277
+ // or any build command that invokes react-scripts directly.
278
+ const isReactScripts =
279
+ service.framework === 'cra' ||
280
+ service.role === 'frontend' ||
281
+ (typeof service.buildCommand === 'string' && service.buildCommand.includes('react-scripts'));
282
+ if (!isReactScripts) return '';
283
+ return 'CI=false GENERATE_SOURCEMAP=false NODE_OPTIONS=--max-old-space-size=1024 DISABLE_ESLINT_PLUGIN=true ';
284
+ }
285
+
286
+ function countLineDiff(a = '', b = '') {
287
+ const left = String(a).split(/\r?\n/);
288
+ const right = String(b).split(/\r?\n/);
289
+ const max = Math.max(left.length, right.length);
290
+ let lineDiffCount = 0;
291
+
292
+ for (let i = 0; i < max; i += 1) {
293
+ if ((left[i] || '') !== (right[i] || '')) lineDiffCount += 1;
294
+ }
295
+
296
+ return {
297
+ lineDiffCount,
298
+ score: max === 0 ? 0 : Number((lineDiffCount / max).toFixed(2)),
299
+ };
300
+ }
301
+
302
+ function normalizeDockerfileText(text) {
303
+ if (!text) return text;
304
+ return String(text)
305
+ .replace(/─/g, '-')
306
+ .replace(/[\u2500-\u257F]/g, '-')
307
+ .replace(/[–—]/g, '-')
308
+ .replace(/⚠️/g, 'WARNING:');
309
+ }
310
+
311
+ function finalizeResult(result) {
312
+ return {
313
+ ...result,
314
+ dockerfile: addDockerfileHeader(result.dockerfile),
315
+ validationDockerfile: result.validationDockerfile
316
+ ? addDockerfileHeader(result.validationDockerfile)
317
+ : result.validationDockerfile,
318
+ };
319
+ }
320
+
321
+ function withValidationVariant(result, validationDockerfile) {
322
+ if (!validationDockerfile || validationDockerfile === result.dockerfile) {
323
+ return {
324
+ ...result,
325
+ validationDockerfile: null,
326
+ validationDrift: null,
327
+ };
328
+ }
329
+
330
+ return {
331
+ ...result,
332
+ validationDockerfile: normalizeDockerfileText(validationDockerfile),
333
+ validationDrift: countLineDiff(
334
+ normalizeDockerfileText(result.dockerfile),
335
+ normalizeDockerfileText(validationDockerfile)
336
+ ),
337
+ };
338
+ }
339
+
340
+ // ── Single service: lives at project root ────────────────────────────────────
341
+ // Existing behaviour — nothing changes for projects where package.json is at /
342
+
343
+ function generateSingleRoot(a, envVars = [], rootConfigFiles = []) {
344
+ if (a.stack === STACKS.NODE) return generateNodeRoot(a, envVars, rootConfigFiles);
345
+ if (a.stack === STACKS.PYTHON) return generatePythonRoot(a, rootConfigFiles);
346
+ if (a.stack === STACKS.DOTNET) return generateDotnetRoot(a);
347
+ throw new Error(`No template for stack: ${a.stack}`);
348
+ }
349
+
350
+ // ── Single service: lives in a subdirectory ──────────────────────────────────
351
+
352
+ function generateSingleSub(a, sharedDirs, staticAssets, rootConfigFiles = [], envVars = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
353
+ const dir = a.serviceDir;
354
+
355
+ if (a.stack === STACKS.NODE) return generateNodeSub(a, dir, sharedDirs, staticAssets, rootConfigFiles, envVars, workspacePackageDirs, workspaceSharedConfigs);
356
+ if (a.stack === STACKS.PYTHON) return generatePythonSub(a, dir, sharedDirs, staticAssets);
357
+ if (a.stack === STACKS.DOTNET) return generateDotnetSub(a, dir, sharedDirs);
358
+ throw new Error(`No template for stack: ${a.stack}`);
359
+ }
360
+
361
+ // ── Multi-service ────────────────────────────────────────────────────────────
362
+
363
+ function generateMultiService(services, sharedDirs, rootConfigFiles = [], envVars = [], dockerignoreBlockedDirs = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
364
+ const improvements = [];
365
+
366
+ if (dockerignoreBlockedDirs.length > 0) {
367
+ improvements.push(
368
+ `⚠️ Your .dockerignore uses a whitelist that blocks: ${dockerignoreBlockedDirs.map(d => d + '/').join(', ')}. ` +
369
+ `The generated .dockerignore tab fixes this — you MUST apply it alongside the Dockerfile or COPY commands will fail.`
370
+ );
371
+ }
372
+
373
+ // Split into frontends and backends/services
374
+ const frontends = services.filter(s => s.role === 'frontend');
375
+ const backends = services.filter(s => s.role !== 'frontend');
376
+
377
+ if (frontends.length > 0 && backends.length === 0) {
378
+ throw new Error('Multiple frontend services detected but no backend/runtime service was found. Generate each frontend separately or add a backend service to serve the built assets.');
379
+ }
380
+
381
+ const stages = [];
382
+
383
+ // ── Frontend builder stages ──
384
+ for (const fe of frontends) {
385
+ if (fe.stack !== STACKS.NODE) continue;
386
+ const baseImage = BASE_IMAGES[STACKS.NODE](fe.version);
387
+ const stageName = `${fe.serviceDir.replace(/\//g, '-')}-build`;
388
+
389
+ let stageContent;
390
+ if (fe.lockFileAtRoot) {
391
+ const buildPrefix = buildCommandPrefix(fe);
392
+ const buildCmd = fe.packageManager === 'yarn'
393
+ // Explicitly prepend /build/node_modules/.bin to PATH before build.
394
+ // Hoisted devDeps (cross-env, vite, etc.) land in root node_modules/.bin,
395
+ // but yarn 1.x script runner does not reliably add them via --cwd or nested
396
+ // yarn calls. export PATH is inherited by all child processes.
397
+ ? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${fe.serviceDir} && ${buildPrefix}yarn build`
398
+ : fe.packageManager === 'pnpm'
399
+ ? `RUN ${buildPrefix}pnpm --filter ./${fe.serviceDir} build`
400
+ : `RUN ${buildPrefix}npm run build --workspace=${fe.serviceDir}`;
401
+
402
+ if (fe.packageManager === 'yarn') {
403
+ // Copy ALL workspace member package.json files before install.
404
+ // Yarn hoists devDeps from ALL members to root node_modules/.bin.
405
+ // Missing members = their devDeps (cross-env, vite, etc.) not installed.
406
+ const allServicePkgCopies = services
407
+ .filter(s => s.lockFileAtRoot && s.serviceDir !== fe.serviceDir)
408
+ .map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/package.json`)
409
+ .join('\n');
410
+ const extraPkgCopies = workspacePackageDirs
411
+ .filter(d => d !== fe.serviceDir && !services.some(s => s.serviceDir === d))
412
+ .map(d => `COPY ${d}/package.json ./${d}/package.json`)
413
+ .join('\n');
414
+ const allExtraCopies = [allServicePkgCopies, extraPkgCopies].filter(Boolean).join('\n');
415
+ stageContent = `
416
+ # ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
417
+ ${builderFrom(baseImage, stageName)}
418
+
419
+ WORKDIR /build
420
+
421
+ COPY ${fe.lockFile} package.json ./
422
+ COPY ${fe.serviceDir}/package.json ./${fe.serviceDir}/package.json${allExtraCopies ? '\n' + allExtraCopies : ''}
423
+ RUN yarn install --frozen-lockfile
424
+
425
+ # Copy only this service's source — other workspace dirs not needed for this build
426
+ COPY ${fe.serviceDir}/ ./${fe.serviceDir}/
427
+ ${buildCmd}`.trim();
428
+ } else {
429
+ // pnpm/npm: workspace root install with all workspace package.json files
430
+ // COPY . . is banned — leaks .env and secrets into build context.
431
+ const installCmd = fe.packageManager === 'pnpm'
432
+ ? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`
433
+ : `RUN npm ci`;
434
+ const workspacePkgCopies = services
435
+ .filter(s => s.lockFileAtRoot)
436
+ .map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/package.json`)
437
+ .join('\n');
438
+ stageContent = `
439
+ # ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
440
+ ${builderFrom(baseImage, stageName)}
441
+
442
+ WORKDIR /build
443
+
444
+ # Copy manifests first for better Docker layer caching
445
+ COPY ${fe.lockFile} package.json ./
446
+ ${workspacePkgCopies}
447
+ ${installCmd}
448
+
449
+ # Copy only this service's source — other workspace dirs not needed for this build
450
+ COPY ${fe.serviceDir}/ ./${fe.serviceDir}/
451
+ ${buildCmd}`.trim();
452
+ }
453
+ } else {
454
+ // Lock file lives inside the service directory — standalone package
455
+ const installCmd = fe.packageManager === 'pnpm'
456
+ ? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`
457
+ : fe.packageManager === 'yarn'
458
+ ? `RUN yarn install --frozen-lockfile`
459
+ : `RUN npm ci`;
460
+
461
+ stageContent = `
462
+ # ─── Stage: Build ${fe.serviceDir} ───────────────────────────────────────
463
+ ${builderFrom(baseImage, stageName)}
464
+
465
+ WORKDIR /build
466
+
467
+ COPY ${fe.serviceDir}/${fe.lockFile} ${fe.serviceDir}/package.json ./
468
+ ${installCmd}
469
+
470
+ COPY ${fe.serviceDir}/ .
471
+ RUN ${fe.buildCommand || 'npm run build'}`.trim();
472
+ }
473
+
474
+ stages.push(stageContent);
475
+ improvements.push(`Frontend (${fe.serviceDir}): built in isolated stage — build tools never reach production image`);
476
+ }
477
+
478
+ // ── Backend / service runtime stages ──
479
+ for (const be of backends) {
480
+ const dir = be.serviceDir;
481
+ const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
482
+
483
+ // Frontend artifact copies — use correct build output path per frontend
484
+ const frontendCopies = frontends.map(fe => {
485
+ const stageName = `${fe.serviceDir.replace(/\//g, '-')}-build`;
486
+ const buildOutputDir = fe.buildOutputDir || 'dist';
487
+ const buildOutputPath = fe.lockFileAtRoot
488
+ ? (fe.serviceDir === '.' ? `/build/${buildOutputDir}` : `/build/${fe.serviceDir}/${buildOutputDir}`)
489
+ : `/build/${buildOutputDir}`;
490
+ return `# Copy built ${fe.serviceDir} assets into server static dir\nCOPY --from=${stageName} ${buildOutputPath} ./${dir}/public`;
491
+ }).join('\n');
492
+
493
+ if (be.stack === STACKS.NODE) {
494
+ const baseImage = BASE_IMAGES[STACKS.NODE](be.version);
495
+ const beStageName = `${dir.replace(/\//g, '-')}-build`;
496
+
497
+ // ── If backend has its own build step, add a dedicated build stage ──
498
+ if (be.hasBuild) {
499
+ const rootConfigCopy = rootConfigFiles.length > 0
500
+ ? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./`
501
+ : '';
502
+
503
+ let beBuildDepsSection, beBuildInstallSection, beBuildCmd;
504
+ if (be.lockFileAtRoot) {
505
+ const allPkgJsonCopies = services
506
+ .filter(s => s.lockFileAtRoot)
507
+ .map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/`)
508
+ .join('\n');
509
+ beBuildDepsSection = `COPY ${be.lockFile} package.json ./\n${allPkgJsonCopies}`;
510
+ beBuildInstallSection = be.packageManager === 'pnpm'
511
+ ? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile && pnpm store prune`
512
+ : be.packageManager === 'yarn'
513
+ ? `RUN yarn install --frozen-lockfile && yarn cache clean`
514
+ : `RUN npm ci && npm cache clean --force`;
515
+ beBuildCmd = be.packageManager === 'yarn'
516
+ ? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && yarn build`
517
+ : be.packageManager === 'pnpm'
518
+ ? `RUN pnpm --filter ./${dir} build`
519
+ : `RUN npm run build --workspace=${dir}`;
520
+ } else {
521
+ beBuildDepsSection = `COPY ${dir}/${be.lockFile} ${dir}/package.json ./${dir}/`;
522
+ beBuildInstallSection = be.packageManager === 'pnpm'
523
+ ? `RUN ${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile && pnpm store prune`
524
+ : be.packageManager === 'yarn'
525
+ ? `RUN cd ${dir} && yarn install --frozen-lockfile && yarn cache clean`
526
+ : `RUN cd ${dir} && npm ci && npm cache clean --force`;
527
+ beBuildCmd = `RUN cd ${dir} && ${be.buildCommand}`;
528
+ }
529
+
530
+ stages.push(`
531
+ # ─── Stage: Build ${dir} ─────────────────────────────────────────────────
532
+ ${builderFrom(baseImage, beStageName)}
533
+
534
+ WORKDIR /build
535
+
536
+ ${beBuildDepsSection}
537
+ ${beBuildInstallSection}
538
+ ${rootConfigCopy ? '\n' + rootConfigCopy : ''}
539
+ COPY ${dir}/ ./${dir}/
540
+ ${beBuildCmd}`.trim());
541
+
542
+ improvements.push(`Backend (${dir}): compiled in isolated build stage — source and dev tools never reach runtime image`);
543
+ }
544
+
545
+ // ── Runtime stage ──
546
+ let copyDepsSection, installSection;
547
+ if (be.lockFileAtRoot) {
548
+ const allPkgJsonCopies = services
549
+ .filter(s => s.lockFileAtRoot)
550
+ .map(s => `COPY ${s.serviceDir}/package.json ./${s.serviceDir}/`)
551
+ .join('\n');
552
+ let installCmd;
553
+ if (be.packageManager === 'pnpm') {
554
+ const filter = be.isWorkspace ? ` --filter ${dir}` : '';
555
+ installCmd = `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod${filter} && pnpm store prune`;
556
+ } else if (be.packageManager === 'yarn') {
557
+ installCmd = be.isWorkspace
558
+ ? `RUN yarn --cwd ./${dir} install --frozen-lockfile --production && yarn cache clean`
559
+ : `RUN yarn install --frozen-lockfile --production && yarn cache clean`;
560
+ } else {
561
+ installCmd = `RUN npm ci --omit=dev && npm cache clean --force`;
562
+ }
563
+ copyDepsSection = `COPY ${be.lockFile} package.json ./\n${allPkgJsonCopies}`;
564
+ installSection = installCmd;
565
+ } else {
566
+ const installCmd = be.packageManager === 'pnpm'
567
+ ? `RUN ${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile --prod && pnpm store prune`
568
+ : be.packageManager === 'yarn'
569
+ ? `RUN cd ${dir} && yarn install --frozen-lockfile --production && yarn cache clean`
570
+ : `RUN cd ${dir} && npm ci --omit=dev && npm cache clean --force`;
571
+ copyDepsSection = `COPY ${dir}/${be.lockFile} ${dir}/package.json ./${dir}/`;
572
+ installSection = installCmd;
573
+ }
574
+
575
+ // Runtime copies either compiled dist OR raw source (when no build step)
576
+ const sourceSection = be.hasBuild
577
+ ? `# Copy compiled output from build stage\nCOPY --from=${beStageName} /build/${dir}/${be.buildOutputDir || 'dist'} ./${dir}/${be.buildOutputDir || 'dist'}`
578
+ : `COPY ${dir}/ ./${dir}/`;
579
+
580
+ const startCmdJson = JSON.stringify(runtimeStartCmd(be));
581
+
582
+ stages.push(`
583
+ # ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
584
+ FROM ${baseImage}
585
+
586
+ WORKDIR /app
587
+
588
+ ${buildEnvBlock(envVars)}
589
+
590
+ ${copyDepsSection}
591
+ ${installSection}
592
+
593
+ ${sourceSection}
594
+ ${sharedCopies ? sharedCopies + '\n' : ''}${frontendCopies ? frontendCopies + '\n' : ''}
595
+ RUN addgroup -S appgroup && adduser -S appuser -G appgroup
596
+ USER appuser
597
+
598
+ ${nodeStopSignalBlock()}
599
+ EXPOSE ${be.port}
600
+ ${simpleHttpHealthcheck(be.port)}
601
+ WORKDIR /app/${dir}
602
+ CMD ${startCmdJson}`.trim());
603
+
604
+ } else if (be.stack === STACKS.PYTHON) {
605
+ const baseImage = BASE_IMAGES[STACKS.PYTHON](be.version);
606
+ stages.push(`
607
+ # ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
608
+ FROM ${baseImage}
609
+
610
+ ENV PYTHONDONTWRITEBYTECODE=1
611
+ ENV PYTHONUNBUFFERED=1
612
+
613
+ WORKDIR /app
614
+
615
+ COPY ${dir}/${be.requirementsFile} ./${dir}/
616
+ ${pythonInstallBlock(be, `cd ${dir} && `)}
617
+
618
+ COPY ${dir}/ ./${dir}/
619
+ ${sharedCopies ? sharedCopies : ''}
620
+
621
+ RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
622
+ USER appuser
623
+
624
+ EXPOSE ${be.port}
625
+ WORKDIR /app/${dir}
626
+ CMD ${JSON.stringify(dockerCmdArray(be.startCmd, ['python', be.entryPoint]))}`.trim());
627
+
628
+ } else if (be.stack === STACKS.DOTNET) {
629
+ const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(be.version);
630
+ const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(be.version);
631
+ const stageName = `${dir.replace(/\//g, '-')}-dotnet-build`;
632
+ stages.push(`
633
+ # ─── Stage: Build ${dir} (.NET) ──────────────────────────────────────────
634
+ ${builderFrom(sdkImage, stageName)}
635
+
636
+ WORKDIR /src
637
+
638
+ COPY ${dir}/${be.csprojPath} ./${dir}/
639
+ RUN dotnet restore "${dir}/${be.csprojPath}"
640
+
641
+ COPY ${dir}/ ./${dir}/
642
+ ${dotnetPublishLine(`${dir}/${be.csprojPath}`, runtimeImage)}
643
+
644
+ # ─── Stage: Runtime ${dir} ───────────────────────────────────────────────
645
+ FROM ${runtimeImage}
646
+
647
+ WORKDIR /app
648
+
649
+ ENV DOTNET_RUNNING_IN_CONTAINER=true \\
650
+ ASPNETCORE_URLS=http://+:${be.port}
651
+
652
+ RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
653
+ USER appuser
654
+
655
+ COPY --from=${stageName} /app/publish .
656
+
657
+ EXPOSE ${be.port}
658
+ ENTRYPOINT ["dotnet", "${be.projectName}.dll"]`.trim());
659
+ }
660
+ }
661
+
662
+ const header = `# ⚠️ IMPORTANT: Replace your .dockerignore with the generated one from the .dockerignore tab.\n# Without it, COPY commands may fail if your existing .dockerignore uses a whitelist.\n`;
663
+ const dockerfile = header + stages.join('\n\n');
664
+ const dockerignore = buildDockerignore(services);
665
+
666
+ if (frontends.length > 0 && backends.length > 0) {
667
+ improvements.push('Multi-stage build: frontend compiled separately, backend runs lean runtime image');
668
+ }
669
+ improvements.push('HEALTHCHECK should probe /health for each runtime service; add a /health endpoint returning 2xx where missing');
670
+
671
+ const collectedNginxConf = frontends.length > 0 ? nginxConf(frontends[0].port) : undefined;
672
+ return { dockerfile, dockerignore, improvements, nginxConf: collectedNginxConf };
673
+ }
674
+
675
+ // ── Node at root (original behaviour) ───────────────────────────────────────
676
+
677
+ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
678
+ const baseImage = BASE_IMAGES[STACKS.NODE](a.version);
679
+ const improvements = [];
680
+ const startCmdJson = JSON.stringify(runtimeStartCmd(a));
681
+ const extraConfigs = rootConfigFiles.filter(f => !['package.json', a.lockFile].includes(f));
682
+ const configCopyLine = extraConfigs.length > 0 ? `COPY ${extraConfigs.join(' ')} ./\n` : '';
683
+ const frontendAssetCopyLine = (a.role === 'frontend' || a.framework === 'cra') ? 'COPY public/ ./public/\n' : '';
684
+
685
+ let dockerfile;
686
+
687
+ if (a.hasBuild) {
688
+ const buildInstall = nodeInstallLine(a.packageManager, false, { hasScopedPackages: a.hasScopedPackages });
689
+ const buildPrefix = buildCommandPrefix(a);
690
+ const buildOut = a.buildOutputDir || 'dist';
691
+ const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine, frontendAssetCopyLine);
692
+
693
+ // CRA / pure-frontend: runtime is a static file server, not a Node app.
694
+ // react-scripts is a devDep — it won't be present after --production install,
695
+ // and react-scripts start is a dev server anyway.
696
+ if (a.role === 'frontend' || a.framework === 'cra') {
697
+ dockerfile = `
698
+ # ─── Stage 1: Build ───────────────────────────────────────
699
+ ${builderFrom(baseImage, 'builder')}
700
+
701
+ WORKDIR /app
702
+
703
+ COPY ${a.lockFile} package.json ./
704
+ ${buildInstall}
705
+
706
+ # Copy source
707
+ ${sourceCopyBlock}
708
+ RUN ${buildPrefix}${a.buildCommand}
709
+
710
+ # ─── Stage 2: Runtime ─────────────────────────────────────
711
+ FROM ${STATIC_RUNTIME_IMAGE}
712
+
713
+ COPY --from=builder /app/${buildOut} /usr/share/nginx/html
714
+ ${nginxStaticServerBlock(a.port)}
715
+
716
+ EXPOSE ${a.port}
717
+ ${simpleHttpHealthcheck(a.port)}
718
+ CMD ["nginx", "-g", "daemon off;"]`.trim();
719
+
720
+ const validationDockerfile = dockerfile;
721
+ improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. No Node runtime or node_modules in final image.`);
722
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
723
+
724
+ } else {
725
+ const pruneCmd = nodePruneLine(a.packageManager);
726
+ dockerfile = `
727
+ # ─── Stage 1: Build ───────────────────────────────────────
728
+ ${builderFrom(baseImage, 'builder')}
729
+
730
+ WORKDIR /app
731
+
732
+ COPY ${a.lockFile} package.json ./
733
+ ${buildInstall}
734
+
735
+ # Copy source
736
+ ${sourceCopyBlock}
737
+ RUN ${buildPrefix}${a.buildCommand}
738
+ ${pruneCmd}
739
+
740
+ # ─── Stage 2: Runtime ─────────────────────────────────────
741
+ FROM ${baseImage}
742
+
743
+ WORKDIR /app
744
+
745
+ ${buildEnvBlock(envVars)}
746
+
747
+ COPY ${a.lockFile} package.json ./
748
+ COPY --from=builder /app/node_modules ./node_modules
749
+ COPY --from=builder /app/${buildOut} ./${buildOut}
750
+
751
+ ${runtimeUserBlock(true)}
752
+
753
+ ${nodeStopSignalBlock()}
754
+ EXPOSE ${a.port}
755
+ ${simpleHttpHealthcheck(a.port)}
756
+ CMD ${startCmdJson}`.trim();
757
+ }
758
+ } else {
759
+ dockerfile = `
760
+ FROM ${baseImage}
761
+
762
+ WORKDIR /app
763
+
764
+ ${buildEnvBlock(envVars)}
765
+
766
+ COPY ${a.lockFile} package.json ./
767
+ ${nodeInstallLine(a.packageManager, true, { hasScopedPackages: a.hasScopedPackages })}
768
+
769
+ # Copy source
770
+ ${nodeSourceCopyBlock(a, configCopyLine)}
771
+
772
+ ${runtimeUserBlock(true)}
773
+
774
+ ${nodeStopSignalBlock()}
775
+ EXPOSE ${a.port}
776
+ ${simpleHttpHealthcheck(a.port)}
777
+ CMD ${startCmdJson}`.trim();
778
+ }
779
+
780
+ improvements.push('HEALTHCHECK probes /health; add a /health endpoint returning 2xx if your app does not already expose one');
781
+ if (!a.hasBuild) improvements.push('If you add a build step later, use multi-stage builds to keep image small');
782
+ if (a.framework === null) {
783
+ improvements.push('WARNING: Unknown Node backend source layout - verify your source is not under src/ before using the generated COPY src/ line.');
784
+ }
785
+ if (a.hasScopedPackages) {
786
+ improvements.push('Scoped packages detected: pass private registry credentials with docker build --secret id=npmrc,src=.npmrc');
787
+ }
788
+ improvements.push('Verify your source lives in src/ — if not, update the COPY src/ line in the build stage to match your actual source directory.');
789
+
790
+ improvements.push('Add a SIGTERM handler: process.on(\'SIGTERM\', () => server.close())');
791
+
792
+ return { dockerfile, dockerignore: nodeDockerignore(), improvements };
793
+ }
794
+
795
+ // ── Node in a subdirectory ───────────────────────────────────────────────────
796
+
797
+ function generateNodeSub(a, dir, sharedDirs, staticAssets = [], rootConfigFiles = [], envVars = [], workspacePackageDirs = [], workspaceSharedConfigs = []) {
798
+ const baseImage = BASE_IMAGES[STACKS.NODE](a.version);
799
+ const improvements = [];
800
+ const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
801
+ const staticCopies = staticAssets.map(s => `COPY ${s}/ ./${s}/`).join('\n');
802
+ // Sibling dirs detected via require('../xxx') in the entry point — deduplicated against sharedDirs
803
+ const siblingOnlyCopies = (a.siblingDeps || [])
804
+ .filter(d => !sharedDirs.includes(d))
805
+ .map(d => `COPY ${d}/ ./${d}/`)
806
+ .join('\n');
807
+
808
+ let dockerfile;
809
+
810
+ if (a.hasBuild && a.role === 'frontend') {
811
+ // Frontend build → serve static via nginx
812
+ const stageName = 'build';
813
+
814
+ // When lock file is at project root, copy root files + service package.json and
815
+ // use workspace-scoped build. Build output lands at /build/<dir>/dist in that case.
816
+ let lockCopy, buildInstall, sourceCopy, buildRun, buildOutput;
817
+ if (a.lockFileAtRoot) {
818
+ // Workspace monorepo: copy root lockfile + this service's package.json,
819
+ // then copy service source explicitly.
820
+ // COPY . . is banned — leaks .env and secrets into build context.
821
+ if (a.packageManager === 'yarn') {
822
+ // Copy root lockfile + package.json + ALL workspace member package.json files.
823
+ // Yarn hoists devDeps from ALL workspace members to root node_modules/.bin.
824
+ // Missing members = their devDeps (cross-env, vite, etc.) not installed at all.
825
+ // COPY . . solved this by accident; we replicate it selectively here.
826
+ const extraPkgCopies = workspacePackageDirs
827
+ .filter(d => d !== dir)
828
+ .map(d => `COPY ${d}/package.json ./${d}/package.json`)
829
+ .join('\n');
830
+ lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/package.json${extraPkgCopies ? '\n' + extraPkgCopies : ''}`;
831
+ buildInstall = `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile`;
832
+ } else {
833
+ lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/package.json`;
834
+ buildInstall = installLine(a.packageManager, false);
835
+ }
836
+ sourceCopy = `COPY ${dir}/ ./${dir}/`;
837
+ // Copy root-level dirs imported by vite config (e.g. scripts/) — needed at build time
838
+ const rootDepCopies = (a.rootBuildDeps || []).map(d => `COPY ${d}/ ./${d}/`).join('\n');
839
+ if (rootDepCopies) sourceCopy += '\n' + rootDepCopies;
840
+ // Workspace packages are local source imports — vite resolves them at build time
841
+ const workspaceSrcCopies = workspacePackageDirs
842
+ .filter(d => d !== dir)
843
+ .map(d => `COPY ${d}/ ./${d}/`)
844
+ .join('\n');
845
+ if (workspaceSrcCopies) sourceCopy += '\n' + workspaceSrcCopies;
846
+ const buildPrefix = buildCommandPrefix(a);
847
+ buildRun = a.packageManager === 'yarn'
848
+ // Explicitly prepend /build/node_modules/.bin to PATH before build.
849
+ // Hoisted devDeps (cross-env, vite, etc.) land in root node_modules/.bin,
850
+ // but yarn 1.x script runner does not reliably add them when running via
851
+ // --cwd or from a sub-yarn call. export PATH is inherited by all child
852
+ // processes (yarn build:app → shell → cross-env) so this covers nested calls.
853
+ ? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && ${buildPrefix}yarn build`
854
+ : a.packageManager === 'pnpm'
855
+ ? `RUN ${buildPrefix}pnpm --filter ./${dir} build`
856
+ : `RUN ${buildPrefix}npm run build --workspace=${dir}`;
857
+ buildOutput = `/build/${dir}/${a.buildOutputDir || 'build'}`;
858
+ } else {
859
+ lockCopy = `COPY ${dir}/${a.lockFile} ${dir}/package.json ./`;
860
+ buildInstall = installLine(a.packageManager, false);
861
+ sourceCopy = `COPY ${dir}/ .`;
862
+ buildRun = `RUN ${buildCommandPrefix(a)}${a.buildCommand}`;
863
+ buildOutput = `/build/${a.buildOutputDir || 'build'}`;
864
+ }
865
+
866
+ const rootConfigCopy = rootConfigFiles.length > 0
867
+ ? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./\n`
868
+ : '';
869
+ const sharedConfigCopy = workspaceSharedConfigs.length > 0
870
+ ? workspaceSharedConfigs.map(f => `COPY ${f} ./${path.dirname(f)}/`).join('\n') + '\n'
871
+ : '';
872
+
873
+ dockerfile = `
874
+ # ─── Stage 1: Build ───────────────────────────────────────
875
+ ${builderFrom(baseImage, stageName)}
876
+
877
+ WORKDIR /build
878
+
879
+ ${lockCopy}
880
+ ${buildInstall}
881
+ ${rootConfigCopy}
882
+ ${sourceCopy}
883
+ ${sharedConfigCopy}${buildRun}
884
+
885
+ # ─── Stage 2: Serve ───────────────────────────────────────
886
+ FROM ${STATIC_RUNTIME_IMAGE}
887
+
888
+ COPY --from=${stageName} ${buildOutput} /usr/share/nginx/html
889
+ ${nginxStaticServerBlock(a.port)}
890
+
891
+ EXPOSE ${a.port}
892
+ ${simpleHttpHealthcheck(a.port)}
893
+ CMD ["nginx", "-g", "daemon off;"]`.trim();
894
+
895
+ const validationDockerfile = dockerfile;
896
+ improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. If a backend also serves the files, merge into one multi-service build.`);
897
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
898
+
899
+
900
+ } else {
901
+ // Backend/service in subdirectory
902
+ const startCmdJson = JSON.stringify(runtimeStartCmd(a));
903
+ const outputDir = a.buildOutputDir || 'dist';
904
+
905
+ let lockCopy, installStep;
906
+ if (a.lockFileAtRoot) {
907
+ lockCopy = `COPY ${a.lockFile} package.json ./\nCOPY ${dir}/package.json ./${dir}/`;
908
+ if (a.packageManager === 'pnpm') {
909
+ const filter = a.isWorkspace ? ` --filter ${dir}` : '';
910
+ installStep = `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod${filter} && pnpm store prune`;
911
+ } else if (a.packageManager === 'yarn') {
912
+ installStep = a.isWorkspace
913
+ ? `RUN ${nodeSecretMount(a)}yarn --cwd ./${dir} install --frozen-lockfile --production && yarn cache clean`
914
+ : `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile --production && yarn cache clean`;
915
+ } else {
916
+ installStep = `RUN ${nodeSecretMount(a)}npm ci --omit=dev && npm cache clean --force`;
917
+ }
918
+ } else {
919
+ lockCopy = `COPY ${dir}/${a.lockFile} ${dir}/package.json ./${dir}/`;
920
+ installStep = a.packageManager === 'pnpm'
921
+ ? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile --prod && pnpm store prune`
922
+ : a.packageManager === 'yarn'
923
+ ? `RUN ${nodeSecretMount(a)}cd ${dir} && yarn install --frozen-lockfile --production && yarn cache clean`
924
+ : `RUN ${nodeSecretMount(a)}cd ${dir} && npm ci --omit=dev && npm cache clean --force`;
925
+ }
926
+
927
+ // When backend has a build step, add a build stage and copy only compiled output
928
+ if (a.hasBuild) {
929
+ const rootConfigCopy = rootConfigFiles.length > 0
930
+ ? `# Copy root config files needed during build\nCOPY ${rootConfigFiles.join(' ')} ./\n`
931
+ : '';
932
+ const buildInstall = a.lockFileAtRoot
933
+ ? (a.packageManager === 'pnpm'
934
+ ? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile && pnpm store prune`
935
+ : a.packageManager === 'yarn'
936
+ ? `RUN ${nodeSecretMount(a)}yarn install --frozen-lockfile && yarn cache clean`
937
+ : `RUN ${nodeSecretMount(a)}npm ci && npm cache clean --force`)
938
+ : (a.packageManager === 'pnpm'
939
+ ? `RUN ${nodeSecretMount(a)}${installPnpmGlobalCmd()} && cd ${dir} && pnpm install --frozen-lockfile && pnpm store prune`
940
+ : a.packageManager === 'yarn'
941
+ ? `RUN ${nodeSecretMount(a)}cd ${dir} && yarn install --frozen-lockfile && yarn cache clean`
942
+ : `RUN ${nodeSecretMount(a)}cd ${dir} && npm ci && npm cache clean --force`);
943
+ const buildCmd = a.lockFileAtRoot
944
+ ? (a.packageManager === 'yarn' ? `RUN export PATH="/build/node_modules/.bin:$PATH" && cd ${dir} && yarn build`
945
+ : a.packageManager === 'pnpm' ? `RUN pnpm --filter ./${dir} build`
946
+ : `RUN npm run build --workspace=${dir}`)
947
+ : `RUN cd ${dir} && ${a.buildCommand}`;
948
+
949
+ const buildStageName = `${dir.replace(/\//g, '-')}-build`;
950
+ const pruneCmd = nodePruneLine(a.packageManager, a.lockFileAtRoot ? '' : dir);
951
+ const runtimeNodeModulesCopy = a.lockFileAtRoot
952
+ ? `COPY --from=${buildStageName} /build/node_modules ./node_modules`
953
+ : `COPY --from=${buildStageName} /build/${dir}/node_modules ./${dir}/node_modules`;
954
+ dockerfile = `
955
+ # ─── Stage 1: Build ───────────────────────────────────────
956
+ ${builderFrom(baseImage, buildStageName)}
957
+
958
+ WORKDIR /build
959
+
960
+ # Install all deps (dev included) for build
961
+ ${lockCopy}
962
+ ${buildInstall}
963
+ ${rootConfigCopy}
964
+ COPY ${dir}/ ./${dir}/
965
+ ${buildCmd}
966
+ ${pruneCmd}
967
+
968
+ # ─── Stage 2: Runtime ─────────────────────────────────────
969
+ FROM ${baseImage}
970
+
971
+ WORKDIR /app
972
+
973
+ ${buildEnvBlock(envVars)}
974
+
975
+ # Install prod deps only
976
+ ${lockCopy}
977
+ ${runtimeNodeModulesCopy}
978
+
979
+ # Copy compiled output from build stage — no raw source in runtime
980
+ COPY --from=${buildStageName} /build/${dir}/${outputDir} ./${dir}/${outputDir}
981
+ ${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}${siblingOnlyCopies ? `\n# Copy sibling dirs required via require('../xxx')\n${siblingOnlyCopies}` : ''}
982
+ RUN addgroup -S appgroup && adduser -S appuser -G appgroup
983
+ USER appuser
984
+
985
+ ${nodeStopSignalBlock()}
986
+ EXPOSE ${a.port}
987
+ ${simpleHttpHealthcheck(a.port)}
988
+ WORKDIR /app/${dir}
989
+ CMD ${startCmdJson}`.trim();
990
+
991
+ improvements.push(`Backend (${dir}): compiled in isolated build stage — source and dev tools stay out of runtime image`);
992
+ } else {
993
+ dockerfile = `
994
+ FROM ${baseImage}
995
+
996
+ WORKDIR /app
997
+
998
+ ${buildEnvBlock(envVars)}
999
+
1000
+ # Install prod deps — separate layer so Docker cache busts only on dependency changes
1001
+ ${lockCopy}
1002
+ ${installStep}
1003
+
1004
+ # Copy service source
1005
+ COPY ${dir}/ ./${dir}/
1006
+ ${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}${siblingOnlyCopies ? `\n# Copy sibling dirs required via require('../xxx')\n${siblingOnlyCopies}` : ''}
1007
+ ${staticCopies ? `\n# Copy static assets served by the backend\n${staticCopies}` : ''}
1008
+ RUN addgroup -S appgroup && adduser -S appuser -G appgroup
1009
+ USER appuser
1010
+
1011
+ ${nodeStopSignalBlock()}
1012
+ EXPOSE ${a.port}
1013
+
1014
+ # Set working dir to service root so relative require() paths resolve correctly
1015
+ ${simpleHttpHealthcheck(a.port)}
1016
+ WORKDIR /app/${dir}
1017
+ CMD ${startCmdJson}`.trim();
1018
+ }
1019
+
1020
+ if (sharedDirs.length > 0) {
1021
+ improvements.push(`Shared dirs copied: ${sharedDirs.join(', ')} — mounted alongside service so relative imports resolve`);
1022
+ }
1023
+ if ((a.siblingDeps || []).length > 0) {
1024
+ improvements.push(`Sibling dirs detected and copied: ${a.siblingDeps.join(', ')} — referenced via require('../xxx') in your entry point`);
1025
+ }
1026
+ }
1027
+
1028
+ improvements.push('HEALTHCHECK probes /health; add a /health endpoint returning 2xx if your app does not already expose one');
1029
+ improvements.push('Add a SIGTERM handler: process.on(\'SIGTERM\', () => server.close())');
1030
+
1031
+ return { dockerfile, dockerignore: nodeDockerignore(), improvements };
1032
+ }
1033
+
1034
+ // ── Python at root ───────────────────────────────────────────────────────────
1035
+
1036
+ function generatePythonRoot(a, rootConfigFiles = []) {
1037
+ const baseImage = BASE_IMAGES[STACKS.PYTHON](a.version);
1038
+ const builderImage = pythonBuilderImage(a);
1039
+ const improvements = [];
1040
+
1041
+ // Copy root config files if any exist (e.g. pyproject.toml, setup.cfg)
1042
+ const extraConfigs = rootConfigFiles.filter(f => f !== a.requirementsFile);
1043
+ const configCopyLine = extraConfigs.length > 0
1044
+ ? `COPY ${extraConfigs.join(' ')} ./\n`
1045
+ : '';
1046
+
1047
+ // Explicit source copy — COPY . . is banned.
1048
+ // Django: manage.py lives at root; user must add app dirs themselves.
1049
+ // FastAPI/Flask: copy the detected entry point.
1050
+ const sourceCopyBlock = a.framework === 'django'
1051
+ ? `COPY manage.py ./\n# TODO: add your Django app directories below, e.g.:\n# COPY myapp/ ./myapp/`
1052
+ : `COPY ${a.entryPoint} ./`;
1053
+
1054
+ const detectedDjangoCopyBlock = a.framework === 'django' && (a.djangoAppDirs || []).length > 0
1055
+ ? `COPY manage.py ./\n${a.djangoAppDirs.map(d => `COPY ${d}/ ./${d}/`).join('\n')}`
1056
+ : sourceCopyBlock;
1057
+
1058
+ {
1059
+ const dockerfile = `
1060
+ ${builderFrom(builderImage, 'builder')}
1061
+
1062
+ ENV VIRTUAL_ENV=/opt/venv
1063
+ RUN python -m venv $VIRTUAL_ENV
1064
+ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
1065
+
1066
+ WORKDIR /app
1067
+
1068
+ COPY ${a.requirementsFile} ./
1069
+ ${pythonInstallBlock(a)}
1070
+
1071
+ FROM ${baseImage}
1072
+
1073
+ ENV VIRTUAL_ENV=/opt/venv
1074
+ ENV PATH="/opt/venv/bin:$PATH"
1075
+ ENV PYTHONDONTWRITEBYTECODE=1 \\
1076
+ PYTHONUNBUFFERED=1 \\
1077
+ PYTHONPATH=/app
1078
+
1079
+ WORKDIR /app
1080
+
1081
+ COPY --from=builder /opt/venv /opt/venv
1082
+ ${configCopyLine}${detectedDjangoCopyBlock}
1083
+
1084
+ ${runtimeUserBlock(false)}
1085
+
1086
+ EXPOSE ${a.port}
1087
+ CMD ${JSON.stringify(dockerCmdArray(a.startCmd, ['python', a.entryPoint]))}`.trim();
1088
+
1089
+ if (a.hasNativeDeps) {
1090
+ improvements.push(`Native Python dependencies detected (${(a.nativeDeps || []).join(', ')}) - installed in a builder venv so build tooling stays out of runtime`);
1091
+ }
1092
+ improvements.push('Pin all dependency versions in requirements.txt for reproducible builds');
1093
+ if (!a.requirementsHasHashes) {
1094
+ improvements.push('Use pip-tools hashes in requirements.txt to enable pip --require-hashes');
1095
+ }
1096
+ improvements.push('Verify all source directories are explicitly COPYed - add COPY <yourdir>/ ./<yourdir>/ for each module');
1097
+ if (a.framework === 'django') {
1098
+ improvements.push('Run collectstatic as part of your build or entrypoint for production');
1099
+ improvements.push('Use gunicorn instead of the Django dev server in production');
1100
+ }
1101
+ if (a.framework === 'fastapi') {
1102
+ improvements.push('Tune FastAPI --workers for the CPU quota assigned to the container');
1103
+ }
1104
+ return { dockerfile, dockerignore: pythonDockerignore(), improvements };
1105
+ }
1106
+
1107
+ }
1108
+
1109
+ // ── Python in subdirectory ───────────────────────────────────────────────────
1110
+
1111
+ function generatePythonSub(a, dir, sharedDirs) {
1112
+ const baseImage = BASE_IMAGES[STACKS.PYTHON](a.version);
1113
+ const builderImage = pythonBuilderImage(a);
1114
+ const sharedCopies = sharedDirs.map(s => `COPY ${s}/ ./${s}/`).join('\n');
1115
+
1116
+ {
1117
+ const dockerfile = `
1118
+ ${builderFrom(builderImage, 'builder')}
1119
+
1120
+ ENV VIRTUAL_ENV=/opt/venv
1121
+ RUN python -m venv $VIRTUAL_ENV
1122
+ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
1123
+
1124
+ WORKDIR /app
1125
+
1126
+ COPY ${dir}/${a.requirementsFile} ./${dir}/
1127
+ ${pythonInstallBlock(a, `cd ${dir} && `)}
1128
+
1129
+ FROM ${baseImage}
1130
+
1131
+ ENV VIRTUAL_ENV=/opt/venv
1132
+ ENV PATH="/opt/venv/bin:$PATH"
1133
+ ENV PYTHONDONTWRITEBYTECODE=1 \\
1134
+ PYTHONUNBUFFERED=1 \\
1135
+ PYTHONPATH=/app
1136
+
1137
+ WORKDIR /app
1138
+
1139
+ COPY --from=builder /opt/venv /opt/venv
1140
+ COPY ${dir}/ ./${dir}/
1141
+ ${sharedCopies ? `\n# Copy shared utilities\n${sharedCopies}` : ''}
1142
+
1143
+ ${runtimeUserBlock(false)}
1144
+
1145
+ EXPOSE ${a.port}
1146
+ WORKDIR /app/${dir}
1147
+ CMD ${JSON.stringify(dockerCmdArray(a.startCmd, ['python', a.entryPoint]))}`.trim();
1148
+
1149
+ const improvements = [];
1150
+ if (a.hasNativeDeps) {
1151
+ improvements.push(`Native Python dependencies detected (${(a.nativeDeps || []).join(', ')}) - installed in a builder venv so build tooling stays out of runtime`);
1152
+ }
1153
+ return { dockerfile, dockerignore: pythonDockerignore(), improvements };
1154
+ }
1155
+
1156
+ }
1157
+
1158
+ // ── .NET at root ─────────────────────────────────────────────────────────────
1159
+
1160
+ function generateDotnetRoot(a) {
1161
+ const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(a.version);
1162
+ const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(a.version);
1163
+
1164
+ // Determine source root — csprojPath is relative, e.g. "MyApp.csproj" or "src/MyApp.csproj"
1165
+ // If the .csproj is in a subdirectory, copy that subdirectory. Otherwise copy common C# dirs.
1166
+ const csprojDir = a.csprojPath.includes('/')
1167
+ ? a.csprojPath.split('/').slice(0, -1).join('/')
1168
+ : null;
1169
+
1170
+ let sourceCopyBlock = csprojDir
1171
+ ? `COPY ${csprojDir}/ ./${csprojDir}/`
1172
+ : `# Copy source - add explicit COPY for each source directory in your project\nCOPY *.cs ./\nCOPY Properties/ ./Properties/`;
1173
+
1174
+ if (!csprojDir && a.dotnetSourceDirs && a.dotnetSourceDirs.length > 0) {
1175
+ sourceCopyBlock = ['COPY *.cs ./', ...a.dotnetSourceDirs.map(d => `COPY ${d}/ ./${d}/`)].join('\n');
1176
+ }
1177
+
1178
+ const dockerfile = `
1179
+ # ─── Stage 1: Build ───────────────────────────────────────
1180
+ ${builderFrom(sdkImage, 'builder')}
1181
+
1182
+ WORKDIR /src
1183
+
1184
+ COPY ${a.csprojPath} ./
1185
+ RUN dotnet restore "${a.csprojPath}"
1186
+
1187
+ ${sourceCopyBlock}
1188
+ ${dotnetPublishLine(a.csprojPath, runtimeImage)}
1189
+
1190
+ # ─── Stage 2: Runtime ─────────────────────────────────────
1191
+ FROM ${runtimeImage}
1192
+
1193
+ WORKDIR /app
1194
+
1195
+ ENV DOTNET_RUNNING_IN_CONTAINER=true \\
1196
+ ASPNETCORE_URLS=http://+:${a.port}
1197
+
1198
+ COPY --from=builder /app/publish .
1199
+
1200
+ ${runtimeUserBlock(false)}
1201
+
1202
+ EXPOSE ${a.port}
1203
+ ENTRYPOINT ["dotnet", "${a.projectName}.dll"]`.trim();
1204
+
1205
+ return {
1206
+ dockerfile,
1207
+ dockerignore: dotnetDockerignore(),
1208
+ improvements: [
1209
+ 'Set ASPNETCORE_ENVIRONMENT=Production in your container runtime config, not in the Dockerfile',
1210
+ 'Consider adding a health check endpoint (/health) and HEALTHCHECK instruction',
1211
+ 'Add explicit COPY instructions for each source directory (Controllers/, Models/, Services/, etc.) to avoid leaking secrets into build context',
1212
+ ],
1213
+ };
1214
+ }
1215
+
1216
+ // ── .NET in subdirectory ─────────────────────────────────────────────────────
1217
+
1218
+ function generateDotnetSub(a, dir, sharedDirs) {
1219
+ const runtimeImage = BASE_IMAGES[STACKS.DOTNET].runtime(a.version);
1220
+ const sdkImage = BASE_IMAGES[STACKS.DOTNET].sdk(a.version);
1221
+
1222
+ const dockerfile = `
1223
+ # ─── Stage 1: Build ───────────────────────────────────────
1224
+ ${builderFrom(sdkImage, 'builder')}
1225
+
1226
+ WORKDIR /src
1227
+
1228
+ COPY ${dir}/${a.csprojPath} ./${dir}/
1229
+ RUN dotnet restore "${dir}/${a.csprojPath}"
1230
+
1231
+ COPY ${dir}/ ./${dir}/
1232
+ ${dotnetPublishLine(`${dir}/${a.csprojPath}`, runtimeImage)}
1233
+
1234
+ # ─── Stage 2: Runtime ─────────────────────────────────────
1235
+ FROM ${runtimeImage}
1236
+
1237
+ WORKDIR /app
1238
+
1239
+ ENV DOTNET_RUNNING_IN_CONTAINER=true \\
1240
+ ASPNETCORE_URLS=http://+:${a.port}
1241
+
1242
+ RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
1243
+ USER appuser
1244
+
1245
+ COPY --from=builder /app/publish .
1246
+
1247
+ EXPOSE ${a.port}
1248
+ ENTRYPOINT ["dotnet", "${a.projectName}.dll"]`.trim();
1249
+
1250
+ return { dockerfile, dockerignore: dotnetDockerignore(), improvements: [] };
1251
+ }
1252
+
1253
+ // ── Helpers ──────────────────────────────────────────────────────────────────
1254
+
1255
+ function installLine(packageManager, prodOnly) {
1256
+ if (packageManager === 'pnpm') {
1257
+ return prodOnly
1258
+ ? `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile --prod`
1259
+ : `RUN ${installPnpmGlobalCmd()} && pnpm install --frozen-lockfile`;
1260
+ }
1261
+ if (packageManager === 'yarn') {
1262
+ return prodOnly
1263
+ ? 'RUN yarn install --frozen-lockfile --production'
1264
+ : 'RUN yarn install --frozen-lockfile';
1265
+ }
1266
+ return prodOnly ? 'RUN npm ci --omit=dev' : 'RUN npm ci';
1267
+ }
1268
+
1269
+ function buildDockerignore(services) {
1270
+ const hasNode = services.some(s => s.stack === STACKS.NODE);
1271
+ const hasPython = services.some(s => s.stack === STACKS.PYTHON);
1272
+ const hasDotnet = services.some(s => s.stack === STACKS.DOTNET);
1273
+
1274
+ const lines = ['.git', '.gitignore', '*.md', '.env', '.env.*', 'Dockerfile', '.dockerignore'];
1275
+
1276
+ if (hasNode) lines.push('**/node_modules', '**/npm-debug.log', '**/.next', '**/dist', '**/coverage');
1277
+ if (hasPython) lines.push('**/__pycache__', '**/*.pyc', '**/.venv', '**/venv', '**/*.egg-info');
1278
+ if (hasDotnet) lines.push('**/bin', '**/obj', '**/*.user', '**/TestResults');
1279
+
1280
+ // Explicitly un-exclude all top-level service dirs so existing whitelist dockerignores
1281
+ // don't silently block COPY commands. Safe no-op on repos without a whitelist.
1282
+ const topLevelServiceDirs = [...new Set(
1283
+ services
1284
+ .map(s => s.serviceDir.split('/')[0])
1285
+ .filter(d => d && d !== '.')
1286
+ )];
1287
+ if (topLevelServiceDirs.length > 0) {
1288
+ lines.push('', '# Ensure service dirs are always in build context');
1289
+ topLevelServiceDirs.forEach(d => lines.push(`!${d}/`));
1290
+ }
1291
+
1292
+ return lines.join('\n');
1293
+ }
1294
+
1295
+ function nodeDockerignore() {
1296
+ return `node_modules
1297
+ npm-debug.log
1298
+ yarn-error.log
1299
+ .pnpm-debug.log
1300
+ .git
1301
+ .gitignore
1302
+ .env
1303
+ .env.*
1304
+ !.env.example
1305
+ !.env.sample
1306
+ *.md
1307
+ dist
1308
+ coverage
1309
+ .nyc_output
1310
+ .next
1311
+ .nuxt
1312
+ Dockerfile
1313
+ .dockerignore`.trim();
1314
+ }
1315
+
1316
+ function pythonDockerignore() {
1317
+ return `__pycache__
1318
+ *.pyc
1319
+ *.pyo
1320
+ *.pyd
1321
+ .Python
1322
+ .venv
1323
+ venv
1324
+ env
1325
+ ENV
1326
+ .git
1327
+ .gitignore
1328
+ .env
1329
+ .env.*
1330
+ *.md
1331
+ *.egg-info
1332
+ dist
1333
+ build
1334
+ .pytest_cache
1335
+ .mypy_cache
1336
+ .ruff_cache
1337
+ Dockerfile
1338
+ .dockerignore`.trim();
1339
+ }
1340
+
1341
+ function dotnetDockerignore() {
1342
+ return `bin
1343
+ obj
1344
+ .git
1345
+ .gitignore
1346
+ .env
1347
+ *.md
1348
+ **/*.user
1349
+ **/*.suo
1350
+ .vs
1351
+ TestResults
1352
+ Dockerfile
1353
+ .dockerignore`.trim();
1354
+ }
1355
+ module.exports = { generateDockerfile, addPowerDockerfileHeader };